公開日 2026-09-09

Dartのtypedefで副作用を差し替える:コード生成なしのテストシーム

dart:ioなどの副作用を関数型typedefに切り出し、コンストラクタで既定実装とfakeを差し替える。mockitoやコード生成に頼らず、副作用を含む処理をテストできる境界をDartで作る形を、最小の実装とテストで示す。

目次

  1. 1. この記事で作るものと扱わない範囲
  2. 想定読者
  3. 仕上がりでできること
  4. 扱わない範囲
  5. 2. 副作用を直に呼ぶと、テストが実環境に触れる
  6. 3. どこで差し替えるかを先に図で見る
  7. 4. typedefで副作用を切り出す
  8. 開始状態
  9. 副作用に名前を付ける
  10. コードのポイント
  11. 5. fake を渡してテストする
  12. 6. 本番配線と、typedefではなくクラスにする場面
  13. typedef で足りるか、クラスにするか
  14. 7. まとめ

dart:io の File や Platform.environment を業務ロジックの中で直接呼ぶと、そのクラスのテストは実ファイルや実環境に触れてしまう。かといって、副作用を差し替えるためだけに mockito やコード生成を持ち込むと、依存とビルド手順が増えます。この記事では、副作用を関数型の typedef に切り出し、コンストラクタで既定実装と fake を差し替える形を、端末登録の最小コードで作ります。狙いは、追加パッケージもコード生成もなく dart test だけで副作用のある処理を検証できる状態にすることだ。

題材は業務端末でよくある「端末の登録」です。登録のたびに端末シリアルを環境変数から読み、監査ログへ 1 行残します。読む側と書く側で性質の違う 2 つの副作用が、1 つのメソッドに同居している。

1. この記事で作るものと扱わない範囲

想定読者

  • dart:io の File や Platform.environment を呼ぶクラスを書き始めた人
  • テストで実ファイルや実環境に触れてしまい、後片付けやばらつきに困っている人
  • mockito やコード生成を入れるほどの規模ではない、と感じている人

仕上がりでできること

  • 副作用を EnvReader / AuditWriter という関数型 typedef に名付けて切り出す
  • コンストラクタで「未指定なら本番の既定実装」に落とし込み、本番コードは注入を意識しない
  • テストでは fake の関数を渡すだけで、ファイルにも環境変数にも触れずに済む
  • typedef で足りる場面と、抽象クラスへ寄せる場面の線引きを持てる

扱わない範囲

今回は副作用を「関数として差し替える」ことに絞り、次は扱いません。

  • mockito や build_runner によるモック生成
  • dart:io を包む既存パッケージ(file パッケージなど)の導入
  • 時刻という副作用の扱い(末尾に関連記事を置きます)
  • Riverpod や get_it での DI 配線(注入する場所の例だけ示します)

2. 副作用を直に呼ぶと、テストが実環境に触れる

副作用を業務ロジックの中で直接呼ぶと、DeviceRegistry は次の形になる。

// 悪い例: 副作用を業務ロジックの中で直接呼ぶ
class DeviceRegistry {
  String? register() {
    final String? serial = Platform.environment['DEVICE_SERIAL'];
    if (serial == null || serial.isEmpty) {
      return null;
    }
    final String record = 'registered: $serial';
    File('audit.log').writeAsStringSync('$record\n', mode: FileMode.append);
    return record;
  }
}

この register() を素直にテストしようとすると、2 つの実環境に縛られます。Platform.environment['DEVICE_SERIAL'] は実行環境の環境変数を読むので、テスト結果がマシンの状態で変わる。File('audit.log') は実ファイルへ追記するため、テストのたびに audit.log が育ち、後始末をしないと次のテストに影響します。成功パスを一度通すだけで、カレントディレクトリにファイルが残る。

テストのために環境変数を仕込み、後で audit.log を消す、という段取りは書けます。ただ、その段取り自体がテストの本題である登録の判定から注意をそらす。副作用を呼ぶ場所を業務ロジックの外へ追い出せると、判定だけを見たテストが書けます。

3. どこで差し替えるかを先に図で見る

register() は 2 つの副作用を、それぞれ別の関数型 typedef 越しに呼び分ける。本番では既定の実装、テストでは fake を差し込みます。

flowchart TD
  R[DeviceRegistry.register] -->|環境変数を読む| E[EnvReader]
  R -->|監査ログを書く| W[AuditWriter]
  E -->|本番| EP[Platform.environment を参照]
  E -->|テスト| EF[固定値を返す fake]
  W -->|本番| WP[audit.log へ追記]
  W -->|テスト| WF[List へ集める fake]

押さえる点は 2 つ。

  • 本番では readEnv / writeAudit を渡さず、既定の環境変数参照とファイル追記に任せる
  • テストでは 2 つとも fake の関数を渡し、環境変数にもファイルにも触れない

4. typedefで副作用を切り出す

開始状態

Dart の CLI プロジェクトがあれば足ります。まだなければ、次のコマンドで用意します。

dart create typedef_seam
cd typedef_seam

パッケージ名は typedef_seam になり、dart test に必要な test は dev_dependencies に入った状態で生成されます。テンプレートはサンプルコードも一緒に作るので、使わない 2 つは先に削除しておきます。

rm lib/typedef_seam.dart
rm test/typedef_seam_test.dart

bin/typedef_seam.dart は残します。これが dart run の既定の入口で、§6 で中身を書き換えます。

以降のファイルは、このプロジェクト直下に置きます。別名のプロジェクトを使う場合は、後述の import 'package:typedef_seam/...' を pubspec.yaml の name に読み替えてください。

副作用に名前を付ける

lib/device_registry.dart を次の内容で作成します。副作用を 2 つの typedef に名付け、コンストラクタで既定実装へフォールバックさせた実装です。

import 'dart:io';

/// 環境変数を 1 件読む副作用。既定は Platform.environment を参照する。
typedef EnvReader = String? Function(String key);

/// 監査ログを 1 行書き出す副作用。既定は audit.log への追記。
typedef AuditWriter = void Function(String line);

/// 端末登録の受け口。登録のたびに端末シリアルを読み、監査ログへ 1 行残す。
/// 環境変数の読み取りとファイル追記という 2 つの副作用を typedef 越しに受け取り、
/// 未指定なら本番用の既定実装へフォールバックする。
class DeviceRegistry {
  DeviceRegistry({
    EnvReader? readEnv,
    AuditWriter? writeAudit,
  })  : _readEnv = readEnv ?? _platformEnv,
        _writeAudit = writeAudit ?? _appendToFile;

  final EnvReader _readEnv;
  final AuditWriter _writeAudit;

  /// 端末を登録し、監査ログへ残した文字列を返す。
  /// シリアルが読めなければ登録せず null を返す。
  String? register() {
    final String? serial = _readEnv('DEVICE_SERIAL');
    if (serial == null || serial.isEmpty) {
      return null;
    }
    final String record = 'registered: $serial';
    _writeAudit(record);
    return record;
  }

  // 本番用の既定実装。テストでは差し替えるため、ここへは触れない。
  static String? _platformEnv(String key) => Platform.environment[key];

  static void _appendToFile(String line) {
    File('audit.log').writeAsStringSync('$line\n', mode: FileMode.append);
  }
}

追加パッケージはありません。依存は dart:io だけです。DeviceRegistry を呼ぶ入口は §6 で作るので、先にテストから確かめます。

コードのポイント

① 副作用を関数型 typedef で名付ける

typedef EnvReader = String? Function(String key);
typedef AuditWriter = void Function(String line);

副作用を「環境変数を 1 件読む関数」「ログを 1 行書く関数」として型に名付けます。クラスやインターフェースを新設せず、関数のシグネチャそのものを型にするのが typedef です。名前を付けておくと、コンストラクタの引数やフィールドの型として使い回せて、String? Function(String) のような生のシグネチャが本文に散らばりません。

② コンストラクタで ?? 既定実装 に落とす

DeviceRegistry({
  EnvReader? readEnv,
  AuditWriter? writeAudit,
})  : _readEnv = readEnv ?? _platformEnv,
      _writeAudit = writeAudit ?? _appendToFile;

引数は省略可能にし、渡されなければ本番用の既定実装へ ?? でフォールバックします。_platformEnv と _appendToFile は静的メソッドで、名前をそのまま関数値として渡すメソッド参照(tear-off)です。この形なら、本番のコードは DeviceRegistry() と書くだけで従来どおり動き、テストのときだけ引数で差し替えられます。

③ register() は typedef 越しにだけ副作用へ触れる

String? register() {
  final String? serial = _readEnv('DEVICE_SERIAL');
  if (serial == null || serial.isEmpty) {
    return null;
  }
  final String record = 'registered: $serial';
  _writeAudit(record);
  return record;
}

業務ロジックの中には Platform も File も現れません。読むのは _readEnv、書くのは _writeAudit だけです。副作用へ触れる箇所が 2 つの関数呼び出しに閉じているので、テストではその 2 つを差し替えるだけで判定ロジックを丸ごと確かめられる。

5. fake を渡してテストする

test/device_registry_test.dart を次の内容で作成します。環境変数を読む fake とログを集める fake を渡し、実環境に触れずに register() の判定を検証します。

import 'package:typedef_seam/device_registry.dart';
import 'package:test/test.dart';

void main() {
  group('DeviceRegistry', () {
    test('シリアルが読めたら監査ログへ 1 行書き、記録内容を返す', () {
      final List<String> audit = <String>[];
      final DeviceRegistry registry = DeviceRegistry(
        readEnv: (String key) => key == 'DEVICE_SERIAL' ? 'TST-001' : null,
        writeAudit: audit.add,
      );

      final String? record = registry.register();

      expect(record, 'registered: TST-001');
      expect(audit, <String>['registered: TST-001']);
    });

    test('シリアルが無ければ登録せず、監査ログにも書かない', () {
      final List<String> audit = <String>[];
      final DeviceRegistry registry = DeviceRegistry(
        readEnv: (String key) => null,
        writeAudit: audit.add,
      );

      expect(registry.register(), isNull);
      expect(audit, isEmpty);
    });
  });
}

dart test を実行すると、2 件とも通る。

00:00 +0: loading test\device_registry_test.dart
00:00 +0: test\device_registry_test.dart: DeviceRegistry シリアルが読めたら監査ログへ 1 行書き、記録内容を返す
00:00 +1: test\device_registry_test.dart: DeviceRegistry シリアルが無ければ登録せず、監査ログにも書かない
00:00 +2: All tests passed!

writeAudit: audit.add は、List<String> の add メソッドをそのまま void Function(String) として渡すメソッド参照だ。書き込み先をテスト内の List に向けたので、監査ログの中身を expect(audit, [...]) で直接確かめられます。環境変数の fake も、DEVICE_SERIAL にだけ値を返す小さな関数で済む。audit.log は生成されず、テストの後始末も要りません。

6. 本番配線と、typedefではなくクラスにする場面

本番のコードは、副作用を渡さずに DeviceRegistry を作る。dart create が生成した bin/typedef_seam.dart を、次の内容で書き換えます。

import 'package:typedef_seam/device_registry.dart';

void main() {
  final DeviceRegistry registry = DeviceRegistry();
  final String? record = registry.register();
  if (record == null) {
    print('DEVICE_SERIAL が未設定のため登録しませんでした');
    return;
  }
  print(record);
}

引数を省いた DeviceRegistry() なので、_platformEnv と _appendToFile の既定実装が使われます。DEVICE_SERIAL の有無で動きが変わるので、両方を実行して確かめます。

まず、環境変数を設定せずに実行します。bin/typedef_seam.dart はパッケージの既定の入口なので、パスの指定は要りません。

dart run
Building package executable...
Built typedef_seam:typedef_seam.
DEVICE_SERIAL が未設定のため登録しませんでした

先頭 2 行は初回のビルドで出るもので、2 回目以降は最後の 1 行だけになります。

次に DEVICE_SERIAL を設定して実行します。環境変数の渡し方はシェルによって変わるので、使っているほうを選んでください。

PowerShell(Windows)の場合は、環境変数を設定してから実行します。

$env:DEVICE_SERIAL = 'A1B2C3'
dart run

bash(WSL・macOS・Linux)の場合は、1 行で渡せます。

DEVICE_SERIAL=A1B2C3 dart run

どちらも同じ結果になります。

registered: A1B2C3

このとき、既定の _appendToFile がプロジェクト直下の audit.log へ 1 行追記しています。

cat audit.log
registered: A1B2C3

本番のふるまいは、副作用を直接呼んでいたときと同じです。分割の代償は「既定実装を静的メソッドとして 2 つ持つ」ことだけで、実行時の動きは変わらない。

typedef で足りるか、クラスにするか

差し替えたい副作用が 1 つの操作なら、関数型 typedef が軽く収まります。関連する操作が増えたり、状態を持たせたくなったりしたら、抽象クラスへ寄せたほうが読みやすくなります。目安は次の 4 点。

  • 差し替えたい副作用が 1 メソッド相当 → typedef で切り出す
  • 読み書き・削除など複数の操作が同じ相手に必要 → それらを束ねた抽象クラス(abstract interface class)にまとめる
  • fake が内部に状態(呼ばれた履歴、疑似的な保存領域)を持つ → クラスのほうが書きやすい
  • 同じ型の実装を複数の名前付きクラスとして並べたい → クラスにして implements で表す

たとえば監査ログが「追記」だけでなく「直近 N 件の取得」や「ローテーション」も担うようになったら、AuditWriter 1 つの関数では足りません。その段階で AuditStore のような抽象クラスへ格上げすると、関連する操作を 1 つの型にまとめられる。時刻のように「1 つの now() を複数の実装で持ちたい」ケースも、関数 typedef で書けはしますが、名前付きの抽象クラスにすると本番用とテスト用の実装が型として並びます。この判断は Flutterの時計注入を用途で分ける:業務時刻とクールダウンを混ぜない で AppClock を抽象クラスにした選択と地続きです。

7. まとめ

副作用を関数として切り出すと、コード生成を足さずにテストの差し替え口が作れます。

  • dart:io の読み書きを EnvReader / AuditWriter の typedef に名付け、業務ロジックからは関数呼び出しとしてだけ触れる
  • コンストラクタで ?? 既定実装 に落とすと、本番は注入を意識せず、テストだけ fake を渡せる
  • 操作が 1 つなら typedef、増えるなら抽象クラス、と副作用の広がりで選ぶ

手元のクラスで File や Platform、DateTime.now() を直接呼んでいる箇所を 1 つ探し、それが判定ロジックと同じメソッドに同居していないか見てください。同居しているなら、その 1 呼び出しを typedef に切り出すところから始められます。テストの足場づくりは FlutterのWidgetテスト入門(最初の1本) が入り口になる。