Subterranean Flower

[翻訳]Dartにおけるユニットテスト

Author
古都こと
ふよんとえんよぷよぐやま!!!

この記事は、Dart公式サイトのUnit Testing with Dartの翻訳です。

Dartにおけるユニットテスト

Dartには、使いやすく柔軟性があり、同期・非同期ともにサポートする、ユニットテスト(※訳注:単体テストとも)ライブラリがあります。この記事では、どのようにテストを書き実行するのか、またいかにDartのエコシステム全体にテストを適合させるのかについて説明します。

詳しくはunit test library API documentationをご覧ください。

初期のライブラリとの違い

ユニットテストライブラリは新しいものではありませんが、いくつかの変更を経ています。もし変更前のライブラリやdart:coreのExpectクラスを使用していたなら、以下の変更点を心に留めておいてください:

  •  setUptearDownはネストされたグループ内で連鎖するようになりました。
  •  テスト失敗メッセージのフォーマットが、「Expected」と「Actual」の行、および追加情報を含むオプションの「Which」行を含むよう変更されました。「Which」行は、前のオプションではなかった「But」行に対応します。

簡単なユニットテストの例

すぐにでも飛びつきたい人のために、実例から始めましょう。Dartでクイックソートアルゴリズムを書いて、それをテストしたいとします。Dart Editorを開いて、新しいアプリケーション「quicksort」を作成してください。そしてunittestパッケージをインポートするために「pubspec.yaml」ファイルを編集します:

name: quicksort
version: 0.0.1
author: First Last <email@example.com>
description: A quicksort implementation
dev_dependencies:
  unittest: '>=0.10.0'

pub getをコマンドラインかエディタで実行して、unittestパッケージをインストールします。パッケージをインストールしたら、「quicksort.dart」ファイルを以下のように変更してください:

import 'package:unittest/unittest.dart';

// 注意: このコードには後ほどユニットテストを説明するための意図的なエラーが含まれています。

int _partition(List array, int left, int right, int pivotIndex) {
  var pivotValue = array[pivotIndex];
  array[pivotIndex] = array[right];
  array[right] = pivotValue;
  var storeIndex = left;
  for (var i = left; i < right; i++) {
    if (array[i] < pivotValue) {
      var tmp = array[i];
      array[i] = array[storeIndex];
      array[storeIndex] = tmp;
    }
  }
  var tmp = array[storeIndex];
  array[storeIndex] = array[right];
  array[right] = tmp;
  return storeIndex;
}

void _quickSort(List array, int left, int right) {
  if (left < right) {
    int pivotIndex = left + ((right-left) / 2);
    pivotIndex = _partition(array, left, right, pivotIndex);
    _quickSort(array, left, pivotIndex-1);
    _quickSort(array, pivotIndex+1, right);
  }
}

List quickSort(List array) {
  _quickSort(array, 0, array.length-1);
  return array;
}

void main() {
  test('QuickSort', () =>
    expect(quickSort([5, 4, 3, 2, 1]),
      orderedEquals([1, 2, 3, 4, 5]))
  );
}

このコードには、ユニットテストを使って発見するための、いくつかの問題があります。まず、main()にはひとつだけテストがあります。これは、quickSort呼び出し後に5要素の配列が正しくソートされているかをアサートするテストです。テストがどのように書かれているかに注目してください。test()の呼び出しの中に、以下のように書きます:

test(String testName, functionToTest);

テストしている関数の中で、expect()を用いてアサーションを書きます:

expect(actualValue, expectedValueMatcher);

expect()は、Hamcrestのような第三世代のアサートライブラリをモデルにしています。また、Ladislav Thonさんの初期のDart matcherライブラリであるdarmatchから、いくつかアイデアを取り入れています。

ふたつめの引数として要求されているのは、我々が「Matcher」と呼ぶものです。Matcherはスカラ値でもかまいませんし、ライブラリが多数提供している特殊なMatcherを使用することもできます。今回はorderedEquals()を使用しています。これはIterableオブジェクトに対してインデックス順でマッチします。

このアプリケーションをcheckedモードで実行すると、以下のような出力が得られます:

unittest-suite-wait-for-done
ERROR: QuickSort
  Test failed: Caught type 'double' is not a subtype of type 'int' of 'pivotIndex'.
  quicksort.dart 24:27  _quickSort
  quicksort.dart 32:13  quickSort
  quicksort.dart 38:21  main.<fn>
  dart:async             _createTimer.<fn>

0 PASSED, 0 FAILED, 1 ERRORS

そして以下の行がスタックトレースによって続きます:

Uncaught Error: Exception: Some tests failed.

これは今は無視してかまいません。unittestライブラリの標準の挙動は、もしいずれかのテストが成功しなかった場合、最後に例外をスローすることです。スタックトレースはこれに関係します。

「unittest-suite-wait-for-done」の行は、ライブラリがこのテストをホストしているテストハーネスと通信するために使われており、今は無視してかまいません。

このテストは失敗として報告されたのではないことに注意してください。エラーがあると報告されたのです。unittestライブラリは、うまく動作はするがエクスペクテーションを失敗するものと、予期しないランタイムエラーによってスローするものとを区別します。このクイックソートの例は二番目のタイプです。

問題は以下の行にありました:

int pivotIndex = left + ((right-left) / 2);

右辺がdoubleで、intには代入できません。以下のように修正を加える事で修正できます:

int pivotIndex = left + ((right-left) ~/ 2);

もう一度テストを実行すると、以下の出力が得られます:

FAIL: QuickSort
  Expected: equals [1, 2, 3, 4, 5] ordered
    Actual: [3, 5, 2, 4, 1]
     Which: was <3> instead of <1> at location [0]

(簡潔さのために、最初の行、スタックダンプ、およびサマリーは省略しています。)

これはバグがあることを示しています(この例では失敗したエクスペクテーション)。しかしバグを見つける役には立ちません。より深いところを見る必要があります。

クイックソートの分割パートは、ピボットのインデックス(ピボットの値へ順番に割り当てられる)が与えられ、ピボット値よりも小さい値は全てピボットの左側へ、大きな値は全てピボットの右側へ移動し、ピボット値の最終的な位置を返すことになっています。仮に[3, 2, 1]とピボットインデックス1(つまりピボット値は2)を渡せば、パーティショニングの後は[1, 2, 3]が得られ、返されたピボットインデックスは1のままのはずです。ふたつ目のテストを追加して、これをテストしてみましょう。以下のハイライトされたコードを追加して、main()を変更します:

void main() {
  test('QuickSort', () =>
    expect(quickSort([5, 4, 3, 2, 1]),
      orderedEquals([1, 2, 3, 4, 5]))
  );
  test('Partition', () {
    List array = [3, 2, 1];
    int index = _partition(array, 0, array.length-1, 1);
    expect(index, equals(1));
    expect(array, orderedEquals([1, 2, 3]));
  });
}

もう一度実行すると、最初のテストは同じように失敗しますが、以下の結果も得られます:

FAIL: Partition
  Expected: <1>
    Actual: <0>

そう、_partitionに問題があるのです。ふたつ目のexpect()には到達していません。最初のexpect()で失敗しています。コードを注意して見てみると、_partitionstoreIndexの最終的な値を返していることがわかります。これはleftの値に初期化される変数です。もっと入念に見ると、storeIndexは初期化後に値が変わっていないことがわかります。これでなぜleftに0を渡した後にインデックス値0が返ってくるのか説明がつきます。ここで明らかに何かミスをしています。記憶を頼りにアルゴリズムを書いたことによる危機です!Wikipediaを見てみましょう……

少し調べてみると原因がわかります。_partition内のループは、storeIndexをインクリメントすることになっています:

for (var i = left; i < right; i++) {
  if (array[i] < pivotValue) {
    var tmp = array[i];
    array[i] = array[storeIndex];
    array[storeIndex++] = tmp;
  }
}

この変更を加えた後に、もう一度アプリを実行してみましょう。幸せに出会えます:

PASS: QuickSort
PASS: Partition

All 2 tests passed.

これはコマンドラインからスタンドアロンDart仮想マシンを用いることで容易に実行できます。もしスタンドアロンVMから終了コードをテストしたい場合は、以下のimportを加えてください:

import 'package:unittest/vm_config.dart';

そしてmain()内のテストの前に以下の行を加えてください:

useVMConfiguration();

これで全てのテストがパスした場合は終了コード0を、テストに失敗した場合は終了コード1を結果として得ることができます。詳細は後ほどテスト環境の構成をご覧ください。テストは以下のコマンドで実行することができます:

dart Quicksort.dart

Dart editor環境を使用していて、ブラウザ上でテストを実行したい場合は、以下のインポートを加えてください:

import 'package:unittest/html_config.dart';

そしてテストの前に以下の行を加えてください:

useHtmlConfiguration();
  • 重要: 構成はひとつだけをインポートして使用してください。複数の構成をインポートすると、テストは実行されません。

テスト結果はDartiumのウィンドウに表示されます。そして終了コードはエディタのデバッガタブに表示されます。

この記事の続きで、ユニットテストライブラリをより深く見ていきます。

基本的な同期テスト

テストは、トップレベル関数testを使用することによって作られます。この関数はテストのための名前と、実行するための関数を取ります。一般的には、test()main()関数の中か、main()から呼ばれる関数の中で呼び出されます。ユニットテスト関数をリフレクションを使うことで特定するライブラリとは違って、unittestではtest()を呼び出して明示的に作成する必要があります。

ここにtest()のシンタックスを説明するための簡単な例があります:

import 'package:unittest/unittest.dart';
main() {
 test('An empty test', () {
   // エクスペクテーションとMatcherを含むテスト
 });
}

このテストは何も役に立つことをしませんし、常にパスします。テスト関数は引数を一切取らず、どんな値も返しません。もし値を返した場合は、Future以外は無視されます。詳しくは後述します。

もちろん実際のテストは何かしらの内容を関数の本体に持ちますし、通常その関数本体はテスト下のシステムの状態についてアサーションをするでしょう。そういったアサーションを表現するために、expect()を使います。expect()は一般的にはふたつの引数と共に呼ばれます。実際の値と、その値が制約を満たしているかテストする「Matcher」です。例えば:

test('Addition test', () {
  expect(2 + 2, equals(5));
});

Matcherが失敗すると、TestFailureオブジェクトがスローされます。これはユニットテストフレームワークによってキャッチされ処理されます。後ほど、他の状況で使用するために、このexpect()の挙動をどのようにカスタマイズできるかについて説明します。

単純に述語をexpect()に渡すだけなら、以下のようにisTrue Matcherを使用できます:

test('Addition test', () {
  expect(2 + 2 == 5, isTrue);
});

しかし、より粒度の大きいMatcherを使う場合、expect()は失敗した際に便利な説明的メッセージを生成します。これはTestFailureのコンストラクタに引数として渡されます。上記のような述語を使う場合はexpect()は有用な情報を持ちません。つまり二番目のケースでは、説明はシンプルになります:

Expected: true
  Actual: <false>

一方で最初のケースではより説明的になります:

Expected: <5>
  Actual: <4>

expect()には、出力に付け加える追加のString引数を渡すことが(どちらの形式でも)可能です。そして述語の形式を使用する場合、出力結果を改善するためにそうすることが強く推奨されます。追加の引数はreasonと呼ばれる名前付き引数です。以下は例です:

test('Addition test', () => expect(2 + 2 == 5, isTrue, reason:'ふたつの2は5ではありません'));

結果は以下のようになります:

Expected: true
  Actual: <false>
ふたつの2は5ではありません<br>

単純な述語形式を満足するとき、そこには状況というものがあります。例えば、到達不可能とされるコードがあるとき、以下のように使用できます:

expect(false, isTrue, reason:'到達不可能');

他のケースとしては、複合Matcherを使って書くにはうんざりするほど複雑な述語がある場合や、述語が関数によって実装されている場合、あるいはより単純な説明テキストが有用である場合などが考えられます。例えば:

expect(isPrime(x), isTrue, reason:'${x}は素数ではありません');

expect()と共に使用できるMatcherは多数あり、カスタムMatcherを作ることも可能です。それどころか、Matcherを組み合わせてもっと複雑なMatcherを作ることも可能です。Matcherについては後ほどこの記事でより詳しく説明します。

test()の呼び出しはネストできないことに気をつけてください。一度のtest()呼び出しにつき、テストはただひとつしか定義できません。

expectは他にもふたつの名前付きオプショナル引数を取ることができます。failureHandlerverboseです。後者は、セットすればよってはより冗長なエラーメッセージが得られる場合があるbooleanフラグです(例えば、コンテナMatcherの中には、不整合が起こりverboseがセットされていた場合は、実際のコンテナの中身を全て出力するものもあります)。failureHandler引数については、後ほどカスタム失敗ハンドラのセクションで説明します。

テストのグルーピング

似たようなテストをグループにまとめることは役に立つでしょう。これはgroup()で実現できます。*group()*関数の形式はtest()と似ています。名前と関数を引数として取り、関数はテストをグループに含みます。以下は例です:

group('My test group', () {
  test('Test 1', () => expect(0, equals(1)));
  test('Test 2', () => expect(1, equals(0)));
});

テスト名はグループ名を先頭に持ちます。この場合、最初のテストの名前は「My test group Test 1」で、二番目は「My test group Test 2」になります。テストグループはネスト可能です(複数のグループ名が前に付け加えられます)。グループ名とテスト名を繋ぐデフォルトのセパレータはスペースですが、unittestのトップレベルのString変数であるgroupSepを設定することで変更できます。

グループ自身はテストではありません。なので、内包するテストの外側に、アサーションやexpect()の呼び出しがあってはいけません。

セットアップとテアダウン

グループ本体の内部、あるいはmain()のトップレベルでは、関数の引数とともにsetUP()/tearDown()関数を呼び出すことができます。setUp()への引数として渡された関数は各テストの実行前に呼び出され、tearDown()へ渡された関数は各テストの後に呼び出されます。

これらは、通常はグループの初めに準備します:

group('foo', () {
  setUp(() {/*...*/});
  tearDown(() {/*...*/});
  test(description, () {/*...*/});
  /*...*/
});

しかし別々に織り交ぜて書くこともできます。各テスト関数は、最後に設定された値を使用します。

新しいグループが開始するたびに、これらの関数はリセットされます。これはネストされたグループにも当てはまります。ネストされたグループはこれらの関数を継承しますが、setUp()teartDown()を順番に呼び出すことで増幅することができます。親のsetUp関数は子の関数より前に呼び出され、親のtearDown関数は子の関数よりも後に呼び出されます。以下のコードはネストされたsetUp/tearDownの例で、何度もsetUpを呼び出すことでテストに使われているsetUp関数がどのように変わるかを示します:

group('outer', () {
  test('outer test 1', () => print('outer test 1'));
  setUp(() => print('outer setup 1'));
  tearDown(() => print('outer teardown 1'));
  test('outer test 2', () => print('outer test 2'));
  setUp(() => print('outer setup 2'));
  tearDown(() => print('outer teardown 2'));
  test('outer test 3', () => print('outer test 3'));
  group('inner', () {
    test('inner test 1', () => print('inner test 1'));
    setUp(() => print('inner setup 1'));
    tearDown(() => print('inner teardown'));
    test('inner test 2', () => print('inner test 2'));
    setUp(() => print('inner setup 2'));
    test('inner test 3', () => print('inner test 3'));
    setUp(() => print('inner setup 3'));
  });
});

出力は以下のようになります:

outer test 1
outer setup 1, outer test 2, outer teardown 1
outer setup 2, outer test 3, outer teardown 2
outer setup 2, inner test 1, outer teardown 2
outer setup 2, inner setup 1, inner test 2, inner teardown, outer teardown 2
outer setup 2, inner setup 2, inner test 3, inner teardown, outer teardown 2

上記の出力は、簡潔のため、一行につきひとつのテストを表すように編集しています。

setUp/tearDown関数が非同期処理を実行する必要があるときは、Futureを返すようにすることで実現できます。テストの実行は、Futureが完了するまで待機します。setUpかtearDownが失敗した場合は、テストエラーとして扱われます。setUpかテスト関数が失敗した場合でも、対応するtearDown関数は呼び出されることに気をつけてください。setUpかtearDownが失敗したテストは、FAILではなくERRORとして報告されます。

一部のテストのみを実行する

Dartのunittestライブラリには、単一のユニットテストのみを素早く実行するための仕組みがあります。これはデバッガで調べたい失敗するテストがあって、他のテストのノイズを取り除きたいときに役立ちます。テストを孤立させるためには、そのテストの呼び出し名をtest()からsolo_test()に変更します。solo_test()がひとつだけ存在すると、そのテストだけが実行されます(もし間違って二個以上のsolo_test()を書いた場合は、例外がスローされます)。同様に、group()solo_group()に変更することで、単一のグループの実行のみを孤立させることができます。テストを除外するには、test()group()の呼び出しを、それぞれskip_test()skip_group()に変更してください。

実行されるテストを減らす他の方法は、filterTests関数を呼ぶことです。この関数は引数としてRegExpか、RegExpを作るのに使われるStringを取り、各々のテスト説明と順番に突き合わせます。そしてマッチしたテストのみが実行されます。

テストの実行を制限する第三の方法は、テストケースのenabledフラグをセットしたり消したりすることです。各test()の呼び出しは、トップレベルのtestCasesリストに新しいTestCaseオブジェクトを作成します。

非同期テスト

これまで紹介してきたテストは全て同期的です。つまり、テスト関数本体は完了へと向かい、返ってきたときにはテストは終了しています。非同期コードをテストするためには、ある条件が満たされるまでテストが完了したと考えるべきではないということを、テストフレームワークに伝える方法が必要になります。これは一般にFutureの完了か、一定回数呼ばれた、ひとつ以上のコールバックになります。

我々のアプリケーションの中に以下のコードがあるとしましょう:

new Timer(new Duration(milliseconds:100), checkProgress);

そしてTimerがcheckProgress関数を呼び出しているかテストしたいとします。以下のテストはパスしますが、望んだ通りには動作しません:

// BAD TEST
test('Timer test', () {
  new Timer(new Duration(milliseconds:100), checkProgress);
});

このテストには、checkProgressが呼び出されたか判別するためのアサーションなどがありません。テストにこれは非同期コードのテストであることを理解させ、コールバックが呼ばれれば成功し、タイムアウト前に一度も呼び出されなければ失敗させる必要があります。expectAsyncはまさにこのように振る舞います:

test('Window timeout test', () {
  new Timer(new Duration(milliseconds:100), expectAsync(checkProgress));
});

このテストが実行され始めると、new Timerを呼び出し、expectAsyncによって作られたクロージャをイベントハンドラとして渡します。このクロージャは次々にcheckProgress()を呼び出します。checkProgress()が例外をスローした場合は、クロージャがキャッチし、テストを失敗として扱います。クロージャが実行されるか、テストフレームワークがタイムアウトして失敗するまで、テストが完了したとはみなされません。このとき、テストのタイムアウトはunittestライブラリ自身によって処理されるものではないことに中止してください。タイムアウトは、通常はテストの実行を管理するテストハーネスによって実装されます。

expectAsync()は、テストが完了と見なされるにはコールバックが何度呼び出されなければいけないかを指定する、名前付き引数countを取ることができます。例えば:

test('Double callback', () {
  var callback = expectAsync(foo, count: 2);
  new Timer(new Duration(milliseconds:100), callback);
  new Timer(new Duration(milliseconds:100), callback);
});

何度呼び出されるかわからないコールバックがあることもあります。あるテストが、これが最後の呼び出しであると伝える場合です。この場合はexpectAsyncUntil()を使うことができます。この関数は、より多くのコールバックが期待されている場合はfalseを返して、全てのコールバックが完了しテストが完了したと考えられる場合はtrueを返すふたつめの述語関数を取ります。

どちらの関数も名前付きString引数idを取り、この引数はコールバックを特定するためにエラーメッセージ内で使用されます。これは特に匿名クロージャやミニファイされたコードに役立ちます。名前付き関数やメソッドに対しては、フレームワークはデフォルトのidとして関数名やメソッド名を使用します。

また、Futureを返すことでもテストを非同期的にすることができます。その場合、Futureが完了したときのみテストが完了したとみなされます。必要ならばFutureを返すこととexpectAsyncの呼び出しを合わせることもできます。

非同期ユニットテスト関数についてより詳しく学ぶには、unittest documentationをご覧ください。

Matcher

これまでequals(v)のみを見てきました。Dartのunittestライブラリには多くの定義済みMatcherがあるので、手短に説明しましょう。Dart SDKドキュメントに、それぞれのMatcherについての詳細があります。多くのMatcherは引数としてMatcherを入れ子にして取ることができます。この場合、単純な値も使うことができ、equals(v) Matcherで自動的にラップされます。以下は例です:

expect(foo, hasLength(6));

これが以下のようになります:

expect(foo, hasLength(equals(6));

以下の単純なmatcherは引数を取らず、たいていは自明な意味を持ちます:

// General matchers
anything
isEmpty
isTrue
isFalse
isNull
isNotNull
isList
isMap

// Numeric matchers
isZero
isNonZero
isPositive
isNonPositive
isNegative
isNonNegative
isNaN
isNotNaN

isTrueisFalseには気をつけてください。これらは対応するBoolean値の等価性をテストします。つまり以下は両方とも失敗します:

expect(10, isTrue)
expect(10, isFalse)

tureでないかどうかをテストしたい場合は、以下を使用してください:

isNot(isTrue)

isEmptyはStringとMap、コレクションで動作します。

等価性や同一性をそれぞれテストするには、以下のmatcherがあります:

equals(expected)
same(expected)

数値の不等価性に対しては:

greaterThan(v)
greaterThanOrEqualTo(v)
lessThan(v)
lessThanOrEqualTo(v)
closeTo(value, delta)
inInclusiveRange(low, high) // low <= actual <= high
inExclusiveRange(low, high) // low < actual < high
inOpenClosedRange(low, high) // low < actual <= high
inClosedOpenRange(low, high) // low <= actual < high

文字列のマッチングに対しては:

equalsIgnoringCase(v)
equalsIgnoringWhitespace(v)
startsWith(prefix)
endsWith(suffix)
stringContainsInOrder(List<String> substrings)
matches(regexp)

equalsIgnoringWhitespace(v)は、まずホワイトスペースをひとつのスペースに正規化して、前後のスペースを削除します。

lengthプロパティを持つオブジェクトに対しては、以下のMatcherがあります:

hasLength(m)

ここでmは値かMatcherです。例:hasLength(6)hasLength(greaterThan(5))

関数が例外をスローするかどうかをテストするには:

throws
throwsA(m)
returnsNormally

throwsAは例外とマッチするMatcherを引数として取ります。例は次のパラグラフで示します。returnsNormallyはスローされたどんな例外も取り込み、かわりにスタックトレースを含む内部の例外の詳細とともにTestFailureをスローします。

型をチェックするには:

new isInstanceOf<T>()

例えば:

test('Exception type', () {
    expect(()=> throw 'X',
    throwsA(new isInstanceOf<String>()));
});

isInstanceOfによって生成されたエラーメッセージは、型名Tを含みません。なのでexpect()の呼び出しに理由を含めるというのはいいアイデアです。特定の型に対して何度もマッチする場合は、以下のようにカスタムMatcherを作成したくなるでしょう:

class _IsFoo extends TypeMatcher {
  const _IsFoo() : super('Foo');
  bool matches(item, Map matchState) => item is Foo;
}
const isFoo = const _IsFoo();

expect(x, isFoo);

カスタムMatcherについての説明は、後ほど詳しくします。

通常の場合は例外をスローするので、多くのコア例外とそれをスローする関数のために、定義済みMatcherがあります:

isException
throwsException
isFormatException
throwsFormatException
isArgumentError
throwsArgumentError
isRangeError
throwsRangeError
isNoSuchMethodError
throwsNoSuchMethodError
isNullThrownError
throwsNullThrownError
isUnimplementedError
throwsUnimplementedError
isStateError
throwsStateError
isUnsupportedError
throwsUnsupportedError

なので、例えば以下のように書くことができます:

test('Range Error', () {
    expect(()=> throw new RangeError("out of range"),
        throwsRangeError);
});

複合オブジェクトの内部コンテンツとマッチさせるために、どこにでもあるequals()から始まる多くのMatcherがあります。

equals(object, [depth])

これはスカラ、Map、Iterable(順番にマッチすべきである)に対応しています。depthパラメータは循環構造に対処するためにあります。[depth]回の比較のあと、まだ終了していなければ失敗します。デフォルトのdepthは100です。

ここにJSONパーステストから取られた例があります:

expect(JSON.parse('{"x": {"a":3, "b": -4.5}, "y":[{}], '
               '"z":"hi","w":{"c":null,"d":true}, "v":null}'),
  equals({"x": {"a":3, "b": -4.5}, "y":[{}],
               "z":"hi","w":{"c":null,"d":true}, "v":null}));

オブジェクトの部分だけをテストする場合、以下のものが使用できます:

contains(m)

これはString(部分文字列にマッチします)、Map(Mapがそのキーを持っていたらマッチします)、コレクション(コレクション内にマッチする要素があったらマッチします)に対応しています。後者はmにMatcherを使用できます。例:

expect([1, 2, 3, 4], contains(isNonZero));

contains()と反対のMatcherはisIn()です。

everyElement(m)
someElement(m)

これらはコレクションに対応しています。mは値かMatcherです。例:

expect(foo, someElement(greaterThan(10)));

任意のIterableに対しては:

orderedEquals(Iterable expected)
unorderedEquals(Iterable expected)

unorderedEqualsはO(n^2)なので、大きなオブジェクトに対して使用するときは気をつけてください。

Mapに対しては:

containsValue(v)
containsPair(key, valueOrMatcher)

matcherを組み合わせたり逆にしたりする操作もあります:

isNot(matcher)
allOf(List<Matcher> matchers)
anyOf(List<Matcher> matchers)

allOf()anyOf()はAND/OR操作を表しています。これらはMatcherのリストか、別々の個別Matcherか、スカラ引数を取ります(後者の場合、7個までです)。

最後に、任意の関数を使用可能にするpredicate Matcherがあります。

predicate(fn, reason)

例えば、型matcherを作る別の方法として:

var isString = predicate((e) => e is String, 'is a String');

expect(() => throw 'X', throwsA(isString));

カスタムMatcherの作成

デフォルトで提供されているMatcherが不十分であるときは、自分で作ることが可能です。MatcherはMatcherクラスを実装するか継承します:

abstract class Matcher {
  /// これは実際の値と期待値とをマッチングします。
  bool matches(item, Map matchState);
  /// これはMatcherの説明を生成します。
  Description describe(Description description);
  /// これは特定の不整合についての説明を生成します。
  Description describeMismatch(item, Description mismatchDescription,
      MatchState matchState, bool verbose) => mismatchDescription;
}

ここに、スペースを無視して文字列のプレフィックスにマッチする、カスタムmatcherの例があります:

class PrefixMatcher extends Matcher {
  final String _prefix;
  PrefixMatcher(prefix) : this._prefix = collapseWhitespace(prefix);
  bool matches(item, Map matchState) {
    return item is String &&
        collapseWhitespace(item).startsWith(_prefix);
  }
  Description describe(Description description) =>
    description.add('a string starting with ').
        addDescriptionOf(collapseWhitespace(_prefix)).
        add(' ignoring whitespace');
}

 

これには3つの重要な点があります:

  • コンストラクタ。期待値の情報か、期待値をテストするMatcherを取り入れる必要があります。
  • matches(item, Map matchState)メソッド。実際の値とマッチして、マッチが良ければtrueを返して、そうでなければfalseを返します。
  • describe()メソッド。Matcherの説明本文を生成します。

expect()のこのような典型的エラーメッセージを思い出してください:

Expected: <matcher description>
  Actual: <value>
   Which: <mismatch description>

Matcherのdescribe()メソッドはエラーメッセージの「Expected:」の部分を作り、「Which:」部分はdescribeMismatch()メソッドで生成されます。Matcherのデフォルトの実装では「Which:」の部分は生成しません。

describe()describeMismatch()は両方ともDescribeクラスを使用します。これは以下の様な有用なメソッドを持っています:

  • add(text)。説明にテキストを追加します。
  • addDescriptionOf(value)。valueがmatcherならば再帰的にdescribe()を呼び出して説明します。
  • addAll(start, separator, end, list)。start、end、separatorの文字を使いフォーマットしたlist(Iterator)の内容を追加します。

matchState Mapはmatchesから計算し直すにはコストがかかるマッチ失敗情報をdescribeMismatchへ渡すために使用されます。ほとんどの場合これは必要ありません。これがどのように使われるかの例を見るにはライブラリのソースを見てください。例えばeveryElementをご覧ください。

多くの場合、CustomMatcherの派生クラスを作ることで、すぐに新しいMatcherを作ることができます。このクラスを使うことで、機能名と説明、Matcher、そしてなんらかのオブジェクトからその機能の値を取得する関数を提供できるようになります。また、そのクラスにはMatcherに対応する機能にマッチするインスタンスがあります。これは例を見るのが一番わかり易いでしょう。例えば、Widgetクラスがあるとします。各Widgetクラスはpriceを持ちます。そしてウィジェットの価格についてアサートしたいとします。以下のように、ウィジェットの価格に対してのMatcherを作成することができます:

class _Price extends CustomMatcher {
  _Price(matcher) : super('Widget with price that is', 'price', matcher);
  featureValueOf(actual) => actual.price;
}
Matcher price(m) => new _Price(wrapMatcher(m));

wrapMatcherは、もし渡された値がMatcherならばその値を返し、そうでなければequals() Matcherを作成する関数です。

これは以下のように使用することができます:

expect(widget, price(greaterThan(0)));

var isFree = price(0);
expect(special, isFree);

カスタムMatcherを作る他の簡単な方法は、型Matcherに対してTypeMatcherクラスを前に説明したように使用するか、predicate関数を使うことです。例えば、lengthwidthareaプロパティを持つRectangleクラスがあるとして、areaが正確に計算されていることをアサートしたいとしたとき、このように使えます:

var hasCorrectArea = predicate((rect) => rect.area == rect.length * rect.width,
    "has correct area");

expect(myRect, hasCorrectArea);

テスト環境の構成

テストをコマンドラインで実行しているか、エディタ上か、ブラウザからかによって、出力がどうやって生成されるかを変更したくなることがあります(例えばprintかHTMLマークアップか)。それをするには、unittestConfigurationに適切なConfigurationインスタンスを設定することで、テスト環境を構成する必要があります。構成は、テスト中に別々に呼ばれるいくつかの関数を持っています。

  • onInit()はテストが追加される前にテストフレームワークが初期化された時に呼ばれます。
  • onStart()は最初のテストが実行される前に呼ばれます。
  • onTestResult(TestCase)は各テストの完了時に呼ばれます。
  • onDone(passed, failed, errors, List<TestCase> results, String uncaughtError)は全てのテストが完了した時に呼ばれます。デフォルトの構成ではこの関数はテストのサマリーを表示します。

自分でConfigurationクラスを作成する必要はありません。ライブラリにはほとんどの目的には十分なビルトインの構成がいくつかあります。

  • デフォルトのConfiguration。テスト結果を標準出力に表示します。
  • VMConfiguration。失敗したときに1を返して終了します。特に、失敗か成功か判別するのにプロセスの終了コードが役に立つような、他のプログラムやスクリプトからテストを実行するときに便利です。useVMConfiguration()を呼ぶとこの構成を使えます。使用するにはvm_config.dartをインポートしてください。
  • CompactVMConfiguration。コンパクトな一行のプログレスバーを生成します。コマンドラインからテストを実行するのに有用で、デフォルトの構成よりも綺麗な出力を表示します。これを使用するには、useCompactVMConfiguration()を呼び出し、compact_vm_configuration.dartをインポートしてください。
  • HtmlConfiguration。テスト結果をHTMLテーブルで出力し、ブラウザのドキュメントボディにこのテーブルをセットします。これを使用するには、useHtmlConfiguration()を呼び出し、html_config.dartをインポートしてください。
  • HtmlEnhancedConfigurationHtmlConfigurationに似ていますが、よりリッチなレイアウトを提供します。これを使用するには、useHtmlEnhancedConfiguration()を呼び出し、html_enhanced_config.dartをインポートしてください。

継続的インテグレーション環境でテストを実行するには、デフォルトかVM構成が適しています。

ベース構成クラスには、最初の*test()*が呼ばれる前に変更して構成の挙動を変更できる、いくつかのフラグがあります。

  • autoStartは、テストが自動的に開始するかどうかをコントロールします(デフォルトはtrueです)。falseに設定すると、runtests()を呼ぶまでテストは実行されません。
  • throwOnTestFailuresは、成功しなかったテストがあったときに、全てのテスト終了時に例外をスローするかどうかをコントロールします(デフォルトはtrueです)。
  • stopTestOnExceptFailureは、エクスペクテーションが失敗したときにテストを終了して次のテストを実行するかどうかをコントロールします(デフォルトはtrueです)。falseにすると、エクスペクテーションが失敗した後も続行します(他の例外はテストを停止させます)。エクスペクテーション失敗メッセージはバッファされ、テストの終わりに全て出力されます。

他の状況でexpect()を使う

ここではexpect()はユニットテストの環境で使ってきましたが、他の状況でも一般的なアサーションメカニズムとして使用することが可能です。デフォルトの挙動では失敗時に失敗理由をメッセージプロパティとして伴うTestFailureオブジェクトをスローしますが、これはカスタマイズすることが可能です。事実、expectはこれをするための自身のユニットテストを持っています。

expectの挙動をカスタマイズする関数はふたつあります:

  • configureExpectFailureHandlerexpect()の失敗を処理するオブジェクトを変更します。デフォルトのオブジェクトは単にTestFailureをスローするだけです。これは異なることをするために自由に変更できます。例えばかわりにログを取って例外を飲み込むこともできます。
  • configureExpectFormatterexpect()がエラーメッセージのフォーマットに使用する関数を変更します。これを変更する必要が出てくることはあまりないので、ここではこれ以上考えないことにします。詳しくはSDKドキュメントをご覧ください。

エラーハンドラをカスタマイズする一番簡単な方法は、DefaultFailureHandlerを継承したクラスを作り、メソッドをオーバーライドすることです:

void fail(String reason) {
  throw new TestFailure(reason);
}

例えば、この失敗ハンドラは失敗のカウント数を保持します:

class MyFailureHandler extends DefaultFailureHandler {
  int errorCount;
  MyFailureHandler() {
    errorCount = 0;
    // set this to be the expect() failure handler
    configureExpectFailureHandler(this);
  }
  void fail(String reason) {
    ++errorCount;
  }
}

デフォルトの失敗ハンドラと異なり、失敗ハンドラへの参照は名前付き引数failureHandlerを使って、expect()へ明示的に渡します。