テストコードのデザインパターン:Robotパターン

 アプリケーションなどのテストコードのデザインパターンの一つに、Robotパターン(Testing Robotパターン、Robot Testingパターン)という汎用的なパターンがあります。

Presentation: Testing Robots - Jake Wharton
Robot Pattern Testing for XCUITest | by Rob Whitaker | Capital One Tech | Medium

 Robotパターンは、テスト対象のセグメンテーション(例えば画面)ごとにRobotオブジェクトを用意し、テストコード上にテストのWhat(テストしたい振る舞いや仕様)を、RobotオブジェクトにテストのHow(テスト対象の操作や期待値比較の詳細な実装や、テスト対象への直接の依存部分)を実装するものです。これによりテストコードの可読性や再利用性を向上させることを目的とします。

 このパターンは、テストコード上にWhatを、PageオブジェクトにHowを実装する、一般的なPage Objectパターンと目的やアプローチが類似しています(ただRobotパターンは、Robotオブジェクト内にAssertion機能を実装するのを許容するという違いがあります)。

 今回はFlutterアプリを対象に、このRobotパターンの実装例を示します。

テスト対象のサンプル

 HOME画面に次のようなログイン画面を表示する、単純なログイン機能を有したFlutterアプリを対象にします。今回は、これに対し、ログインして、Executeという内部機能実行ボタンが表示されることを確認するIntegration Testを実装します。

f:id:goyoki:20210422003746p:plain

Robotパターンを使用しないテストコード

 Robotパターンを使用せずにIntegration Testを実装した例を次に示します。

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import 'package:flutter_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('E2E Test for Login', () {
    testWidgets('authenticate a user', (WidgetTester tester) async {
      app.main();
      await tester.pumpAndSettle();

      await tester.enterText(find.byKey(Key('usernameTextField')), 'hoge hoge');
      await tester.enterText(find.byKey(Key('passwordTextField')), 'sample password');

      await tester.tap(find.byKey(Key('login')));

      await tester.pumpAndSettle();

      expect(find.text('Execute'), findsOneWidget);
    });
  });
}

 テストコードにはusernameTextFieldといったキー名など、テスト対象の詳細な操作がハードコーディングされています。

Robotパターンを使用したテストコード

 次にRobotパターンを使用してIntegration Testを実装した例を次に示します。

 まず画面ごとにRobotオブジェクトを用意します。Robotオブジェクトでは、テスト対象の操作や期待値比較についての詳細な実装(How)を、わかりやすい名前(What)のメソッドにラッピングしていきます。

// login_robot.dart(ログイン画面のRobotクラス)
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

class LoginRobot {
  const LoginRobot(this.tester);

  final WidgetTester tester;

  Future<void> enterUserName(String username) async {
    await tester.enterText(find.byKey(Key('usernameTextField')), 'hoge hoge');
  }

  Future<void> enterPassword(String password) async {
    await tester.enterText(find.byKey(Key('passwordTextField')), 'sample password');
  }

  Future<void> tapLoginButton() async {
    await tester.tap(find.byKey(Key('login')));
    await tester.pumpAndSettle();
  }
}
// index_robot.dart(ログイン後画面のRobotクラス)
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

class IndexRobot {
  const IndexRobot(this.tester);
  final WidgetTester tester;

  Future<void> checkUserIsOnTheIndexScreen() async {
    expect(find.text('Execute'), findsOneWidget);
  }
}

 次にテストコードです。Robotオブジェクトを経由してテスト対象を操作することにより、テストコードは次のように実装できます。usernameTextFieldといった内部のキーへの直接の依存がなくなり、テストしたい振る舞いや仕様が端的に表現されたコードになります。

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import 'robots/login_robot.dart';
import 'robots/index_robot.dart';

import 'package:flutter_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
  group('E2E Test for Login', () {
    testWidgets('authenticate a user', (WidgetTester tester) async {
      final loginRobot = LoginRobot(tester);
      final indexRobot = IndexRobot(tester);
      app.main();
      await tester.pumpAndSettle();

      await loginRobot.enterUserName('hoge hoge');
      await loginRobot.enterPassword('sample password');
      await loginRobot.tapLoginButton();
      await indexRobot.checkUserIsOnTheIndexScreen();
    });
  });
}