Nature Remo開発におけるテストフレームワーク『Catch2』の活用方法を紹介します

3日目!

Nature Engineering Blog祭3日目は、ファームウェアエンジニアの中林 (id:tomo-wait-for-it-yuki) がお送りします。みなさま、自動テストはお好きですか?私は大好きです。手動で何度も同じことをテストするのは苦痛ですが、それをプログラミングのタスクに転化できるとなれば、最高ですよね!

今回はNature Remoのファームウェア開発で使用しているユニットテストフレームワーク『Catch2』の活用方法を紹介します。ESP-IDFで使えるテンプレートプロジェクトも用意してありますので、少し長いですが、最後まで楽しく読んでいただけると嬉しいです。

Catch2

Catch2は (modern) C++で書かれたユニットテストフレームワークです。Nature RemoのファームウェアC言語で書いていますが、テストフレームワークC++で書かれたものを選択しています (理由は後述)。

github.com

Cactch2はシングルヘッダーの実装もあり、Nature Remoの開発ではこのシングルヘッダーバージョンを使用しています。

現在Catch2 v3の開発が進められており、v3からはシングルヘッダー実装がなくなるようです。本エントリは私たちが使用しているCatch2 v2について紹介したものとなります。

なぜCatch2を選んだのか?

圧倒的ビルド容易性と、ほどほどの機能性、というのが選択した理由になります。

メジャーなテストフレームワークとして、Cで書かれたものだとUnityC++で書かれたものだとgoogletestCppUnitがあります。

余談ですが、ESP-IDFではUnityを使ったユニットテスト環境が提供されています。exampleはこのあたりです。

大正義「圧倒的ビルド容易性」

まず、組込み開発でテストフレームワークを導入する場合、私はデュアルターゲットでテストし続けることができるという点を重視します。この場合のデュアルターゲットとは、開発しているホストPC上と、ターゲットのマイコン上、両方でテストが実行可能、ということです。

なぜデュアルターゲットにこだわるのかと言うと、下記の要望を満たすためです。

  1. ホストPC上で最速の開発サイクルを回し続けたい (マイコンにプログラムロードすると待ち時間が長い…
  2. ホストPC上では最新のツールチェインを使ってコンパイラによるチェックや実行時解析を実施したい (sanitizer最高です!
  3. ホストPCで使うコンパイラは、ターゲットマイコンとはコンパイラが異なるので、微妙な挙動の差異があるかもしれないので、そこは潰しておきたい
  4. ハードウェアが絡む部分でモック作るのとか頑張りたくない

これらの要望を全て満たすためには、どちらかのテスト環境があるだけではダメです。多少コストをかけてでもデュアルターゲットを維持し続けるしかありません。そこで重要なのが、ビルド容易性です。

シングルヘッダー実装のCatch2は、ヘッダを1つincludeするだけで利用できるという圧倒的な優位性があります。C++11以降に対応したコンパイラが使える、スタックをそれなりに消費するのでターゲットマイコンにその程度の余裕はある、という条件さえクリアできれば、デュアルターゲットを維持しつづけるのが比較的容易です。

Cで書かれたテストフレームワークとの比較

いくらテストが好きとは言っても、楽してテストが書きたい、という衝動を抑えることはできません!C言語でテストを書くのはやはり面倒です…。コンストラクタ / デストラクタ / スマートポインタあたりが使いたくなるのが人情です。

ここは、テストがプロダクトコードの使い方を示すドキュメントであってほしい、という要望とトレードオフになっています (当然、テストもCで書いた方が実際の使い方と近くなります) 。とは言え、便利にテストを書ける程度にC++を使うようにすれば、乖離が激しくなることは少ないです。

C++で書かれたテストフレームワークとの比較

では、googletestと比較した場合はどうでしょうか?googletestにはgooglemockもあり、機能の豊富さで言えば、googletestに軍配が上がると考えています。特にmockが強力です。

ただ最近では、mockで頑張るより、必要なら下の依存ごと動かしたり、簡易的に関数ポインタ付け替えてあげるくらいがちょうど良い塩梅なのではないかな、というところに落ち着いています。mockを使わない、と決めるとCatch2で困ることが急に少なくなり、幸せになれます。

と言った具合で、ビルド容易性とほどほどの機能性とを両立しているCatch2を採用するに至っています。いまのところ、特に困っていることもなく、良い選択だったかな、と感じています。

最初のテストを書いてみる

テストを書き始めるのはメチャクチャ簡単です。まずは、シングルヘッダー実装をダウンロードします。

 curl -L https://raw.githubusercontent.com/catchorg/Catch2/v2.x/single_include/catch2/catch.hpp -O

ダウンロードしたヘッダをincludeして、テストを書きます。

#define CATCH_CONFIG_MAIN  // main()関数を自動で作ってくれる
#include "catch.hpp"

int add(int a, int b) {
    return a + b;
}

TEST_CASE("Add two numbers", "[add]" ) {
    REQUIRE( add(1, 2) == 3 );
}

コンパイルして実行します。コンパイルには少し時間がかかります。

$ g++ main.cpp -o add
$ ./add
===============================================================================
All tests passed (1 assertion in 1 test case)

以上!わーぉ!お手軽!

#define CATCH_CONFIG_MAINを一番最初に書いておくと、テストケースを実行するmain関数を自動生成してくれるため、main関数を書く必要すらありません!

よく使う機能紹介

私がよく使う機能について紹介していきます。Catch2のリポジトリチュートリアルリファレンスが用意されていますので、網羅的な情報はこちらを参照ください。

REQUIRE

assertionマクロです。REQUIRE(式);で使います。C++のオペレーターオーバーロードにより、文字列なんかはそのまま一致比較できます。

#define CATCH_CONFIG_MAIN  // main()関数を自動で作ってくれる
#include "catch.hpp"

TEST_CASE("compare string", "[functions]" ) {
    REQUIRE( "string" == "string" );
}

assertionが失敗した場合、stream outオペレーターのオーバーロードがあると、自動的に展開してくれます。便利。

TEST_CASE("assertion failed", "[functions]" ) {
    const char *str1 = "string1";
    const char *str2 = "string2";
    REQUIRE( str1 == str2 );
}

実行結果:

-------------------------------------------------------------------------------
assertion failed
-------------------------------------------------------------------------------
main.cpp:8
...............................................................................

main.cpp:11: FAILED:
  REQUIRE( str1 == str2 )
with expansion:
  "string1" == "string2"

REQUIREの中は式が書けるので、==以外でもOKです。

TEST_CASE("greater than", "[functions]") {
    int i = 10;
    REQUIRE( i > 0 );
}

ただし複雑な式を書くことはできません。

TEST_CASE("complex expression", "[functions]") {
    int a = 10;
    int b = 10;
    REQUIRE(a == 1 && b == 2);
}

このコードはコンパイルエラーになります。

$ g++ main.cpp -o functions
In file included from main.cpp:2:
catch.hpp: In instantiation of ‘const Catch::BinaryExpr<LhsT, const RhsT&> Catch::BinaryExpr<LhsT, RhsT>::operator&&(T) const [with T = bool; LhsT = const int&; RhsT = const int&]’:
main.cpp:22:5:   required from here
catch.hpp:2244:44: error: static assertion failed: chained comparisons are not supported inside assertions, wrap the expression inside parentheses, or decompose it
 2244 |             static_assert(always_false<T>::value,
      |

2つのassertionに分けるか、式をあらかじめ評価しておけば良いので、実際はそれほど困ることはありません。

TEST_CASEとSECTION

TEST_CASE内にSECTIONを複数書くことができます。SECTIONの外に書かれたコードは、どのSECTIONを実行するときにも実行されます。そのため、Catch2ではsetup / teardownのコードをテストケースの前後にまとめて書けます。例えば、次のコードでは、最初の2行と最後の1行は、assert valuesセクションとpush backセクション、それぞれの前後で実行されます。

TEST_CASE("sections", "[functions]") {
    std::vector<int> v{ 0, 1, 2, 3, 4 };
    REQUIRE(v.size() == 5);

    SECTION("assert values") {
        for (auto i = 0; i < 5; i++) {
            REQUIRE(i == v[i]);
        }
    }
    SECTION("push back") {
        v.push_back(5);
        REQUIRE(v.size() == 6);
        for (auto i = 0; i < 6; i++) {
            REQUIRE(i == v[i]);
        }
    }

    REQUIRE(v.size() != 0);
}

このTEST_CASEとSECTIONの作りはとても気に入っています。他のテストフレームワークでは、テストケース用のベースクラスを継承し、setup() / teardown()をメソッドとして書くことが多いです。その結果、setup / teardownのコードがテスト本体と少し離れたところにあることが多く、不便を感じることもあります。

ここまで解説してきませんでしたが、TEST_CASEはTEST_CASE( test_name [,tag] )というフォーマットになっています。テストフレームワークには必須と言って過言でない機能ですが、特定のテストケースや特定のタグに属するテストケースだけをフィルターして実行できます*1

# テスト名を指定して実行
$ ./functions "compare string"

# タグを指定して実行
$ ./functions [functions]

これまた、よくある機能ではありますが、特定のテストケースをスキップすることもできます。テストケースのタグを[.]にするとそのテストケースをスキップできます。

TEST_CASE("skip me!", "[.]") {
    REQUIRE(false); // 実行されれば失敗するが…!
}

上のテストはタグが[.]になっているためスキップされます。

$ g++ main.cpp -o functions
$ ./functions 
===============================================================================
No tests ran

スキップ用タグのように、いくつか特殊な用途のタグがあるので、興味があればこちらをご覧ください。

ジェネレータ

GENERATEマクロでテストデータを生成することができます。パラメタライズドテストを書くときに重宝します。

TEST_CASE("generator", "[functions]") {
    auto i = GENERATE(0, 2, 4);
    REQUIRE((i % 2) == 0);
}

上のコードでは、REQUIRE((i % 2) == 0)が3回実行され、それぞれのiは0, 2, 4となります。

===============================================================================
All tests passed (3 assertions in 1 test case)

コマンドライン・インタフェース

ビルドしたテストバイナリにはコマンドライン・インタフェースがついています。-hでヘルプが出力されます。

$ ./test -h

Catch v2.13.9
usage:
  functions [<test name|pattern|tags> ... ] options

where options are:
  -?, -h, --help                            display usage information
  -l, --list-tests                          list all/matching test cases
  -t, --list-tags                           list all/matching tags
# 以下略
...

-lでテストが見れたりして便利です。

$ ./test -l
All available test cases:
  compare string
      [functions]
  sections
      [functions]
  generator
      [functions]
3 test cases

vscode C++ TestMate

vscodeC++ TestMate拡張を使うと、どのテストが失敗した、とか、テストごとにデバッグセッションを開始することができます。とても便利です。

marketplace.visualstudio.com

vscode C++ TestMate

一度ビルドしてテストバイナリが生成されている必要があることに注意が必要です。デフォルトだと、ワークスペース下のbuild/testあたりをglob pattern検索するようになっています。デフォルトのglob patternは次のようになっています。

{build,Build,BUILD,out,Out,OUT}/**/{test,Test,TEST}

ビルド時間を減らす

さて、非常に便利なシングルヘッダー実装ですが、欠点がないわけではありません。その欠点の1つはビルド時間が長くなることです。なぜなら、ヘッダーをincludeしているソースコードを修正すると、ヘッダーごと再コンパイルになってしまうためです。Catch2のヘッダーファイルはそこそこ規模が大きく、コンパイルに時間がかかります。

私の手元のPCでは、数件のテストコードが含まれているソースコードをビルドするのに10秒かかりました。毎回10秒待たされてしまっては、待ち時間でコーヒーを飲みすぎてしまいますね。

$ time g++ main.cpp -o functions

real    0m9.379s
user    0m8.740s
sys     0m0.588s

心配はいりません!良い方法があります。テストファイルを分割するだけでこの問題を解決できます。まず、catch.hppをincludeするだけのソースファイルを用意します。

main.cpp

// このファイルにはテストを書かない
#define CATCH_CONFIG_MAIN
#include "catch.hpp"

他のソースファイル、例えばtest.cppにテストを書いておきます。

test.cpp

#include "catch.hpp"

TEST_CASE("generator", "[functions]") {
    auto i = GENERATE(0, 2, 4);
    REQUIRE((i % 2) == 0);
}

こうしておいて、まず、main.cppコンパイルして、オブジェクトファイル (main.o) を生成します。

$ g++ main.cpp -c

次に、test.cppをmain.oと一緒にビルドします。すると、なんということでしょう!Catch2は再コンパイルされずに、ビルド時間が10分の1にまで短縮されます!

$ time g++ main.o test.cpp -o test

real    0m0.935s
user    0m0.815s
sys     0m0.114s

これで、心置きなくテストサイクルが回せますね!

ESP-IDFでLet's Test!

こんな便利でステキなCatch2ですが、なんとマイコン上で簡単 (?) に動いてしまいます!ここでは、ESP-IDFを例にしてみましょう。ホストで動かしていたときから、それほど多くの変更は必要ありません。

本日はこのあたりの諸々を全て設定済みのテンプレートプロジェクトを用意してありますので、興味のある方はぜひ使ってみて下さい。

github.com

main.cpp

まず、main.cppから見ていきましょう。main.cppは次のように書いておきます (解説はコードの後にあります) 。

#define CATCH_CONFIG_RUNNER // main()関数の自動生成はせずに、自分でテストを起動する。
#define CATCH_CONFIG_NO_POSIX_SIGNALS // POSIXのシグナルは使わない。
#define CATCH_CONFIG_DISABLE_EXCEPTIONS // 例外はオフにしておく。
#include <catch.hpp>

extern "C" int app_main(void)
{
    // テストを実行する
    int result = Catch::Session().run();
    return result;
}

ホストでのテストでは、#define CATCH_CONFIG_MAINを使って、main関数を自動生成していました。マイコンの開発環境では必ずしもmainのシンボルを持つ関数が、ユーザーアプリケーションのエントリ関数とは限りません。ESP-IDFでもapp_mainがユーザーアプリケーションのエントリ関数です。

そこで、Catch2のmain関数自動生成機能は使わず、手動でテストを開始します。main関数自動生成をしないようにするにはCATCH_CONFIG_RUNNERマクロを定義します。そして、Catch2のテストを開始するには、Catch::Session().run()を呼び出します。

残り2つのマクロCATCH_CONFIG_NO_POSIX_SIGNALSCATCH_CONFIG_DISABLE_EXCEPTIONSはCatch2で使ってほしくない機能を無効にしています。例外については、ESP-IDFのコンフィグで有効にすることもできますが、デフォルトでは無効になっていますし、例外を有効にしても特に嬉しいことがないため、無効にしています。

sdkconfig.defaults

SDKの設定ですが、メインスタックサイズを増やすだけです。どのあたりがネックなのか、までは見てないですが、いまのところ8KBの設定で困ったことはないです。

# ちょっとメインスタックサイズを多めに使うので増やす
CONFIG_MAIN_TASK_STACK_SIZE=8192

CMakeLists.txt / component.mk

詳細はAppendixに書きますが、ビルドシステムがcmakeの場合、テストを書くコンポーネントのCMakeLists.txtに下記コードを追加して下さい。

CMakeLists.txt

idf_build_set_property(LINK_OPTIONS
    "-Wl,--whole-archive ${CMAKE_CURRENT_BINARY_DIR}/lib${COMPONENT_NAME}.a -Wl,--no-whole-archive"
    APPEND
)

ビルドシステムがMakeの場合はcomponent.mkに下記を追加して下さい。

COMPONENT_ADD_LDFLAGS = -Wl,--whole-archive -l$(COMPONENT_NAME) -Wl,--no-whole-archive

以上です!これでCatch2を使ったテストが実機でも動きます。

build & run!

次のtest.cppを作成して、実際にテストをbuild & runします。

#include <catch.hpp>

TEST_CASE("first test") {
    REQUIRE(true);
}

なお今回の動作環境は、ESP32-DevKitC + ESP-IDF v4.4.1の組み合わせで行っています。

$ idf.py build
$ idf.py flash monitor

いつものブートログが出力されたあとに、テスト成功のログが出力されます!やったね!

I (459) cpu_start: Starting scheduler on PRO CPU.
I (0) cpu_start: Starting scheduler on APP CPU.
===============================================================================
All tests passed (1 assertion in 1 test case)

でも、本当にテストが実行されているのでしょうか?あやしいものです。試しに、わざと失敗するテストも追加して実行してみます。

TEST_CASE("test failed") {
    REQUIRE(false);
}

はい。アサーション失敗したところで、バックトレースが出力されて無事、再起動ループになりました!ちゃんとテストが実行されていそうですね。安心です。

I (460) cpu_start: Starting scheduler on PRO CPU.
I (0) cpu_start: Starting scheduler on APP CPU.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 is a Catch v2.13.1 host application.
Run with -? for options

-------------------------------------------------------------------------------
test failed
-------------------------------------------------------------------------------
../components/tests/test.cpp:7
...............................................................................

../components/tests/test.cpp:8: FAILED:
  REQUIRE( false )

Catch will terminate because it needed to throw an exception.
The message was: Test failure requires aborting test!

abort() was called at PC 0x400f5597 on core 0
0x400f5597: __cxxabiv1::__terminate(void (*)()) at /builds/idf/crosstool-NG/.build/xtensa-esp32-elf/src/gcc/libstdc++-v3/libsupc++/eh_terminate.cc:47

Backtrace:0x400819f2:0x3ffb7f300x400852e9:0x3ffb7f50 0x4008a16e:0x3ffb7f70 0x400f5597:0x3ffb7fe0 0x400f55de:0x3ffb8000 0x400d8e1b:0x3ffb8020 0x400d8e42:0x3ffb8050 0x400ea42f:0x3ffb8080 0x400f32ba:0x3ffb80d0 0x4012c069:0x3ffb8140 0x4012cc1e:0x3ffb8160 0x400d9319:0x3ffb8180 0x400ee141:0x3ffb81a0 0x400ee2a8:0x3ffb82f0 0x400f28ad:0x3ffb8420 0x400f2a11:0x3ffb8610 0x400f2a48:0x3ffb8630 0x4012f79d:0x3ffb8770 0x40087c51:0x3ffb8790 
0x400819f2: panic_abort at /home/tomoyuki/espv4/esp-idf/components/esp_system/panic.c:402
0x400852e9: esp_system_abort at /home/tomoyuki/espv4/esp-idf/components/esp_system/esp_system.c:128
0x4008a16e: abort at /home/tomoyuki/espv4/esp-idf/components/newlib/abort.c:46
0x400f5597: __cxxabiv1::__terminate(void (*)()) at /builds/idf/crosstool-NG/.build/xtensa-esp32-elf/src/gcc/libstdc++-v3/libsupc++/eh_terminate.cc:47
0x400f55de: std::terminate() at /builds/idf/crosstool-NG/.build/xtensa-esp32-elf/src/gcc/libstdc++-v3/libsupc++/eh_terminate.cc:57
0x400d8e1b: Catch::throw_exception(std::exception const&) at /home/tomoyuki/work/blogs/catch2/esp-idf/hello_world/build/../components/catch/catch.hpp:10523
0x400d8e42: Catch::throw_domain_error(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) at /home/tomoyuki/work/blogs/catch2/esp-idf/hello_world/build/../components/catch/catch.hpp:10534
0x400ea42f: Catch::AssertionHandler::complete() at /home/tomoyuki/work/blogs/catch2/esp-idf/hello_world/build/../components/catch/catch.hpp:8275
0x400f32ba: ____C_A_T_C_H____T_E_S_T____2() at /home/tomoyuki/work/blogs/catch2/esp-idf/hello_world/build/../components/tests/test.cpp:8
0x4012c069: Catch::TestInvokerAsFunction::invoke() const at /home/tomoyuki/work/blogs/catch2/esp-idf/hello_world/build/../components/catch/catch.hpp:14225
0x4012cc1e: Catch::TestCase::invoke() const at /home/tomoyuki/work/blogs/catch2/esp-idf/hello_world/build/../components/catch/catch.hpp:14068
0x400d9319: Catch::RunContext::invokeActiveTestCase() at /home/tomoyuki/work/blogs/catch2/esp-idf/hello_world/build/../components/catch/catch.hpp:12927
0x400ee141: Catch::RunContext::runCurrentTest(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&) at /home/tomoyuki/work/blogs/catch2/esp-idf/hello_world/build/../components/catch/catch.hpp:12900
0x400ee2a8: Catch::RunContext::runTest(Catch::TestCase const&) at /home/tomoyuki/work/blogs/catch2/esp-idf/hello_world/build/../components/catch/catch.hpp:12661
0x400f28ad: Catch::Session::runInternal() at /home/tomoyuki/work/blogs/catch2/esp-idf/hello_world/build/../components/catch/catch.hpp:13255
 (inlined by) Catch::Session::runInternal() at /home/tomoyuki/work/blogs/catch2/esp-idf/hello_world/build/../components/catch/catch.hpp:13461
0x400f2a11: Catch::Session::run() at /home/tomoyuki/work/blogs/catch2/esp-idf/hello_world/build/../components/catch/catch.hpp:13417
0x400f2a48: app_main at /home/tomoyuki/work/blogs/catch2/esp-idf/hello_world/build/../main/main.cpp:9
0x4012f79d: main_task at /home/tomoyuki/espv4/esp-idf/components/freertos/port/port_common.c:129 (discriminator 2)
0x40087c51: vPortTaskWrapper at /home/tomoyuki/espv4/esp-idf/components/freertos/port/xtensa/port.c:131

Appendix A CMakeLists.txt / component.mkの呪文解説

ESP-IDF上でCatch2のテストを書く際に、CMakeLists.txt / component.mkにそれぞれ次のコードを追加する手順を書きました。静的ライブラリのリンクではよくある問題ですが、こちらについて改めて解説します。

idf_build_set_property(LINK_OPTIONS
    "-Wl,--whole-archive ${CMAKE_CURRENT_BINARY_DIR}/lib${COMPONENT_NAME}.a -Wl,--no-whole-archive"
    APPEND
)
COMPONENT_ADD_LDFLAGS = -Wl,--whole-archive -l$(COMPONENT_NAME) -Wl,--no-whole-archive

上記コードの前半は (-Wl,--whole-archive -l$(COMPONENT_NAME)) 、リンカに対して、指定のアーカイブファイルに含まれるオブジェクトファイルを全てをリンクするように指示します。後に続く-Wl,--no-whole-archiveは、アーカイブ内のオブジェクトファイルを全てリンクしないように、リンカへの指示を元に戻すものとなります。

このリンクオプションがないと、書いたはずのテストケースが実行されない、という事態が発生します。正確にはテストファイルを分割してアーカイブファイルにした場合に、main.cpp以外に書いたテストコードが実行されません。

この現象を理解する鍵は、次の2つです。

  1. ESP-IDFのビルドシステム
  2. Catch2のテストケース登録方法

ESP-IDFのビルドシステム

さて、テストファイルを分割しても、ホスト上では全てのテストが滞り無く実行されていました。しかし、ESP-IDFでは異なります。これは、ESP-IDFがコンポーネントごとにアーカイブファイル (.a) を作成し、最後にリンクするというビルドシステムになっているためです。

このアーカイブファイルを経由する、というのが今回のポイントです。ホスト上で同じ状態を再現するのであれば、一度test.cppをオブジェクトファイルにコンパイルし、アーカイブファイルを作成すると同様の現象を起こすことができます。

例えば、main.cppとtest.cppをそれぞれ次のように作成します。

#define CATCH_CONFIG_MAIN
#include "catch.hpp"

TEST_CASE("test in main.cpp") {
    REQUIRE(true);
}
#include "catch.hpp"

TEST_CASE("test in test.cpp", "[functions]") {
    REQUIRE(true);
}

test.cppからlibtest.aを作成します。

$ g++ -c test.cpp
$ ar r libtest.a test.o

最終的なテストバイナリを作成して、実行してみます。

$ g++ main.c libtest.a -o tests
$ ./tests
===============================================================================
All tests passed (1 assertion in 1 test case)

あら、不思議!テストケースはmain.cppとtest.cppにそれぞれ1つずつ、合計2つあるはずなのに、1つしか実行されていません!これはいけませんね!

では、リンクオプションを加えてみましょう。

$ g++ main.cpp -Wl,--whole-archive libtest.a -Wl,--no-whole-archive -o tests
$ ./tests 
===============================================================================
All tests passed (2 assertions in 2 test cases)

おわかりいただけましたか?テストケースが2つになりました。(./tests -sとすると実際にどのテストが動いたか出力されるので、詳細を確認したい場合はお試し下さい)

つまり、-Wl,--whole-archiveオプションを指定しない場合、最終的な実行バイナリにtest.cppのオブジェクトファイル (テストケース) がリンクすらされていない、というわけです。一方、-Wl,--whole-archiveオプションを指定すると、アーカイブファイル内のオブジェクトファイルを強制的に全てリンクするので、テストケースが実行バイナリに含まれるようになります。

テストケースはいつ登録される?

発生する現象とその対処方法はわかりました。では、なぜこうなってしまうのでしょうか?その答えはテストケースがいつ登録されるか?にあります。

勿体ぶっても仕方ないので結論から書くと、テストケースはコンパイル時ではなく、実行時に登録されていますC++だとよくある方法で、グローバルオブジェクトのコンストラクタを利用して、自身のオブジェクトを登録させています。

main.cppでtest.cppに書かれたテストケースを明示的に登録しなくても、勝手にtest.cppのテストケースが登録されるのはこのためです。詳しくは下のstackoverflowの回答が、疑似コード付きで非常にわかりやすいので、より詳しく知りたい場合は、ご参照下さい。

stackoverflow.com

では、最後に、なぜ実行時にテストケースを登録していると、テストがリンクされないのでしょうか?それは、main.cppからtest.cpp (libtest.atest.o) に対して、解決すべき参照がないためです。

アーカイブファイルをリンクするとき、これまでに処理したオブジェクトファイルからの参照がないオブジェクトファイルはリンクされません。具体的に書くと、libtest.aにはtest.oというオブジェクトファイルが含まれていますが、main.cpp (main.o) はtest.oに対する参照を持たないため、test.oはリンクされない、ということです。

そこで、-Wl,--whole-archiveオプションで強制的にアーカイブ内のtest.oをリンクします。すると最終的なテストバイナリにめでたくtest.cppのテストケースが含まれるようになります。

めでたしめでたし!

WHOLE_ARCHIVE

https://github.com/espressif/esp-idf/issues/8654によると、ESP-IDFのcmakeビルドシステムにWHOLE_ARCHIVEというCMakeマクロが追加されるようです。このマクロを使うと、idf_build_set_propertyで長々書いていたリンカへのオプションが、WHOLE_ARCHIVE TRUEだけで済むようになります。

v4.4.1のあとにmasterにmergeされており、次のreleaseくらいから使えそうです。

Appendix B ESP-IDF v3.xで動かす

ESP-IDF v3.xではgccのバージョンが5.4です。gcc 5.4ではC++14が完全にはサポートされていませんし、C++11の範囲でも既知のバグがあります。このgcc 5.4のバグを回避するために、main.cppに次の1行を追加しておきましょう (あとコンパイルオプションに-std=c++11を追加しましょう) 。

#define CATCH_CONFIG_NO_CPP11_TO_STRING

4日目の明日は、メカエンジニアのたるちさんです。ソフトウェア以外の話題になりますのでお楽しみに!


Natureでは自動テストが好きなエンジニアを募集しています。

herp.careers

カジュアル面談も実施していますので、興味がある方はお気軽にご応募ください!

herp.careers

*1:タグは複数つけることもできます