108
89

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 5 years have passed since last update.

Flutterでの単体テスト(完全版)

Last updated at Posted at 2018-09-30

Flutterをプロダクトで利用するには単体テストの書きやすさも重要だと思います。
この点、Dartは単体テストが書きやすいです。
特にJavaでMockitoを使っていた人は、Dartにもmockitoがあり、ほぼ学習コストがかかりません。

1年間、Flutter開発で単体テストを書いてきたので、ハマりポイントはほぼ網羅したつもりです。
足りない部分があったら完全版になるように都度更新していきます。

基本

まずは、テストできるようにします。

pubspec.yaml
dev_dependencies:
  mockito: ^4.0.0
  flutter_test:
    sdk: flutter

以上です。

暗黙的インターフェイス

DartではJavaの文法でのinterface はありません。代わりにabstract クラスで代用することになりますが、わざわざテストのためにabstract クラスを ___作る必要はない___です。

DartではImplicit interface(暗黙的インターフェイス)が言語レベルで組み込まれています。
つまり、全てのクラスに対して、implements するとそのインターフェイスを実装することになります。

class Person {
  final _name;

  Person(this._name);

  // publicなこのメソッドがinterface
  String greet(String who) => 'Hello, $who. I am $_name.';
}

class Impostor implements Person {
   ・・・
   
   // これを実装しないとエラー
   String greet(String who) {
       ・・・
   }
}

たまにJavaや他の言語でもレイヤーにinterfaceを持ちたがる人がいますが、実装クラスを単体テストのライブラリでMockにできる場合は、(依存の方向を逆にしたい場合を除いて)ほぼ不要なので、Dartが特別優れている訳ではない気がします。が、言語レベルであることで余計なインターフェイスを作る発想がなくなるのはとても良いことだと思います。

次から、レイヤーごとの単体テストの例を書いていきます。

HttpClientのテスト

ほとんどのアプリではHttp通信をすると思うので先に書いておきます。

mock_web_serverを使うのが簡単です。
Androidで利用されることが多いMockWebServerとほぼ同じです。

ただし、Dart2に対応していなくPull Reqeustが出ていますが、マージされていません。
ひとまずは、フォークしているものを使いましょう。
マージされました。

pubspec.yaml
dev_dependencies:
  mock_web_server: ^4.0.0

マージされたので以下の設定は不要です。

dependency_overrides:
  mock_web_server:
    git:
      url: https://gitlab.com/haarts/MockWebServer.git

余談ですが、このように本家で対応されていない場合でも dependency_overrides を使うことでオーバーライドをすることができます。
本家で対応された場合は、dependency_overrides を削除するだけです。
dependency_overrides している場合は実行時に赤字でコンソールで表示されるので、オーバーライドしていることを忘れることがありません。

次にテストの例を挙げます。
HogeHttpClientというテスト対象があったとします。

hoge_http_client.dart
class HogeHttpClient {
  final String baseUrl;
  static HogeHttpClient _instance;

  factory HogeHttpClient({String baseUrl = 'https://hoge.com'}) {
    if (_instance == null) {
      _instance = new HogeHttpClient._internal(baseUrl);
    }
    return _instance;
  }

  HogeHttpClient._internal(this.baseUrl);

  Future<HogeEntity> fetch() async {
    final response = await http.get('$baseUrl/hoge');
    final responseJson = json.decode(response.body);
    return new HogeEntity.fromJson(responseJson);
  }
}

class HogeEntity {
  final String fuga;

  const HogeEntity({
    this.fuga,
  });

  factory HogeEntity.fromJson(Map<String, dynamic> json) {
    return HogeEntity(fuga: json['fuga']);
  }
}

それのテストケースは以下です。

hoge_http_client_test.dart
void main() {
  var _server = new MockWebServer(port: 8081);

  setUp(() async {
    await _server.start();
  });

  tearDown(() {
    _server.shutdown();
  });

  test("fetchForcingUpdate()", () async {
    _server.enqueue(body: '''
{
  "fuga" : "abcdefg"
}       
    ''');

    var target = HogeHttpClient(baseUrl: "http://127.0.0.1:8081");
    var actual = await target.fetch();

    expect(actual.fuga.toString(), "abcdefg");
    
    var request = _server.takeRequest();
    expect(request.uri.path, "/hoge");    
    
  });
}

説明するまでもなく簡単ですね。
urlをmockwebserver用に渡して、start() して、 MockWebServer にレスポンス情報を渡すだけです。
もっと細かい情報を渡したい場合は、enqueueResponse メソッドを使でばいいでしょう。

    _server.enqueueResponse(MockResponse()
      ..httpCode = 201
      ..body = "Created"
      ..headers = {"hogehoge": "1234"}
      ..delay = new Duration(seconds: 2));

requestを検証したい場合に、takeRequest() を呼び出して利用できます。

気をつけることは、外部からbaseUrlを渡せるようにHttpClientクラスを設計しておくことぐらいです。

Repositoryなどのテスト

HogeRepositoryというhttpclientを呼び出すだけのクラスがあったとします。

hoge_repository.dart
class HogeRepository {
  final HogeHttpClient _client;

  HogeRepository(this._client);

  Future<HogeEntity> fetch() {
    return _client.fetch();
  }
}

そのテストケースは以下です。
Repository以外でもほとんどのクラスはこんな感じでテストしていくはずです。

hoge_repository_test.dart
class MockHogeHttpClient extends Mock implements HogeHttpClient {}

main() {
  group('fetch()', () {
    var client = new MockHogeHttpClient();

    test('return null.', () async {
      when(client.fetch()).thenAnswer((_) => Future.value(null));

      var target = new HogeRepository(client);
      var actual = await target.fetch();

      expect(actual, isNull);
    });

    test('return entity', () async {
      var entity = HogeEntity(fuga: "fuga");

      when(client.fetch()).thenAnswer((_) => Future.value(entity));

      var target = new HogeRepository(client);
      var actual = await target.fetch();

      expect(actual.fuga, "fuga");
    });
  });
}

一行目で、HogeHttpClientをmockitoでmock化しています。
その際に暗黙的インターフェイスでモック対象を実装して、mockitoのMockクラスを継承します。

client.fetch() の戻り値はFuture(=Promiseのこと)です。
その場合の戻り値の宣言は、thenReturn ではなくて、thenAnswerを使います。
Futureのインスタンスは、ここでは、Future.value() を使っていますが、SynchronousFuture を使っても同じです。
ただし、SynchronousFuture を使うと catchError() がある場合に動作が止まるので、Future.value() で返すのが無難です。

Reduxのテスト

Reduxを使わなければ関係ないですが、おそらくflutterではReduxで実装することが多いと思われるので書いときます。

Middlewareのテスト

テスト対象の関数です。

hoge_middleware.dart
List<Middleware<AppState>> hogeMiddleware(
  HogeRepository repository,
) {
  return [
    TypedMiddleware<AppState, HogeAction>(_fetchHoge(repository)),
  ];
}

void Function(
  Store<AppState> store,
  HogeAction action,
  NextDispatcher next,
) _fetchHoge(
  HogeRepository repository,
) {
  return (store, action, next) {
    next(action);
    next(LoadingAction());

    repository.fetch().then((HogeEntity entity) {
      store.dispatch(HogeSucceededAction(entity));
    }).catchError((error) {
      print(error);
    }).whenComplete(() {
      next(LoadCompleteAction());
    });
  };
}

テストケースは以下です。最初にstoreのインスタンスで必要なものを設定して、dispatchします。

hoge_middleware_test.dart
class MockMiddleware extends Mock implements MiddlewareClass<AppState> {}
class MockHogeRepository extends Mock implements HogeRepository {}

main() {
  group('HogeMiddleware', () {
    final repository = MockHogeRepository();
    final captor = MockMiddleware();

    test('should fetch hoge', () async {
      final store = Store<AppState>(
        appReducer,
        initialState: new AppState.loading(),
        middleware: hogeMiddleware(repository)..add(captor),
      );

      var entity = HogeEntity(
        fuga: "fuga",
      );

      when(repository.fetch()).thenAnswer((_) => Future.value(entity));

      // テスト開始
      store.dispatch(HogeAction());

      verify(repository.fetch());

      verify(captor.call(store, predicate((action) {
        return (action is HogeAction);
      }), any));

      verify(captor.call(store, predicate((action) {
        return (action is LoadingAction);
      }), any));

      // ここでFutureが終わるのを待つ。
      await untilCalled(store.dispatch(any));

      verify(captor.call(store, predicate((action) {
        if (action is HogeSucceededAction) {
          return action.entity.fuga == 'fuga';
        }
        return false;
      }), any));

      verify(captor.call(store, predicate((action) {
        return (action is LoadCompleteAction);
      }), any));
    });

    reset(repository);
    reset(captor);
  });
}

ポイントは、await untilCalled の部分です。
repository.fetch() の戻り値はFutureで処理が終わるのを待つ必要があります。
そこで、store.dispatch が呼ばれるまで待っています。
あとは、呼ばれているactionが正しいかを検証しています。

Reducerのテスト

テスト対象の関数です。

hoge_reduder.dart
final hogeReducer = combineReducers<HogeState>([
  TypedReducer<HogeState, HogeSucceededAction>(_healthCheck),
]);

HogeState _healthCheck(HogeState hogeState, HogeSucceededAction action) {
  var entity = action.entity;
  return HogeState(
    fuga: entity.fuga,
  );
}

テストケースです。これもstoreに必要な値を設定して、dispatchしています。

main() {
  group('HogeReducer', () {
    final store = new Store<AppState>(
      appReducer,
      initialState: new AppState(
        hogeState: HogeState(
          fuga: 'fuga',
        ),
      ),
    );

    test('fugafuga', () {
      var entity = HogeEntity(
        fuga: 'fugafuga',
      );

      store.dispatch(HogeSucceededAction(entity));

      expect(store.state.hogeState.fuga, "fugafuga");
    });
  });
}

単純にstoreの値が正しいかを検証します。

Widgetのテスト

WidgetのテストでもMockを返す必要がありますが、要素が多いとmockitoを使うのは大変です。
なので、必ず同じ結果が返るスタブクラスを用意しておくと便利です。

こんな感じです。

mock_hoge_repository.dart
class MockHogeRepository implements HogeRepository {
  @override
  Future<HogeEntity> fetch() {
    return Future.value(HogeEntity(fuga: 'fugafuga'));
  }
}

このクラスをテストで使います。

以下が、テスト対象のWidgetです。
onInitで、hogeState.fugaに「fugafuga」という文字列が入り、それをタップするとHoge2Pageに遷移するWidgetです。

hoge_page.dart
class HogePage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return HogePageState();
  }
}

class HogePageState extends State<HogePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('hogeページ'),
      ),
      body: StoreConnector<AppState, _ViewModel>(
        onInit: (store) => store.dispatch(HogeAction()),
        converter: _ViewModel.fromStore,
        builder: (context, viewModel) {
          return Column(
            children: <Widget>[
              InkWell(
                key: ValueKey('ToHoge2Key'),
                onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => Hoge2Page())),
                child: Text(viewModel.fuga),
              ),
            ],
          );
        },
      ),
    );
  }
}

class _ViewModel {
  final String fuga;

  _ViewModel({
    @required this.fuga,
  });

  static _ViewModel fromStore(Store<AppState> store) {
    return _ViewModel(
      fuga: store.state.hogeState.fuga,
    );
  }
}

遷移先のWidgetです。

class Hoge2Page extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('hoge2ページ'),
      ),
      body: Text('ページ2'),
    );
  }
}

テストケースです。MiddlewareにはMockのrepositoryを渡しています。
必要な値を設定したstoreを利用します。
ポイントは、テスト対象のWidgetを、MaterialApp(home: HogePage()) のように設定しておくことです。

hoge_page_test.dart
void main() {
  final hogeRepository = MockHogeRepository();

  final _hogeMiddleware = hogeMiddleware(hogeRepository);

  testWidgets('HogePage', (WidgetTester tester) async {
    var _store = Store<AppState>(
      appReducer,
      initialState: AppState(
        hogeState: HogeState(),
      ),
      middleware: _hogeMiddleware,
    );

    // 1.reduxのonInitが呼ばれる前
    await tester.pumpWidget(
      StatefulBuilder(
        builder: (BuildContext buildContext, StateSetter setState) {
          return StoreProvider(
            store: _store,
            child: MaterialApp(home: HogePage()),
          );
        },
      ),
    );

    // 2. 検証
    expect(find.text('fuga'), findsOneWidget);

    // 3. reduxのonInitが呼ばれた後
    await tester.pumpWidget(
      StatefulBuilder(
        builder: (BuildContext buildContext, StateSetter setState) {
          return StoreProvider(
            store: _store,
            child: MaterialApp(home: HogePage()),
          );
        },
      ),
    );

    // 4. 検証
    expect(find.text('fugafuga'), findsOneWidget);

    // 5. タップ対象を取得
    var toHoge2 = find.byKey(ValueKey('ToHoge2Key'));
    // 6. タップと遷移が終わるのを待つ。
    await tester.tap(toHoge2);
    await tester.pumpAndSettle();

    // 7. 遷移したかの検証
    expect(find.text('ページ2'), findsOneWidget);

    // 8. テスト対象の画面に戻る
    await tester.tap(find.byIcon(Icons.arrow_back));
    await tester.pumpAndSettle();
    expect(find.text('fugafuga'), findsOneWidget);
  });
}

2で、onInitが呼ばれる前の検証をしています。
HogeState.fugaの初期値が「fuga」で、それが、Textに設定されています。それが一つあるという検証です。

4で、onInit内で呼ばれる、非同期処理のHogeRepository.fetch()が終わった後の検証をしています。

5で、タップ対象を取得して、
6で実際にタップしています。 tester.pumpAndSettle を呼ぶことで遷移が終わるのを待ちます。

8では定義していないはずの Icons.arrow_back をタップしていますが、これはpushで遷移した場合に戻るボタンをflutterが付けるからです。(ちなみにfullscreenDialog=true で遷移した場合は、Icons.close が付きます。)

このようにwidgetのテストは、find というオブジェクトで対象を検証したり、タップしたりとするのが基本です。
find の戻り値は、Finder クラスです。
実際のWidgetを取得したい場合は、以下のようにwidget メソッドにFind クラスを渡すことで取得できます。

Text text = tester.widget(find.text('fugafuga'));

Integration Test

正直、このテストは、費用対効果が薄く感じるので利用していませんが、少し触れておきます。
これはSimulatorなどにインストールして動作させるので遅いです。

まずは、設定です。

dev_dependencies:
  flutter_driver:
    sdk: flutter

test ディレクトリと同じ階層に test_driver を作成しておきます。

そこにテスト用のmainクラスを用意しておきます。
本当のmainメソッドを呼びますが、Repositoryなどをmockにしたい場合は設定できるように引数で渡せるようにします。

main_integration.dart
import 'package:hoge/main.dart' as app;

void main() {
  enableFlutterDriverExtension();
  app.main(MockHogeRepository());
}
main.dart
// テスト以外で呼ばれるファイル。引数でモックを渡せるようにしておく。
void main([
  HogeRepository hogeRepository,
]) {
    hogeRepository = hogeRepository ?? HogeRepository()
	・・・
}

テストケースは以下です。

main_integration_test.dart
void main() {
  group('Hoge test', () {
    FlutterDriver driver;

    setUpAll(() async {
      driver = await FlutterDriver.connect();
    });

    tearDownAll(() async {
      if (driver != null) {
        driver.close();
      }
    });

    test('fuga表示', () async {
      var key = find.text('fuga');
      bool exist = await driver.waitFor(key);

      expect(exist, isTrue);
      
    });

    test('hoge2に遷移', () async {
      var key = find.text('fuga');

      await driver.tap(key);
      var page2Key = find.text('ページ2');
      await driver.waitFor(page2Key);
      await driver.waitForAbsent(key);
    });

  });
}

Widgetのテストと似ています。
なのでWidgetのテストを書いた方が起動も早いしお勧めです。
ただし、Http通信も含め、本当の意味でのIntegeration Testをしたい場合は使えると思います。

以下で動作させます。

$ flutter drive --target=test_driver/main_integration.dart

mockitoのTips集

Dartでの単体テストではmockitoを上手に扱えるかで生産性が変わります。
なので特にdart固有のmockitoのTipsを書いておきます。

戻り値がvoidのメソッドのmock化

/// モックにしたいクラス
class Hoge {
  void doNothing() {}
}

/// テスト対象のクラス
class Fuga {
  final Hoge hoge;

  Fuga(this.hoge);

  void fuga1() {
    hoge.doNothing();
  }
}

JavaのMockitoでは doNothing() ですが、dart版はありません。
でも単純にthenReturn(null); にすれば良いです。

class MockHoge extends Mock implements Hoge {}

main() {
  test('fuga1()', () {
    var mockHoge = MockHoge();
    var target = Fuga(mockHoge);
    when(mockHoge.doNothing()).thenReturn(null);
    target.fuga1();

    verify(mockHoge.doNothing());
  });

名前付きパラメータのmock化

dartでの書き方は特殊なので気をつけましょう。知らないとハマります。

/// モックにしたいクラス
class Hoge {

  String methodA({String nameA}) {
    return "A";
  }
}

/// テスト対象のクラス
class Fuga {
  final Hoge hoge;

  Fuga(this.hoge);

  void fuga2() {
    hoge.methodA(nameA: "fuga");
  }
}

テストケースです。

  test('fuga2()', () {
    var mockHoge = MockHoge();
    var target = Fuga(mockHoge);

    when(mockHoge.methodA(nameA: anyNamed("nameA"))).thenReturn("1");

    target.fuga2();

    verify(mockHoge.methodA(
      nameA: argThat(equals("fuga"), named: "nameA"),
    ));
  });

例えば、引数を any にしたい場合でも、any にするとmockが呼ばれません。
代わりに、anyNamed() を使いましょう。さらに引数には、名前付きパラメータ名を文字列で指定します。
ちゃんと値を検証したい場合は、argThat() メソッドを使います。
引数には、以下を指定します。

  • 検証したい引数の値
  • named パラメータに名前付きパラメータ名を指定

引数がFunctionのメソッドのmock化

/// モックにしたいクラス
class Hoge {
  int methodB(int Function() hogeFunc) {
    return hogeFunc();
  }
}

/// テスト対象のクラス
class Fuga {
  final Hoge hoge;

  Fuga(this.hoge);

  int fuga3() {
    return hoge.methodB(() {
      return 1;
    });
  }
}

Javaの場合は、ArgumentCaptorを使って実行させていましたがdart版ではありません。
代わりに thenAnswerInvocation クラスから実行することができます。

  test('fuga3()', () {
    var mockHoge = MockHoge();
    var target = Fuga(mockHoge);

    when(mockHoge.methodB(any)).thenAnswer((Invocation invocation) {
      var func = invocation.positionalArguments.first;
      return func();
    });

    // テスト対象
    var actual = target.fuga3();

    expect(actual, 1);
  });
}

TODO 他にあれば追記していきます。

108
89
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
108
89

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?