●Windows 10でC言語開発をしよう! テストフレームワーク Kyuaで行く

既存のテストフレームワークを使う

既存のテストフレームワークを使わなければならないという規則はないが、広く使われているテストフレームワークにはそれなりの利便性があるので、既存のテストフレームワークを使うのもよい選択肢だ。

特定のテストフレームワークに慣れておけば、別の開発プロジェクトを始めたときもテストコード記述のスキルを使い回せるというメリットがある。また、逆説的な利点となるが、テストフレームワークを利用するようにソフトウェアを書いたり、開発環境を用意したりするようになることで、プロジェクトやソースコードの整理が進むという側面もある。テストフレームワークはうまく使えばメリットが得られるのだ。

デメリットとしては、そのフレームワークの学習コストや、テストフレームワークの複雑さがもたらすことで発生する時間の消費といったところだろうか。これはバランスの問題なので、開発しているソフトウェアに合わないと思ったら別のテストフレームワークに変えればよいし、テストフレームワークを使わずに自前のテストコードだけを使うというのも常に選択肢としてとっておけばよい。

テストフレームワーク「Kyua」とは

今回は、テストフレームワークとしてKyuaを使った例を取り上げる。Windowsではあまり使われていないテストフレームワークだと思うので、以下を参考程度に読んでもらえればと思う。

jmmv/kyua: Testing framework for infrastructure software

jmmv/kyua: Testing framework for infrastructure software


Kyuaはソフトウェアパッケージの品質保証ツールチェーン。ソフトウェアに対して自動テストを実装して実行するためのライブラリとツールの集まりだ。主な特徴として、以下がある。

異なるプログラミング言語で開発されたテストプログラムやテストライブラリに対応。ATFライブラリに対応しているほか、ATFに対応していないプログラムにも対応

テスト結果を履歴データベースへ保存。テストの出力、テストが失敗した場合のスタックトレース、テストが実行された環境、テストで発生した特定のエラーなどの詳細が保存されており、より詳しい分析に使用できる

履歴データベースに保存した記録からHTML形式やプレーンテキスト形式のレポートを作成

Kyuaは2007年にNetBSDで開発が始まったATF (Automated Testing Framework)と呼ばれる取り組みの後継版と言えるもので、2010年に開発が開始された。ATFはもともとNetBSDオペレーティングシステムの自動テストスイートを実装するためのライブラリとツールセットを提供することが目的とされていた。KyuaはそのATFが抱えていた課題を解決するために生まれたテストフレームワークであり、現在ではNetBSDのみならず、他かのオペレーティングシステムやプロジェクトでも利用されている。

KyuaはPOSIXベースのコードであるため、LinuxやMac、*BSDといったUNIX系のオペレーティングシステムで使いやすい。今のところ、公式にはWindows版は存在していない。著者がKyuaをよく使っているので、自分でWindowsに移植してWindowsでもKyuaを使っている。KyuaをWindowsで動作するように移植する話は、面白いのでいずれ取り上げるつもりだ。

今回はKyuaがWindowsで動くというところから話をはじめる。なお、本連載はKyuaの連載ではないので、kyuaの詳しい説明はしない。Kyua用のテストコードを追加して、kyuaコマンドで利用するということだけ把握しておいてもらえればと思う。



kyuaコマンドとKyuafile

Kyuaはkyuaコマンドで制御する。テストの実行、テストの個別実行、テストケースの出力、報告書の作成といった操作をkyuaコマンドから実行する。このコマンドはKyuafileというファイルの内容に従って処理を行う。makeコマンドに対するMakefileがあるように、kyuaコマンドにKyuafileがあるといった感じだ。

今回は、次のようなKyuafileを用意した。

Kyuafile

syntax(2)

test_suite("tests")

atf_test_program{name="test.atf-sh", }

書き方はいろいろあるが、ここではシンプルにtest.atf-shというファイルにテストコードを書くように指定した。test.atf-shは次のように書いてある。

test.atf-sh

#!/usr/bin/env atf-sh

atf_test_case normal

normal_head() {

atf_set "descr" "Normal data handling test"

}

normal_body() {

atf_check -s ignore \

-o file:$(atf_get_srcdir)/../data/zip.tsv \

-x "$(atf_get_srcdir)/../csv2tsv.exe " \

"$(atf_get_srcdir)/../data/zip.csv"

}

atf_test_case empty

empty_head() {

atf_set "descr" "Emtpy file handling test"

}

empty_body() {

atf_check -s ignore \

-o file:$(atf_get_srcdir)/../data/empty.tsv \

-x "$(atf_get_srcdir)/../csv2tsv.exe " \

"$(atf_get_srcdir)/../data/empty.csv"

}

atf_test_case doublequotes

doublequotes_head() {

atf_set "descr" "Double quotation handling test"

}

doublequotes_body() {

atf_check -s ignore \

-o file:$(atf_get_srcdir)/../data/doublequotes.tsv \

-x "$(atf_get_srcdir)/../csv2tsv.exe " \

"$(atf_get_srcdir)/../data/doublequotes.csv"

}

atf_test_case spaces

spaces_head() {

atf_set "descr" "Spaces handling test"

}

spaces_body() {

atf_check -s ignore \

-o file:$(atf_get_srcdir)/../data/spaces.tsv \

-x "$(atf_get_srcdir)/../csv2tsv.exe " \

"$(atf_get_srcdir)/../data/spaces.csv"

}

atf_init_test_cases()

{

atf_add_test_case normal

atf_add_test_case empty

atf_add_test_case doublequotes

atf_add_test_case spaces

}

前回までは、PowerShellでテスト用のスクリプトファイルを4つ用意した。このスクリプトを実行するメタスクリプトをさらに1つ用意し、合計で5つのPowerShellスクリプトを書いたわけだが、これを1つにまとめたようなファイルが上記のtest.atf-shだ。

test.atf-shの書き方にはルールがあるので、基本的にはそのルールに従って処理を記述している。ここまで準備したら、次のようにkyuaコマンドを実行するとテストコードが実行される。

テストコードの実行

kyua test



Makefileにテストコードをマージ

現在でファイルの配置は次のようになっている。Kyuafileとtest.atf-shはtestsディレクトリ以下に配置されている。

csv2tsvの現在のファイル配置

vscode-csv2tsv

├── data

│ ├── doublequotes.csv

│ ├── doublequotes.tsv

│ ├── empty.csv

│ ├── empty.tsv

│ ├── spaces.csv

│ ├── spaces.tsv

│ ├── zip.csv

│ └── zip.tsv

├── LICENSE

├── main.c

├── main.h

├── Makefile

├── tests

│ ├── Kyuafile

│ ├── test.atf-sh

│ ├── test.ps1

│ ├── test001.ps1

│ ├── test002.ps1

│ ├── test003.ps1

│ └── test004.ps1

├── util_csv.c

└── util_file.c

2 directories, 21 files

前回までの作業で、「make test」でテストコードが動作するようになっていた。今回は、make testで今回追加したKyuaのテストコードが動くようにMakefileを書き換える。書き換えた結果は次のとおりだ。

Kyuaに対応させたMakefile

CMD= csv2tsv.exe

SRCS= $(wildcard *.c)

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

CC= clang

CFLAGS+=-g

EXIST= cmd.exe //C if exist

build: $(CMD)

$(CMD): $(OBJS)

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

.c.o:

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

test: test-kyua

test-kyua: $(CMD)

cd tests; kyua test

test-original: $(CMD)

pwsh .\tests\test.ps1

report: $(CMD) clean-report

cd tests; kyua report-html

clean: clean-report

$(EXIST) $(CMD) del $(CMD)

$(EXIST) main.o del $(OBJS)

$(EXIST) $(CMD:.exe=.ilk) del $(CMD:.exe=.ilk)

$(EXIST) $(CMD:.exe=.pdb) del $(CMD:.exe=.pdb)

clean-report:

$(EXIST) .\tests\html rmdir .\tests\html //S //Q

上記のMakefileは、「make report」でHTML版のレポートも生成できるようにしてある。テストが失敗した場合はこのレポートが役に立つ。

この状態でmake testを実行すると次のようになる。

make testの実行結果

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

cd tests; kyua test

test.atf-sh:doublequotes -> passed [0.716s]

test.atf-sh:empty -> passed [0.629s]

test.atf-sh:normal -> passed [0.637s]

test.atf-sh:spaces -> passed [0.641s]

Results file id is c_Users_daichi_Documents_vscode-csv2tsv_tests.20211102-002327-296475

Results saved to /c/Users/daichi/.kyua/store/results.c_Users_daichi_Documents_vscode-csv2tsv_tests.20211102-002327-296475.db

4/4 passed (0 failed)

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

make testで実際には「cd tests; kyua test」というコマンドが実行されている。Kyuaによるテストコードが実行され、そのすべてがテストをクリアしている。



テストに失敗した場合と報告書

テストに失敗すると、次のようにfailedが出力に現れる。

テストに失敗した場合

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

cd tests; kyua test

test.atf-sh:doublequotes -> passed [0.679s]

test.atf-sh:empty -> passed [0.675s]

test.atf-sh:normal -> failed: atf-check failed; see the output of the test for details [0.710s]

test.atf-sh:spaces -> passed [0.639s]

Results file id is c_Users_daichi_Documents_vscode-csv2tsv_tests.20211102-002304-386321

Results saved to /c/Users/daichi/.kyua/store/results.c_Users_daichi_Documents_vscode-csv2tsv_tests.20211102-002304-386321.db

3/4 passed (1 failed)

make: *** [Makefile:21: test-kyua] エラー 1

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

テストに失敗した場合は、まず報告書をチェックする。Kyuaでは次のようにkyua reportでHTML版の報告書が生成される。

Kyuaで報告書を作成

kyua report

ここではMakefileにも取り込んであるので、「make report」でもレポートの作成ができる。作成される報告書は次のようなものだ。

Kyuaのテスト報告書


失敗したテストにはリンクが張られ、より詳しい情報を確認することができる。

失敗したテストの詳細情報


失敗したテストの詳細情報


報告書には、テストを実行した環境の環境変数一覧も出力されている。環境変数が挙動に影響を及ぼしていることも多いため、大切なデータだ。

テスト実行時の環境変数


こんな感じで失敗したテストコードを調べて、実際にプログラムの修正へ結びつけていくという作業を行うことになる。

テストフレームワークの利用を検討しよう

Kyuaの使い方自体を1回で説明するのは無理があるので、簡単にその雰囲気だけつかんでいただければと思う。

テストフレームワークを使うかどうかは、ケースバイケースで検討する必要がある。ただ、互換性を維持しつつソフトウェアの開発を継続していくならテストコードの実装は必須にしておきたいし、その場合はテストフレームワークの採用も検討したいところだ。うまくハマれば力強いツールとして開発をサポートしてくれると思う。

これまで、Windowsにおける開発環境のセットアップから、簡単ながらも実用的なコマンドの開発、互換性を維持するためのテストコードの追加とテストフレームワーム利用といった作業を一通り行ってきた。規模はミニマムだが、実用的な内容を示すことができたのではないかと思う。これまで取り上げてきた開発方法はLinuxやMacにも応用が可能だし、カスタマイズして演習として一度取り組んでもらえれば幸いだ。



付録: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

EXIST= cmd.exe //C if exist

build: $(CMD)

$(CMD): $(OBJS)

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

.c.o:

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

test: test-kyua

test-kyua: $(CMD)

cd tests; kyua test

test-original: $(CMD)

pwsh .\tests\test.ps1

report: $(CMD) clean-report

cd tests; kyua report-html

clean: clean-report

$(EXIST) $(CMD) del $(CMD)

$(EXIST) main.o del $(OBJS)

$(EXIST) $(CMD:.exe=.ilk) del $(CMD:.exe=.ilk)

$(EXIST) $(CMD:.exe=.pdb) del $(CMD:.exe=.pdb)

clean-report:

$(EXIST) .\tests\html rmdir .\tests\html //S //Q

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