【Dart】Dio CancelToken + CancelableOperationをテストする

# 通信をキャンセルしたい
UI操作に応じて通信など非同期処理をキャンセルしたい場面は多々あると思います。dartでは`http`ライブラリで通信まわりを実装する場合も多いですが、同じくらい人気のライブラリに`dio`があります。そして`dio`にはなんとキャンセルのための仕組みが用意されています！早速使ってみましょう。

[dio | Dart Package - Pub.dev](https://pub.dev/packages/dio)  
[CancelToken class - dio library - Dart API - Pub.dev](https://pub.dev/documentation/dio/latest/dio/CancelToken-class.html)

# テストしよう
具体的なUIとの連携などここで書くのは面倒なので、ユニットテストを書く体で`dio`による通信とそのキャンセル操作を試してみます。

## 通信をモックする
`dio`では実際に通信処理を行うクライアントObjectをプログラマーが指定できます。

[HttpClientAdapter class - dio library - Dart API - Pub.dev](https://pub.dev/documentation/dio/latest/dio/HttpClientAdapter-class.html)

（例）デフォルト実装
```dart
final dio = Dio();
dio.httpClientAdapter = DefaultHttpClientAdapter();
```

単純にこのadapterを差し替えるだけで通信をモックできます。今回は通信をキャンセルするので、timeoutの間はレスポンスを返さないようなモックサーバを用意します。
```dart:MockServer.dart

/// 任意のリクエストに対してレスポンスを遅延させ、タイムアウトしたらErrorを投げるテスト用サーバ
class MockPendingServer implements HttpClientAdapter {
  MockPendingServer({this.timeout = const Duration(seconds: 100)});

  final Duration timeout;

  @override
  void close({bool force = false}) {
    // nothing to do
  }

  @override
  Future<ResponseBody> fetch(
    RequestOptions options,
    Stream<Uint8List>? requestStream,
    Future? cancelFuture,
  ) async {
    await Future<void>.delayed(timeout);
    throw UnimplementedError(); // not reply valid response
  }
}
```

## CancelTokenのテスト
では`dio`でのキャンセルを試します。`CancelToken`オブジェクトを一緒に渡して、キャンセルしたいタイミングで`cancel()`を呼び出すだけ。通信がキャンセルされると`DioError`が投げられるので適当にcatchして確認します。

注意として、キャンセルされるまで`await dio.get`は完了しないので、`Future.sync`により`token.cancel()`とは別の非同期処理中から呼び出しています。

```dart
void main() {
  test("basic CancelToken usage", () async {
    // 通信のモック
    final dio = Dio()..httpClientAdapter = MockPendingServer();
    final token = CancelToken();
    final calling = Future.sync(() async {
      try {
        debugPrint("start");
        await dio.get<dynamic>(
          "http://test.com/hoge",
          cancelToken: token, // CancelTokenを一緒に渡す
        );
      } on DioError catch (e) {
        expect(e.type, DioErrorType.cancel);
        debugPrint("cancelled");
        return;
      }
      fail("DioError expected, but not received");
    });
    debugPrint("cancel");
    token.cancel();
    await calling;
  });
}
```

実行結果からもキャンセルの様子が分かります。
```bash
start
cancel
cancelled
```

## CancelableOperationでラップするテスト
`CancelToken`をそのまま扱ってもいいですが、`async`ライブラリに便利クラスがあるので`CancelableOperation`でラップしてみます。

[CancelableOperation class - async library - Dart API - Pub.dev](https://pub.dev/documentation/async/latest/async/CancelableOperation-class.html)

**注意** `Future`を`CancelableOperation`でラップするだけでは、`CancelableOperation#cancel()`を呼び出しても元の非同期処理はキャンセルされません。後述の通り、`onCancel`コールバックで適切なキャンセル処理を自前で定義する必要があります。

### CancelableOperationの初期化
`CancelableOperation`には`fromFuture`というfactoryコンストラクターしかありません。元の非同期処理をラップするように使用します。同時に`onCancel`を渡すと、`CancelableOperation#cancel()`のタイミングで呼び出してくれるので、`onCancel`コールバックには元の非同期処理のキャンセル処理を置きます。今回で言えば`CancelToken#cancel()`の呼び出しです。
```dart
    final original = dio.get<dynamic>(
      "http://test.com/hoge",
      cancelToken: token,
    );
    final operation = CancelableOperation.fromFuture(
      original,
      onCancel: () => token.cancel(),
    );
```
### CancelableOperationの主なAPI

よく使うやつだけ紹介
- `cancel()` キャンセルする。factoryコンストラクタで渡した`onCancel`を呼び出す。
- `value` ラップした元の非同期処理が完了したら`then`,エラーが発生したら`catchError`が呼ばれるような`Future`を返すgetter。ただし`cancel`が呼ばれた後では、ずっと終了しない`Future`を返す。
- `valueOrCancellation()` `value`と似ているが、`cancel`が呼ばれた後では指定した値（デフォルトは`null`)で終了するような`Future`を返す。


### テスト
先程と異なり、`CancelableOperation`でラップすると、キャンセルしても`valueOrCancellation()`の`Future`は`DioError`を投げません。

```dart
void main() {
  test("CancelableOperation", () async {
    final dio = Dio()..httpClientAdapter = MockPendingServer();
    final token = CancelToken();
    final operation = CancelableOperation.fromFuture(
      dio.get<dynamic>(
        "http://test.com/hoge",
        cancelToken: token,
      ),
      onCancel: token.cancel,
    );
    final calling = Future.sync(() async {
      debugPrint("start");
      final res = await operation.valueOrCancellation(null);
      debugPrint("cancelled");
      expect(res, isNull);
    });
    debugPrint("cancel");
    operation.cancel();
    await calling;
  });
}
```

出力結果は同じ。
```bash
start
cancel
cancelled
```