はじめに
dioのinterceptorの実装方法については、以前こちらの記事で解説しました。
一方で、 Interceptor
のテストを行うには少々工夫が必要です。
この記事では、実践的なテスト手法とその背景にある考え方を具体的に紹介します。
「どこまでテストすべきか」「何をどうモックすべきか」といった疑問を持った方の助けになれば幸いです。
記事の対象者
- Flutter で Dio を使ってネットワーク層を構築している方
- 自作の
Interceptor
をしっかりテストしたい方 -
dio_cache_interceptor
やdio_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)
ソースコード
記事内では必要箇所のコードを抜粋していますが、全体を掴みたい方はGitHubをご覧ください。
テストの前提
今回は以下の条件であることを含みます。
- riverpodを使って依存性注入を行う
- mockitoを使ってモックを作成する
Interceptor
のテストにおける基本方針
handlerをモックする
Interceptor
のテストを行う上で、基本的な考え方はそれぞれのhandlerを呼ばれているかどうかをテストします。
Interceptor
を継承した独自のクラスの基本形は以下となります。
class ExampleInterceptor extends Interceptor {
const ExampleInterceptor();
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// このインターセプターで挟みたい処理を記述
//...
handler.next(options); // 次のInterceptorに進む
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
// このインターセプターで挟みたい処理を記述
//...
handler.next(response); // 次のInterceptorに進む
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// このインターセプターで挟みたい処理を記述
//...
handler.next(err); // 次のInterceptorに進む
}
}
よって、各メソッドの結果である handler.next(...);
を検証するために、まずは各種の handler
をmockitoでモックします。
@GenerateNiceMocks([
// ...
MockSpec<RequestInterceptorHandler>(), // <----- 💡
MockSpec<ResponseInterceptorHandler>(), // <----- 💡
MockSpec<ErrorInterceptorHandler>(), // <----- 💡
// ...
])
void main() {}
FakeErrorInterceptor
のテスト ~実装の確認
手始めに FakeErrorInterceptor
をテストしてみます。
この Interceptor
はクラス内に保持したカウントに応じてエラーをスローします。
dioでのhttp通信を行うごとにカウントは増えていき、最終的には20回目でカウントをリセットします。
機能としては onResponse
のみでしか反応しない作りになっています。
リクエストの送信やエラーが流れてきた場合はこのinterceptorは素通りします。
@Riverpod(keepAlive: true)
FakeErrorInterceptor fakeErrorInterceptor(Ref ref) => FakeErrorInterceptor();
class FakeErrorInterceptor extends Interceptor {
int _count = 0;
@override
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
だけをモックで差し込んでいます。
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
をモックすればその他の onError
や onRequest
をテストすることも可能です。
DioCacheInterceptor
を使っている場合のテスト
キャッシュ機構を簡単に実装できるdio_cache_interceptorを実装した場合をのテストを見ていきます。
テストしやすいように実装する
まず、テストしやすいように DioCacheInterceptor
をクラス内で直接インスタンス化せずに ref
経由で渡す形にしています。
@Riverpod(keepAlive: true)
DioCacheInterceptor dioCacheInterceptor(Ref ref) {
return DioCacheInterceptor(
options: CacheOptions(
store: MemCacheStore(),
maxStale: const Duration(minutes: 10),
),
);
}
class CustomDioCacheInterceptor extends Interceptor {
CustomDioCacheInterceptor(this.ref);
final Ref ref;
// 💡 ここでref経由でインスタンスを取得する
DioCacheInterceptor get _interceptor => ref.read(dioCacheInterceptorProvider);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
_interceptor.onRequest(options, handler);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
_interceptor.onResponse(response, handler);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
_interceptor.onError(err, handler);
}
}
さらに CustomDioCacheInterceptor
自体もテストしやすいように provider化しておきます。
@Riverpod(keepAlive: true)
CustomDioCacheInterceptor customDioCacheInterceptor(Ref ref) =>
CustomDioCacheInterceptor(ref);
テストコード ~mainとsetUp
今回の場合は handler
は3種類全て検証するので、3種モックで差し込みます。
また、DioCacheInterceptor
をモックで差し込みます。
@GenerateNiceMocks([
MockSpec<DioCacheInterceptor>(), // <----- 💡
MockSpec<RequestInterceptorHandler>(), // <----- 💡
MockSpec<ResponseInterceptorHandler>(), // <----- 💡
MockSpec<ErrorInterceptorHandler>(), // <----- 💡
// ...
])
void main() {}
ここは ref
経由なので overrideWithValue
を忘れずに行いましょう。
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
検証内容は以下のコメントにもあるとおり、DioCacheInterceptor
の onRequest
が呼ばれたかどうかです。
また、通常の handler.next
が呼ばれていないことを verifyNever
で検証します。
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
というものを実装したカスタムクラスを用意する必要があります。
dioの内部処理の順番としては最初にinterceptorを通り、最後に HttpClientAdapter
を通って通信を行っています。
基本的には HttpClientAdapter
をカスタマイズすることはあまりないのですが、今回のようにテストで検証するときにはカスタムしたものを差し込むことで検証ができます。
有名なhttpモックパッケージのhttp_mock_adapterというのがありますが、これは内部でHttpClientAdapterを使っています。
https://pub.dev/packages/http_mock_adapter
ただ、今回のようにfetchの回数を数える機構がないので今回は自作します。
dioのfetchを監視するカスタム HttpClientAdapter
HttpClientAdapter
を実装したカスタムクラスを作成します。
その中でfetch関数をオーバーライドして、その中に回数を数える機構を入れ込みます。
後に紹介するリトライ処理でも使えるように引数 final DioException? dioException;
がある場合は例外をスローするようにしています。
また、今回はキャッシュができることを検証したいので、このレスポンスがキャッシュできることを許可するようにheadersに設定する必要があります。
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のインターフェースに合わせるために実装
@override
void close({bool force = false}) {}
@override
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を使ってキャッシュしているかをテストする
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を実装した場合のテストを見ていきましょう。
基本的な流れは DioCacheInterceptor
の時と同じで、依存性注入をriverpodで行うようにproviderで定義しています。
ここではリトライのテスト独自の部分について解説していきます。
リトライする時間を差し替え可能にしておく
RetryInterceptor
の設定項目のうち、retryDelays
があります。
これはリトライから次のリトライを実行するまでの間隔をどの程度にするかの設定です。
基本設定はリトライの上限が3となっていることを前提としていますが、次のとおりです。
List<Duration> retryDelays = const [
Duration(seconds: 1),
Duration(seconds: 3),
Duration(seconds: 5),
]
特に設定を変えなくてもいい場合はこのままとするのですが、このままだと実際にテストをした場合も同様の時間経過を待たなくてはなりません。
そこで差し替え可能なように定数化したものを設定します。
@Riverpod(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
ではないことが重要です。
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/
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
/// テストするときにリトライの時間をすべて0にする
Constants.dioRetryDelays = [
Duration.zero,
Duration.zero,
Duration.zero,
];
await testMain();
}
RetryInterceptor
の評価関数の受け渡し
RetryInterceptor
にはその関数がどのエラーに反応してリトライ処理を実行するのかの条件を関数で定義するようになっています。
しかし、テストでモックを差し込むときにも全く同じ関数を渡さないといけません。
わざわざテスト側で同じ関数を定義しなくてもいいようにこの関数は static
で宣言します。
ただ、静的関数にしてしまうと間違った場所で呼ばれてしまう危険性もあるので、@visibleForTesting
アノテーションをつけて警告が出るようにしておきましょう、
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内かテストのみで使用する
@visibleForTesting
static bool retryEvaluator(DioException error, int attempt) {
if (error.type == DioExceptionType.badResponse &&
error.response?.statusCode == 401) {
return true;
}
return false;
}
// ...
}
テストでは以下のように retryEvaluator
を渡しています。
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
をインスタンス化してセットしている点です。
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));
});
});
onRequest
, onResponse
, onError
のテストの掲載は割愛します。
終わりに
Interceptor
のテストは一見地味ですが、アプリの信頼性を高めるうえで非常に重要な工程です。
今回紹介したように、handler のモックやカスタム HttpClientAdapter
の活用により、Interceptor
の挙動を精密に検証できるようになります。
とくにキャッシュやリトライといった仕組みは、正しく動作しているかどうかの確認が難しい分、こうしたアプローチが有効です。
この記事が Interceptor
テストの第一歩、あるいは次の改善ステップの参考になれば幸いです。