【実務・中級編】Dartのパターンマッチングで「複雑な条件分岐」をテスト可能な単位に分割する – Dart コア文法・オブジェクト指向・Null安全解析バイブル

コードレビューをしていて、最もエンジニアとしての血が逆立つ瞬間がどこか知っているか?
それは、フロントエンドのコンポーネント状態やAPIからの生データを処理する箇所で、「深さ4段の `if-else` と `switch` の迷宮」 に遭遇したときだ。

// 【アンチパターン】これを見た瞬間にリファクタリングチケットを切るべき悪夢
String processApiResponse(Map response) {
if (response.containsKey(‘status’)) {
if (response[‘status’] == 200) {
if (response[‘data’] != null) {
var data = response[‘data’];
if (data is List && data.isNotEmpty) {
return ‘Success: ${data.length} items’;
} else if (data is Map && data.containsKey(‘token’)) {
return ‘Auth Success’;
}
}
return ‘Empty Success’;
} else if (response[‘status’] >= 400 && response[‘status’] < 500) { return 'Client Error: ${response['status']}'; } } return 'Unknown'; } このコードの何がクソか? 1. サイクロMatic複雑度が跳ね上がり、テストケースが爆発する(すべての分岐網羅に何通り書くつもりだ?)
2. データの形状(Shape)検証とビジネスロジックが密結合している
3. Dart 3以降の世界に生きているのに、C言語時代の遺物のような書き方をしている

Dart 3で導入されたパターンマッチング(Pattern Matching)とレコード(Records)は、単なる「シンタックスシュガー」ではない。コンパイラが型の網羅性を静的に検証し、複雑な条件分岐を「データ構造の分解と関数の純粋化」へと昇華させるための最強の武器だ。

今回は、このスパゲッティと化した条件分岐を、単体テストが容易で、AOTコンパイラにとっても最適化しやすい「美しいテスト可能な単位」に分割する極限の設計パターンを伝授しよう。

—

1. パターンマッチングによる「条件の宣言的記述」

Dartの `switch` 式とパターンは、右辺のオブジェクト構造をそのまま「型と値の形」で剥ぎ取る。
まずは、APIレスポンスのドメインモデルを定義し、先ほどの汚染された `if-else` を `switch` 式によるパターンマッチングで置き換えよう。

sealed class ApiResponse {}

class SuccessList extends ApiResponse {
final List items;
SuccessList(this.items);
}

class SuccessAuth extends ApiResponse {
final String token;
SuccessAuth(this.token);
}

class SuccessEmpty extends ApiResponse {}

class ClientError extends ApiResponse {
final int code;
ClientError(this.code);
}

class UnknownResponse extends ApiResponse {}

ここに、Dart 3のオブジェクトパターンとガード節(`when`)を適用する。

ApiResponse parseResponse(Map json) {
return switch (json) {
// ステータス200かつデータがリストの場合
{‘status’: 200, ‘data’: List data} when data.isNotEmpty =>
SuccessList(data),

// ステータス200かつデータにtokenが含まれる場合
{‘status’: 200, ‘data’: {‘token’: String token}} =>
SuccessAuth(token),

// ステータス200のその他
{‘status’: 200} =>
SuccessEmpty(),

// 400番台のエラー
{‘status’: int code} when code >= 400 && code < 500 =>
ClientError(code),

// フォールバック
_ => UnknownResponse(),
};
}

どうだ。これだけでも可読性は劇的に向上したが、「データをパースする処理」と「それをどうハンドリングするか」がまだ同じ関数に同居している。これでは単体テストでモックを差し込みにくい。真のクリーンアーキテクチャを目指すなら、これをさらに「純粋関数」へと分割する。

—

2. 複雑性を粉砕する:テスト可能な「小さな関数」への分解

コードレビューで私が「この関数、責務が多すぎます。分解しなさい」と言うとき、基準は明確だ。
「1つの関数は、1つのデータ構造の変形(Transform)のみを行え」。

先ほどの処理を、以下の3つのレイヤーに完全に分離する。
1. Raw JSON → Typed Records (データ形状の抽出)
2. Domain Rules (純粋なビジネスロジックの評価)
3. UI / Controller Presentation (表示文字列への変換)

究極のプロダクションコード例

以下のコードは、実務のフロントエンド/API連携基盤でそのまま使える、極めて堅牢でテスタブルな設計だ。

// — 1. 型定義とレコード —
sealed class ResponseResult {}
class RenderList extends ResponseResult { final int count; RenderList(this.count); }
class RenderAuth extends ResponseResult { RenderAuth(); }
class RenderEmpty extends ResponseResult { RenderEmpty(); }
class RenderError extends ResponseResult { final int code; RenderError(this.code); }
class RenderUnknown extends ResponseResult { RenderUnknown(); }

// — 2. 純粋関数:パターンマッチングによるデータ抽出層 —
/// JSONの構造からドメイン特有の状態を純粋に抽出する。
/// 外部依存(I/Oやグローバル状態)を持たないため、単体テストは完全に決定的(Deterministic)になる。
ResponseResult evaluateResponseShape(Map json) {
return switch (json) {
// リストデータの抽出パターン
{‘status’: 200, ‘data’: List data} when data.isNotEmpty =>
RenderList(data.length),

// 認証トークンの抽出パターン
{‘status’: 200, ‘data’: {‘token’: String _}} =>
RenderAuth(),

// 空データのパターン
{‘status’: 200} =>
RenderEmpty(),

// クライアントエラーのパターン
{‘status’: int code} when code >= 400 && code < 500 =>
RenderError(code),

// デフォルト
_ => RenderUnknown(),
};
}

// — 3. プレゼンテーション層:UIやメッセージへのマッピング —
/// 抽出されたドメイン状態を、表示用テキストへ変換する。
/// ここも純粋関数であるため、UIフレームワーク(Flutter等)のコンテキストから完全に切り離してテスト可能。
String presentResponse(ResponseResult result) {
return switch (result) {
RenderList(:var count) => ‘Success: $count items’,
RenderAuth() => ‘Auth Success’,
RenderEmpty() => ‘Empty Success’,
RenderError(:var code)=> ‘Client Error: $code’,
RenderUnknown() => ‘Unknown’,
};
}

// — 4. 統合エントリーポイント —
String handleApiResponse(Map json) {
// パイプラインとして合成
final evaluated = evaluateResponseShape(json);
return presentResponse(evaluated);
}

—

3. なぜこの設計が「最強」なのか?(Dart VMとテストの観点から)

ここで、アーキテクトとしてコンパイラとテストの観点からこのコードの優位性を解説しておこう。

A. 網羅性チェック(Exhaustiveness Checking)の恩恵

Dartの `switch` 式は、`sealed` クラスや特定の型に対して網羅性(Exhaustiveness)を強制する。
もし将来、新しい状態(例: `RenderMaintenance`)を追加した場合、`presentResponse` 内の `switch` でそれをハンドリングし忘れると、コンパイルエラーになる。
Runtimeでの `NoSuchMethodError` や予期せぬ `null` の混入を、コードを書いた瞬間にコンパイラが防いでくれるのだ。

B. 単体テストのコストが劇的にゼロに近づく

この設計により、テストコードは以下のように極めてシンプルかつ高速になる。
Flutterのwidgetテストや重いモックフレームワークすら不要だ。純粋なDartの `test` ライブラリだけで回せる。

import ‘package:test/test.dart’;

void main() {
group(‘evaluateResponseShape Tests’, () {
test(‘should return RenderList when status is 200 and data is non-empty list’, () {
final json = {‘status’: 200, ‘data’: [1, 2, 3]};
final result = evaluateResponseShape(json);

expect(result, isA());
expect((result as RenderList).count, equals(3));
});

test(‘should return RenderError when status is 404’, () {
final json = {‘status’: 404};
final result = evaluateResponseShape(json);

expect(result, isA());
expect((result as RenderError).code, equals(404));
});
});
}

ご覧の通り、`evaluateResponseShape` は「Mapを渡して期待する構造体(Result)が返るか」を確認するだけでよく、API通信のモックも不要、非同期の待ち時間もゼロ、1ミリ秒でテストが完了する。

—

4. パフォーマンス上の注意点:AOTコンパイルとパターンマッチング

最後に、パフォーマンスにこだわるプロフェッショナルへ向けて、Dart VMの挙動を補足しておこう。

Dart 3のパターンマッチングは、開発者が書くコード上は宣言的でエレガントだが、JIT / AOTコンパイラ(特にFlutterで使われるReleaseモードのAOT)においては、最適化された効率的なジャンプテーブルや型ガードのネストへとコンパイルされる。

ただし、以下のアンチパターンは避けるべきだ。

  • 深すぎる `when` ガードの多用: 複雑な条件式を `when` の中に書きすぎると、コンパイラが効率的な分岐ツリーを構築できず、線形探索(O(n))に近いコードに落ちる可能性がある。条件分岐の優先順位を考え、より厳密な型パターンを上に、一般的なパターンを下に配置する「ガードの順序」を意識せよ。

チーフアーキテクトからの総括

複雑な `if-else` の迷宮を放置することは、技術的負債への利息を複利で払い続ける行為に等しい。
Dart 3のパターンマッチングとレコードを活用すれば、データ構造の分解とビジネスロジックを美しく分離し、「コンパイラに検証させ、人間はテストに集中する」という理想的な開発サイクルを手に入れることができる。

今日のコードレビューから、その巨大な `if-else` を切り刻むことを始めよう。あなたの書くコードは、もっとエレガントで、もっと堅牢であるべきだ。

タイトルとURLをコピーしました