プログラミング
25年前のミニマルC++単体テストフレームワークの近代化(パート2)
Modernizing a 25-year-old minimal C++ unit testing framework (Part 2) (freshsources.com)
要約
この記事は、25年前のシンプルなC++単体テストフレームワークを、C++17のインライン変数とC++20のモジュール、std::source_locationといった最新機能を用いて近代化するパート2です。前回の記事で残された、テストフレームワークのカウンタがファイルごとに分離してしまう問題と、ヘッダーファイルへの依存による管理の煩雑さを解決するため、インライン変数とモジュールが導入されます。
全文翻訳
オン・ザ・チープな自動単体テスト:パート2 C++コードカプセル
この2部構成シリーズのパート1では、例外処理の適切な検証を含む、驚くほどシンプルな方法で自動単体テストを処理する、実績のある(つまり古い :-)テクニックを紹介しました。しかし、前回の投稿では2つの問題が残されたままで、それらを修正する旅は、モダンC++の2つの主要な機能、すなわちインライン変数とモジュールを巡る楽しいツアーとなるでしょう。
パート1で議論されたテストフレームワークのシンプルさは、すべてが小さなヘッダーファイル、test.h(インクルードガードは省略)に収められていることから来ています。
namespace {
std::size_t nPass = 0;
std::size_t nFail = 0;
inline void do_fail(const char* text, const char* fileName, long lineNumber) {
std::cout << "FAILURE: " << text << " in file " << fileName << " on line " << lineNumber << std::endl;
++nFail;
}
inline void do_test(const char* condText, bool cond, const char* fileName, long lineNumber) {
if (!cond)
do_fail(condText, fileName, lineNumber);
else
++nPass;
}
inline void succeed_() noexcept {
++nPass;
}
inline void report_() {
std::cout << "\nTest Report:\n\n";
std::cout << "\tNumber of Passes = " << nPass << std::endl;
std::cout << "\tNumber of Failures = " << nFail << std::endl;
}
}
#define test_(cond) do_test(#cond, cond, __FILE__, __LINE__)
#define fail_(expr) do_fail(expr, __FILE__, __LINE__)
#define throw_(expr,T)
try {
expr;
std::cout << "THROW ";
do_fail(#expr,__FILE__,__LINE__);
} catch (const T&) {
++nPass;
} catch (...) {
std::cout << "THROW ";
do_fail(#expr,__FILE__,__LINE__);
}
#define nothrow_(expr)
try {
expr;
++nPass;
} catch (...) {
std::cout << "NOTHROW ";
do_fail(#expr,__FILE__,__LINE__);
}
一般的に、ユーザーはtestマクロを呼び出すだけでよく、これはテストされている式をテキストとして、関連するファイル名と行番号と共にキャプチャします。例えば、ソースコードの行がtest_(stk.top() == 1); の場合、プリプロセッサはこれをコンパイルストリームで以下のテキストに置き換えます。
do_test("stk.top() == 1", stk.top() == 1, "tstack.cpp", 17);
これは、ファイル名がtstack.cppであり、そのファイルの17行目で呼び出しが発生したことを示しています。テストが失敗した場合、コンソールには次のように表示されます。
FAILURE: stk.top() == 1 in file tstack.cpp on line 17
report_関数は、成功と失敗の数を表示します。例えば、次のようになります。
Test Report:
Number of Passes = 13
Number of Failures = 0
他の関数は完全性のために存在しますが、ユーザーが使用することはめったにありません。
この記事では、パート1の最後に特定された2つの問題、すなわち、匿名名前空間内のカウンタが個々のファイルに固有であるという問題を修正します。これは、グローバル変数を回避するために意図されたもので、このフレームワークは単一ファイルの学生プロジェクトで使用することを目的としていましたが、これは不要な制約です。大規模なプロジェクトでは、One Definition Rule(ODR)に違反することなく、すべてのプロジェクトファイル間で成功と失敗の合計カウントを共有する必要があります。
ヘッダーファイルへの依存関係は、C++において長年問題の元凶として認識されてきました。上記のマクロは匿名名前空間内のインライン関数を呼び出すため、テスト対象の各ファイルはコードの独自のコピーを取得します。モジュールはC++20で導入され、このような問題を軽減しました。ここではマクロが依然として必要になることが判明したため、ハイブリッドアプローチを採用して、可能な限り多くの機能をモジュールに移行します。
インライン変数
C++17はインライン変数の概念を導入しました。関数と同様に、インライン変数は複数の翻訳単位で定義でき、リンカはそれらの定義すべてを1つにまとめることが要求されます。ルールはインライン関数と同じです。すべての定義は同一でなければなりません(同じトークン、同じ型、同じ初期化子)。名前空間スコープでの変数はデフォルトで外部リンケージを持ちます。すべての翻訳単位で同じオブジェクトであることが保証されます(どこでも同じアドレス)。
ここでの修正は、名前付き名前空間を選択し、nPassとnFailをインラインとして宣言することです。
namespace TestFramework {
inline std::size_t nPass = 0;
inline std::size_t nFail = 0;
// 他の関数も同じ名前空間に配置されます...
inline void fail...
inline void test...
inline void succeed...
inline void report...
}
関数がすべてTestFramework名前空間にあるため、do_failをfailに、do_testをtestに名前変更しました。最後の2つの関数から末尾のアンダースコアも削除しました。末尾のアンダースコアはマクロのみに残ります。マクロは、関連する関数への完全修飾呼び出しも行う必要があります。例:
#define test_(cond) TestFramework::test(#cond, cond, __FILE__, __LINE__)
変数はODRを満たし、グローバル名前空間を汚染しません。これは、ヘッダーオンリーを維持したい場合には良い解決策です。それは簡単でした。
モジュールへの移行
モジュールは、機能のパッケージングのための、よりクリーンでモダンなアプローチです。実際、C++にはモジュールがあるため、インライン変数への呼び出しはほとんどありません。変数と関数は、それらを独自のモジュールに移動できます。そうする前に、マクロがまだ必要かどうか、そしてそれらがモジュールとどのように相互作用するかを決定する必要があります。そもそもマクロを使用する主な動機は、テストされている式を文字列としてキャプチャすることでした。これに代わるものは知りません。そのため、マクロtest_、fail_、throw_、nothrow_はそのまま残ります。しかし、ファイル名と行番号をキャプチャするために、C++20で導入されたstd::source_locationを使用します。
void test( bool cond, std::string_view expr, const std::source_location& loc = std::source_location::current()) {
if (cond)
++nPass;
else
fail(expr, loc);
}
std::source_location::current()がデフォルト引数として現れるため、呼び出しサイトで正しい情報をキャプチャします。(式テキストを受け取るためにstd::string_viewも使用しました。)
マクロはプリプロセッサの成果物であり、モジュールからエクスポートすることはできません。ここでの解決策は、マクロを別のヘッダーファイルに保持し、それらがTestFramework名前空間を呼び出すようにすることです。これは以前に言及したとおりです。
// test_macros.h: testモジュールのマクロコンパニオン。
// test_を使用するテストドライバでこれをインクルードしてください。
// マクロはモジュールからエクスポートできません — このヘッダーがそのギャップを埋めます。
// 作者:Chuck Allison(Creative Commons License、2001 - 2026)
// このヘッダーはクライアントコードで `import test;` の後に現れる必要があります。
#ifndef TEST_MACROS_H
#define TEST_MACROS_H
#define test_(cond) TestFramework::test(cond, #cond)
#define fail_(msg) TestFramework::fail(msg)
#define throw_(expr, T)
try {
expr;
TestFramework::fail("THROW expected in: " #expr);
} catch (const T&) {
++TestFramework::nPass;
} catch (...) {
TestFramework::fail("THROW wrong exception: " #expr);
}
#define nothrow_(expr)
try {
expr;
++TestFramework::nPass;
} catch (...) {
TestFramework::fail("NOTHROW expected: " #expr);
}
#define report_() TestFramework::report() // 一貫性のために
#endif
現在の慣習に従い、変数と関数をtestという名前のモジュールに、test.cppmというファイルに移動します。
// test.cppm: シンプルで効果的な自動テストのスキャフォールディング
// import std および std::source_location を使用するC++20モジュールバージョン。
// マクロはコンパニオンヘッダー test_macros.h に存在します(モジュールはマクロをエクスポートできません)。
// 作者:Chuck Allison(Creative Commons License、2001 - 2026)
export module test;
import std;
export namespace TestFramework {
std::size_t nPass = 0;
std::size_t nFail = 0;
void fail( std::string_view msg, const std::source_location& loc = std::source_location::current()) {
std::cout << "FAILURE: " << msg << " in file " << loc.file_name() << " on line " << loc.line() << " in function " << loc.function_name() << '\n';
++nFail;
}
void test( bool cond, std::string_view expr, const std::source_location& loc = std::source_location::current()) {
if (cond)
++nPass;
else
fail(expr, loc);
}
void succeed() {
++nPass;
}
void report() {
std::cout << "\nTest Report:\n\n" << "\tNumber of Passes = " << nPass << '\n' << "\tNumber of Failures = " << nFail << '\n';
}
}
TestFramework名前空間も明示的にエクスポートする必要があることに注意してください。インラインキーワード