3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

テストで使う偽物を、全部「モック」と呼んでいた。

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方向に分かれる。

  1. テスト対象に材料を入れる係。「findById と聞かれたらこのユーザーを返して」と答えを仕込む側(stub / fake)
  2. テスト対象から出てきたものを見る係。「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.dartMockClient がある。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.dartMockClient との混同を避けるため」と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)

解説

パッケージ(mocktail, fake_cloud_firestore, MockClient, http_mock_adapter)と Flutter 公式ドキュメントへのリンクは本文の脚注に記載。

  1. Fowler "Mocks Aren't Stubs" の "The Difference Between Mocks and Stubs" 節。Meszaros の5定義の要約と「スタントダブル語源」「振る舞い検証を要求するのはモックのみ」はここに依る。 2 3 4 5 6

  2. Mackinnon, Freeman, Craig "Endo-Testing: Unit Testing with Mock Objects" (XP2000)。「Mock Object」の初出論文。

  3. Robert C. Martin "The Little Mocker" (2014)。 2 3

  4. Marabesi(後述の参考文献)の記事。JavaScript のテストフレームワーク jest の jest.fn() を渡し、実行後に toHaveBeenCalledWith で検証する例が "true mock" として載っている。mock の定義文自体は「返り値ではなく、どの関数がどんな引数で何回呼ばれたかに関心を持つ」と正確に書かれている。定義は正しくても、コードに落とすと spy になる。この滑りこそが「全部モックと呼んでしまう」現象の実例だと思う。

  5. https://pub.dev/packages/fake_cloud_firestore

  6. https://pub.dev/documentation/http/latest/testing/MockClient-class.html

  7. https://docs.flutter.dev/cookbook/testing/unit/mocking

  8. https://pub.dev/packages/http_mock_adapter

  9. Freeman, Mackinnon, Pryce, Walnes "Mock Roles, not Objects" (OOPSLA 2004). http://jmock.org/oopsla2004.pdf

  10. この二派は Fowler の記事では classicist / mockist と呼ばれ、XP の C3 プロジェクトがデトロイト発、mockist スタイルがロンドンの初期 XP コミュニティ発であることから Detroit / London という通称もある。ただし Fowler によれば mockist 側にはこの呼び分け自体を嫌う人も多い。

3
2
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
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?