ugo Tech Blog

ugoの日々の開発・生産について

C++ ユニットテストライブラリ doctest を触ってみた

はじめまして、アバター開発部の佐々木です。

普段はソフトウェアエンジニアとしてロボットの機能開発に携わっています。

ロボットのソフトウェアでよく使われる言語のひとつに C++ があります。 今回は、その C++ユニットテストライブラリのひとつ doctest を触ってみたのでご紹介したいと思います。

doctest とは?

C++ には CppTest や Google Test, Boost.Test など、様々なユニットテストツールが存在しています。 doctest もそのひとつで、2016 年に登場した比較的新しい C++ユニットテストライブラリです。

Catch2 というユニットテストライブラリがベースになっており、多くの特徴を引き継いでいます。

他のユニットテストツールと比べて、次のような点が特徴です。

  • ヘッダーオンリーライブラリなので導入が容易
  • 軽量でコンパイル・実行が高速
  • テスト対象のコードに直接テストを書くことができる
  • スレッドセーフ

その他にもたくさんの特徴がこちらで紹介されています。

実際に使ってみた

導入

doctest はヘッダーオンリーライブラリなので、導入はとても簡単です。

まず doctest.h をダウンロードします。今回は現時点の最新版 v2.4.9 を選びました。

$ wget https://github.com/doctest/doctest/releases/download/v2.4.9/doctest.h

あとはテスト対象のファイルでこの doctest.h をインクルードするだけで OK です。

#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN については後ほど説明します)

#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"

...

実行

doctest では、テスト対象のコードと同じファイルにテストコードを書くことができます。

これは、doctest が Python の doctest 等と同様の考え方、『テストコードをドキュメントの一種として考えれば、テスト対象のコードの近くに置くべきだよね』という思想に基づいているためだそうです。

実際に簡単なテストを書いてみます。

// main.cpp

#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"

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

TEST_CASE("testing the add function") {
    CHECK(add(1, 1) == 2);
} 

#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN というマクロを定義したファイルには、doctest が自動的に main 関数を実装してくれるので、このままコンパイル・実行ができます。コンパイルも一瞬で終わります。

$ g++ main.cpp -o main
$ ./main
[doctest] doctest version is "2.4.9"
[doctest] run with "--help" for options
===============================================================================
[doctest] test cases: 1 | 1 passed | 0 failed | 0 skipped
[doctest] assertions: 1 | 1 passed | 0 failed |
[doctest] Status: SUCCESS!

以上でテストが完了しました。導入からテストまでが簡単なので、テストを書くハードルがぐっと下がりますね。

既存の main 関数を使いたい場合や、デフォルトで実装されている main にはない機能を加えたい場合には、自作の main 関数を使用する方法もあります。

また、バイナリからテストを完全に取り除く方法も用意されています。

特長

以下では doctest の特長を紹介していきます。ほんの一部分だけの紹介になるので、網羅的な情報は doctest の公式 GitHub ページをご覧ください。

アサーション

アサーションの書き方はとてもシンプルで、基本的には CHECK() マクロに通常の C++ の比較式を与えるだけで OK です。

CHECK(flags == state::alive | state::moving);
CHECK(thisReturnsTrue());

多くのユニットテストツールのように条件ごとにマクロを使い分ける必要がなく、直感的な記述が可能です。

TESTCASE・SUBCASE

フィクスチャ機構はよくあるクラスベースのものではなく、TEST_CASE の中に SUBCASE を入れ子状に書くアプローチがとられています。 この仕組みを理解するには、次の例がとても分かりやすいです。

TEST_CASE("lots of nested subcases") {
    cout << endl << "root" << endl;
    SUBCASE("") {
        cout << "1" << endl;
        SUBCASE("") { cout << "1.1" << endl; }
    }
    SUBCASE("") {   
        cout << "2" << endl;
        SUBCASE("") { cout << "2.1" << endl; }
        SUBCASE("") {
            cout << "2.2" << endl;
            SUBCASE("") {
                cout << "2.2.1" << endl;
                SUBCASE("") { cout << "2.2.1.1" << endl; }
                SUBCASE("") { cout << "2.2.1.2" << endl; }
            }
        }
        SUBCASE("") { cout << "2.3" << endl; }
        SUBCASE("") { cout << "2.4" << endl; }
    }
}

このテストを実行すると、次の出力結果が得られます。

root
1
1.1

root
2
2.1

root
2
2.2
2.2.1
2.2.1.1

root
2
2.2
2.2.1
2.2.1.2

root
2
2.3

root
2
2.4

はじめに TEST_CASE の処理が実行されます。その後処理が進み最深部の SUBCASE が実行され終えると、再び TEST_CASE が実行されています。 また、末端の SUBCASE はそれぞれ 1 度ずつしか実行されません。

この仕組みにより、いちいちクラスを定義する手間が省けるだけでなく、setup() / teardown() メソッドを使うよりも柔軟にセッティングを変更しながらテストを書くことができます。

コンパイル時間

ベースとなった Catch2 と多くの点で特徴を共有していますが、大きく異なるのがコンパイル時間です。

https://github.com/doctest/doctest/blob/master/scripts/data/benchmarks/header.png

こちらのグラフは GitHub のトップページからの引用ですが、ひと目で違いが確認できます。

なお、Catch2 とのその他の相違点はこちらで紹介されています。

その他

触ってみて少し残念だったのは、モックをサポートしていない点です。人によってはここで評価が分かれるかもしれません。

一応 FAQ では trompeloeilFakeIt などのサードパーティ制モックライブラリを使うことはできるはずとのことですが、FaleIt は C++11, trompeloeil は C++11/14 までしか正式対応していないようです。モックを多用するプロジェクトへ利用するには少しハードルがありそうですね。

おわりに

今回は C++ユニットテストライブラリ doctest についてご紹介しました。

ugoでは、一緒にロボットを社会実装していく仲間を絶賛募集中です。

詳しくはこちら👇まで。

herp.careers