テストケースを用意した!
このデータパックは以下の記事をベースに限りなく全力で xUnit を 6,7 割くらい再現しようと努力したデータパックです。
ダウンロード先とリファレンスはこちら
100%再現は無理😭
どうあがいてもできない部分はあります。
assertのような「式」を評価する関数などは用意できないですし、何よりテストケースを書いただけでテスト登録されないのが致命的です。単体テストでバグへの防御力は高まりますが登録し忘れたら単体テストしない場合より危険です。
テスト駆動では「まずテストが失敗することを確認してから実装する」という手順が推奨なので、nestを使う際はテストケースを書く前にテストケース名を登録して、存在しないテストケースでエラーを発することを確認した方がいいです。これについては最後に説明します。
単体テストの構造
単体テストは3つの要素で構築されています。
テストケース
単体の関数でテストの実行と成否を行う最小単位です。
テストスイート
テストケースの集合体で、モジュールなどのカテゴリごとにまとめます。スイート内では全てのテストケースに対して構築(Setup)・解体(Teardown)という共通の前後処理を用意できます。
対象のモジュールを動かすための前提として最低限必要な関数呼び出しやエンティティの準備、それらの後始末は共通の記述になることが多いはずなので、前後処理でまとめると楽です。
テストユニット
テストスイートの集合体で、これが単体テスト本体と言えます。全てのテストケース・テストスイートの結果を出力してバグの有無を評価します。
用意するもの
これらの要素において、開発者が準備するものは「テストケースの関数」「構築・解体の関数」「関数の登録」の3つです。前述したとおり「関数の登録」を忘れるとテスト自体が実行されないので注意してください。
テストケースの定義
テストケースはただの関数ですが、戻り値に条件があります。
| 戻り値 | 意味 |
|---|---|
| 1 | テスト成功 |
| 0 | テスト継続 |
| -1 | テスト失敗 |
| -2 | 致命的エラー |
必ず上記の値を返してください。
戻り値を返さないと致命的エラーとしてテスト自体が中断されます。
致命的エラーになるパターン
致命的エラーは「関数が見つからない」「関数が戻り値を返さない」「関数が -2 を返す」の3パターンです。fail を返しても関数の処理失敗になるので致命的エラーに分類されます(失敗とエラーを区別するために fail は推奨していません)。
# 致命的エラー時は -2 を返す
execute unless function xxx:check_fatal_error run return -2
# 失敗時は -1 を返す
execute unless function xxx:do_something run return -1
...
# 成功時は 1 を返す
execute if ... run return 1
# 継続時は 0 を返す
return 0
実際のテスト実行時にはテスト環境と呼ばれる構造物が生成され、その中心座標の地表に存在するテストケースエンティティを @s として関数が呼び出されます。
このテストケースエンティティは nest.case.tick というテストケース開始からの tick を保持するスコアを参照できます(変更は自己責任という名の禁止です)。
このスコアは複数回テストを試行したり、テスト内容を数パターン用意する場合に利用できます。
# 10回繰り返してから状態を確認する
execute if score @s nest.case.tick matches 10 if function xxx:check_state run return 1
execute if score @s nest.case.tick matches 10 unless function xxx:check_state run return -1
...
# 継続時は 0 を返す
return 0
# A, B, C の順番に実行して状態を見る
execute if score @s nest.case.tick matches 0 unless function xxx:call_a run return -1
execute if score @s nest.case.tick matches 1 unless function xxx:call_b run return -1
execute if score @s nest.case.tick matches 2 unless function xxx:call_c run return -1
execute if score @s nest.case.tick matches 2 if function xxx:check_state run return 1
execute if score @s nest.case.tick matches 2 unless function xxx:check_state run return -1
# 継続時は 0 を返す
return 0
「異常な状態で異常を返す」のを確認するのがコツ
正常を正常と返すのは割と思ったとおりになりやすいですが、異常を異常と返すのは漏れが起きやすいです。
特に境界線が一番危険なので注意してください。
例えば「HPが10以下でのみ効果が出る」のなら、HPが 10 付近での動作を注意すべきです。
もしかすると条件を書き間違えて「10未満で効果が出る」ようになってしまっているかもしれません。境界線の 10 は必ずチェックしておいた方が良いと思われます。
また「HPが 0 の死亡時には発動してほしくない」のなら 0 の時も注意ですね。@a を使っている場合、生死を問わず全てのプレイヤーが対象になるので発動する可能性はあるはずです(@e[type=player] で回避もできますが…)。
テストスイートの前後処理定義
構築・解体の関数はテストケースとは戻り値の仕様が違います。
| 戻り値 | 意味 |
|---|---|
| 1 | 成功 |
| 0 | 失敗 |
こちらも必ず戻り値を返さないと致命的エラーとしてテストが中断されます。
# 初期化失敗した場合はエラー
execute unless function xxx:initialize run return fail
...
# 成功時は必ず 1 を返すこと
return 1
# 後処理など
# 基本失敗することはないと思いますが
# もし失敗して次のテストケースに影響が出る場合は
# 0 を返して中断してください
kill @e[tag=xxx.entity]
...
# 成功時は必ず 1 を返すこと
return 1
そもそも前後処理が必要ない場合はこれらの関数は不要です。
テストユニットへの関数の登録
エイリアス(別名)定義
nestはマクロの仕様上、テストケースなどの関数を直接呼び出すことができません。
そのため、エイリアス(別名)を定義して呼び出せるようにする必要があります。
エイリアスは data/nest/tags/function/alias/ のディレクトリに関数タグとして定義します(サブディレクトリは利用できません)。
{
"replace": false,
"values": [
"xxx:.../case_a"
]
}
{
"replace": false,
"values": [
"xxx:.../case_b"
]
}
{
"replace": false,
"values": [
"xxx:.../case_c"
]
}
これは function xxx:.../case_a を function #nest:alias/case_a に平坦化しています。
内部でマクロを使う上で ".../case_a" のようにスラッシュ / 混じりのテストケースパスは都合が悪いので case_a のようなサブディレクトリなしの形に別名を用意してもらっています。
よってサブディレクトリによる小分けはできません。
関数登録
テストユニットへ登録する関数は全て上記の別名で行います。
data modify storage nest:test/run << set value { \
unit:xxx, \
data:[ \
{ \
suite:xxx-aaa, \
setup:xxx-aaa-setup, \
teardown:xxx-aaa-teardown, \
cases:[ \
xxx-aaa-case-hoge, \
xxx-aaa-case-piyo, \
xxx-aaa-case-hogepiyo
] \
}, \
{ \
suite:xxx-bbb, \
setup:xxx-bbb-setup, \
teardown:xxx-bbb-teardown, \
cases:[ \
xxx-bbb-case-foo, \
xxx-bbb-case-bar, \
xxx-bbb-case-foobar \
] \
} \
] \
}
function nest:test/run
関数の登録は上記の storage nest:test/run << というストレージに記述します。
unit と suite 項目は他のテストユニットやテストスイートと被らない一意な名前を設定してください。
setup と teardown はテストスイートの前後処理で定義した関数のエイリアスを指定します。
cases にはテストケースで定義した関数のエイリアスを記述します。
登録情報はクォーテーション " なしで
エイリアスを含め、登録情報はクォーテーションなしの文字列で記述してください。
クォーテーションが必要な文字列を登録することはできません。
クォーテーションが必要かどうかでマクロの文字列への扱い方が変わるため、このデータパックの仕様上、登録情報はクォーテーションなしで記述できる文字列に制限されます。
最後に登録情報をストレージに設定した状態で function:nest/test/run を実行すると、単体テストが開始されます。
テスト環境
単体テストを開始すると、付近にテスト環境が生成されます。
このテスト環境はブロックを容赦なく置き換えるため、テスト用のスーパーフラットワールドで生成することを推奨します。
実際やる時は逆順で
最初に注意喚起したとおり nest はテストケースを登録し忘れると実行されません。
こういうミスを防ぐためにも逆順に作業を行いテストが失敗することを確認してから開発を始めるのがよいかと思います。
- テストケースのエイリアス名だけを先に決めて、それを登録した状態で単体テストを実行し、致命的エラーが出ることを確認する
- テストケースのエイリアスが参照する実際の関数名を定義して、単体テストを実行し、やはり致命的エラーが出ることを確認する
- 実際のテストケースの関数内容を定義して、単体テストを実行し、成否を返すことを確認する
- 失敗ならバグを取り成功するまで確認する
テスト駆動は最初に必ず絶対失敗する状態からスタートして、成功する状態に持っていくスタイルなので、まずはエイリアス名だけ定義して、エイリアスの定義も、テストケースの定義も、テストする対象も空の状態で確実に失敗させるのがコツです。この方法なら登録されているかどうかから確認できます。
まとめ
実際 nest を作って他のデータパック (egg) の開発に活用すると、かなり便利に感じています。正直、最初はテストケースを書く分手間ですが、一度書いてしまえば仕様変更がない限りずっと使っていけるので、不安要素がなくなり、テンポよく開発ができます。
機能としては至らない部分があるかと思いますが、もしよければぜひご活用ください。