Uzabase Tech Blog

SPEEDA, NewsPicks, FORCASなどを開発するユーザベースの技術チームブログです。

Mockitoを使ってDartでのTDDを加速させよう

初めて会社のブログに書きます。SPEEDA事業でCTOをしている林です。 TDDをこよなく愛する身として今日はDartでTDD、そしてテストの独立性を担保していく上で欠かせないMockライブラリーのMockitoについて書こうと思います。

Mockitoとは

Dart開発チームが作成している公式Mockライブラリーです。

名前の通りJavaにおいてメジャーなMockライブラリーの1つであるMockitoにインスパイアされたもので、DartでMockオブジェクトを使う場合においてもっともメジャーな選択肢となっています。

では使い方を見ていきましょう。Mockライブラリーを使ってテストを書いたことがある人であれば特に違和感なく使えると思います。

※以下単にMockitoと記述する場合はDartのMockitoを指します。

今回Mock化するクラス

以下のクラスに対してMockの振る舞いを定義していきたいと思います。

class Count {
  final int countValue;
  final int unit;
  Count(this.countValue, this.unit);

  int get nativeValue => countValue;

  Count increment() => Count(countValue + unit, unit);

  Count changeUnit(int newUnit) => Count(countValue, newUnit);

  Future<Count> sampling() => Future.value(Count(0, unit));

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is Count &&
          runtimeType == other.runtimeType &&
          countValue == other.countValue &&
          unit == other.unit;

  @override
  int get hashCode => countValue.hashCode ^ unit.hashCode;
}

事前準備

Mock用クラスを定義する

違和感無く使えると言ってすぐで申し訳ないのですが、この事前準備は他のMockライブラリーではあまり見ない形です。下記のようにモック対象のクラスをimplementsしてMockitoのMockクラスをextendsします。

import 'package:mockito/mockito.dart';

class CountMock extends Mock implements Count{}

正直な所、この一手間が毎回面倒だなと感じてしまう部分です。そんな面倒な事がなぜ必要かと言うと、Dartはリフレクションが非推奨になっているため裏でいろいろゴニョゴニョ黒魔術を使うのが難しいというのが大きな理由になります。*1 じゃあどうやってるのかというとextendsするMockクラスの方でNoSuchMethodをオーバーライドしていてその中でいろいろハンドリングをしています。通常、暗黙的インターフェースを実装したのみであればCountMockはコンパイルエラーになりますが、MockクラスでNoSuchMethodをオーバーライドしているためコンパイルエラーにはなりません。これを利用してMockitoはMockライブラリーとして必要な機能を提供しています。*2

ちょっと前置きが長くなってしまいましたが実際の振る舞い定義等を見ていきましょう。

振る舞いを定義する

下記のようにwhenでmock化したいメソッドを指定し、thenReturnで戻り値を定義します。

class CountMock extends Mock implements Count{}

void main() {
  test('incrementの振る舞いに関するサンプル', (){
    var mock = CountMock();
    when(mock.increment()).thenReturn(Count(0,1));
    expect(mock.increment(), Count(0, 1));
  });
}

振る舞いを定義する(Future、Streamの場合)

import 'package:mockito/mockito.dart';
import 'package:test/test.dart';

void main() {
  test('戻り値がFutureの場合に関するサンプル', () async {
    var mock = CountMock();
    when(mock.sampling()).thenAnswer((_) => Future.value(Count(1,1)));
    expect(await mock.sampling(), Count(1, 1));
  });
}

非同期のメソッドに対して振る舞いと戻り値を設定する場合は thenReturn ではなく thenAnswer を使用する必要があります。戻り値もFutureやStreamにしないといけない点などが面倒ですが thenReturn にしている場合はエラーになるので注意が必要です。

検証する

一般的なMockライブラリーと同様にMockitoでもメソッド呼び出しの検証が可能です。この検証を記述する事で「実は重要なメソッドを呼び出してなかった」というのを防げますし、TDDをしていく上でより良い設計の指針にもなります。

import 'package:mockito/mockito.dart';
import 'package:test/test.dart';

void main() {
  test('検証に関するサンプル', (){
    var mock = CountMock();
    when(mock.increment()).thenReturn(Count(0,1));

    expect(mock.increment(), Count(0, 1));

    verify(mock.increment());
  });
}

検証する(回数チェック)

対象のメソッドが何回呼び出されたかというチェックをしたい場合に使います。

import 'package:mockito/mockito.dart';
import 'package:test/test.dart';

void main() {
  test('検証に関するサンプル(回数)', (){
    var mock = CountMock();
    when(mock.increment()).thenReturn(Count(0,1));

    expect(mock.increment(), Count(0, 1));

    verify(mock.increment()).called(1);
    verify(mock.increment()).called(greaterThan(0));
    verifyNever(mock.nativeValue);
  });
}
  • calledにて指定した回数の検証
  • calledにはMatcherを指定可能
  • verifyNeverにて一度も呼び出されない事を検証

検証する(引数チェック)

検証時に引数を柔軟にチェックする事が出来ます。引数のチェックには柔軟性を持たせたい場合などに使います。

import 'package:mockito/mockito.dart';
import 'package:test/test.dart';

void main() {
  test('引数の検証に関するサンプル', (){
    var mock = CountMock();
    
    when(mock.format('回目です')).thenReturn('2回目です');

    expect(mock.format('回目です'), '2回目です');

    verify(mock.format(argThat(startsWith('回'))));
  });
}

argThat を使用します。引数にMatcherを受け取るので独自にMatcherを作成して検証する事も可能です。

検証する(呼び出し順序)

Mock化したオブジェクトの各メソッドがどういう順番で呼び出されたかというのを検証する事が出来ます。

import 'package:mockito/mockito.dart';
import 'package:test/test.dart';

void main() {

  test('呼び出し順序検証に関するサンプル',(){
    var mock = CountMock();
    when(mock.increment()).thenReturn(Count(0,1));
    when(mock.nativeValue).thenReturn(0);

    expect(mock.nativeValue, 0);
    expect(mock.increment(), Count(0, 1));

    verifyInOrder([
      mock.nativeValue,
      mock.increment()
    ]);
  });
}

verifyInOrder を使って呼び出されるべき順序通りに記述する事で呼び出される順番の検証が出来ます。

Fakeクラス

自分は使ったことが無いのですがFakeクラスというのが用意されています。これを使うとオーバーライドしてテスト用に独自に振る舞いを定義したメソッド以外を呼び出すとエラー(テストが失敗)になります。

import 'package:mockito/mockito.dart';
import 'package:test/test.dart';

class CountFake extends Fake implements Count{
  @override
  int get nativeValue => 10;
}

void main() {
  test('Fakeのサンプル', (){
    var mock = CountFake();
    mock.increment(); // OK
    mock.increment(); // UnimplementedErrorがThrowされる
  });
}

出来ない事

Mockitoでは出来ない事が結構あります。

  1. インスタンス化(new)のMock
  2. staticメソッドのMock
  3. 拡張関数のMock

リフレクションを使用していないというのが理由なのですが、JMockit等の強力なMockライブラリーに慣れている人等は不都合に感じるかと思います(実際自分はそうでした)

まとめ

Mockitoは上記のように出来ない事も結構あります。ただし逆に上記のような制限がある事でいろいろな依存関係を整理するきっかけになるとも思っています。インスタンスはRepositoryやPort等から必ず取得するように意識したり、staticなメソッド等は小さくラップするクラスを作って結合度を緩めたり。昔JMock*3を使い始めた時はそういうのを意識しないと綺麗なテストが書けなかったので、結果的に自分の設計力が上がったと思っています。なんでもやってくれるMockライブラリーは逆に設計に対する示唆に欠ける可能性があるとポジティブに考えてMockitを使うのが精神衛生上良いかなと思います!

以上になりますがいかがでしたでしょうか。それでは皆さんDartでも良いTDDライフをお送りください!

*1:dart:mirrorsがあるのですが、JSへのトランスパイルやFlutterでのビルド時のサイズが肥大化し、パフォーマンスも悪くなるためです

*2:Rubyのmethod_missing的なもの

*3:名前が似てますが上記のJMockitとは別。やれる事はJMockitほど多くなかった