マクロと動的メモリを使用しないC ++でのテスト

Google Test、Catch2、Boost.Testなど、テスト用の多くの一般的なライブラリはマクロの使用に大きく関係しているため、これらのライブラリのテストの例として、通常次のような図が表示されます。







namespace { // Tests the default c'tor. TEST(MyString, DefaultConstructor) { const MyString s; EXPECT_STREQ(nullptr, s.c_string()); EXPECT_EQ(0u, s.Length()); } const char kHelloString[] = "Hello, world!"; // Tests the c'tor that accepts a C string. TEST(MyString, ConstructorFromCString) { const MyString s(kHelloString); EXPECT_EQ(0, strcmp(s.c_string(), kHelloString)); EXPECT_EQ(sizeof(kHelloString)/sizeof(kHelloString[0]) - 1, s.Length()); } // Tests the copy c'tor. TEST(MyString, CopyConstructor) { const MyString s1(kHelloString); const MyString s2 = s1; EXPECT_EQ(0, strcmp(s2.c_string(), kHelloString)); } } // namespace
      
      





C ++のマクロは注意が必要ですが、なぜテストを作成するためのライブラリで非常に盛んなのですか?







単体テストライブラリは、テストランタイムが何らかの方法でテストを見つけて実行できるように、テストを記述する方法をユーザーに提供する必要があります。 これを行う方法を考えると、マクロを使用するのが最も簡単なようです。 TEST()マクロは通常、何らかの方法で関数を定義し(Google Testの場合、マクロはクラスも作成します)、この関数のアドレスがグローバルコンテナーに入ることを保証します。







単一のマクロを使用しないアプローチが実装されている有名なライブラリは、 tut-frameworkです。 チュートリアルから彼女の例を見てみましょう:







 #include <tut/tut.hpp> namespace tut { struct basic{}; typedef test_group<basic> factory; typedef factory::object object; } namespace { tut::factory tf("basic test"); } namespace tut { template<> template<> void object::test<1>() { ensure_equals("2+2=?", 2+2, 4); } }
      
      





その根底にある考え方は非常に興味深いものであり、うまく機能します。それほど難しくありません。 つまり、整数によるパラメーター化を伴うテンプレート関数を実装する基本クラスがあります。







 template <class Data> class test_object : public Data { /** * Default do-nothing test. */ template <int n> void test() { called_method_was_a_dummy_test_ = true; } }
      
      





そのようなテストを書くとき:







 template<> template<> void object::test<1>() { ensure_equals("2+2=?", 2+2, 4); }
      
      





実際には、特定の数N = 1に対してテストメソッドの特殊化を作成します(これは、 template<>template<>



略です)。 test<N>()



呼び出すことにより、テストランタイムは、それが実際のテストであるか、テストの実行後にcalled_method_was_a_dummy_test_



という値を見るスタブであるかを理解できます。







次に、テストグループを宣言する場合:







 tut::factory tf("basic test");
      
      





最初に、すべてのtest<N>



をライブラリに配線された特定の定数に列挙し、次にグループ情報をグローバルコンテナー(すべてのテスト関数のグループ名とアドレス)に追加します。







tutのテスト条件として例外が使用されるため、 tut::ensure_equals()



関数は、渡された2つの値が等しくない場合に単に例外をスローし、テスト実行環境は例外をキャッチし、テストが失敗したと見なします。 私はこのアプローチが好きで、そのようなアサーションを使用できるC ++開発者にはすぐに明らかになります。 たとえば、テストで補助スレッドを作成した場合、アサーションを配置しても意味がありません。 さらに、例外が発生した場合、通常の例外に対して安全なコードであるかのように、テストでリソースを解放できることは明らかです。







原則として、tut-frameworkライブラリはかなり良いように見えますが、その実装にはいくつかの欠点があります。 たとえば、私の場合は、テストに番号だけでなく、他の属性、特に名前、およびテストの「サイズ」(たとえば、統合テストか単体テストか)も持たせたいと思います。 これはAPI tutのフレームワーク内で解決できます。何かが既に存在している場合、メソッドをライブラリAPIに追加してテスト本体に呼び出していくつかのパラメーターを設定すると、何かを実装できます。







 template<> template<> void object::test<1>() { set_name("2+2"); // Set test name to be shown in test report ensure_equals("2+2=?", 2+2, 4); }
      
      





もう1つの問題は、Tutテスト実行環境がテストの開始などのイベントについて何も知らないことです。 環境はobject::test<N>()



を実行し、テストが特定のNに対して実装されているか、それとも単なるスタブであるかを事前に知りません。 彼女は、値called_method_was_a_dummy_test_



分析することで、テストが終了したことをcalled_method_was_a_dummy_test_



ています。 この機能は、プログラムがテストの開始と終了の間に作成した出力をグループ化できるCIシステムでは、あまりうまく表示されません。







しかし、私の意見では、改善できる主なもの(「致命的な欠陥」)は、テストの記述に必要な追加の補助コードの存在です。 チュートリアルtut-frameworkには非常に多くのことがあります。最初に特定のクラスstruct basic{}



作成し、これに関連するオブジェクトメソッドとしてテストを記述することが提案されています。 このクラスでは、テストグループで使用するメソッドとデータを定義し、コンストラクターとデストラクタがテストの実行をフレーミングし、jUnitからフィクスチャなどを作成します。 私のtutの練習では、このオブジェクトはほとんど常に空ですが、コードの特定の行数に沿ってドラッグします。







そこで、自転車のワークショップに参加し、小さな図書館の形でアイデアを整理しようとします。







これは、テストされたライブラリでの最小限のテストファイルの外観です。







 // Test group for std::vector (illustrative purposes) #include "tested.h" #include <vector> template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime) { runtime->StartCase("emptiness"); std::vector<int> vec; tested::Is(vec.empty(), "Vector must be empty by default"); } template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime) { runtime->StartCase("AddElement"); std::vector<int> vec; vec.push_back(1); tested::Is(vec.size() == 1); tested::Is(vec[0] == 1); tested::FailIf(vec.empty()); } void LinkVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); }
      
      





マクロの欠如に加えて、ボーナスはライブラリ内の動的メモリの使用の欠如です。







テストケースの定義



テストの登録には、tutと同じ原理で基本レベルのマジックが使用されます。 tests.hのどこかに、この種の定型的な関数があります。







 template <int N> static void Case(IRuntime* runtime) { throw TheCaseIsAStub(); }
      
      





ライブラリユーザーが作成したテストケースは、単にこのメソッドを特殊化したものです。 関数は静的、つまり宣言されています 各翻訳単位で、リンク時に名前が互いに重複しない特殊化を作成します。







最初にStartCase()



を呼び出す必要があるというルールがあります。このルールに、テストの名前など、まだ開発中のその他のものを渡すことができます。







テストがruntime->StartTest()



呼び出すと、興味深いことが起こります。 まず、テストが現在実行モードになっている場合、テストが実行を開始したことをどこかに伝えることができます。 次に、使用可能なテストに関する情報を収集するモードがある場合、 StartTest()



、テストがスタブではなく実際のものであることを意味する特別な種類の例外をスローします。







登録



ある時点で、すべてのテストケースのアドレスを収集し、どこかに配置する必要があります。 テストでは、これはグループを使用して行われます。 テストされた:: Groupクラスのコンストラクターは副作用としてこれを行います:







 static tested::Group<CASE_COUNTER> x("std.vector", __FILE__);
      
      





コンストラクターは、指定された名前でグループを作成し、現在の翻訳単位で見つかったすべてのCase<N>



ケースを追加します。 1つの翻訳単位では、2つのグループを持つことはできません。 また、1つのグループを複数の翻訳単位に分割できないことも意味します。







テンプレートのパラメーターは、作成されたグループの現在の翻訳単位で検索するテストケースの数です。







リンク



上記の例では、テスト済みの:: Group()オブジェクトの作成は、テストを登録するためにアプリケーションから呼び出す必要がある関数内で発生します。







 void LinkStdVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); }
      
      





関数は常に必要なわけではなく、 tested::Group



クラスのオブジェクトをファイル内で単純に宣言できる場合もあります。 ただし、私の経験では、リンカーはファイルがライブラリ内でアセンブルされるとファイル全体を「最適化」することがあり、メインアプリケーションはいずれもこのcppファイルの文字を使用しません。







 calc.lib <- calc_test.lib(calc_test.cpp) ^ ^ | | app.exe run_test.exe
      
      





calc_test.cppがrun_test.exeソースからリンクされていない場合、リンカは、必要な副作用があるにもかかわらず、静的オブジェクトの作成とともに、このファイルを考慮から完全に削除します。







run_test.exeの結果であるチェーンの場合、静的オブジェクトは実行可能ファイルに表示されます。 そして、例のように、これがどのように行われるかは正確には関係ありません。







 void LinkStdVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); }
      
      





または:







 static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); void LinkStdVectorTests() { }
      
      





私の意見では、コンストラクターはmain()の開始後に呼び出され、アプリケーションがこのプロセスをある程度制御できるため、最初のオプションの方が優れています。







この松葉杖のセットアップは、グローバル変数とコンストラクターの副作用を使用してテストデータベースを作成する単体テストライブラリに必要だと思います。 ただし、テストライブラリをキー--whole-archive(MSVCの類似物はVisual Studio 2015.3でのみ登場)にリンクすることにより、おそらく回避できます。







マクロ



マクロはないことを約束しましたが、それはCASE_COUNTER



です。 __COUNTER__



オプションは、これが__COUNTER__



によって使用されることです。これは、コンパイラーが翻訳単位内で使用されるたびに1ずつ増加するマクロです。

GCC、CLANG、MSVCでサポートされていますが、標準ではサポートされていません。 これがイライラする場合は、いくつかの選択肢があります。









__LINE__



の問題は、テンプレートオプションで大きな数値を使用すると、実行可能ファイルのサイズが大きくなることです。 そのため、グループ内のテストの最大数として、signed charパターンのタイプを128に制限しました。







動的メモリの障害



テストを登録するときに、使用した動的メモリを使用できないことが判明しました。 環境に動的メモリがないか、テストケースでメモリリークの検索を使用している可能性があるため、テスト実行環境の介入は必要なものではありません。 Google Testはこれに苦労しています。そこからスニペットを示します:







 // Use the RAII idiom to flag mem allocs that are intentionally never // deallocated. The motivation is to silence the false positive mem leaks // that are reported by the debug version of MS's CRT which can only detect // if an alloc is missing a matching deallocation. // Example: // MemoryIsNotDeallocated memory_is_not_deallocated; // critical_section_ = new CRITICAL_SECTION; class MemoryIsNotDeallocated
      
      





そして、私たちは単に困難を生み出すことはできません。







どのようにしてテストのリストを取得しますか? これらはより技術的な内部構造であり、ソースコードで見やすくなっていますが、とにかく説明します。







グループを作成すると、そのクラスは、 tested::CaseCollector<CASE_COUNTER>::collect



関数へのポインターを受け取ります。この関数は、すべての翻訳単体テストをリストに収集します。 仕組みは次のとおりです。







 // Make the anonymouse namespace to have instances be hidden to specific translation unit namespace { template <Ordinal_t N> struct CaseCollector { // Test runtime that collects the test case struct CollectorRuntime final : IRuntime { void StartCase(const char* caseName, const char* description = nullptr) final { // the trick is exit from test case function into the collector via throw throw CaseIsReal(); } }; // Finds the Case<N> function in current translation unit and adds into the static list. It uses the // reverse order, so the case executed in order of appearance in C++ file. static CaseListEntry* collect(CaseListEntry* tail) { CaseListEntry* current = nullptr; CollectorRuntime collector; try { Case<N>(&collector); } catch (CaseIsStub) { current = tail; } catch (CaseIsReal) { s_caseListEntry.CaseProc = Case<N>; s_caseListEntry.Next = tail; s_caseListEntry.Ordinal = N; current = &s_caseListEntry; } return CaseCollector<N - 1>::collect(current); } private: static CaseListEntry s_caseListEntry; }; // This static storage will be instantiated in any cpp file template <Ordinal_t N> CaseListEntry CaseCollector<N>::s_caseListEntry; }
      
      





各翻訳単位で、CaseListEntry CaseCollector \ :: s_caseListEntry型の多くの静的変数が作成されます。これはテストリストの要素であり、collect()メソッドはこれらの要素を単一接続リストに収集します。 ほぼ同じ方法で、リストはテストグループを形成しますが、パターンと再帰はありません。







構造



テストには、赤文字のコンソールへの出力に失敗するなど、異なるバインディングが必要です。テストレポートのリストを表示し、選択したテストを実行できるCIまたはGUIで理解できる形式でテストレポートを作成します。 私はこれをどのように行うことができるかについてのビジョンを持っています。これは、テストライブラリで以前見たものとは異なります。 クレームは主に、ヘッダーファイル専用ではない大量のコードを含みながら、「ヘッダーのみ」と呼ばれるライブラリを対象としています。







私が想定するアプローチは、ライブラリをフロントエンドに分割することです。これは、tests.hとバックエンドライブラリ自体です。 テストを作成するには、tested.hのみが必要です。tested.hは、C ++ 17(std :: std :: string_viewによる)ですが、C ++ 98があることを前提としています。 Tested.hは、テストの登録と検索、最小限の便利な起動オプション、およびテスト(グループ、テストケース関数のアドレス)のエクスポート機能を実際に実行します。 まだ存在しないバックエンドライブラリは、結果を出力し、エクスポート機能を使用して起動するという点で必要なことは何でもできます。 同様に、プロジェクトのニーズに合わせて起動を調整できます。







まとめ



テスト済みのライブラリ( githubコード )にはまだ安定化が必要です。 近い将来、非同期テスト(WebAssemblyの統合テストに必要)を実行し、テストのサイズを示す機能を追加します。 私の意見では、ライブラリはまだ実稼働で使用する準備ができていませんが、予想外に多くの時間を費やし、そのようなステージは停止し、息を吸い、コミュニティからフィードバックを求めてきました。 この種のライブラリを使用したいですか? マクロなしでライブラリを作成することが可能になるため、C ++の武器には他のアイデアがありますか? 問題のそのような声明はまったく興味深いですか?








All Articles