🧪

【Flutter】Interceptorを丁寧にテストするためのベストプラクティス

に公開

はじめに

dioのinterceptorの実装方法については、以前こちらの記事で解説しました。

https://zenn.dev/harx/articles/6460e36ea3f6f2

一方で、 Interceptor のテストを行うには少々工夫が必要です。

この記事では、実践的なテスト手法とその背景にある考え方を具体的に紹介します。
「どこまでテストすべきか」「何をどうモックすべきか」といった疑問を持った方の助けになれば幸いです。

記事の対象者

  • Flutter で Dio を使ってネットワーク層を構築している方
  • 自作の Interceptor をしっかりテストしたい方
  • dio_cache_interceptordio_smart_retry などの拡張パッケージを導入している方
  • HttpClientAdapter の仕組みに興味がある方
  • 「モックで表面的なテストはできたけど、本当に動作確認したい」と思っている方

記事を執筆時点での筆者の環境

[✓] Flutter (Channel stable, 3.29.0, on macOS
    15.3.1 24D70 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android
    devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.100.0)

ソースコード

https://github.com/HaruhikoMotokawa/dio_interceptors_sample/tree/main

テストの前提

今回は以下の条件であることを含みます。

  • riverpodを使って依存性注入を行う
  • mockitoを使ってモックを作成する

https://pub.dev/packages/riverpod

https://pub.dev/packages/mockito

Interceptor のテストにおける基本方針

handlerをモックする

Interceptor のテストを行う上で、基本的な考え方はそれぞれのhandlerを呼ばれているかどうかをテストします。
Interceptor を継承した独自のクラスの基本形は以下となります。

class ExampleInterceptor extends Interceptor {
  const ExampleInterceptor();

  
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    // このインターセプターで挟みたい処理を記述
    //...
    handler.next(options); // 次のInterceptorに進む
  }

  
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    // このインターセプターで挟みたい処理を記述
    //...
    handler.next(response); // 次のInterceptorに進む
  }

  
  void onError(DioException err, ErrorInterceptorHandler handler) {
    // このインターセプターで挟みたい処理を記述
    //...
    handler.next(err); // 次のInterceptorに進む
  }
}

よって、各メソッドの結果である handler.next(...); を検証するために、まずは各種の handler をmockitoでモックします。

test/_mock/mock.dart
([
  // ...
  MockSpec<RequestInterceptorHandler>(), // <----- 💡
  MockSpec<ResponseInterceptorHandler>(), // <----- 💡
  MockSpec<ErrorInterceptorHandler>(), // <----- 💡
  // ...
])
void main() {}

FakeErrorInterceptor のテスト ~実装の確認

手始めに FakeErrorInterceptor をテストしてみます。
この Interceptor はクラス内に保持したカウントに応じてエラーをスローします。
dioでのhttp通信を行うごとにカウントは増えていき、最終的には20回目でカウントをリセットします。

機能としては onResponse のみでしか反応しない作りになっています。
リクエストの送信やエラーが流れてきた場合はこのinterceptorは素通りします。

lib/data/sources/http/interceptors/_fake_error/fake_error_interceptor.dart
(keepAlive: true)
FakeErrorInterceptor fakeErrorInterceptor(Ref ref) => FakeErrorInterceptor();

class FakeErrorInterceptor extends Interceptor {
  int _count = 0;

  
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    final netWorkException = DioException(
      requestOptions: response.requestOptions,
      response: response,
      type: DioExceptionType.badCertificate,
    );

    final authException = DioException.badResponse(
      statusCode: 401,
      requestOptions: response.requestOptions,
      response: Response(
        requestOptions: response.requestOptions,
        statusCode: 401,
        data: response.data,
      ),
    );
    _count++;
    if (_count <= 2) {
      logger.w('FakeErrorInterceptor: Simulating error #$_count');
      // 第二位置引数のcallFollowingErrorInterceptorをtrueにすると
      // 次のInterceptorに進む
      handler.reject(netWorkException, true);
    } else if (_count == 11) {
      logger.w('FakeErrorInterceptor: Simulating error #$_count');
      handler.reject(authException, true);
    } else if (_count == 12) {
      logger.w('FakeErrorInterceptor: Simulating error #$_count');
      handler.reject(authException, true);
    } else if (_count == 20) {
      logger.i('FakeErrorInterceptor: Simulating count reset');
      _count = 0;
      handler.next(response);
    } else {
      // 正常であれば次のInterceptorに進む
      handler.next(response);
    }
  }
}

テストコード ~ mainとsetUp

先に述べたように FakeErrorInterceptor では handler はレスポンスのみ実装されています。
よってmockitoで作成した MockResponseInterceptorHandler だけをモックで差し込んでいます。

lib/data/sources/http/interceptors/_fake_error/fake_error_interceptor.dart
void main() {
  late ProviderContainer container;
  late FakeErrorInterceptor interceptor;

  final mockResponseHandler = MockResponseInterceptorHandler();

  setUp(() {
    reset(mockResponseHandler);

    container = ProviderContainer();

    interceptor = container.read(fakeErrorInterceptorProvider);
  });

  tearDown(() {
    container.dispose();
  });

 // ...

}

テストコード ~ テストの一例

ここでは一部のテストを抜粋してご紹介します。

  group('onResponse', () {
    test('レスポンスが1回目と2回目の時に DioException をスローする', () async {
      final response = Response(
        requestOptions: RequestOptions(path: '/test_api/test'),
        data: 'test',
      );

      // 1回目のレスポンスで DioException がスローされることを確認
      interceptor.onResponse(response, mockResponseHandler);
      verify(
        mockResponseHandler.reject(
          argThat(isA<DioException>()),
          true,
        ),
      ).called(1);

      // 2回目のレスポンスで DioException がスローされることを確認
      interceptor.onResponse(response, mockResponseHandler);
      verify(
        mockResponseHandler.reject(
          argThat(isA<DioException>()),
          true,
        ),
      ).called(1);

      // 3回目のレスポンスで正常に処理されることを確認
      interceptor.onResponse(response, mockResponseHandler);
      verify(mockResponseHandler.next(response)).called(1);
    });

    // ...
  });

テスト上で interceptor.onResponse を実行するためにモックした handler であるmockResponseHandler を渡しています。

interceptor.onResponse(response, mockResponseHandler);

そしてレスポンスが流れてきた回数毎に handler が何を実行しているのかをmockitoの verify 関数で検証することができます。

このようにして handler をモックすればその他の onErroronRequest をテストすることも可能です。

DioCacheInterceptor を使っている場合のテスト

キャッシュ機構を簡単に実装できるdio_cache_interceptorを実装した場合をのテストを見ていきます。

https://pub.dev/packages/dio_cache_interceptor

テストしやすいように実装する

まず、テストしやすいように DioCacheInterceptor をクラス内で直接インスタンス化せずに ref 経由で渡す形にしています。

lib/data/sources/http/interceptors/cache/dio_cache_interceptor.dart
(keepAlive: true)
DioCacheInterceptor dioCacheInterceptor(Ref ref) {
  return DioCacheInterceptor(
    options: CacheOptions(
      store: MemCacheStore(),
      maxStale: const Duration(minutes: 10),
    ),
  );
}
lib/data/sources/http/interceptors/cache/custom_dio_cache_interceptor.dart
class CustomDioCacheInterceptor extends Interceptor {
  CustomDioCacheInterceptor(this.ref);

  final Ref ref;

  // 💡 ここでref経由でインスタンスを取得する
  DioCacheInterceptor get _interceptor => ref.read(dioCacheInterceptorProvider);

  
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    _interceptor.onRequest(options, handler);
  }

  
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    _interceptor.onResponse(response, handler);
  }

  
  void onError(DioException err, ErrorInterceptorHandler handler) {
    _interceptor.onError(err, handler);
  }
}

さらに CustomDioCacheInterceptor 自体もテストしやすいように provider化しておきます。

lib/data/sources/http/interceptors/cache/custom_dio_cache_interceptor.dart
(keepAlive: true)
CustomDioCacheInterceptor customDioCacheInterceptor(Ref ref) =>
    CustomDioCacheInterceptor(ref);

テストコード ~mainとsetUp

今回の場合は handler は3種類全て検証するので、3種モックで差し込みます。
また、DioCacheInterceptor をモックで差し込みます。

test/_mock/mock.dart
([
  MockSpec<DioCacheInterceptor>(), // <----- 💡
  MockSpec<RequestInterceptorHandler>(), // <----- 💡
  MockSpec<ResponseInterceptorHandler>(), // <----- 💡
  MockSpec<ErrorInterceptorHandler>(), // <----- 💡
  // ...
])
void main() {}

ここは ref 経由なので overrideWithValue を忘れずに行いましょう。

test/data/sources/http/interceptors/cache/custom_dio_cache_interceptor_test.dart
void main() {
  late ProviderContainer container;
  late CustomDioCacheInterceptor interceptor;

  final mockDioCacheInterceptor = MockDioCacheInterceptor();
  final mockRequestHandler = MockRequestInterceptorHandler();
  final mockResponseHandler = MockResponseInterceptorHandler();
  final mockErrorHandler = MockErrorInterceptorHandler();

  setUp(() {
    reset(mockDioCacheInterceptor);
    reset(mockRequestHandler);
    reset(mockResponseHandler);
    reset(mockErrorHandler);

    container = ProviderContainer(
      overrides: [
        dioCacheInterceptorProvider.overrideWithValue(mockDioCacheInterceptor),
      ],
    );

    interceptor = container.read(customDioCacheInterceptorProvider);
  });

  tearDown(() {
    container.dispose();
  });

  // ...
}

テストコード ~onRequest

検証内容は以下のコメントにもあるとおり、DioCacheInterceptoronRequest が呼ばれたかどうかです。
また、通常の handler.next が呼ばれていないことを verifyNever で検証します。

test/data/sources/http/interceptors/cache/custom_dio_cache_interceptor_test.dart
  group('onRequest', () {
    test('DioCacheInterceptor のonRequestが呼ばれる', () async {
      final requestOptions = RequestOptions(path: '/test_api/test');

      // リクエストを実行
      interceptor.onRequest(requestOptions, mockRequestHandler);

      // DioCacheInterceptor の onRequest が呼ばれたことを確認
      verify(
        mockDioCacheInterceptor.onRequest(
          requestOptions,
          mockRequestHandler,
        ),
      ).called(1);

      // 通常のリクエストハンドラーが呼ばれないことを確認
      verifyNever(mockResponseHandler.next(any));
    });
  });

その他の onResponse onError も同様なので、ここでは割愛します。

キャッシュできているかをテストするには? ~HttpClientAdapterを活用

前述したテスト内容だと CustomDioCacheInterceptor は内部で DioCacheInterceptor を使っているか?
ということしかテストしておらず、実際にキャッシュ機能が動いているかのテストにはなっていません。

そこでしっかりテストするにはhttp通信を複数回行った結果、dioの fetch 関数が1回しか呼ばれていないことを検証する必要があります。
ただ、そのままテスト内で実際のhttp通信を行うのはテストとしてよろしくありません。

通信環境に依存してしまう、本番のAPIと実際に通信してしまうので本番のAPI側の状態にテストが依存してしまいます。

そこでhttp通信をモックする必要があるのですが、そのモックを行うには HttpClientAdapter というものを実装したカスタムクラスを用意する必要があります。

https://pub.dev/documentation/dio/latest/dio/HttpClientAdapter-class.html

dioの内部処理の順番としては最初にinterceptorを通り、最後に HttpClientAdapter を通って通信を行っています。
基本的には HttpClientAdapter をカスタマイズすることはあまりないのですが、今回のようにテストで検証するときにはカスタムしたものを差し込むことで検証ができます。

dioのfetchを監視するカスタム HttpClientAdapter

HttpClientAdapter を実装したカスタムクラスを作成します。

その中でfetch関数をオーバーライドして、その中に回数を数える機構を入れ込みます。

後に紹介するリトライ処理でも使えるように引数 final DioException? dioException; がある場合は例外をスローするようにしています。

また、今回はキャッシュができることを検証したいので、このレスポンスがキャッシュできることを許可するようにheadersに設定する必要があります。

test/test_util/fetch_behavior_test_adapter.dart
class FetchBehaviorTestAdapter implements HttpClientAdapter {
  FetchBehaviorTestAdapter({
    required this.onAttempt,
    this.responseData,
    this.dioException,
  });

  final void Function(int attempt) onAttempt;
  final String? responseData;
  final DioException? dioException;

  int _internalAttempt = 0;

  // 特に必要ないが、HttpClientAdapterのインターフェースに合わせるために実装
  
  void close({bool force = false}) {}

  
  Future<ResponseBody> fetch(
    RequestOptions options,
    Stream<List<int>>? requestStream,
    Future<void>? cancelFuture,
  ) async {
    _internalAttempt++; // 💡 <= ここでカウントを増やす
    onAttempt(_internalAttempt); // 💡 <= 外部で取得できるように関数で公開

    if (dioException case final dioException?) {
      throw dioException;
    } else {
      // レスポンスを返す
      return ResponseBody.fromString(
        responseData ?? 'success',
        200,
        headers: {
          Headers.contentTypeHeader: ['application/json'],
          'cache-control': ['public, max-age=120'], // 💡 <= ここが重要!ここでキャッシュを許可
        },
      );
    }
  }
}

独自のHttpClientAdapterを使ってキャッシュしているかをテストする

test/data/sources/http/interceptors/cache/custom_dio_cache_interceptor_test.dart
group('キャッシュの挙動確認', () {
  /// 全体テストとは別のcontainerを使う
  late ProviderContainer container;
  late CustomDioCacheInterceptor customDioCacheInterceptor;
  late Dio dio;
  late FetchBehaviorTestAdapter dioHttpClientAdapter;
  late int fetchCount;

  const path = '/test_api/test';
  const mockedResponse = 'mocked response';

  setUp(() {
    container = ProviderContainer();
    fetchCount = 0;
    dio = Dio(BaseOptions(baseUrl: 'https://example.com'));

    dioHttpClientAdapter = FetchBehaviorTestAdapter(
      onAttempt: (attempt) {
        fetchCount = attempt;
      },
      responseData: mockedResponse,
    );

    customDioCacheInterceptor =
        container.read(customDioCacheInterceptorProvider);

    // customDioCacheInterceptor をDioに追加
    dio.interceptors.add(customDioCacheInterceptor);

    // FetchBehaviorTestAdapterをDioのHttpClientAdapterに設定
    dio.httpClientAdapter = dioHttpClientAdapter;
  });

  tearDown(() {
    container.dispose();
  });

  test('同じリクエストはキャッシュが使われ fetch されない', () async {
    // 初回
    final res1 = await dio.get<String>(path);
    expect(res1.data, mockedResponse);

    // 2回目
    final res2 = await dio.get<String>(path);
    expect(res2.data, mockedResponse);

    // キャッシュを使っているので fetchCount は 1 のまま
    expect(fetchCount, equals(1));
  });
  test('異なるリクエストはキャッシュが使われず fetch される', () async {
    // 初回
    final res1 = await dio.get<String>(path);
    expect(res1.data, mockedResponse);

    // 異なるリクエスト
    final res2 = await dio.get<String>('/test_api/another_test');
    // 異なるリクエストでもレスポンスは同じになるように今回はモックされている
    expect(res2.data, mockedResponse);

    // 異なるリクエストなので fetchCount は 2 になる
    expect(fetchCount, equals(2));
  });
});

RetryInterceptor を使っている場合のテスト

ここではリトライ処理を簡単に実装できるdio_smart_retryを実装した場合のテストを見ていきましょう。

https://pub.dev/packages/dio_smart_retry

基本的な流れは DioCacheInterceptor の時と同じで、依存性注入をriverpodで行うようにproviderで定義しています。
ここではリトライのテスト独自の部分について解説していきます。

リトライする時間を差し替え可能にしておく

RetryInterceptor の設定項目のうち、retryDelays があります。
これはリトライから次のリトライを実行するまでの間隔をどの程度にするかの設定です。
基本設定はリトライの上限が3となっていることを前提としていますが、次のとおりです。

List<Duration> retryDelays = const [
  Duration(seconds: 1),
  Duration(seconds: 3),
  Duration(seconds: 5),
  ]

特に設定を変えなくてもいい場合はこのままとするのですが、このままだと実際にテストをした場合も同様の時間経過を待たなくてはなりません。
そこで差し替え可能なように定数化したものを設定します。

lib/data/sources/http/interceptors/retry/retry_interceptor.dart
(keepAlive: true)
RetryInterceptor retryInterceptor(
  Ref ref,
  Dio dio, {
  FutureOr<bool> Function(DioException, int)? retryEvaluator,
}) {
  return RetryInterceptor(
    dio: dio,
    retryEvaluator: retryEvaluator,
    logPrint: logger.d,
    retryDelays: Constants.dioRetryDelays, // 💡 <= 差し替え可能な変数を入れておく
  );
}

Constants.dioRetryDelays は次のとおりに宣言しています。
コメントにもありますが、ここでの宣言方法が static のみであり、static const ではないことが重要です。

lib/core/constant/constants.dart
abstract final class Constants {
  /// dioのリトライ待機時間
  ///
  /// テストで時間を変更できるようにするためstaticのみで宣言する
  static List<Duration> dioRetryDelays = const [
    Duration(seconds: 1),
    Duration(seconds: 3),
    Duration(seconds: 5),
  ];
}

次にtestディレクトリの配下に flutter_test_config.dart というファイルを作り以下のように書きます。
すると、このテストディレクトリ内では Constants.dioRetryDelays はここで設定した時間と差し替えることができ、実際の実装に影響を与えずに変更することができます。

つまり、このファイル内で書かれていることが実際のテストを行う前に一回だけ実行されるということですね。
例えるならテスト全体のsetUpAll関数といった感じでしょうか。

https://api.flutter.dev/flutter/flutter_test/

test/flutter_test_config.dart
/// テスト実行前に一番最初に実行される
///
/// ここで細かい設定を行える
///
/// https://api.flutter.dev/flutter/flutter_test/
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  /// テストするときにリトライの時間をすべて0にする
  Constants.dioRetryDelays = [
    Duration.zero,
    Duration.zero,
    Duration.zero,
  ];

  await testMain();
}

RetryInterceptor の評価関数の受け渡し

RetryInterceptor にはその関数がどのエラーに反応してリトライ処理を実行するのかの条件を関数で定義するようになっています。
しかし、テストでモックを差し込むときにも全く同じ関数を渡さないといけません。
わざわざテスト側で同じ関数を定義しなくてもいいようにこの関数は static で宣言します。
ただ、静的関数にしてしまうと間違った場所で呼ばれてしまう危険性もあるので、@visibleForTestingアノテーションをつけて警告が出るようにしておきましょう、

lib/data/sources/http/interceptors/retry/auth_error_retry_interceptor.dart
class AuthErrorRetryInterceptor extends Interceptor {
  AuthErrorRetryInterceptor(this.ref, this.dio);

  final Ref ref;
  final Dio dio;

  RetryInterceptor get _interceptor =>
      ref.read(retryInterceptorProvider(dio, retryEvaluator: retryEvaluator));

  /// 認証エラーのリトライを行うかどうかの評価関数
  ///
  /// AuthErrorRetryInterceptor内かテストのみで使用する
  
  static bool retryEvaluator(DioException error, int attempt) {
    if (error.type == DioExceptionType.badResponse &&
        error.response?.statusCode == 401) {
      return true;
    }

    return false;
  }

  // ...
}

テストでは以下のように retryEvaluator を渡しています。

test/data/sources/http/interceptors/retry/auth_error_retry_interceptor_test.dart
void main() {
  late ProviderContainer container;
  late AuthErrorRetryInterceptor interceptor;

  // ...
  final mockDio = MockDio();

  setUp(() {
    // ...
    reset(mockDio);

    container = ProviderContainer(
      overrides: [
        retryInterceptorProvider(
          mockDio,
          // 💡💡💡💡💡💡
          retryEvaluator: AuthErrorRetryInterceptor.retryEvaluator,
        ).overrideWithValue(mockRetryInterceptor),
      ],
    );

    interceptor = container.read(authErrorRetryInterceptorProvider(mockDio));
  });

  // ...
}

特定のエラーでリトライするかの挙動テスト

DioCacheInterceptor のテストの時と同様に独自の HttpClientAdapter である FetchBehaviorTestAdapter を使って検証しています。

差分としてはテストごとにスローするエラーが違うので、テストごとに FetchBehaviorTestAdapter をインスタンス化してセットしている点です。

test/data/sources/http/interceptors/retry/auth_error_retry_interceptor_test.dart
group('リトライの挙動テスト', () {
  /// 全体テストとは別のcontainerを使う
  late ProviderContainer container;
  late AuthErrorRetryInterceptor authErrorRetryInterceptor;
  late Dio dio;
  late int fetchCount;

  const path = '/test_api/test';
  setUp(() {
    container = ProviderContainer();
    fetchCount = 0;

    dio = Dio(BaseOptions(baseUrl: 'https://example.com'));
    authErrorRetryInterceptor =
        container.read(authErrorRetryInterceptorProvider(dio));

    dio.interceptors.add(authErrorRetryInterceptor);
  });

  tearDown(() {
    container.dispose();
  });

  test('DioExceptionType.badResponse の場合、3回リトライされる', () async {
    // 検証用のHttpClientAdapterを作成
    final adapter = FetchBehaviorTestAdapter(
      onAttempt: (attempt) {
        fetchCount = attempt;
      },
      dioException: DioException(
        requestOptions: RequestOptions(path: path),
        type: DioExceptionType.badResponse,
        response: Response(
          requestOptions: RequestOptions(path: path),
          statusCode: 401,
        ),
      ),
    );

    // Dioにアダプターを設定
    dio.httpClientAdapter = adapter;

    // 処理を実行し、最終的にDioExceptionがスローされることを確認
    await expectLater(
      dio.get<String>(path),
      throwsA(
        isA<DioException>()
            .having((e) => e.response?.statusCode, 'statusCode', 401)
            .having(
              (e) => e.type,
              'type',
              DioExceptionType.badResponse,
            ),
      ),
    );

    // 初回1 + リトライ3回 = 4回行われている
    expect(fetchCount, equals(4));
  });
  test('DioExceptionType.badResponse以外 の場合は何もしない', () async {
    // 検証用のHttpClientAdapterを作成
    final adapter = FetchBehaviorTestAdapter(
      onAttempt: (attempt) {
        fetchCount = attempt;
      },
      dioException: DioException(
        requestOptions: RequestOptions(path: path),
        type: DioExceptionType.connectionTimeout,
        response: Response(
          requestOptions: RequestOptions(path: path),
        ),
      ),
    );

    // Dioにアダプターを設定
    dio.httpClientAdapter = adapter;

    // 処理を実行し、最終的にDioExceptionがスローされることを確認
    await expectLater(
      dio.get<String>(path),
      throwsA(
        isA<DioException>().having(
          (e) => e.type,
          'type',
          DioExceptionType.connectionTimeout,
        ),
      ),
    );

    // リトライは行われないので、1回だけ呼ばれる
    expect(fetchCount, equals(1));
  });
});

終わりに

Interceptor のテストは一見地味ですが、アプリの信頼性を高めるうえで非常に重要な工程です。

今回紹介したように、handler のモックやカスタム HttpClientAdapter の活用により、Interceptor の挙動を精密に検証できるようになります。
とくにキャッシュやリトライといった仕組みは、正しく動作しているかどうかの確認が難しい分、こうしたアプローチが有効です。

この記事が Interceptor テストの第一歩、あるいは次の改善ステップの参考になれば幸いです。

Discussion