はじめに
テストで使う偽物を、全部「モック」と呼んでいた。
MockUserRepository というクラス名を付け、mockRepo という変数に入れ、「ここモックしといて」とレビューコメントを書く。mockito や mocktail という Dart のモックライブラリの名前が、それを後押しする。
だが Gerard Meszaros の『xUnit Test Patterns』(2007) による分類に照らすと、mockito / mocktail で日常的に作っているものの大半は、モックではない。スタブかスパイ。when() で返り値を仕込んだ瞬間それはスタブで、verify() で呼び出しを検証したらスパイ。
言い換えると、「モックライブラリを使っているのに、モックは作っていない」という捻れが起きている。
この記事では、Meszaros の5分類(dummy / stub / spy / mock / fake)を Dart のコードで切り分ける。分類のための分類ではなく、最後に「どう使い分けるか」「どう命名するか」を考えていく。
用語の整理: テストダブル
まず総称から。
テストのために本物と差し替えるオブジェクトの総称が テストダブル (Test Double)。本物の DB や API 通信は遅い・不安定・準備が面倒なので、テストでは代役に置き換える。映画で危険なシーンを俳優の代わりに演じるスタントダブルが語源で、Meszaros が導入した用語だ1。
つまり、モックはテストダブルの一種にすぎない。
「モック」を総称として使うのは、「乗り物」と言うべき場面で「トラック」と言っているようなもの。
今日どのトラックに乗ってきたの?みたいな。
なぜ二重の意味になったのか。Uncle Bob の説明によれば、「Mock Object」という用語を定義した論文2を読んだ人が正確な意味で使い始め、論文を読んでいない人が耳で聞いて広い意味で使い始めた。
5種類を見ていく前に、分類の見取り図をひとつ。テストダブルの仕事は、大きく2方向に分かれる。
- テスト対象に材料を入れる係。「findById と聞かれたらこのユーザーを返して」と答えを仕込む側(stub / fake)
- テスト対象から出てきたものを見る係。「save がちゃんと呼ばれたか」を観測する側(spy / mock)
テストの assert もこれに対応して2種類ある。返り値・例外・最終状態をチェックするのが状態検証、「正しく呼んだか」をチェックするのが振る舞い検証。Fowler の整理では、5分類のうち振る舞い検証を強制するのはモックだけで、他のダブルは通常、状態検証と組み合わせて使う1。この見取り図さえ押さえれば、5分類は暗記不要になる。
題材
以降、この依存関係で統一する。「ユーザーを取得し、admin に昇格させ、保存する」ロジックを持つ ViewModel。
abstract class UserRepository {
Future<User?> findById(String id); // ユーザーを探す
Future<void> save(User user); // ユーザーを保存する
}
class PromoteViewModel {
PromoteViewModel(this.repo, this.logger);
final UserRepository repo;
final Logger logger;
Future<User> promote(String id) async {
final user = await repo.findById(id);
if (user == null) throw UserNotFoundException(id);
final promoted = user.copyWith(role: Role.admin);
await repo.save(promoted);
return promoted;
}
}
実アプリの ViewModel なら結果を AsyncValue 等の state に畳むが、この記事ではダブルの差分に集中したいので、返り値と例外で検証できる素の形にしてある。state 版でも差し替える境界は変わらない。
本物の UserRepository の先は API につながっている(全体の層構造は後半で扱う)。テストで DB や API を使いたくないので、UserRepository の境界で代役に差し替えたい。ここから5種類。
1. Dummy — 渡すだけで使われない
役割: ただの置物。コンストラクタが引数を要求するから渡すだけで、テスト中に一切使われない。
class DummyLogger implements Logger {
@override
void log(String message) =>
throw UnimplementedError('dummy が呼ばれた。設計を見直すこと');
}
呼ばれたら例外で落とすのがポイント。「このテストパスでは logger に触れない」という暗黙の主張を、実行可能な形で表明している。もし落ちたら、テストの前提か実装のどちらかが変わったサイン。Uncle Bob も dummy には null を返させ、使われたら NullPointerException で気づける状態を「dummy が返せる最良の値」と呼んでいる3。Dart なら null より UnimplementedError のほうが意図が明示的だ。
mocktail の Mock を渡して放置するのも実質 dummy だが、呼ばれても(スタブ漏れで型エラーになるケースを除き)静かに通ることがあり、「使われていない」保証は弱くなる。
2. Stub — 間接入力を固定する
役割: 決まった答えを返すだけの入力係。「findById と聞かれたら、必ずこれを返してね」と答えを仕込んでおく。
class StubUserRepository implements UserRepository {
StubUserRepository(this._user);
final User? _user;
@override
Future<User?> findById(String id) async => _user; // 何を聞かれてもこれを返す
@override
Future<void> save(User user) async {} // 何もしない
}
使い方はこう。「ユーザーが見つからなかったら例外が出るか?」をテストしたいので、null を返すスタブを渡す。
test('user が null なら UserNotFoundException', () {
final viewModel = PromoteViewModel(StubUserRepository(null), DummyLogger());
expect(() => viewModel.promote('42'), throwsA(isA<UserNotFoundException>()));
});
検証しているのは promote の結果(例外が出たか)であって、スタブ自身は何も検証しない。つまり状態検証。
mocktail で書くとこうなる。
final repo = MockUserRepository(); // class MockUserRepository extends Mock implements UserRepository {}
when(() => repo.findById(any())).thenAnswer((_) async => null);
when で返り値を仕込み、verify を一切呼ばないなら、クラス名が Mock〜 でも実態はスタブ。ここが本記事の主張の半分。
3. Spy — 間接出力を記録し、後から見る
役割: 呼ばれたことをこっそりメモしておく記録係。Meszaros の定義では、スパイとは「どう呼ばれたかの情報を記録する機能を持ったスタブ」だ1。「save が本当に呼ばれたか? どんなユーザーで?」を知りたいとき、呼ばれた内容をノートに取っておき、テストの最後にテストコード側が開いて確認する。
class SpyUserRepository extends StubUserRepository {
SpyUserRepository(super.user);
final savedUsers = <User>[]; // 記録用ノート
@override
Future<void> save(User user) async => savedUsers.add(user); // メモる
}
extends StubUserRepository にしているのは手抜きではなく、定義(spy = stub + 記録)をそのまま型階層に写した結果だ。
test('昇格後の user が save される', () async {
final spy = SpyUserRepository(User(id: '42', role: Role.member));
await PromoteViewModel(spy, DummyLogger()).promote('42');
expect(spy.savedUsers.single.role, Role.admin); // ノートを後から確認
});
mocktail の verify はまさにこれ。
verify(() => repo.save(any(that: predicate<User>((u) => u.role == Role.admin))))
.called(1);
実行が終わってから、記録を開いて確認する。「何を期待するか」を知っているのはテストコードであって、ダブル自身ではない。これがスパイ。
つまり、mockito / mocktail のデフォルトの使い方は「スタブ + スパイ」 ということになる。ライブラリ名に反して、Meszaros 的な意味でのモックはここまで一度も登場していない。
4. Mock — 期待を事前に宣言し、モック自身が検証する
役割: 事前に台本を渡され、台本から外れたら自分で怒る監視役。厳密なモックの定義は「受け取るべき呼び出しの仕様(期待)を事前にプログラムされたオブジェクト」1。「save は admin ロールの user で1回だけ呼ばれるはず」という期待をセットアップ段階で仕込み、検証フェーズではモック自身に期待が満たされたか確認させる。検証ロジックの住所が、テスト側でなくモック側にある。
class StrictMockUserRepository implements UserRepository {
var _saveCalled = false;
@override
Future<User?> findById(String id) async => User(id: id, role: Role.member);
@override
Future<void> save(User user) async {
if (_saveCalled) fail('save が2回呼ばれた'); // 台本違反
expect(user.role, Role.admin); // モック自身が検証する
_saveCalled = true;
}
void verifyExpectations() {
if (!_saveCalled) fail('save が呼ばれなかった'); // 呼ばれなくても失敗
}
}
スパイとの違いは「期待をどちらが知っているか」。
| Spy | Mock | |
|---|---|---|
| 期待の宣言 | しない(記録するだけ) | 実行前に宣言 |
| 「何が正しいか」を知っているのは | テストコード | モック自身 |
| 検証 | 実行後、テスト側が assert | モックの verify に委ねる(超過呼び出し時に即失敗させる実装も可) |
Uncle Bob はこの関係を「mock は spy の一種、spy は stub の一種、stub は dummy の一種。fake だけがどれの一種でもない」と包含関係で整理している3。モックとは、スパイに「期待の知識」を持たせたもの、という理解でいい。
この「期待を先に宣言する」スタイルを体現しているのが Java の jMock や EasyMock だ1。一方、同じ Java でも Mockito は verify() を実行後に呼ぶ設計で、分類上はスタブ+スパイ寄り。Dart の mockito はその移植なのだから、同じ性質を受け継ぐのは当然の帰結ということになる。「Dart にはモックライブラリがあるが、厳密なモックはほぼ書かれていない」という状況は、上流から連綿と続いていた。
余談だが、この混乱はライブラリに限らない。テストダブルの解説記事ですら、"true mock" と称して、実態は「実行後にテスト側が検証する」コード例——期待の知識がテスト側にあるので、分類上はスパイ——を載せていたりする4。用語の混線はそれくらい根深い。
5. Fake — 動くが本番には出せない簡易実装
役割: 簡易版の本物。ロジックを持つ、動く実装。ただしインメモリで永続化しない等の理由で本番投入はできない。Fowler の要約では「動く実装を持つが、本番には適さないショートカットをしているオブジェクト。インメモリDBが好例」1。
class FakeUserRepository implements UserRepository {
final _store = <String, User>{}; // Map を DB の代わりにする
@override
Future<User?> findById(String id) async => _store[id];
@override
Future<void> save(User user) async => _store[user.id] = user;
}
スタブと違い、save したものが findById で返ってくる。「保存 → 読み出し → 更新」のように複数操作をまたぐシナリオで威力を出す。個々のメソッド呼び出しに when を積み上げるより、Fake ひとつのほうがテストが宣言的になるケースは多い。
Flutter/Firebase 界隈の典型例が fake_cloud_firestore。インメモリで動く Firestore の Fake で、クエリ、トランザクション、セキュリティルールの検証までサポートしている5。
そしてこのパッケージ、元の名前は cloud_firestore_mocks だった。README に "Previously known as cloud_firestore_mocks" と明記されている。実態は Fake なのに mock と名乗っていて、後に改名した。本記事のテーマがパッケージ名の歴史としてそのまま刻まれている。
Fake のトレードオフは、Fake 自体がバグを持ちうること。本物と Fake の挙動差(例: クエリの細かい仕様)がテストの信頼性の穴になる。Uncle Bob は「Fake は極端に複雑化しうる。Fake 自身にユニットテストが必要になるほどに」と警告している3。
実践: MVVM 構成のどこで何を差し替えるか
ここまでの分類を、実際のアプリの層構造に当てはめる。Flutter の MVVM でよくある構成を例にする。
View → ViewModel → Repository → Service (API クライアント) → HTTP
役割分担はこう。View が画面、ViewModel が画面のロジック、Repository がデータの出し入れの窓口、Service が実際に HTTP を叩いて DTO(API のレスポンスをそのまま写した型)を返す層。Repository は Service を束ねて、DTO をアプリ内で使うドメインモデルに変換する。題材の PromoteViewModel はこの図の ViewModel に当たる。
// Service: HTTP を叩いて DTO を返す層
class UserService {
UserService(this._client);
final http.Client _client;
Future<UserDto> fetchUser(String id) async {
final res = await _client.get(Uri.parse('$baseUrl/users/$id'));
if (res.statusCode != 200) throw ApiException(res.statusCode);
return UserDto.fromJson(jsonDecode(res.body));
}
}
// Repository: Service を束ね、ドメインモデルに変換する層
class UserRepositoryImpl implements UserRepository {
UserRepositoryImpl(this._service);
final UserService _service;
@override
Future<User?> findById(String id) async {
try {
final dto = await _service.fetchUser(id);
return dto.toDomain();
} on ApiException catch (e) {
if (e.statusCode == 404) return null;
rethrow;
}
}
}
原則は1つ。テスト対象から見て、最初に現れる「自分が所有する抽象」の境界で差し替える。二段下まで差し替えない。
| テスト対象 | 差し替える境界 | 使うダブル |
|---|---|---|
| ViewModel | Repository | 前半の5分類でやったことそのもの |
| Repository | Service | Stub(正常系・異常系の間接入力) |
| Service | http.Client |
MockClient(実態は設定可能な Stub) |
ViewModel のテスト: Repository を差し替える
前半の5分類は、すべて ViewModel のテストとして書いてきた。差し替え境界は Repository で、正常系の複数操作なら Fake、異常系の注入なら Stub、という使い分けもすでに見たとおり。Riverpod で DI(依存の注入)をしているなら、構築の仕方が overrideWithValue に変わるだけで境界は同じだ。
final container = ProviderContainer(overrides: [
userRepositoryProvider.overrideWithValue(FakeUserRepository()),
]);
ここで http.Client まで差し替えて JSON を仕込む書き方も可能だが、やらない。二段下を差し替えると、Repository の変換ロジックや Service のエラーハンドリングまで ViewModel のテストに漏れ込み、テストが層の内部実装に結合する。
Repository のテスト: Service を Stub に
Repository の責務は「DTO → ドメイン変換」と「例外の翻訳(404 → null 等)」。Service を Stub にして、正常系・異常系の間接入力を注入する。
test('404 なら null を返す', () async {
final repo = UserRepositoryImpl(StubUserService(throws: ApiException(404)));
expect(await repo.findById('42'), isNull);
});
Service のテスト: http.Client を差し替える
package:http には package:http/testing.dart の MockClient がある。handler 関数を渡し、リクエストに対して固定レスポンスを返させる構造6なので、分類上は設定可能な Stub。名前は Mock。
final client = MockClient((request) async =>
http.Response(jsonEncode({'id': '42', 'role': 'member'}), 200));
final service = UserService(client);
面白いのは、Flutter 公式のテストクックブックがこの命名衝突を認識していることだ。mockito で生成した http.Client のモックを、コメント付きでわざわざ MockHttpClient と改名している——「package:http/testing.dart の MockClient との混同を避けるため」と7。公式ドキュメントが命名の混乱を回避するためのワークアラウンドを書いている。dio 派なら http_mock_adapter が同系統で、onGet(path, (server) => server.reply(200, ...)) と応答を宣言する Stub スタイルだ8。
なぜ「自分の抽象」の境界で差し替えるのか
モックオブジェクトの考案者たち自身が示したガイドライン "Only mock types you own"(自分が所有する型だけをモックせよ)が答えだ。
出典は Freeman らの論文 "Mock Roles, not Objects" (OOPSLA 2004)9で、同著者らの書籍 GOOS でも展開されている。
http.Client や FirebaseFirestore のような外部ライブラリの型を直接 verify すると、テストがライブラリの API 仕様に結合し、ライブラリ更新のたびに割れる。
自分が所有する抽象(Repository / Service インターフェース)を境界にすれば、外部依存の変更は Impl のテスト1箇所に閉じる。fake_cloud_firestore のようにコミュニティが保守する高品質な Fake がある場合だけ、この原則の例外として直接使ってよい。
そして表を見返すと、この構成に spy(verify)がどこにも登場しないことに気づく。spy が正当化されるのは、analytics 送信や logger のように、state に現れない副作用を検証するときだけ。
まとめ: 判別フローと実務への落とし込み
判別は質問形式が早い。
- テスト中に使われない → dummy
- 決まった答えを返すためだけにいる → stub(状態検証)
- 呼び出しの記録を、実行後にテスト側が検証する → spy(振る舞い検証)
- 期待を事前に宣言し、モック自身が検証する → mock(振る舞い検証・strict)
- 簡易だがロジックを持って動く → fake(状態検証)
1. mocktail のコードを分類で読み直す。 when だけならスタブ。verify を足したらスパイ。厳密なモックは書いていない。この認識があると、「verify を消せるか?」という問いが立てられるようになる。
2. 状態検証を先に検討する。 verify を使った瞬間、テストは実装の「呼び出し回数・順序」に結合する。リファクタリングで内部の呼び方を変えただけでテストが割れる。Fowler はこれを mockist テストの主要な懸念として挙げ、自身は classicist だと明言している10。振る舞い検証が正当化されるのは、副作用が外部に飛ぶ(メール送信、決済、ログ出力)ために状態として観測できないとき。Fowler が挙げる例外はキャッシュで、ヒットしたかミスしたかは状態から判別できないため、古典派でも振る舞い検証を選ぶべき場面だとしている。
3. 役割で命名する。 変数名を全部 mockRepo にせず、stubRepo / spyRepo / fakeRepo と役割で名付ける。テストを読む人が「これは材料係か、監視係か」を名前だけで判別できる。テストの意図(状態検証か振る舞い検証か)が命名に現れる。
分類を覚えること自体に価値はない。「今このテストは何を検証しているのか」を言語化できることに価値がある。全部モックと呼んでいるうちは、その言語化ができない。
参考文献
一次資料
- Gerard Meszaros, xUnit Test Patterns: Refactoring Test Code, Addison-Wesley, 2007. 5分類の原典。Web 版: http://xunitpatterns.com/
- Martin Fowler, "Mocks Aren't Stubs", 2004年初出・2007年改訂. https://martinfowler.com/articles/mocksArentStubs.html
- Tim Mackinnon, Steve Freeman, Philip Craig, "Endo-Testing: Unit Testing with Mock Objects", XP2000. 「Mock Object」の初出論文
- Steve Freeman, Tim Mackinnon, Nat Pryce, Joe Walnes, "Mock Roles, not Objects", OOPSLA 2004. http://jmock.org/oopsla2004.pdf — "Only mock types you own" の原典。書籍展開が同著者らの Growing Object-Oriented Software, Guided by Tests (2009)
解説
- Robert C. Martin, "The Little Mocker", 2014. https://blog.cleancoder.com/uncle-bob/2014/05/14/TheLittleMocker.html — mock ⊂ spy ⊂ stub ⊂ dummy の包含関係はここから
- Matheus Marabesi, "Mocks, dummies, spies, fakes - testing your code", 2020年公開・2025年更新. https://marabesi.com/software-engineering/mocks-dummies-spies-fakes-testing-your-code.html (CC BY 4.0)
パッケージ(mocktail, fake_cloud_firestore, MockClient, http_mock_adapter)と Flutter 公式ドキュメントへのリンクは本文の脚注に記載。
-
Fowler "Mocks Aren't Stubs" の "The Difference Between Mocks and Stubs" 節。Meszaros の5定義の要約と「スタントダブル語源」「振る舞い検証を要求するのはモックのみ」はここに依る。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Mackinnon, Freeman, Craig "Endo-Testing: Unit Testing with Mock Objects" (XP2000)。「Mock Object」の初出論文。 ↩
-
Marabesi(後述の参考文献)の記事。JavaScript のテストフレームワーク jest の
jest.fn()を渡し、実行後にtoHaveBeenCalledWithで検証する例が "true mock" として載っている。mock の定義文自体は「返り値ではなく、どの関数がどんな引数で何回呼ばれたかに関心を持つ」と正確に書かれている。定義は正しくても、コードに落とすと spy になる。この滑りこそが「全部モックと呼んでしまう」現象の実例だと思う。 ↩ -
https://pub.dev/documentation/http/latest/testing/MockClient-class.html ↩
-
Freeman, Mackinnon, Pryce, Walnes "Mock Roles, not Objects" (OOPSLA 2004). http://jmock.org/oopsla2004.pdf ↩
-
この二派は Fowler の記事では classicist / mockist と呼ばれ、XP の C3 プロジェクトがデトロイト発、mockist スタイルがロンドンの初期 XP コミュニティ発であることから Detroit / London という通称もある。ただし Fowler によれば mockist 側にはこの呼び分け自体を嫌う人も多い。 ↩