🫠

単体テストの考え方[コード例あり]

2024/08/29に公開

本記事では、単体テストの考えかた/使い方を読んで、業務にいかしたので、その知見を共有しようとおもいます。ユニットテストやe2eテストなどにも触れます。コード例では、Flutterを用いて解説しますが、多言語でも読めるようにコメントアウトを多めにしました。

単体テストの考え方の 古典学派/ロンドン学派 の2つの学派を見ていきます!

こんな人におすすめ

・単体テストの書き方がきまってない
・なんとなく verifyメソットを使って、呼び出した回数などをテストしている
・単体テストの書き方に学派があることをしらない
・そもそもテストをやる意味がわからない

⭐️ 具体例を混ぜながら、上記が説明できるようになります。

参考

本記事では、以下の書籍を参考にしながら説明します。
また、参考になる部分がかなり多く、購入されることをお勧めします。
https://www.amazon.co.jp/単体テストの考え方-使い方-Vladimir-Khorikov/dp/4839981728

単体テストを行う意味

単体テストを行う理由は、プロジェクトを持続可能にするためです。単体テストを前提にしたコードでは、設計に大きな違いが生まれます。具体的には、疎結合な設計が求められ、各コンポーネントが独立してテスト可能になります。これにより、バグの早期発見や修正が容易になり、コードの品質が向上します。また、テストダブルを使うことで、外部依存を排除し、より安定したテストが可能になります。

古典学派とロンドン学派の違い

単体テストには 主に2つの学派があります。

隔離対象 単体の意味 テスト・ダブル
ロンドン学派 Units 1つのクラス 不変依存を除くすべての依存
古典学派 テスト・ケース 1つのクラス、もしくは、同じ目的を達成するためのクラスの1グループ 共有依存

過程を重視する ロンドン学派
結果を重視する 古典学派

と理解することで、腑に落ちると考えています。

例えば、こんなたとえがあります。

ロンドン学派

私が犬を呼ぶと、犬はまず左前足を動かし、次に右前足を動かし、頭を動かし、尻尾を動かす。
そして足を動かし、.....

古典学派

私が犬を呼ぶと、犬はすぐに私のところに来る。

ロンドン学派は、犬がどんな振る舞いをしているのかを見ていますよね。
一方、古典学派では、犬の振る舞いは気にせずに 来る という事実にフォーカスしています。
ここが、2つの学派を理解するうえで大事な要素になります。

ロンドン学派では、モックを多用しますが、古典学派ではモックを最小限にとどめようという考え方があります。

(共有依存とは)

ざっくり言うと、複数のテストケースが共通して依存するリソースのことです。例えば、同じデータベースやファイルシステムにアクセスするテストがこれに該当します。

異なるDBを、別々のDockerコンテナで稼働させてテストする場合は、共有依存にならないのでそのままでokです。

結局どっちを使えばいいのか?

本書は、古典学派推しです。

負債の軽減

ロンドン学派で書くと、テストコードが実装の詳細に依存しやすく、変更があるたびにテストコードの修正が必要となり、メンテナンスの負担が大きくなる。

実際の環境に近づける

古典学派では、モックの使用を最小限にするため、テストが実際の運用環境に近い形で行われ、より信頼性の高い結果が得られる。

モックをガンガン使うか、そうでないのかはやはり議論があるみたいですね。

https://blog.8-p.info/ja/2021/10/12/mock/

単体テストの4つの柱

そもそも、どんな要素が含まれていれば いい単体テストといえるのでしょうか

  • 退行に対する耐性(新しい機能を追加した後に、既存の機能が意図したようにうごかなくなったなど)
  • リファクタリング耐性
  • 迅速なフィードバック
  • 保守のしやすさ

上記を備えたテストがいいテストと言えます。

保守のしやすさを除く 3つの柱は排反する

退行に対する耐性、リファクタリング耐性、迅速なフィードバック これらすべてを、持ち合わせる単体テストを作成するのは不可能であり、リファクタリング耐性を含む、2本に絞る必要があるということです。

そこで、本書では、E2Eテストで退行に対する保護を重視し、
単体テストで迅速なフィードバックを重視します。

単体テストで大切なこと

単体テストを行うときに、一番注意しなければいけないことは、偽陽性(false positive)を出さないことです。
偽陽性は、実際には機能に問題がないのにテストが失敗する場合を指します。

例えば、内部実装が変わっただけで、実際の出力や機能には影響がないにもかかわらず、テストが失敗するケースがこれに該当します。

偽陽性はなぜ、おこるのか

答えは、実装の詳細と結びついているからです。

” ある地点で Method Aがよびだされるはず ”

といった、実装の詳細に結びつくテストがある場合、リファクタリングで順番がかわったり、廃止されることは容易に想像できます。

そのため、内部の決まりや、順番をテストするのではなく、結果をテストするという思考が大切です。

モックと スタブの使い分け

テストダブルの種類 依存の向き 検証 模倣
モック 外部 する する
スタブ 内部 しない する

依存の向きというのは、こちら側で制御可能か? ととらえるとわかりやすいかもしれません。
たとえば、メールサービスやインターネット接続の有無など、こちらはシステムの外部サービスに依存しますよね。 それらは、モックを使います。

逆に、ローカルDBなどは、こちらで制御可能ですよね。 こういう場合は スタブを使います。

また、スタブとのコミュニケーションを検証するのは、アンチパターンです。

モックと スタブの種類

モック、スパイ、スタブ、ダミー、フェイクといろいろありますが、

大まかには、モックとスタブの2種類に分かれて、モックグループにはスパイとモック、
スタブグループにはダミー、フェイク、スタブがあります。

今回は、説明を割愛しますが以下の記事がおすすめです。

https://blog.pragmatists.com/test-doubles-fakes-mocks-and-stubs-1a7491dfa3da

単体テストの3つの手法

単体テスト手法はおおまかに、以下の3種類に分けられます。

  • 出力値ベースのテスト
    テスト対象のコードに入力値を渡したあとに、そこから返される結果を検証する。

  • 状態ベースのテスト
    検証する処理の実行が終わったあとの、テスト対象の状態をテストする。

  • コミュニケーションベースのテスト
    モックを用いて、テスト対象システムとその関係するオブジェクトとの間で行われるコミュニケーションを検証する。

学派別の、テストケースの好みをまとめました。
※まったくしないということではなく、好みの傾向です。

学派 出力値ベースのテスト 状態ベースのテスト コミュニケーションベースのテスト
古典学派 ×
ロンドン学派 ×

実際のコードで確認しよう

今回は、Flutterを用いて 古典学派とロンドン学派のテストコーディングの違いを書いてみますが、
コメントアウトを書くため、他の言語でも読みやすく書きます。

例えば、ローカルDBへのデータ操作を抽象化するrepositoryのテストについて見てみましょう。
以下の具象化クラスを依存注入した、classのテストを行います。

/// [SharedPreferences] へのアクセスを提供する抽象クラス
abstract class SharedPreferencesRepository {
  // 保存
  Future<void> save<T>(AppPrefsKey key, T value);
  // 取得
  T? fetch<T>(AppPrefsKey key);
  // 削除
  Future<void> remove(AppPrefsKey key);
}

テスト対象の classはこちらです

@riverpod
class ThemeColorRepository extends _$ThemeColorRepository {

  // SharedPreferencesRepository をコンストラクタ注入
  late final SharedPreferencesRepository _prefsRepository;

  // key value で保存するため、keyを設定
  final AppPrefsKey _themeKey = AppPrefsKey.configModeType;


  // state パターンで状態管理
  @override
  ThemeMode build() {
    _prefsRepository = ref.read(sharedPreferencesRepositoryProvider);
    loadTheme();
    return state;
  }

  // ローカルDB から keyを指定してvalueを取得して状態を更新
  Future<void> loadTheme() async {
    final themeIndex =
        _prefsRepository.fetch<int>(_themeKey) ?? ThemeMode.light.index;
    state = ThemeMode.values[themeIndex];
  }

   // ローカルDB に keyを指定してvalueをセットし、状態を更新
  Future<void> setTheme(ThemeMode themeMode) async {
    state = themeMode;
    await _prefsRepository.save<int>(_themeKey, themeMode.index);
  }
}

ロンドン学派

⭐️ 特徴

  • ローカルDBをモックする。
  • verify メソットで、内部実装のコミュニケーションまで検証する。

テストコード

group('ThemeTextRepository', () {

test('[正常系] fetch', () async {
  // arrange
      when(mockPrefsRepository.fetch(AppTextScale.large.index)).thenReturn(AppTextScale.la  rge.index);

    // act
    await themeTextRepository.loadScale();

    // assert
    expect(themeTextNotifier.state, AppTextScale.large);
    verify(mockPrefsRepository.fetch(AppTextScale.large.index)).called(1);
  });
  test('[異常系] fetchで localDBからnullが返ったときにnormalが適応されること', () async {
    // arrange
    when(mockPrefsRepository.fetch(AppTextScale.large.index)).thenReturn(null); 
    // act
    await themeTextRepository.loadScale();
    // assert
    expect(themeTextNotifier.state, AppTextScale.normal);
    verify(mockPrefsRepository.getInt(AppTextScale.large.index)).called(1); 
  });

古典学派

⭐️ 特徴

  • ローカルDBの、モックではなくフェイクを使う
  • テスト結果に着目する
  • フェイクを使うことにより、モックでは返せなかった動的な値や状態を持つことができ、実際の動作により近いシナリオでテストが可能。

フェイクを作成

class FakeSharedPreferencesRepository implements SharedPreferencesRepository {
  final Map<String, dynamic> _storage = {};

  @override
  Future<bool> save<T>(AppPrefsKey key, T value) async {
    _storage[key.name] = value;
    return true;
  }

  @override
  T? fetch<T>(AppPrefsKey key) {
    return _storage[key.name] as T?;
  }

  @override
  Future<bool> remove(AppPrefsKey key) async {
    return _storage.remove(key.name) != null;
  }
}

テストコード

group('ThemeTextRepository', () {
    test('[正常系] fetch', () async {
      // arrange
      fakePrefsRepository.save(key, AppTextScale.xlarge.index);
      // act
      await themeTextRepository.loadScale();
      //assert
      expect(themeTextRepository.state, AppTextScale.xlarge);

    });
    test('[異常系] fetch localDBからnullが返ったときにnormalが適応されること', () async {
      // arrange
      fakePrefsRepository.save(key, null);
      // act
      await themeTextRepository.loadScale();
      // assert
      expect(themeTextRepository.state, AppTextScale.normal);
    });

テストコードを書きやすくするには

・テストダブルコード
・疎結合
・DI

このあたりの知識が必須になってくると思います。
以下の書籍で、本質的なことが学べますので、ぜひ!!⭐️

https://www.amazon.co.jp/なぜ依存を注入するのか-DIの原理・原則とパターン-Compass-Booksシリーズ-Steven-Deursen/dp/4839983062

まとめ

テストコードがある前提のプロジェクトとそうでないプロジェクトでは、各段にコーディングの質に差がでると考えています。
また、プロジェクトの性質により

  • どのテストに比重を充てるのか?
  • カバレッジはどれくらいを目指すのか

など、いろいろ変わってくると思いますが、 単体テストの4つの柱を意識すれば答えは見つけやすいのではないかと思います。

ちなみに、私のプロジェクトでは古典学派の考え方を使い、テストを書いています!

次回は、DIについて詳しくまとめてみようとおもいます。

Discussion