テストコードを考える

前回は、開発しているソフトウェアにテストコードを追加する例を紹介した。テストコードは自分の身を守ることになる大切なものだ。機能の追加やバグの修正によって、バグが生まれてしまうのはソフトウェア開発にはよくあることだが、テストコードがあればこうした問題を検出し、リリース前に修正できる可能性が高まるのだ。

前回は、Visual Studio Codeを使ってテストコードを追加する方法を取り上げた。テスト用のPowerShellスクリプトを作成し、make testで実行できるようにし、最後にVisual Studio Codeからテストを実行できるようにした。テストコードを取り込む方法としてはかなりシンプルなものだ。

しかし、実際にはテストコードが単一ということはほとんどない。テストコードはすべてのケースを網羅していることが理想的だ。やり方はいろいろあるが、前回のやり方を踏襲していくなら、テストごとにスクリプトを作成して追加することになる。どれだけ網羅的なテストコードを用意できるかが、どれだけ将来の自分も助けることになるかにつながっていく。

テストコードは網羅的に

テストコードは同じようなものをたくさん用意しても意味がない。できるだけすべてのケースを網羅するようにテストコードを追加していく。どこまで用意するかはケースバイケースだが、特に見逃しがちなケースを重点的にテストコードに落とし込んでおくとよいだろう。

csv2tsvの場合、変換前のCSVファイルと変換後のTSVファイルを用意しておけばテストコードを作成できる。まず、テストコードの元となるデータを用意する。最初はよくある一般的なCSVファイルだ。

zip.csv

13101,100,1000000,トウキョウト,チヨダク,イカニケイサイガナイバアイ

13101,102,1020072,トウキョウト,チヨダク,イイダバシ

13101,102,1020082,トウキョウト,チヨダク,イチバンチョウ

13101,101,1010032,トウキョウト,チヨダク,イワモトチョウ

13101,101,1010047,トウキョウト,チヨダク,ウチカンダ

13101,100,1000011,トウキョウト,チヨダク,ウチサイワイチョウ

13101,100,1000004,トウキョウト,チヨダク,オオテマチ(ツギノビルヲノゾク)

13101,100,1006890,トウキョウト,チヨダク,オオテマチジェイエイビル(チカイ・カイソウフメイ)

13101,100,1006801,トウキョウト,チヨダク,オオテマチジェイエイビル(1カイ)

13101,100,1006802,トウキョウト,チヨダク,オオテマチジェイエイビル(2カイ)

zip.tsv

13101 100 1000000 トウキョウト チヨダク イカニケイサイガナイバアイ

13101 102 1020072 トウキョウト チヨダク イイダバシ

13101 102 1020082 トウキョウト チヨダク イチバンチョウ

13101 101 1010032 トウキョウト チヨダク イワモトチョウ

13101 101 1010047 トウキョウト チヨダク ウチカンダ

13101 100 1000011 トウキョウト チヨダク ウチサイワイチョウ

13101 100 1000004 トウキョウト チヨダク オオテマチ(ツギノビルヲノゾク)

13101 100 1006890 トウキョウト チヨダク オオテマチジェイエイビル(チカイ・カイソウフメイ)

13101 100 1006801 トウキョウト チヨダク オオテマチジェイエイビル(1カイ)

13101 100 1006802 トウキョウト チヨダク オオテマチジェイエイビル(2カイ)

テストデータとスクリプトの実行


次に、問題となりそうなケースを用意していく。最初はダブルクォーテーションの処理だ。CSVファイルではダブルクォーテーションがクォートの対象となるため、その扱いに注意する必要がある。そういったケースをテストデータとして先に用意しておく。仕様に則って機能した場合の適切な出力をあらかじめ用意しておくのだ。

doublequotes.csv

13101,"1""0""0",,100 0000

"13101","""102""",,102 0072

13101,"1""02",,102 0082

13101,"101""""",, 10 10032

13101,"""""101",, 101 0047

,13101,@,"1""0""0",

,"13101","""102""",@

,13101,"1""02",

@,13101,"101""""",

,13101,"""""101",

doublequotes.tsv

13101 1"0"0 100 0000

13101 "102" 102 0072

13101 1"02 102 0082

13101 101"" 10 10032

13101 ""101 101 0047

13101 @ 1"0"0

13101 "102" @

13101 1"02

@ 13101 101""

13101 ""101

ファイルが空、つまりゼロバイトだった場合の処理もレアケースになる。そのときにも適切に動作してもらう必要があるので、次のように空のファイルを用意して動作確認を行うようにする。

empty.csv

empty.tsv

フィールドが空のケースや、フィールドの前後が空白である場合なども処理を間違えがちだ。この辺りもテストデータとして用意しよう。

spaces.csv

,,,100 0000

,,,102 0072

,,,102 0082

,,, 10 10032

,,, 101 0047

,,,,

,,,

,,,

,,,

,,,

spaces.tsv

100 0000

102 0072

102 0082

10 10032

101 0047

どこまでやるかは用途にも依るが、たとえばフィールド数が1000とか10000といった極端に多いケース、1行あたりのデータサイズがきわめて大きなケース、CSVデータが適切なフォーマットではなかったときの動作など、やろうと思えばどこまでもテストケースを用意することができる。どこまで用意するかはバランス感覚が要求されるところだが、仕様の整理とテストコードの開発を行ったり来たりしながら丁寧に作業してみよう。



複数のテストスクリプトと、それをまとめるスクリプト

用意したテストデータを使ってテストを行うPowerShellスクリプトを用意する。先程用意した4種類のデータは次のように4つのスクリプトで動作を確認できる。

test001.ps1

$n = '001'

$d = 'zip'

$f = New-TemporaryFile

.\csv2tsv.exe .\data\${d}.csv > $f

C:\Windows\System32\fc.exe .\data\${d}.tsv $f > $null

if ($?) {

echo "テスト${n}: 成功"

}

else {

echo "テスト${n}: 失敗"

}

Remove-Item $f

test002.ps1

$n = '002'

$d = 'doublequotes'

$f = New-TemporaryFile

.\csv2tsv.exe .\data\${d}.csv > $f

C:\Windows\System32\fc.exe .\data\${d}.tsv $f > $null

if ($?) {

echo "テスト${n}: 成功"

}

else {

echo "テスト${n}: 失敗"

}

Remove-Item $f

test003.ps1

$n = '003'

$d = 'spaces'

$f = New-TemporaryFile

.\csv2tsv.exe .\data\${d}.csv > $f

C:\Windows\System32\fc.exe .\data\${d}.tsv $f > $null

if ($?) {

echo "テスト${n}: 成功"

}

else {

echo "テスト${n}: 失敗"

}

Remove-Item $f

test004.ps1

$n = '004'

$d = 'empty'

$f = New-TemporaryFile

.\csv2tsv.exe .\data\${d}.csv > $f

C:\Windows\System32\fc.exe .\data\${d}.tsv $f > $null

if ($?) {

echo "テスト${n}: 成功"

}

else {

echo "テスト${n}: 失敗"

}

Remove-Item $f

さて、問題はここからだ。Makefileから直接この4つのテストスクリプトを呼び出す仕組みにしたとしよう。そうすると、例えばテストコードが100個など大量になってきたときに、Makefileに100行のテストコード呼び出し処理が記述されることになる。あまり見通しのよい書き方とは言えない。

そこで、ほかのテストスクリプトを呼び出してすべてのテストを実施するスクリプトを用意し、Makefileからはそのスクリプトのみを実行するといった仕組みにする。次のようなスクリプトを用意して、ほかのテストスクリプトを実行させる。

test.ps1

##########################################################################

# テスト数

##########################################################################

$TESTS = 4

##########################################################################

# テスト実施

##########################################################################

$res = $true

1..$TESTS |

% {

$n = $_.ToString("000")

Invoke-Expression ./tests/test${n}.ps1

if (-Not $?) {

$res = $false

}

}

##########################################################################

# テスト結果出力

##########################################################################

if ($res) {

echo '全テストをパス'

}

else {

echo 'テスト失敗'

exit -1

}

PowerShellスクリプト自体の説明はここでは行わないが、上記のスクリプトはtestsディレクトリ以下のテストスクリプトを順番に実行し、1つでも失敗がればこのスクリプトの終了コードも失敗として処理する内容になっている。



ファイルとディレクトリの配置構造

CSVファイルをTSVに変換するだけのコマンドを作っているわけだが、テストスクリプトやテストデータも含めるとファイルやディレクトリの配置が少々複雑になってくる。現在は次のような状態になっている。

csv2tsv - ファイルとディレクトリの配置構造

C:.

│ LICENSE

│ main.c

│ main.h

│ Makefile

│ util_csv.c

│ util_file.c



├───.vscode

│ launch.json

│ tasks.json



├───data

│ doublequotes.csv

│ doublequotes.tsv

│ empty.csv

│ empty.tsv

│ spaces.csv

│ spaces.tsv

│ zip.csv

│ zip.tsv



└───tests

test.ps1

test001.ps1

test002.ps1

test003.ps1

test004.ps1

Makefileへのマージ

今回の書き換えをMakefileにも反映させると次のようになる。

今回の開発を反映させたMakefile

CMD= csv2tsv.exe

SRCS= $(wildcard *.c)

OBJS= $(SRCS:.c=.o)

CC= clang

CFLAGS+=-g

build: $(CMD)

$(CMD): $(OBJS)

$(CC) $(CFLAGS) -o $(CMD) $(OBJS)

.c.o:

$(CC) -c $< -o $@

test: $(CMD)

pwsh .\tests\test.ps1

clean:

rm -f *.exe

rm -f *.o

rm -f *.ilk

rm -f *.pdb

「make test」と実行すると次のような結果を得ることができる。

テストコードの実行

PS C:\Users\daichi\Documents\vscode-csv2tsv> make test

clang -c main.c -o main.o

clang -c util_csv.c -o util_csv.o

clang -c util_file.c -o util_file.o

clang -g -o csv2tsv.exe main.o util_csv.o util_file.o

pwsh .\tests\test.ps1

テスト001: 成功

テスト002: 成功

テスト003: 成功

テスト004: 成功

全テストをパス

PS C:\Users\daichi\Documents\vscode-csv2tsv>

開発を進めつつ、時々テストコードを実行して互換性が維持できているかを確認する。この部分を充実させておくことで、将来、楽になれる。実際に使っていて問題が見つかった場合は、それもテストコードとして作成して追加することで、将来のエンバグを減らす可能性を高めることができる。

テストフレームワークへ

このようにテストコードが増えていくと、いくつもの共通点が出てきて、整理していくことでいずれフレームワークのような状態になっていく。自分でフレームワークとして整理してもよいし、そうなってきたら似たような既存のテストフレームワークを探して置き換えるのも手ではないかと思う。

最初からテスト系のフレームワークを使ってもよいのだが、シンプルなものであれば自分で用意すればよく、そのほうが場合によっては見通しがよく移植性も高くなる。フレームワークを自作してもよいだろう。いずれテストフレームワークも取り上げるつもりだ。



付録:csv2tsvソースコードほか

tasks.json

{

"version": "2.0.0",

"tasks": [

{

"label": "Clang",

"type": "process",

"command": "make",

"args": [

"build"

],

"problemMatcher": [],

"group": {

"kind": "build",

"isDefault": true

}

},

{

"label": "Test",

"type": "process",

"command": "make",

"args": [

"test"

],

"problemMatcher": [],

"group": {

"kind": "build",

"isDefault": true

}

}

]

}

Makefile

CMD= csv2tsv.exe

SRCS= $(wildcard *.c)

OBJS= $(SRCS:.c=.o)

CC= clang

CFLAGS+=-g

build: $(CMD)

$(CMD): $(OBJS)

$(CC) $(CFLAGS) -o $(CMD) $(OBJS)

.c.o:

$(CC) -c $< -o $@

test: $(CMD)

pwsh .\tests\test.ps1

clean:

rm -f *.exe

rm -f *.o

rm -f *.ilk

rm -f *.pdb

main.h

int csv2tsv(const char *, int, char *, int);

char *file2str(const char *)

main.c

#include

#include

#include

#include "main.h"

int main(int argc, char *argv[]) {

char *csvdata, *tsvdata;

int csvdata_bytes, tsvdata_bytes;

csvdata = file2str(argv[1]);

csvdata_bytes = strlen(csvdata);

tsvdata_bytes = csvdata_bytes;

tsvdata = calloc(tsvdata_bytes + 1, sizeof(char));

csv2tsv(csvdata, csvdata_bytes, tsvdata, tsvdata_bytes);

printf("%s", tsvdata);

return 0;

}

util_csv.c

#include

static bool record_outputed;

static char gettsvchar(const char);

int csv2tsv(const char *ibuf, int ibufsize, char *obuf, int obufsize) {

// When the target is empty, no processing is done.

if (0 == ibufsize)

return 0;

const char *p_i, *end_i;

char *p_o;

int tsv_len = 0;

p_i = ibuf;

end_i = &ibuf[ibufsize - 1];

p_o = obuf;

// Indicates the state during parsing.

typedef enum FIELD_STATUS {

FIELD_END,

IN_FIELD,

IN_QUOTED_FIELD

} record_status;

record_status rs = FIELD_END;

record_outputed = false;

while (1) {

if ('\n' == *p_i) {

if (!record_outputed) {

// nothing

}

rs = FIELD_END;

*p_o = gettsvchar('\n');

++p_o;

++tsv_len;

} else {

switch (rs) {

case FIELD_END:

if (',' == *p_i) {

// nothing

} else if ('"' == *p_i) {

rs = IN_QUOTED_FIELD;

} else {

rs = IN_FIELD;

*p_o = gettsvchar(*p_i);

++p_o;

++tsv_len;

}

break;

case IN_FIELD:

if (',' == *p_i) {

rs = FIELD_END;

} else {

*p_o = gettsvchar(*p_i);

++p_o;

++tsv_len;

}

break;

case IN_QUOTED_FIELD:

if ('"' == *p_i) {

if (p_i == end_i) {

rs = FIELD_END;

} else if (',' == *(p_i + 1)) {

rs = FIELD_END;

++p_i;

} else if ('"' == *(p_i + 1)) {

*p_o = gettsvchar(*p_i);

++p_o;

++tsv_len;

++p_i;

}

} else {

*p_o = gettsvchar(*p_i);

++p_o;

++tsv_len;

}

break;

}

switch (rs) {

case FIELD_END:

*p_o = '\t';

++p_o;

++tsv_len;

record_outputed = false;

break;

case IN_FIELD:

case IN_QUOTED_FIELD:

break;

}

}

if (p_i == end_i || tsv_len == obufsize)

break;

else

++p_i;

}

return tsv_len;

}

static char gettsvchar(const char c) {

record_outputed = true;

if ('\t' == c) {

return ' ';

} else {

return c;

}

}

util_file.c

#include

#include

#include

char *file2str(const char *filepath) {

struct stat st;

int filesize, c;

char *buf, *p;

FILE *fp;

stat(filepath, &st);

filesize = st.st_size;

buf = calloc(filesize + 1, sizeof(char));

p = buf;

fp = fopen(filepath, "r");

for (int i = 0; i < filesize; i++) {

c = fgetc(fp);

if (EOF == c) {

break;

}

*p = (char)c;

++p;

}

return buf;

}

○参考

RFC4180 - Common Format and MIME Type for Comma-Separated Values (CSV) Files

Definition of tab-separated-values (tsv), Internet Assigned Numbers Authority

GNU make