はじめに
LLVMを使ったNode.jsミニコンパイラ作りはひと段落しましたが、宿題と感じていることの一つに、テストがあります。
今さらではありますが、今回はテストの整備に取り組みます。
テストの方針
いまから内部の関数にテストを追加するのは、なかなか骨が折れる仕事になりそうです。そこで今回は、大外のテスト(End to Endのテスト)として、2つの実行結果(細かく分けると3つ)を比較するテストを整備することにしました。
- Node.js で直接実行した場合
- ミニコンパイラで生成したコードを実行した場合
- lliでLLVM-IRを実行
- llcとリンカーでバイナリを生成し、実行
また比較する方法の2種類必要になります。
- 終了コードを比較 ... 初期の段階(Step 4まで)のテスト
- 標準出力の内容を比較 ... 組み込み関数putn(), puts()を実装してからのテスト
テストの準備
終了コード比較の準備
次のソースコードをテスト対象とします。
1 * (2 + 3);
しかしそのままでは上手く行きません。
- ミニコンパイラでは、式の結果を終了コードにしている
- 終了コードは 5
- Node.js で直接実行した場合は、式の結果は終了コードにならない
- 終了コードは 0
そこで Node.js で実行する場合は、実行前に対象ソースコードを次のように変換してやることにします。
process.exit(1 * ( 2 + 3 ));
実際にはシェルスクリプトで sed を使って変換しています。
echo "process.exit(" > $direct_file
cat $jsfile | sed -e "s/;\$//" >> $direct_file # remove ';' at line end
echo ");" >> $direct_file
変数の値は次の通りです。
- $jsfile ... テスト対象のソースコードのファイル名
- $direct_file ... Node.jsで直接実行用に変換したファイル名
これで Node.js で直接実行した場合も、式の値が終了コードになります。
標準出力比較の準備
ミニコンパイラでは putn(), puts() の2つの組み込み関数を用意しています。そのため次のソースコードをコンパイル→実行できます。
putn(123);
puts("abc");
Node.js で直接実行するとそのような関数は存在しないため、当然エラーになります。
そこで前処理として関数を追加してから実行することにしました。
cat $helper_file > $direct_file # putn(), puts()
cat $jsfile >> $direct_file
先ほどの変数に加えて、次の変数を使っています。
- $helper_file ... 追加対象のファイル(下記 builtin_helper.js)
// ----- builtin helper for putn(), puts() ---
function putn(n) {
if (n === true) {
console.log(1);
}
else if (n === false) {
console.log(0);
}
else {
console.log(n);
}
}
function puts(s) {
console.log(s);
return 0;
}
// --------------
putn() で true/false を特別に扱っているのは、ミニコンパイラでは trueは1、falseは0として扱っているのに揃えるためです。
テストの実行
Node.js で直接実行
シェルスクリプトの関数で実行しています。終了コードと標準出力結果を保存しておきます。
TestDirectWithHelper() {
# -- 必要な前処理を行う --
PreprocessForDirect
node $direct_file > $direct_result
direct_exit=$?
}
ここで変数が示すのは次の通りです。
- $direct_file ... Node.js 直接実行用に変換したソースコード
- $direct_result ... Node.js で直接実行した場合の標準出力結果を保存するファイル
- $direct_exit ... Node.js で直接実行した場合の終了コード
コンパイルした結果の実行
こちらもシェルスクリプト内で、コンパイルで得られた LLVM-IR を lli で実行します。ここでも終了コードと標準出力結果を保存しておきます。
TestCompile() {
node $compiler $jsfile
if [ "$?" -eq "0" ]
then
echo "compile SUCCERSS"
mv generated.ll $ir_file
else
echo "!! compile FAILED !!"
exit 1
fi
$lli $ir_file > $lli_result
lli_exit=$?
}
ここで各変数は次を示しています。
- $compiler ... ミニコンパイラ自体のソースコード
- $jsfile ... テスト対象のソースコード
- $ir_file ... コンパイル結果の LLVM-IR を保存するファイル名
- $lli_result ... LLVM-IR を lli で実行した場合の標準出力結果を保存するファイル
- $lli_exit ... lli で実行した場合の終了コード
バイナリの実行
こちらもシェルスクリプトでバイナリを生成、実行します。同じく終了コードと標準出力結果を保存しておきます。
TestExecutable() {
$llc $ir_file $llcopt -o=$obj_file
$linker $linkopt $obj_file -o $bin_file
if [ "$?" -eq "0" ]
then
echo "build SUCCERSS"
else
echo "!! build FAILED !!"
exit 1
fi
# --- exec ---
$bin_file > $bin_result
bin_exit=$?
}
ここで各変数は次を示しています。
- $llc ... llcのパス
- $llcopt ... llc に渡すオプション(macOSの場合: -O0 -march=x86-64 -filetype=obj )
- $ir_file ... コンパイル結果の LLVM-IR ファイル名
- $obj_file ... llc で生成するオブジェクトファイル名
- $linker ... 使用するリンカー。(macOSの場合: ld )
- $linkopt ... リンカーに渡すオプション(macOS 10.13の場合: -arch x86_64 -macosx_version_min 10.13.0 -lSystem )
- $bin_file ... 実行可能バイナリのファイル名
- $bin_result ... バイナリを実行した場合の標準出力結果を保存するファイル
- $bin_exit ... バイナリを実行した場合の終了コード
終了コードの比較
テストの対象が終了コードの場合は、シェルスクリプトで比較します。
- Node.js での直接実行 <---> lli での実行
- Node.js での直接実行 <---> バイナリの実行
また、不一致時にはエラーメッセージをファイルに書き出してあとから追跡できるようにしています。
標準出力の比較
標準出力の比較では改行文字が異なっていたので、それを除外して diff を取ることにしました。
DiffResult() {
diff --strip-trailing-cr $direct_result $lli_result > $diff_lli
diff --strip-trailing-cr $direct_result $bin_result > $diff_bin
}
ここで各変数は次を示しています。
- $direct_result ... Node.js で直接実行した場合の標準出力結果を保存するファイル
- $lli_result ... LLVM-IR を lli で実行した場合の標準出力結果を保存するファイル
- $bin_result ... バイナリを実行した場合の標準出力結果を保存するファイル
- $diff_lli ... Node.js で直接実行した場合と lli で実行した場合の、標準出力の差分ファイル
- $diff_bin ... Node.js で直接実行した場合とバイナリを実行した場合の、標準出力の差分ファイル
差分ファイルが「存在して空でない」場合はエラーにしています。
if [ -s $diff_lli ]
then
# NG
echo "!! node <-> lli stdout are different !!"
exit 1
else
# OK
echo "... node <-> lli stdout are same"
fi
単一テストの実行
一連の処理をシェルスクリプトにしています。
$ sh test_direct_compiler.sh コンパイラファイル 対象ファイル 前処理の種類 終了コードのチェック方法 クリーンナップ処理
- 前処理の種類 ... Node.js 直接実行時の前処理 (なし | 終了コード取得 | 組み込み関数追加)
- 終了コードのチェック方法 ... 比較する、比較しない、固定の数値と比較
- クリーンナップ処理 ... テストに成功したら一時ファイルを削除する、または保存する
テストは終了コードで成功/失敗が分かるようにしています。
- テストに成功した場合 → 終了コード=0
- テストで失敗した場合 → 終了コード=1
このシェルスクリプトの全体は GitHub にあります。
複数のテストの実行
先ほどの単一テストのシェルを、対象ファイルを変えながら複数連続して実行します。単一シェルの終了コードを拾って成功/失敗を判定し、最後にレポートするようにしました。
# --- summary ---
case_count=0
ok_count=0
err_count=0
last_case_exit=0
TestSingleWithPreprocess() {
# --- exec 1 test case --
compiler=$1
jsfile=$2
preproc=$3
check_exit=$4
cleanup=$5
sh test_direct_compiler.sh $compiler $jsfile $preproc $check_exit $cleanup
last_case_exit=$?
# --- check test result--
case_count=$(($case_count+1))
if [ "$last_case_exit" -eq 0 ]
then
# -- test OK --
ok_count=$(($ok_count+1))
else
# -- test NG --
err_count=$(($err_count+1))
fi
}
TestSingleWithPreprocess $compiler one.js exitcode compareexit remove
TestSingleWithPreprocess $compiler add.js exitcode compareexit remove
TestSingleWithPreprocess $compiler add_many.js exitcode compareexit remove
TestSingleWithPreprocess $compiler binoperator.js exitcode compareexit remove
TestSingleWithPreprocess $compiler add_var.js builtin ignoreexit remove
TestSingleWithPreprocess $compiler equal.js builtin ignoreexit remove
TestSingleWithPreprocess $compiler equal2.js builtin ignoreexit remove
# ... 省略 ...
TestSingleWithPreprocess $compiler func_add.js builtin ignoreexit remove
TestSingleWithPreprocess $compiler fizzbuzz_func.js builtin ignoreexit remove
TestSingleWithPreprocess $compiler fib_func.js builtin ignoreexit remove
Report() {
echo "===== test finish ======"
echo " total=$case_count"
echo " OK=$ok_count"
echo " NG=$err_count"
echo "======================"
}
このシェルスクリプトの全体は GitHub にあります。
- test/test_multi.sh ... STEP11までのサンプルがテスト対象
終わりに
最後になってしまいましたが、シェルスクリプトを使った簡単なテストを用意しました。実際にテストを実行してみると、今まで手間がかかっていた動作確認が一発で行えてとても気持ち良いです。今後の拡張を行う際には心強い味方です。
宣伝
東京Node学園祭2018 1日目(11/23)の午前に、Node.jsでつくるNode.js ミニインタープリター&コンパイラー と題してこちらの一連の記事をもとに発表します。有料になりますが、よろしかったらぜひご参加お待ちしています。
(このイベントでライオンに怒られないように、急遽テストを追加しました)