Open6

c++ doctest

nonanonnononanonno

doctest はC++向けのユニットテストフレームワーク。シングルヘッダーであり、コンパイルが速く、関数定義の隣りに書くことができる。(テスト用にソースを分ける必要がない)

その他ユニットテストフレームワークの概要はここがわかりやすい。

nonanonnononanonno

使い方

ビルドシステムなし/単独ファイル

カレントディレクトリないし検索可能なディレクトリに doctest.h があれば良い。

lib.cpp
// clang++ lib.cpp -std=c++20
#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, 2) == 3); }

TEST_CASE("will fail") { CHECK(add(1, 2) == 0); }

これは以下のような出力をする

[doctest] doctest version is "2.4.9"
[doctest] run with "--help" for options
===============================================================================
lib.cpp:8:
TEST CASE:  will fail

lib.cpp:8: ERROR: CHECK( add(1, 2) == 0 ) is NOT correct!
  values: CHECK( 3 == 0 )

===============================================================================
[doctest] test cases: 2 | 1 passed | 1 failed | 0 skipped
[doctest] assertions: 2 | 1 passed | 1 failed |
[doctest] Status: FAILURE!

ユニーク(というかどうやってるんだろ)なのは、 == の左右の値が表示されること。ASSERT_EQ(a,b) などのように、条件によってマクロを変える必要がない。

なお、この例はあまり現実的な使い方ではない。doctest が用意した main 関数が含まれるし、これはどうも -DDOCTEST_CONFIG_DISABLEを設定しても、中身はないかもしれないが残っている。また、ファイル分割した時に一貫性がない。

nonanonnononanonno

ビルドシステムなし/複数ファイル

ファイル分割をしている場合。実際にアプリケーションやライブラリを作るパターン。ここではアプリケーションを作るパターンとする。

ファイルは以下の4つ

  • lib1.cpp : ライブラリコードその1
  • lib2.cpp : ライブラリコードその2
  • test_main.cpp : テスト用のエントリーポイント
  • main.cpp : アプリケーションエントリーポイント
lib1.cpp
#include "doctest.h"

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

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

重要な違いは、何もdefine していないこと。DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN は テスト用のライブラリの実装及びmain を作る。DOCTEST_CONFIG_IMPLEMENT はテスト用の実装 を作る。つまり、複数箇所に記述するとリンクエラーになる。何も指定しなければ宣言だけなのだろう。

lib2.cppmain.cpp は省略するとして、test_main.cpp は以下の通り。

test_main.cpp
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"

テスト時は以下のようにビルドする。

clang++ lib1.cpp lib2.cpp test_main.cpp -std=c++20

非テスト時は以下のようにビルドする。

clang++ lib1.cpp lib2.cpp main.cpp -std=c++20 -DDOCTEST_CONFIG_DISABLE

DOCTEST_CONFIG_DISABLE により、テスト周りのシンボルが消える。アプリケーションでもdoctestの機能を使いたい時(CHECK などはテスト外でも便利に使える) は main.cpp に以下を加え、DOCTEST_CONFIG_DISABLE なしでビルドする。

#define DOCTEST_CONFIG_IMPLEMENT
#include "doctest.h"
nonanonnononanonno

meson

メタビルドシステムの meson を使う例。ソースファイルの記述は前述の ビルドシステムなし/複数ファイル と特に変わらない。(srcに置いたりはしている)me

doctest 自体はシングルヘッダーで、ダウンロードすれば設定なしで使えるが、meson の wrap に登録されているのでそれを利用する。

mkdir subprojects # ないと失敗する
meson wrap install doctest # subprojects/doctest.wrap ができる
meson.build
project('hello', 'cpp',
  default_options: ['warning_level=3', 'werror=true', 'cpp_std=c++20'],
)

deps = [
  dependency('doctest'),
]

includes = include_directories('include')

srcs = [
  'src/hello/lib1.cpp',
  'src/hello/lib2.cpp',
]

executable('hello',
  srcs + ['src/main.cpp'],
  include_directories: includes,
  dependencies: [deps],
  cpp_args: ['-DDOCTEST_CONFIG_DISABLE'],
)

test('hello test',
  executable('hello_test',
    src + ['src/hello/test_main.cpp'],
    include_directories: includes,
    dependencies: [deps],
  )
)

非テスト時にも dependencies に doctest を追加していることに注意。TEST_CASE マクロなど、テストの機能を消すことそのものも doctest の機能の一部なので、doctest を入れた上で消す必要がある。それが嫌な場合は、テスト用の(gtest のように)ソースファイルを個別に作ってそちらに書く必要がある。そういったパターンでは以下のようになる。

meson.build
project('hello', 'cpp',
  default_options: ['warning_level=3', 'werror=true', 'cpp_std=c++20'],
)

test_deps = [
  dependency('doctest'),
]

includes = include_directories('include')

srcs = [
  'src/hello/lib1.cpp',
  'src/hello/lib2.cpp',
]

executable('hello',
  srcs + ['src/main.cpp'],
  include_directories: includes,
)

test_srcs = [
  'test/hello/test_lib1.cpp',
  'test/hello/test_lib2.cpp',
  'test/hello/test_main.cpp',
]

test('hello test',
  executable('hello_test',
    src + test_srcs,
    include_directories: includes,
    dependencies: [test_deps],
  )
)

あるいは、ライブラリをターゲットとして作る場合は以下のようになる。(製品コードにテストを書く場合は、テストと非テスト時でビルドオプションを変える必要があるため、こういう分割はやり難い)

meson.build
project('hello', 'cpp',
  default_options: ['warning_level=3', 'werror=true', 'cpp_std=c++20'],
)

includes = include_directories('include')

hello_lib = library('hello',
  [
    'src/hello/lib1.cpp',
    'src/hello/lib2.cpp',
  ],
  include_directories: includes,
)

executable('hello',
  ['src/main.cpp'],
  include_directories: includes,
  link_with: hello_lib,
)

test('hello test',
  executable('hello_test',
    [
      'test/hello/test_lib1.cpp',
      'test/hello/test_lib2.cpp',
      'test/hello/test_main.cpp',
    ],
    include_directories: includes,
    link_with: hello_lib,
    dependencies: [dependency('doctest')],
  )
)
nonanonnononanonno

cmake

meson と同じような感じなはず。気が向いたら書く。