FlutterでWidgetの単体テストを書きましたので、メモしておきます。
Widgetの単体テスト(フォーム画面)で良いサンプルがすぐは見つからなかったので、参考になれば幸いです。
テストする画面
- 認証OK時の動作
- 入力チェックエラー時の動作
- 認証NG時の動作
GitHubレポジトリ
テスト対象画面の解説
テストケース
テストケースは以下としました。
以下3点を実装し、テストを実施しています。
- 要素の存在チェック
- 入力したテキストの表示確認
- モックの使用
No | 観点 | ケース |
---|---|---|
1 | 画面初期表示 | 画面が初期表示されたとき、ログインボタンが存在すること |
2 | 入力チェック | 画面が初期表示されたとき、入力チェックが動作していないこと |
3 | 入力チェック | ユーザIDを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること |
4 | 入力チェック | パスワードを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること |
5 | 入力チェック | ユーザID、及びパスワードを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること |
6 | パスワードマスク | パスワードを入力したとき、入力した文言がマスク(••••)されていること |
7 | パスワードマスク | パスワードを入力し、パスワード表示アイコンを押したとき、入力した文言のマスクが解除されていること。もう一度パスワードマスクアイコンを押したとき、パスワードがマスクされること |
8 | 認証エラー | ユーザID、及びパスワードを入力しログインボタンを押し、認証エラーが発生したとき、パスワード誤り文言が表示されること |
テスト実行結果
% flutter test --no-sound-null-safety
要素の存在チェック
公式に記載がある通り、findsNothing(Widgetが存在しないとき)、findsOneWidget(Widgetが一つ存在するとき)、findsNWidgets(Widget数を検証)を利用し、テストを実装していきます。
- Widgetが存在しないときのテストケース
testWidgets('2. 画面が表示されたとき、入力チェックが動作していないこと', (tester) async {
await tester.pumpWidget(loginApp());
expect(find.text('入力してください'), findsNothing); // エラーメッセージが存在しないこと
});
- Widgetが1つ存在するときのテストケース
final _submitButton = find.text('ログイン');
testWidgets('1. 画面が表示されたとき、ログインボタンが存在すること', (tester) async {
await tester.pumpWidget(loginApp());
expect(_submitButton, findsOneWidget); // ログインボタンが1つ存在すること
});
- Widgetの数を検証するケース
testWidgets('5. ユーザID、及びパスワードを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること',
(tester) async {
await tester.pumpWidget(loginApp());
await tester.tap(_submitButton);
await tester.pump();
final validationErrorMessages = find.text('入力してください');
expect(validationErrorMessages, findsNWidgets(2)); // エラーメッセージが2つ表示されること
});
入力したテキストの表示確認
こちらはFlutter本体のレポジトリの方法を参考にしました。
具体的には以下の関数を利用しています。
RenderObjectを利用し、再帰的に操作すると、入力文言が取得できるようです。
RenderEditable findRenderEditable(WidgetTester tester, int index) {
final RenderObject root =
tester.renderObject(find.byType(EditableText).at(index)); // 対象のフィールドのRenderObjectを取得
expect(root, isNotNull);
late RenderEditable renderEditable;
void recursiveFinder(RenderObject child) {
if (child is RenderEditable) {
renderEditable = child;
return;
}
child.visitChildren(recursiveFinder); // 再帰的に処理する
}
root.visitChildren(recursiveFinder);
expect(renderEditable, isNotNull);
return renderEditable;
}
- Widgetに表示される文言を取得するケース
testWidgets('6. パスワードを入力したとき、入力した文言がマスク(••••)されていること', (tester) async {
await tester.pumpWidget(loginApp());
await tester.enterText(_password, 'hoge');
await tester.pump();
final String editText = findRenderEditable(tester, 1).text!.text!; // 上記関数を利用し、Widgetに表示されたテキストを取得する
print(editText); // ••••が出力される
expect(editText.substring(editText.length - 1), '\u2022'); //表示されたパスワードが「•」であること(=マスクされていること)の検証
});
モックを利用する
今回のテストでは、認証処理をMock化します。
Mockライブラリは、mockitoを利用します。
画面(Widget)のプログラムコード
* 状態クラス
class LoginModel extends ChangeNotifier {
final AuthRepository repository; // モック化対象オブジェクト
...(中略)...
LoginModel(this.repository); // モック化対象オブジェクト
...(中略)...
Future<bool> auth() async {
print("id: $id, password: $password");
var results = await repository.auth(); // モック化対象処理
return results;
}
}
- 状態クラスの利用方法
class LoginPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => LoginModel(
AuthRepository(), // レポジトリのインスタンスを渡す
),
child: LoginApp(),
);
}
}
テストコード
Mockライブラリインポート
pubspec.yamlにmockitoを追加し、パッケージをインポート(dart pub get
)します。
dev_dependencies:
flutter_test:
sdk: flutter
mockito: ^4.1.4
Mockの定義
class MockAuthRepository extends Mock implements AuthRepository {}
Mockの利用
Widgetの初期化時に、mockインスタンスを渡します。
final mockAuthRepository = MockAuthRepository(); //モックインスタンス生成。テストケースで利用する
MaterialApp loginApp() {
return MaterialApp(
home: ChangeNotifierProvider(
create: (context) => LoginModel(
mockAuthRepository, //モックインスタンスを渡す
),
child: LoginApp(),
),
);
}
テストケースでの利用方法
振る舞いを定義(変更)する
- 正常系
testWidgets('5. ユーザID、及びパスワードを入力しログインボタンを押したとき、入力チェックが動作し、エラーとならないこと',
(tester) async {
await tester.pumpWidget(loginApp());
// 定義したインスタンス変数を利用する
// 認証レポジトリのauthメソッドの振る舞いを定義(=trueを返す)する
when(mockAuthRepository.auth())
.thenAnswer((_) => Future.value(true)); // 認証OK
await tester.enterText(_password, 'password');
await tester.enterText(_id, 'demo');
await tester.tap(_submitButton);
await tester.pump(Duration(seconds: 1)); // SnackBarが表示されるのを待ち合わせる
expect(find.text('入力してください'), findsNothing);
expect(find.text('パスワードが誤っています'), findsNothing);
verify(mockAuthRepository.auth()).called(1);
});
- 異常系
testWidgets('8. ユーザID、及びパスワードを入力しログインボタンを押し、認証エラーが発生したとき、パスワード誤り文言が表示されること',
(tester) async {
await tester.pumpWidget(loginApp());
when(mockAuthRepository.auth())
.thenAnswer((_) => Future.value(false)); // 認証NG 認証レポジトリのauthメソッドの振る舞いを定義(=falseを返す)する
await tester.enterText(_password, 'password');
await tester.enterText(_id, 'demo');
await tester.tap(_submitButton);
await tester.pump(Duration(seconds: 1));
expect(find.text('パスワードが誤っています'), findsOneWidget);
verify(mockAuthRepository.auth()).called(1);
});
- 例外系
thenThrowメソッドを利用します。
final mockAuthRepository = MockAuthRepository();
when(mockAuthRepository.auth()).thenThrow(
Exception('api error occurred'),
);
メソッドの呼び出し回数の検証
verify(メソッドの呼び出し回数を検証)、verifyNever(メソッドが1度も呼び出されていないことを検証)を利用し、対象のメソッドの呼び出し回数を検証します。
- メソッドが1度も呼び出されていないことを検証する
入力チェックがエラーとなった場合は、認証処理が呼び出されないので、verifyNeverを利用して検証します。
testWidgets('3. ユーザIDを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること',
(tester) async {
await tester.pumpWidget(loginApp());
await tester.enterText(_password, 'password');
await tester.tap(_submitButton);
await tester.pump();
final validationErrorMessages = find.text('入力してください');
expect(validationErrorMessages, findsOneWidget);
verifyNever(mockAuthRepository.auth()); // 認証処理が1回も呼び出されていないことを検証する
});
- メソッドが呼び出された回数を検証する
入力チェックを通過したあとは、認証処理が呼び出されるので、verifyを利用し、認証処理の呼び出し回数を検証します。
testWidgets('5. ユーザID、及びパスワードを入力しログインボタンを押したとき、入力チェックが動作し、エラーとならないこと',
(tester) async {
await tester.pumpWidget(loginApp());
when(mockAuthRepository.auth())
.thenAnswer((_) => Future.value(true)); // 認証OK
await tester.enterText(_password, 'password');
await tester.enterText(_id, 'demo');
await tester.tap(_submitButton);
await tester.pump(Duration(seconds: 1)); // SnackBarが表示されるのを待ち合わせる
expect(find.text('入力してください'), findsNothing);
expect(find.text('パスワードが誤っています'), findsNothing);
verify(mockAuthRepository.auth()).called(1); // 認証処理が1回呼び出されていることを検証する
});
その他
Widgetの再構築(再描画)
tester.pump()
を利用します。
- Frame処理を伴わない(テキスト入力など)とき
await tester.pump();
- Frame処理を伴う(SnackBar表示など)とき
await tester.pump(Duration(seconds: 1)); // SnackBarが表示されるのを待ち合わせる
テスト対象のWidgetの初期化
MaterialAppでラップして、Widgetを初期化します。
中身はStatelessWidgetで定義したものを記載します。
MaterialApp loginApp() {
return MaterialApp( // MaterialAppでラップ
home: ChangeNotifierProvider(
create: (context) => LoginModel(
mockAuthRepository,
),
child: LoginApp(),
),
);
}
testWidgets('1. 画面が表示されたとき、ログインボタンが存在すること', (tester) async {
await tester.pumpWidget(loginApp()); // 上記を利用して、Widgetを描画する
expect(_submitButton, findsOneWidget);
});
テストのグルーピング
group関数で囲みます。
group('LoginPage ', () { // テストケースのグルーピング
testWidgets('1. 画面が表示されたとき、ログインボタンが存在すること', (tester) async {
await tester.pumpWidget(loginApp());
expect(_submitButton, findsOneWidget);
});
...(中略)...
});
テスト全体で利用する要素(Finder)の定義
テスト全体で利用するFiderの定義。
各テストケースで頻出する要素はテストケースとは別に予め定義しておくと便利です。
final _id = find.byType(TextFormField).at(0); // ID入力フィールド
final _password = find.byType(TextFormField).at(1); // パスワード入力フィールド
final _passwordViewToggle = find.byType(IconButton); // パスワード表示・非表示切り替えアイコン
final _submitButton = find.text('ログイン'); // ログインボタン
テストコードの全量
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_app/repository/auth_repository.dart';
import 'package:flutter_app/ui/login_model.dart';
import 'package:flutter_app/ui/login_page.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:provider/provider.dart';
RenderEditable findRenderEditable(WidgetTester tester, int index) {
final RenderObject root =
tester.renderObject(find.byType(EditableText).at(index));
expect(root, isNotNull);
late RenderEditable renderEditable;
void recursiveFinder(RenderObject child) {
if (child is RenderEditable) {
renderEditable = child;
return;
}
child.visitChildren(recursiveFinder);
}
root.visitChildren(recursiveFinder);
expect(renderEditable, isNotNull);
return renderEditable;
}
class MockAuthRepository extends Mock implements AuthRepository {}
void main() async {
final mockAuthRepository = MockAuthRepository();
MaterialApp loginApp() {
return MaterialApp(
home: ChangeNotifierProvider(
create: (context) => LoginModel(
mockAuthRepository,
),
child: LoginApp(),
),
);
}
/// ///////////////////
/// target elements
/// //////////////////
final _id = find.byType(TextFormField).at(0);
final _password = find.byType(TextFormField).at(1);
final _passwordViewToggle = find.byType(IconButton);
final _submitButton = find.text('ログイン');
group('LoginPage ', () {
testWidgets('1. 画面が表示されたとき、ログインボタンが存在すること', (tester) async {
await tester.pumpWidget(loginApp());
expect(_submitButton, findsOneWidget);
});
testWidgets('2. 画面が表示されたとき、入力チェックが動作していないこと', (tester) async {
await tester.pumpWidget(loginApp());
expect(find.text('入力してください'), findsNothing);
});
testWidgets('3. ユーザIDを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること',
(tester) async {
await tester.pumpWidget(loginApp());
await tester.enterText(_password, 'password');
await tester.tap(_submitButton);
await tester.pump();
final validationErrorMessages = find.text('入力してください');
expect(validationErrorMessages, findsOneWidget);
verifyNever(mockAuthRepository.auth());
});
testWidgets('4. パスワードを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること',
(tester) async {
await tester.pumpWidget(loginApp());
await tester.enterText(_id, 'demo');
await tester.tap(_submitButton);
await tester.pump();
final validationErrorMessages = find.text('入力してください');
expect(validationErrorMessages, findsOneWidget);
verifyNever(mockAuthRepository.auth());
});
testWidgets('5. ユーザID、及びパスワードを入力せずにログインボタンを押したとき、入力チェックが動作し、エラーとなること',
(tester) async {
await tester.pumpWidget(loginApp());
await tester.tap(_submitButton);
await tester.pump();
final validationErrorMessages = find.text('入力してください');
expect(validationErrorMessages, findsNWidgets(2));
verifyNever(mockAuthRepository.auth());
});
testWidgets('5. ユーザID、及びパスワードを入力しログインボタンを押したとき、入力チェックが動作し、エラーとならないこと',
(tester) async {
await tester.pumpWidget(loginApp());
when(mockAuthRepository.auth())
.thenAnswer((_) => Future.value(true)); // 認証OK
await tester.enterText(_password, 'password');
await tester.enterText(_id, 'demo');
await tester.tap(_submitButton);
await tester.pump(Duration(seconds: 1)); // SnackBarが表示されるのを待ち合わせる
expect(find.text('入力してください'), findsNothing);
expect(find.text('パスワードが誤っています'), findsNothing);
verify(mockAuthRepository.auth()).called(1);
});
testWidgets('6. パスワードを入力したとき、入力した文言がマスク(••••)されていること', (tester) async {
await tester.pumpWidget(loginApp());
await tester.enterText(_password, 'hoge');
await tester.pump();
final String editText = findRenderEditable(tester, 1).text!.text!;
print(editText);
expect(editText.substring(editText.length - 1), '\u2022');
});
testWidgets(
'7. パスワードを入力し、パスワード表示アイコンを押したとき、入力した文言のマスクが解除されていること。もう一度パスワードマスクアイコンを押したとき、パスワードがマスクされること',
(tester) async {
await tester.pumpWidget(loginApp());
await tester.enterText(_password, 'hoge');
await tester.tap(_passwordViewToggle);
await tester.pump();
final String editText = findRenderEditable(tester, 1).text!.text!;
print('unMask: $editText');
expect(editText, 'hoge');
await tester.tap(_passwordViewToggle);
await tester.pump();
final String editTextAfter = findRenderEditable(tester, 1).text!.text!;
print('Mask: $editTextAfter');
expect(editTextAfter.substring(editText.length - 1), '\u2022');
});
testWidgets('8. ユーザID、及びパスワードを入力しログインボタンを押し、認証エラーが発生したとき、パスワード誤り文言が表示されること',
(tester) async {
await tester.pumpWidget(loginApp());
when(mockAuthRepository.auth())
.thenAnswer((_) => Future.value(false)); // 認証NG
await tester.enterText(_password, 'password');
await tester.enterText(_id, 'demo');
await tester.tap(_submitButton);
await tester.pump(Duration(seconds: 1));
expect(find.text('パスワードが誤っています'), findsOneWidget);
verify(mockAuthRepository.auth()).called(1);
});
});
}
終わりに
FlutterのWidget単体テストは、mockitoも利用しやすく、かなり書きやすい印象(jestを利用したコンポーネントテストなどに比べ)です。
実行時間も快速なので、テストコードを書く作業が捗りそうですね。
参考
- Widgetテストガイド
- 入力したテキストの表示された文字を取得する
- mockito