17
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Nim公式テストランナーのtestamentを使って単体テストを行う

Last updated at Posted at 2021-03-13

まえがき

  • testament というツールの使い方を説明します
  • testament 用のテストコードを紹介します

testament とは

Nim公式のテストランナーです。Nim自体の開発にも使われている実績があります。
testamentはNimをインストールした時に一緒にインストールされます。

testament に関するドキュメントは testament に記載されています。

Testament is an advanced automatic unittests runner for Nim tests, is used
for the development of Nim itself, offers process isolation for your tests,
it can generate statistics about test cases, supports multiple targets (C,
C++, ObjectiveC, JavaScript, etc), simulated Dry-Runs, has logging, can
generate HTML reports, skip tests from a file, and more, so can be useful to
run your tests, even the most complex ones.

Testamentは、Nimテスト用の高度な自動単体テストランナーであり、Nim自体の開発に
使用され、テストのプロセス分離を提供し、テストケースに関する統計を生成でき、
複数のターゲット(C、C ++、ObjectiveC、JavaScriptなど)をサポートします。シミ
ュレートされたドライラン、ロギング機能、HTMLレポートの生成、ファイルからのテ
ストのスキップなどが可能なため、最も複雑なテストであっても、テストの実行に役
立ちます。

このテストツールの使い方を見ていきます。

その前に、unittestモジュールではダメなのか?

Nimにはユニットテスト用のモジュールとしてunittestが提供されています。
こちらのモジュールを使って単体テストを実施するのではダメなのでしょうか?

unittestの使い方については Nim 0.20 の頃に以下の記事にまとめました。
Nim 0.20は事実上Nim 1.0のRC版らしいのでNimのテストコードを書くためのTIPS

こちらを使ってもダメなわけではないです。
しかしながら、上記unittestモジュールの説明に以下の一文が記載されていました。

Note: Instead of unittest.nim, please consider to use the testament tool
which offers process isolation for your tests. Also when isMainModule:
doAssert conditionHere is usually a much simpler solution for testing
purposes.

注:unittest.nimの代わりに、テストのプロセス分離を提供するtestamentツールの使
用を検討してください。また、isMainModuleの場合:doAssert conditionHereは通常、
テスト目的ではるかに単純なソリューションです。

testamentが利用可能になったのはどうやらNim 1.0.0リリース時からのようです。
Changelog 1.0.0

前述の説明からは、将来的なunittestモジュールの削除の可能性については分かりません。
実際unittestモジュールを使用しているサードパーティのライブラリは多数存在します。
いきなりunittestモジュールをdeprecatedにして削除することは考えにくいです。

ですが、公式としてはtestamentを使うことを推奨しているように受け取れます。

これを踏まえて、「testamentを使うテストの書き方」「テス
トの実施方法」「unittestモジュールは使ってはいけないのか」を調査しようと思い至りました。

testamentを使ってみる

兎にも角にもtestamentを使ってみます。
testamentに書かれている内容と一部重複します。

シンプルな単体テスト

まず、一番単純な単体テストのサンプルを示します。
testamentではデフォルトでtestsディレクトリ配下のファイル拡張子nimのファイルをテスト対象のコードと判定します。

以下のNimプログラムを作成します。

tests/sample.nim
doAssert 1 == 1

次に、testament run <ファイル名|モジュール名>を実行すると、テストが実行されます。
(フルパスでも相対パスでもなく、ファイル名)

$ ls tests/sample.nim
tests/sample.nim

$ testament run sample.nim
PASS: tests/sample.nim C                                           ( 0.46 sec )
Used /home/jiro4989/.nimble/bin/nim to run the tests. Use --nim to override.

また、testament run のエイリアスとして testament r も存在します。
よってtestament r <ファイル名> でも同様にテストが実行できます。

複数ファイルのテスト

testsディレクトリ配下に複数ファイルが存在する場合はtestament pattern <glob>を使用します。

$ ls tests/*.nim
tests/sample.nim  tests/sample2.nim

$ testament pattern 'tests/*.nim'
PASS: tests/sample.nim C                                           ( 0.24 sec )
PASS: tests/sample2.nim C                                          ( 0.43 sec )
Used /home/jiro4989/.nimble/bin/nim to run the tests. Use --nim to override.

また、testament pattern のエイリアスとして testament ptestament patも存在します。

HTMLレポートの生成

前述のtestamentのドキュメントに記載の通りHTMLレポートが取得できます。
testament htmlと実行すると、カレントディレクトリにtestresults.htmlというファイルが生成されます。

$ testament html
Used /home/jiro4989/.nimble/bin/nim to run the tests. Use --nim to override.

$ ls testresults.html
testresults.html

このファイルをブラウザで開いてみると、以下の画面が表示されます。

testament2.PNG

見やすく整えられたレポートが確認できました。

testament用のテストコードを書く

testamentの基本的な使い方がわかったところで、testament用のテストコードを書いて
いきます。testament用のテストコードの書き方についてはtestamentにもサンプルが載っているので重複する内容があります。

specを書く

テストファイルごとの、テストする際の設定やテスト期待値、入力などを制御する設定を書きます。
これらの設定をtestamentでは spec と呼びます。
specは省略可能です。

specはファイルの先頭に以下のような文字列を定義することで表現します。

discard """
  exitcode: 0
"""

# 以降はテストコード
doAssert 1 == 1

このdiscardしている文字列に定義可能な設定の詳細はtestament
「Writing Unittests」を参照してください。

定義可能な設定はたくさん存在しますが、頻繁に使用すると思ったものをピックアップして説明します。

action

テスト実行時の動作を設定します。

設定可能な値は以下の3つです。

  • compile コンパイルが成功することを期待します
  • run コンパイルとプログラムの実行が成功することを期待します
  • reject コンパイルが失敗することを期待します
discard """
  action: "run"
"""

exitcode

テストが終了した時の終了コードを設定します。

exitcodeで、テストの成功を期待したい場合は 0 を設定します。
テストの失敗を期待したい場合は 1 を設定します。
デフォルトでは 0 が設定されています。

discard """
  exitcode: 0
"""

output

テストが終了した時の期待する標準出力を設定します。

outputを設定した場合は、そのテストコードが標準出力に書き出す文字列がoutputと一
致しない場合にテスト失敗と判定されます。

output未設定の場合は、そのテストコードが何を標準出力しても無視されます。

discard """
  output: '''
hello world
'''
"""

echo "hello world"

input

テストを実行する際に、標準入力に文字列を渡します。

discard """
  input: '''
hello world
'''
"""

doAssert stdin.readline == "hello world"

errormsg

コンパイルエラーになったときのエラーメッセージを設定します。

以下はfuncを使用してプロシージャを定義しようとするものの、echoが副作用を持
っているためコンパイルエラーになる例です。

discard """
  errormsg: "'sideeffect' can have side effects"
"""

func sideeffect =
  echo 1

cmd

テストの実行に使用するコマンドです。
省略されている場合、あるいは空文字が設定されている場合は、次のように解釈されます。

"nim $target --hints:on -d:testing --nimblePath:tests/deps $options $file"

この機能の使い所としては、「特定のテストケースだけ GC:ARCGC:ORC を有効
にしたい」や「特定のテストだけフラグをONにしたい」といった場合に役に立つと思い
ます。

以下は GC:ORC を有効にし、debugFlagという独自のフラグ定数をこのテストのみtrueにする例です。

discard """
  cmd: "nim c --gc:orc -d:debugFlag -r $file"
"""

const debugFlag {.booldefine.} = false

doAssert debugFlag == true

target

テストの実行ターゲットを指定します。

Nimでは C, C++, JavaScript などがコンパイル時に指定できますが、ここでもその指定が可能です。
複数指定する場合は、半角スペースで区切って入力します。

target.nim
discard """
  target: "c cpp js"
"""

doAssert true

上記設定をした上でtestamentを実行すると、以下のように3つのテストが走っていることが確認できます。

$ testament pattern 'tests/*.nim'
PASS: tests/target.nim C                                           ( 0.43 sec )
PASS: tests/target.nim C++                                         ( 0.51 sec )
PASS: tests/target.nim JS                                          ( 0.38 sec )

assertionを書く

unittestではcheckマクロを使ってプロシージャのテストを行っていました。

testamentではdoAssertまたはunittest.check, unittest.requireを使うのが良いようです。
参考: Best practices

Use doAssert (or unittest.check, unittest.require), not assert in all tests so they'll be enabled even with --assertions:off.

ただしunittest.suiteunittest.testは使ってはいけません。
これら2つを使ってはいけない理由は後述します。

doAssert 1 + 1 == 2

# または

import unittest
check 1 + 1 == 2

僕はunittest.checkを使うのをオススメします。理由は2つあります。

  1. テストがエラーになっても後続のテストが実行される
  2. テスト失敗理由がわかりやすい

unittest.check

以下にunittest.checkを使うサンプルを用意しました。

tests/t_unittest.nim
discard """
  output: '''
1
'''
"""

import unittest

echo 1

check 1 == 1
check 1 == 1
check 1 == 1

このテストコードに対してtestamentを実行してみると、正常にパスします。

$ testament r t_unittest
PASS: tests/t_unittest C                                           ( 0.63 sec)

次に、このコードをわざと失敗するように修正してみます。

tests/t_unittest.nim
discard """
  output: '''
1
'''
"""

import unittest

echo 1

check 3 == 1
check 2 == 1
check 1 == 1

このテストコードに対してtestamentを実行してみると、エラーになります。
そして、テストが失敗した箇所がすべてメッセージとして出力されます。

⟩ testament r t_unittest
FAIL: tests/t_unittest C
Test "tests/t_unittest" in category "t_unittest"
Failure: reExitcodesDiffer
Expected:
exitcode: 0

Gotten:
exitcode: 1

Output:
1
/tmp/work/tests/t_unittest.nim(11, 8): Check failed: 3 == 1
/tmp/work/tests/t_unittest.nim(12, 8): Check failed: 2 == 1

FAILURE! total: 1 passed: 0 skipped: 0 failed: 1

エラーが発生した箇所がわかりやすくなっています。

doAssert

doAssertを使った場合は以下のようになります。

tests/erros.nim
doAssert false, "error 1"
doAssert false, "error 2"
doAssert false, "error 3"

上記コードをtestamentで実行してみます。

$ testament r errors
FAIL: tests/errors C
Test "tests/errors" in category "errors"
Failure: reExitcodesDiffer
Expected:
exitcode: 0

Gotten:
exitcode: 1

Output:
/tmp/work/tests/errors.nim(1) errors
/home/jiro4989/.choosenim/toolchains/nim-1.4.4/lib/system/assertions.nim(30) failedAssertImpl
/home/jiro4989/.choosenim/toolchains/nim-1.4.4/lib/system/assertions.nim(23) raiseAssert
/home/jiro4989/.choosenim/toolchains/nim-1.4.4/lib/system/fatal.nim(49) sysFatal
Error: unhandled exception: /tmp/work/tests/errors.nim(1, 10) `false` error 1 [AssertionDefect]

FAILURE! total: 1 passed: 0 skipped: 0 failed: 1

Outputには1行目のエラーしか出力されていません。
よって1行目を修正して再度testamentを実行すると、2行目がエラーになります。

このことから、1つのファイル内で複数のassertionを書く場合は
unittest.checkを使ったほうが複数のエラーを一度に確認できて良いと思っています。

例外処理のテストを書く

unittestではexpectマクロを使ってプロシージャのException型を判定するテストを書くのが一般的です。

testamentでも同様にexpectを使ってException型を判定するのが良さそうです。
BestPracticesには特にこういった記載はありませんでしたが、公式リポジトリではstdlib/tnet.nimがこのように例外テストを行っています。

以下のケースではtestamentはPASSします。

discard """
  output: "1"
"""

import unittest

expect IndexDefect:
  var n = @[0, 1, 2]
  discard n[255]

echo 1

以下のように、期待値と異なる例外が返って来た場合はtestamentはエラーになります。

discard """
  output: "1"
"""

import unittest

expect IndexDefect:
  var n = @[0, 1, 2]
  discard n[255]

expect ValueError: # <-- ここがエラー
  var n = @[0, 1, 2]
  discard n[255]

echo 1

参考になるテストコード

公式でtestamentを使用していると記載の通り、公式リポジトリ内にtestament用のコードが存在します。
自分でtestamentのコードを書く時は、このディレクトリ配下のコードを大いに参考にしました。

サードパーティのパッケージでtestamentを使用しているものはあまり見たことないのですが、
WebフレームワークのPrologueがtestamentを使用してテストを行っていました。

testamentでunittestモジュールは使えないの?

結局のところtestamentはNimのファイルを1つ1つコンパイルして実行して、
specに書かれた設定をもとに標準出力や終了コードを比較してるだけの原始的なテストランナーのようでした。

それだったらunittestモジュールもtestament内で使用できるのでは?と思いました。
前述のunittestモジュールの説明では「unittest.nimの代わりに」という記載がありました。
公式ドキュメントのBest practiciesではunitest.checkを使うように書いてありました。
実際のところtestamentでunittestモジュールを使ってはいけないのでしょうか?

これは「使って良い」です。
ただし**「一部」使ってはいけない機能が存在します**。

Writing tests stdlibによると「unittest.suiteunittest.testは使ってはいけない」ようです。

Don't use unittest.suite and unittest.test.

その理由は、おそらくですが unittest.suiteunittest.test が標準出力にメッセージを出力する
ことが原因ではないかと考えています。

というのも、unittest.suiteを使用してもoutputを指定しなければ問題はありませんでした。
以下のtestament用のテストコードは正常に動作します。

import unittest

suite "suite":
  test "test":
    check true

しかしながら、specにoutputを設定した時に問題が発生します。

前述のコードを以下に修正しました。
specに output 1 を追加しただけです。

discard """
  output: '''
1
'''
"""

import unittest

suite "suite":
  test "test":
    echo 1
    check true

この状態でtestamentを実行してみます。
結果は以下のとおりです。

⟩ testament r t_unittest
FAIL: tests/t_unittest C
Test "tests/t_unittest" in category "t_unittest"
Failure: reOutputsDiffer
Expected:
1

Gotten:
[Suite] suite
1

FAILURE! total: 1 passed: 0 skipped: 0 failed: 1

unittest.suiteを使用すると[Suite]といったメッセージを標準出力に出力します。
これがtestamentのoutput比較に干渉してしまい、結果的にテストを意図せず破壊してし
まいます。

おそらくこれと相性が悪くてunittest.suiteunittest.testを使ってはいけない、となっているのでしょう。
逆にいうと、この記事内で記載したunittest.checkunittest.expectは使っても良いはずです。
試した限りではテストの実施に問題はなさそうでした。

unittestのみを使うべきか、testamentを使うべきか

前述の通りtestamentを使ってテストを実施する場合はunittestの一部の機能が利用不可能になります。

本音を言うとtestament用のテストコードの書き方はダサいと思ってます。

testamentを使わずにunittestモジュールだけ使ってテストを実装する場合は
suite, test, setup, teardownなどが利用可能です。
これらの機能はテストの共通処理をシンプルに記述できて、スマートにテストコードを実装できます。

小さなプロジェクトではunittestモジュールを使用してテストコードを書いても悪くな
いと思っています。

testamentを使うべき理由をあげるなら、「より細かいテストを実施したい」場合です。

前述の通りtestamentではファイル単位でspecを設定できるため
unittest + nimble testでのテストよりも、より細かいテストを実施できます。
nimble testでのテストではファイル単位でのコンパイル時のバックエンド指定や、フ
ラグの切り替えなどはできないためです。

特定のテストをスキップするといった制御も可能です。

使ったことはないのですが、testamentではspecにvalgrind: trueといったオプションがあり、
特定のテストでのみvalgrindを使ってメモリリークを検出したりが可能なようです。

また、Nimは複数のGCをサポートしており、ユーザが任意でGCを選んでコンパイルできます。
これらの機能を細かくテストするのにtestamentは有効なのだと思われます。
まさにNimのためのテストランナーだと感じました。

しかしながら、これらの機能は小さなプロジェクトでは多機能すぎるとも感じています。
プロジェクト規模や複雑さ、提供する機能に応じてtestamentの採用を検討するのが良いのでは、と思います。
(なのでunittestのドキュメントでも「please consider to use the testament」と記載されているのかもしれません)

他にもtestamentにはメリットがあります。
testamentはHTMLレポート機能を持っており、テストケースごとの処理時間も出力してい
るため、ある程度テスト結果の分析が可能です。

採用されるかは不明ですが、CodeCoverageを取得できるようにするための改修のPRが作成されています。
https://github.com/nim-lang/Nim/pull/15827

今後CodeCoverageを取得できるようになった場合、機能として提供されるならおそらく
testamentに追加されるだろうと踏んでいます。

よって、僕は新規にテストを書く時はtestamentを使うつもりです。

unittestからtestamentに移行してみた

参考程度に、nimbleに登録している自前のパッケージの一つのenvconfigのテストコードをすべてtestamentに書き直したときのPRを記載します。
(この時点ではunittest.checkを使っても良いことに気づいていなかったのでdoAssertを使っていますが、unittest.checkを使ったほうが良いと思います)

https://github.com/jiro4989/envconfig/pull/3

まとめ

駆け足な説明でしたが、まとめとしては以下の通りとなります。

  • testamentの使い方を説明しました
  • testamentでのテストの書き方を説明しました
  • testamentでunittestモジュールは使っても良いが、一部のunittestの機能が使えなくなります
  • testamentを採用するべきかどうかについての私感を述べました

Nimでのテスト実施の一助となれば幸いです。

17
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?