はじめに
初投稿です。らいむです。
初めての投稿ということでお作法なども知らない者ですが、どうぞお手柔らかに。。。
今回は開発中の自作コンパイラで構築したテストフレームワークについて解説しようかと思います。
※テストケースの実装、いわゆるフロントエンドにあたる箇所はEpic Game様のUnrealEngineの自動化テクニカルガイドに大いに影響されたものです。
テストフレームワークの設計
個人的なテストのモットーとして以下の条件は外せない点であり、優先度はどれも等しく高いと考えています。
(1) テスト限定のコードを極力減らす
テストの信頼性にもつながります。
実際のコードを抜き取ったものが理想形です。これは(3)にもつながります。
(2) テストフレームワーク自体の保守が容易
テストの信頼性につながります。
枠組みは頑健であるべきであり、その観点から保守性はとても大事です。
(3) テストの追加が容易
テストの充実性につながります。
実装コストを減らす、開発者が気軽に実装できることが重要です。
理想は実際に書いたコードをそのままテストにコピペできたら完璧なテストです。
(4) テストフレームワーク自体が実行時のボトルネックになってはいけない
テストの実行頻度に大きな影響を与えます。
仕事終わりに走らせたはずのテストが朝会社行っても終わってなかったら悲惨ですし、
リリース直前にテスト走らせるヒヤヒヤは味わいたくありませんね。
ちなみに2023/09/20時点でコンパイル時にテスト登録がされないことが判明しました
実際のテストコード
上で紹介した自作コンパイラで実際に組み込んでいるコードです。
テストを管理するクラスにはありますが、登録するようなことは不要です。
本当に以下のコードのみでテストが追加できます。
/* ヘッダで下記のコードを書いてはいけません */
IMPLEMENT_TEST_CLASS(HashString)
bool HashStringTest::RunTest() const
{
TUtf32String Str = U"HelloWorld";
TUtf32StringView Str2(U"HelloWorld2", 10);
TUtf8String Str3 = u8"Hello";
TUtf8StringView Str4 = u8"Hello";
AssertEqual(THashString(Str), THashString(Str2), "InValid Hash Value");
AssertEqual(THashString(Str3), THashString(Str4), "InValid Hash Value");
AssertEqual(String::ConvertCharToUtf8(*THashString(U';').GetString().Bytes()), TChar(u8";"), "Invalid Hash Value");
return true;
}
テストはTestFrameworkというシングルトンクラスから実行します。
for (const std::string& TestName : TestFramework::Get().GatherTest())
{
TestFramework::Get().RunTest(TestName.c_str());
}
解説
一つずつ読み解いていきますね。
おそらく読んでいただいてる方たちの疑問は次の項目かと思って解説します。
(i) IMPLEMENT_TEST_CLASSってなに?
(ii) HashStringTestクラスってどこで定義されている?
(iii) テスト実行するクラス(TestFramework)にどうやって登録している?
(i) IMPLEMENT_TEST_CLASSってなに?
A. マクロです。
#define IMPLEMENT_TEST_CLASS(HashString) /* 以降ほにゃらら(後述) */
(ii) HashStringTestクラスってどこで定義されている?
A. 上記のマクロが展開されることで定義されます。
IMPLEMENT_TEST_CLASS
マクロは、引数の文字列+Test(上記であればHashStringTest)クラスを定義します。
もちろんただのクラスではありませんよ。
TestBaseClass
クラスを継承していて、この基底クラスの定義は以下の通りです。
class TestBaseClass {
public:
TestBaseClass();
virtual ~TestBaseClass() = default;
virtual std::string GetTestName() const noexcept = 0;
virtual bool RunTest() const = 0;
};
正確にはアサート系の定義もここに書いてありますが、割愛しています。詳しくはこちら。
では次にもっと具体的にIMPLEMENT_TEST_CLASS
マクロの展開後を解説します。
キーワードに色つかないと見づらいですし、
上で挙げたHashStringのテストの場合に展開されるコードをもとに解説しますね。
class HashStringTest : public TestBaseClass {
public:
~HashStringTest() = default;
HashStringTest(nullptr_t) { TestFramework::Get().AddTestClass(new HashStringTest()); }
HashStringTest() {}
std::string GetTestName() const noexcept override { return "HashString"; }
bool RunTest() const override;
}; HashStringTestTest HashStringTestTestInstance(nullptr);
重要なのはコンストラクタです。
デフォルトコンストラクタとは別にnullptr_t(nullptrしか受け取れない専用の型)を引数荷物コンストラクタがあります。
実はこの一見変わったコンストラクタがテスト実行者であるTestFrameworkクラスに登録しています。
(iii) テスト実行するクラス(TestFramework)にどうやって登録している?
A. グローバル変数が初期化されるタイミングで登録されます。
上記で挙げましたが重要な部分だけを抜き取ったものを載せます。
class HashStringTest : public TestBaseClass {
public:
/* ... */
HashStringTest(nullptr_t) { TestFramework::Get().AddTestClass(new HashStringTest()); }
/* ... */
}; HashStringTestTest HashStringTestTestInstance(nullptr);
TestFramework::AddTestClass関数はTestFrameworkに引数のポインタをテストリストに追加する関数です。
中身はstd::mapに追加しているだけです。
課題
- std::mapを使っているためコンパイル時展開されない
- メモリリークする
1.はC++20からstd::vectorにはconstexprが付いているので置き換えれば解決します。
2.はスマートポインタやテストが一回でも実行された時にメモリ開放を行うようにすれば解決します。
Q & A
Q. new / deleteってコンパイル時実行されないはずでは?
A. C++20からconstexprが付いている関数からでも呼び出せます。
std::vectorがまさにその例となっています(https://cpprefjp.github.io/reference/vector/vector.html )
Q. グローバル変数の初期化タイミングの制御はどうしている?
A. 何もしてません。すみません。。。
テストの実行順序については定義すべきではない(=依存してはいけない)と考えているため、登録順序はあんまり関係ないかなと考えています。
Q. 同名のテストが実装可能ではないか?
A. できなくはないです。
マクロを経由することでテスト名の命名規則を強制しているため、マクロを使っているかぎり発生しないです。
逆に言えばマクロ展開後のコードを自力実装されると破綻する可能性が出てくるので要注意です。
最後に
需要があれば、ぜひテストフレームワークだけを取り出したリポジトリを作りたいと思っています。
参考
簡素ではありますが、今回のフレームワーク実装にあたって特に参考にさせていただいたサイト
Epic Games「自動化テクニカルガイド」(https://docs.unrealengine.com/4.27/ja/TestingAndOptimization/Automation/TechnicalGuide/)
cpprefjp「vector - cpprefjp」(https://cpprefjp.github.io/reference/vector/vector.html)