はじめに
こんにちは、今回の記事ではflutterにおける単体テスト(UnitTest)の書き方を網羅的に解説していきます。
こんな方におすすめ
- flutter初心者
- 単体テストについて勉強したい方
- 筆者のことが好きな方(いるよね。。??)
- きのこの山派の方(当然だよね?)
単体テストとは?
※ 既知な方は読み飛ばしてください。
単体テスト(ユニットテスト)とは、プログラムの最小単位(関数やメソッドなど)が
設計通りに正しく動作するかを個別に検証するテスト のことを指します。
関数ごとに分岐処理や戻り値が期待通りかを確認できるため、大変便利です。
また、将来的にコードが変更された場合でも、単体テストが存在していれば
- その関数の役割が明確になる
- 意図しない変更にすぐ気づける
といったメリットがあり、保守性の高いコード を維持できます。
さらに少し本筋からは逸れますが、
TDD(テスト駆動開発) を行うことで、
- テスト → 実装 → リファクタ
- 関数の責務を整理しながら実装できる
といった利点もあります。
単体テスト観点
単体テストを書くうえで重要なのは、
「何をテストするか」 を意識することです。
闇雲にテストを書いてしまうと、
- テストの保守コストが増える
- 実装変更に弱いテストになる
といった問題が発生します。
ここでは Flutter / Dart における、
基本かつ重要なテスト観点 を整理します。
正常系
まず最優先で書くべきなのが 正常系のテスト です。
- 正しい引数を渡したとき
- 想定通りの状態のとき
- 期待した値が返るか
int add(int a, int b) => a + b;
この場合であれば、
add(1, 2) が 3 を返すか
といった、最も基本的な振る舞い をテストします。
異常系・境界値
次に重要なのが 異常系・境界値のテスト です。
- null が渡されたら?
- 空文字だったら?
- 0 やマイナスの値は?
- 上限・下限ギリギリの値は?
int divide(int a, int b) {
if (b == 0) {
throw Exception('0で割れません');
}
return a ~/ b;
}
この場合、
- b == 0 のときに例外が投げられるか
- 正常時に正しい計算結果になるか
を確認します。
分岐網羅(if / switch)
if や switch がある関数は、
すべての分岐を最低1回は通す のが基本です。
String getGrade(int score) {
if (score >= 80) {
return 'A';
} else if (score >= 60) {
return 'B';
} else {
return 'C';
}
}
この場合、
- 80以上
- 60〜79
- 59以下
の 3パターン をそれぞれテストします。
単体テストの基本的な書き方
Flutter / Dart では test パッケージを使います。
dev_dependencies:
test: ^1.24.0
最もシンプルなテスト
import 'package:test/test.dart';
void main() {
test('1 + 1 は 2 になる', () {
expect(1 + 1, 2);
});
}
上記テストコードの構造
-
test():1つのテストケース
-
第1引数:テスト内容の説明
-
第2引数:テスト処理
expect(実行結果, 期待値):検証
グルーピング
関連するテストは group でまとめると読みやすくなります。
group('加算処理のテスト', () {
test('正の数同士', () {
expect(2 + 3, 5);
});
test('0 を含む場合', () {
expect(0 + 5, 5);
});
});
単体テストで必ず理解すべき構成要素(完全版)
この章では、Flutter / Dart の単体テストで実務上必要な要素をすべて網羅的に解説します。
テスト全体を構成する基本要素
main()
void main() {
// テストはここに書く
}
- テストのエントリーポイント
- 1ファイルにつき1つ必須
test()
test('テスト名', () {
// テスト内容
});
- 1つのテストケースを表す
- テスト名は日本語でOK
- async / await が使用可能
group()
group('add関数のテスト', () {
test('正常系', () {});
test('異常系', () {});
});
- テストを論理的にまとめる
- 関数単位・クラス単位で使用
初期化・後処理
setUp()
setUp(() {
// 各テストの前に毎回実行
});
- 各テストケース前に実行
- モックやテスト対象の初期化に使用
tearDown()
tearDown(() {
// 各テストの後に実行
});
- 後処理用
- 使用頻度は低め
setUpAll / tearDownAll
setUpAll(() {});
tearDownAll(() {});
- ファイル内で1回だけ実行
- 重い初期化処理がある場合のみ使用
検証(expect 系)
expect()
expect(actual, expected);
- 実際の値と期待値を比較
matcher
expect(value, isTrue);
expect(value, isFalse);
expect(value, isNull);
expect(value, isNotNull);
expect(list, isEmpty);
expect(list, isNotEmpty);
- 可読性向上のために使用
- expectの第一引数がmatcherの指定する状態になっているかの検証に使用できる。
例外の検証
expect(
() => divide(10, 0),
throwsException,
);
- 異常系テストで必須
- throwsExceptionをmatcherに指定することでException が発火することが期待として試験できる。
非同期処理のテスト
test('非同期処理', () async {
final result = await fetchNumber();
expect(result, 10);
});
- Future を返す関数は必ず async でテスト
モック関連(mocktail)
Mock クラス
class MockUserRepository extends Mock
implements UserRepository {}
外部依存を切り離すための偽物クラス
以下の機能は端末や通信に依存しており、テストコードではアクセスできないため擬似的に関数の振る舞いを決定しておく必要があります。
- API 通信(HTTP)
- DB アクセス
- SharedPreferences
- ファイル I/O
when()
when(() => mock.fetch())
.thenReturn(10);
- モックの返り値を定義
thenReturn / thenAnswer
thenReturn(10);
thenAnswer((_) async => 10);
- Future を返す場合は thenAnswer
verify()
verify(() => mock.fetch()).called(1);
- 関数が呼ばれたかを検証
- calledの引数に入れた数値回関数が呼ばれていればOKとなります。
verifyNever()
verifyNever(() => mock.fetch());
- 処理が呼ばれていないことの検証
verifyInOrder()
verifyInOrder([
() => mock.login(),
() => mock.fetchUser(),
() => mock.logout(),
]);
-
複数のメソッドが「決められた順番」で呼ばれたかを検証する
-
呼び出し回数だけでなく 順序が重要な処理 で使用する
reset()
reset(mock);
- モックの状態をリセット
- 複雑なテストでのみ使用
引数マッチング
any()
when(() => mock.save(any()))
.thenReturn(true);
- 引数の内容を問わずマッチ
- この場合は引数の値がなんであれsave関数はtrueを出力するようになる
captureAny()
verify(() => mock.save(captureAny()));
- 実際に渡された引数を取得
その他
skip
test('未実装', () {}, skip: true);
- 一時的にテストを無効化
timeout
test(
'時間制限付きテスト',
() async {},
timeout: Timeout(Duration(seconds: 2)),
);
- 非同期テストの無限待ち防止
実際にテストコードを書いてみる
※あくまで例なのでクラスや関数のクオリティはご愛嬌で。。
テストを実行するにはターミナルからflutter testを実行するか、
以下のようにmain()の横のボタンをタップしてテストを実行できます。
テスト対象のクラス
今回は以下のコードをテストしてみましょう。
class UserService {
final UserRepository repository;
UserService(this.repository);
/// 挨拶文を返す
Future<String> getGreetingMessage() async {
try {
final name = await repository.fetchUserName();
if (name.isEmpty) {
return 'Hello Guest';
}
return 'Hello $name';
} catch (_) {
return 'Hello Guest';
}
}
/// 年齢によってメッセージを切り替える
Future<String> getAgeMessage() async {
final age = await repository.fetchUserAge();
if (age < 0) {
throw StateError('invalid age');
}
if (age < 20) {
return 'Minor';
} else if (age < 65) {
return 'Adult';
} else {
return 'Senior';
}
}
/// ログイン処理(順序が重要)
Future<void> login() async {
final name = await repository.fetchUserName();
await repository.saveLoginLog(name);
}
/// 名前+年齢の複合ロジック
Future<String> getUserSummary() async {
try {
final name = await repository.fetchUserName();
final age = await repository.fetchUserAge();
if (name.isEmpty) {
return 'Guest ($age)';
}
return '$name ($age)';
} catch (_) {
return 'Guest';
}
}
}
実際のテストコードは以下です。
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
/// UserRepository をモック化したクラス
/// 実際の API 通信や DB アクセスは行わず、
/// テスト用に任意の振る舞いを定義できる
class MockUserRepository extends Mock implements UserRepository {}
void main() {
/// 各テストで利用するモックインスタンス
late MockUserRepository mockRepository;
/// テスト対象の UserService
late UserService service;
/// setUp は各 test / group の前に毎回実行される
/// テスト間で状態が共有されないように、
/// 毎回新しいインスタンスを生成する
setUp(() {
mockRepository = MockUserRepository();
service = UserService(mockRepository);
});
/// ============================
/// getGreetingMessage のテスト
/// ============================
group('getGreetingMessage', () {
test(
'ユーザー名が正常に取得できる場合は、挨拶文に名前を含めて返せること',
() async {
/// fetchUserName が呼ばれたら 'Alice' を返すように定義
when(mockRepository.fetchUserName())
.thenAnswer((_) async => 'Alice');
/// テスト対象メソッドを実行
final result = await service.getGreetingMessage();
/// 返却値が期待通りかを検証
expect(result, 'Hello Alice');
/// fetchUserName が 1 回だけ呼ばれたことを検証
verify(mockRepository.fetchUserName()).called(1);
},
);
test(
'ユーザー名が空文字の場合は、Guest向けの挨拶文を返せること',
() async {
/// 空文字を返すケースを定義
when(mockRepository.fetchUserName())
.thenAnswer((_) async => '');
final result = await service.getGreetingMessage();
/// 名前が空の場合は Guest 扱いになることを確認
expect(result, 'Hello Guest');
},
);
test(
'ユーザー名取得時に例外が発生した場合は、Guest向けの挨拶文を返せること',
() async {
/// fetchUserName 呼び出し時に例外を投げる
when(mockRepository.fetchUserName())
.thenThrow(Exception());
final result = await service.getGreetingMessage();
/// catch 節により Guest が返ることを確認
expect(result, 'Hello Guest');
},
);
});
/// ============================
/// getAgeMessage のテスト
/// ============================
group('getAgeMessage', () {
test(
'年齢が未成年の場合は、Minorと判定できること',
() async {
/// 年齢が 15 の場合
when(mockRepository.fetchUserAge())
.thenAnswer((_) async => 15);
final result = await service.getAgeMessage();
expect(result, 'Minor');
},
);
test(
'年齢が成人の場合は、Adultと判定できること',
() async {
/// 年齢が 30 の場合
when(mockRepository.fetchUserAge())
.thenAnswer((_) async => 30);
final result = await service.getAgeMessage();
expect(result, 'Adult');
},
);
test(
'年齢が高齢者の場合は、Seniorと判定できること',
() async {
/// 年齢が 70 の場合
when(mockRepository.fetchUserAge())
.thenAnswer((_) async => 70);
final result = await service.getAgeMessage();
expect(result, 'Senior');
},
);
test(
'年齢が不正な値の場合は、例外をスローできること',
() async {
/// 不正な年齢(マイナス値)
when(mockRepository.fetchUserAge())
.thenAnswer((_) async => -1);
/// 例外が発生することを検証
expect(
() => service.getAgeMessage(),
throwsA(isA<StateError>()),
);
},
);
});
/// ============================
/// login のテスト
/// ============================
group('login', () {
test(
'ログイン処理実行時は、ユーザー名取得後にログ保存が順番通りに呼ばれること',
() async {
/// ユーザー名取得結果を定義
when(mockRepository.fetchUserName())
.thenAnswer((_) async => 'Alice');
/// ログ保存処理は戻り値なしのため empty async
when(mockRepository.saveLoginLog(any))
.thenAnswer((_) async {});
/// ログイン処理を実行
await service.login();
/// 呼び出し順序が正しいことを検証
verifyInOrder([
mockRepository.fetchUserName(),
mockRepository.saveLoginLog('Alice'),
]);
},
);
});
/// ============================
/// getUserSummary のテスト
/// ============================
group('getUserSummary', () {
test(
'ユーザー名と年齢が取得できる場合は、要約文字列を返せること',
() async {
when(mockRepository.fetchUserName())
.thenAnswer((_) async => 'Bob');
when(mockRepository.fetchUserAge())
.thenAnswer((_) async => 25);
final result = await service.getUserSummary();
/// 名前と年齢が結合された文字列になることを確認
expect(result, 'Bob (25)');
},
);
test(
'ユーザー名が空の場合は、Guestとして要約文字列を返せること',
() async {
when(mockRepository.fetchUserName())
.thenAnswer((_) async => '');
when(mockRepository.fetchUserAge())
.thenAnswer((_) async => 40);
final result = await service.getUserSummary();
expect(result, 'Guest (40)');
},
);
test(
'ユーザー情報取得中に例外が発生した場合は、Guestを返せること',
() async {
/// 名前取得時に例外を発生させる
when(mockRepository.fetchUserName())
.thenThrow(Exception());
final result = await service.getUserSummary();
/// catch 処理により Guest が返ることを確認
expect(result, 'Guest');
},
);
});
}
まとめ
最後までご覧いただきありがとうございました。
ここまで読んでくださった方はある程度テストコードを書けるようになったんじゃないかなと思います!
本記事の情報誤り、ご質問があればぜひコメントお願いします。
ありがとうございました。
