【Flutter】テストを見据えたOS判定したいなら`defaultTargetPlatform`を使うべし

## はじめに

マルチプラットフォーム開発を行うフレームワークであるFlutterといえども、
OSによって見た目や処理を切り替えたいことがあると思います。
その際によく出てくる代表的な判定方法が`Platform.isXxx`だと思います。
しかし、タイトルにもある通りテストを前提に開発を進める場合は圧倒的に
`defaultTargetPlatform`を使うべきという結論に至りました。
この記事ではその理由を具体例を交えて解説していきます。

https://api.flutter.dev/flutter/package-platform_platform/Platform-class.html

https://api.flutter.dev/flutter/foundation/defaultTargetPlatform.html

https://api.flutter.dev/flutter/foundation/TargetPlatform.html

### 記事の対象者

- OS毎の分岐処理をどうやって実装していくべきか悩んでいる方
- OS毎の分岐処理をテストしたいのに上手くいかず悩んでいる方
- テストに関してある程度の知識を持っている方

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

```

[✓] Flutter (Channel stable, 3.22.1, on macOS 14.3.1 23D60 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.3)
[✓] VS Code (version 1.90.1)

```
### サンプルプロジェクト

ボタンを押すと画面一番上のテキストの数字が変わります。
２つのボタン、どちらを押しても数字は変わります。
実行環境がandroidであれば１、iOSであれば２、それ以外であれば３を返します。

#### Gif

##### android 

![ Androidのタップ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/b78dfb55-a915-3e84-23bf-3b4dd4a26a9b.gif)

##### iOS 
![iOSのタップ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/41ad85d3-248d-61a7-bd3c-6b9d9487c96f.gif)



#### ソースコード

https://github.com/HaruhikoMotokawa/identifying_platform_sample

## 1. 数字を変えるメソッドの実装内容
実装は至ってシンプルです。
OSを判定し、返却する数字を変えています。
二つのメソッドの違いは今回のタイトルにあるとおり以下の違いです。

- `Platform.isXxx`で判定する
-  `defaultTargetPlatform == TargetPlatform.xxx`で判定する

```main.dart

class HomeScreenViewModel {
  /// Platform.isXxxで判定する
  int generateNumberForPlatform() {
    if (Platform.isAndroid) {
      return 1;
    } else if (Platform.isIOS) {
      return 2;
    }
    return 3;
  }

  /// defaultTargetPlatform == TargetPlatform.xxxで判定する
  int generateNumberForDefaultPlatform() {
    if (defaultTargetPlatform == TargetPlatform.android) {
      return 1;
    } else if (defaultTargetPlatform == TargetPlatform.iOS) {
      return 2;
    }
    return 3;
  }
}

```

ボタンの`onPressed`でメソッドを呼び出した結果を変数に代入しています。

```main.dart

class HomeScreen extends StatefulWidget {
  const HomeScreen({
    required this.viewModel,
    super.key,
  });

  final HomeScreenViewModel viewModel;

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  int number = 0;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              '現在の値は$number',
              style: const TextStyle(fontSize: 40),
            ),
            const Gap(50),
            ElevatedButton(
              child: const Text('PlatformIsで判別'),
              onPressed: () {
                setState(() {
                  // 💡💡💡💡💡💡💡
                  final result = widget.viewModel.generateNumberForPlatform();
                  number = result;
                });
              },
            ),
            const Gap(50),
            ElevatedButton(
              child: const Text('DefaultPlatformで判別'),
              onPressed: () {
                setState(() {
                  // 💡💡💡💡💡💡💡
                  final result =
                      widget.viewModel.generateNumberForDefaultPlatform();
                  number = result;
                });
              },
            ),
            const Gap(50),
            ElevatedButton(
              child: const Text('リセット'),
              onPressed: () {
                setState(() {
                  number = 0;
                });
              },
            ),
          ],
        ),
      ),
    );
  }
}

```
:::note
Gifでも見てわかる通り、処理は両方とも同じ結果を返しています。
:::

## 2. テストしてみると結果は違う

ではテストをしてみましょう。すると、明暗が分かれます。
結論、`Platform.isXxx`で判定した`generateNumberForPlatform`メソッドのテストは**失敗**します。

![スクリーンショット 2024-06-20 8.41.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/0e7bac9d-3e2e-5486-79bb-e1fdf7772956.png)

:::note
ちなみに詳しい方法は後述しますが、ここではテスト環境をandroidに偽造していました。
にもかかわらず、帰ってきた値が　**３**　なので、テスト環境がうまく偽装できていないことが考えられます。
:::

### 2-1. テストの実行環境のOSを偽装

テスト実行環境をiOSやandroidなどそれぞれのケースに偽装したい場合は`debugDefaultTargetPlatformOverride`というグローバルなsetterを使います。

https://api.flutter.dev/flutter/foundation/debugDefaultTargetPlatformOverride.html

今回はテストごとにOSを変えたかったので、毎回`debugDefaultTargetPlatformOverride`にOSを指定しています。

※ 通常は`group`内の`setup`内で指定するのがいいと思います。

指定するときは`TargetPlatform.xxx`という形で指定します。
そして、テストが終了するごとに`tearDown`で`null`を入れてリセットします。

```test/home_screen_view_model_test.dart

void main() {
  final viewModel = HomeScreenViewModel();

  tearDown(() {
    debugDefaultTargetPlatformOverride = null;
  });

  group('成功するテスト：generateNumberForDefaultPlatform', () {
    test('androidの場合', () {
      debugDefaultTargetPlatformOverride = TargetPlatform.android;
      final result = viewModel.generateNumberForDefaultPlatform();
      expect(result, 1);
    });
    test('iOSの場合', () {
      debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
      final result = viewModel.generateNumberForDefaultPlatform();
      expect(result, 2);
    });
  });

  group('失敗するテスト：generateNumberForPlatform', () {
    test('androidの場合', () {
      debugDefaultTargetPlatformOverride = TargetPlatform.android;
      final result = viewModel.generateNumberForPlatform();
      expect(result, 1);
    });
    test('iOSの場合', () {
      debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
      final result = viewModel.generateNumberForPlatform();
      expect(result, 2);
    });
  });
}

```

## 3. `Platform.isXxx`がなぜ失敗するのか？

`Platform`はdartの言語機能です。
`Platform`の内部実装を見ると以下のような感じです。

```bin/cache/pkg/sky_engine/lib/io/platform.dart

final class Platform {

 // 省略
 
  @pragma("vm:platform-const")
  static final operatingSystem = _Platform.operatingSystem;

 // 省略

  /// Whether the operating system is a version of
  /// [Android]
  @pragma("vm:platform-const")
  static final bool isAndroid = (operatingSystem == "android");

  /// Whether the operating system is a version of
  /// [iOS](https://en.wikipedia.org/wiki/IOS).
  @pragma("vm:platform-const")
  static final bool isIOS = (operatingSystem == "ios");

  // 省略

}

```
つまり、これは実行環境を直接読み取っています。
え、それでいいのでは？と思いますよね。

ダメなんです。

確かにエミュレーターなどにプログラムを流した場合に実行環境を読みに行ったら
iOSだったり、androidだったりします。

しかし、テストの時はどうでしょう？

私の場合はmacです。
そう、だから実行環境は`macOS`になってしまうのです。

そして最大の問題点が、
**`Platform`クラスにはOSをテスト時に偽装する仕組みがないのです。**


## 4. `defaultTargetPlatform`にはOSを偽装する仕組みがある

`defaultTargetPlatform`は**Flutter**の機能です。
まずは内部実装を見ていきましょう。

```packages/flutter/lib/src/foundation/platform.dart

@pragma('vm:platform-const-if', !kDebugMode)
TargetPlatform get defaultTargetPlatform => platform.defaultTargetPlatform;

```

更にこの`platform.defaultTargetPlatform`の内部実装を見ます。

```packages/flutter/lib/src/foundation/_platform_io.dart

/// The dart:io implementation of [platform.defaultTargetPlatform].
@pragma('vm:platform-const-if', !kDebugMode)
platform.TargetPlatform get defaultTargetPlatform {
  platform.TargetPlatform? result;
  if (Platform.isAndroid) {
    result = platform.TargetPlatform.android;
  } else if (Platform.isIOS) {
    result = platform.TargetPlatform.iOS;
  } else if (Platform.isFuchsia) {
    result = platform.TargetPlatform.fuchsia;
  } else if (Platform.isLinux) {
    result = platform.TargetPlatform.linux;
  } else if (Platform.isMacOS) {
    result = platform.TargetPlatform.macOS;
  } else if (Platform.isWindows) {
    result = platform.TargetPlatform.windows;
  }
  assert(() {
    if (Platform.environment.containsKey('FLUTTER_TEST')) {
      result = platform.TargetPlatform.android;
    }
    return true;
  }());
  if (kDebugMode && platform.debugDefaultTargetPlatformOverride != null) {
    result = platform.debugDefaultTargetPlatformOverride;
  }
  if (result == null) {
    throw FlutterError(
      'Unknown platform.\n'
      '${Platform.operatingSystem} was not recognized as a target platform. '
      'Consider updating the list of TargetPlatforms to include this platform.',
    );
  }
  return result!;
}

```

最初はあれっと思いました。
`Platform.isIOS`とか`Platform.isAndroid`で判定してるじゃん！と。
しかしよく見るとわかってきます。

**テストを実行する場合**だったとして、順番に見ていきましょう。


### 1. 現在の実行環境のプラットフォームを受け取るための変数を定義

```dart

@pragma('vm:platform-const-if', !kDebugMode)
platform.TargetPlatform get defaultTargetPlatform {
platform.TargetPlatform? result; // <= 💡 受け取る変数

```

### 2. `if(Platform.isXxx)`で該当する実行環境のプラットフォームを探り、変数`result`に`TargetPlatform.xxx`の形で代入

ここではテストを実行している環境なので、私の場合で置き換えると`Platform.isMacOS`に該当し、
`result = platform.TargetPlatform.macOS;`が実行されます。

```dart

  if (Platform.isAndroid) {
    result = platform.TargetPlatform.android;
  } else if (Platform.isIOS) {
    result = platform.TargetPlatform.iOS;
  } else if (Platform.isFuchsia) {
    result = platform.TargetPlatform.fuchsia;
  } else if (Platform.isLinux) {
    result = platform.TargetPlatform.linux;
  } else if (Platform.isMacOS) {
    result = platform.TargetPlatform.macOS;
  } else if (Platform.isWindows) {
    result = platform.TargetPlatform.windows;
  }

```
:::note
ここで注意なのが`return result`ではないということです。
つまり処理はまだ続きます。
:::

### 3. テスト中かどうかを確認

assert は、Dart言語のデバッグ用ステートメントで、条件が true であることを確認します。
条件が false の場合、実行を停止し、エラーをスローします。
主にデバッグ目的で使用され、リリースビルドでは無視されます。

そしてその中を見るとif節でテストの実行中かを確認しています。
もし、テストの実行中であれば
`result = platform.TargetPlatform.android;`
つまり、`TargetPlatform`を`android`に書き換えます。
```dart

assert(() {
    if (Platform.environment.containsKey('FLUTTER_TEST')) {
      result = platform.TargetPlatform.android;
    }
    return true;
  }());

```

### 4. debugDefaultTargetPlatformOverrideで偽装する

ここは読んでそのままです。
もし、デバックモードかつ`debugDefaultTargetPlatformOverride`に何かしらの値、
つまり`TargetPlatform.xxx`の値が入っているのならそれを`result`に代入します。

例えばテストコード内で
`debugDefaultTargetPlatformOverride = TargetPlatform.iOS;`
と書いてある部分があればそれを代入します。

```dart

if (kDebugMode && platform.debugDefaultTargetPlatformOverride != null) {
    result = platform.debugDefaultTargetPlatformOverride;
  }

```

### 5. エラーがなければ最終的な`result`を返す

あとはここまできて何も問題なければ最終的な値、つまり今回でいけば`TargetPlatform.ios`を返却します。


```dart

if (result == null) {
    throw FlutterError(
      'Unknown platform.\n'
      '${Platform.operatingSystem} was not recognized as a target platform. '
      'Consider updating the list of TargetPlatforms to include this platform.',
    );
  }
  return result!;

```

### `defaultTargetPlatform`のまとめ

macOSでテスト中で、テスト環境をiOSで偽装する場合の流れ

1. プラットフォームを受け取る変数を定義
1. `if(Platform.isXxx)`で該当する実行環境のプラットフォームを取得（macOS）
1. テスト中かどうかを確認し、テスト中なので一旦`TargetPlatform.android`に上書き（android）
1. debugDefaultTargetPlatformOverrideによって`TargetPlatform.ios`に上書き(iOS)
1. エラーがないので、TargetPlatform.iosを返却(iOS)
1. `defaultTargetPlatform`が`TargetPlatform.ios`になる

低レイヤーであるdartの機能、`Platform`で実行環境を読み取ります。
その`Platform`をラップしたものが`defaultTargetPlatform`であり、
内部でテスト中は偽装できる機能が備わっているのです。

## 2024/6/22追記： `test()`の引数、`testOn`について

この記事を投稿した後に、`test()`の引数、`testOn`というものがあると知りました。
引数に文字列ではありますが、OSを指定するのでもしやと思ったのですが残念ながら
実行環境の偽装ではありませんでした。

これは実行環境が**xxxOS**だったらこのテストを実行するというものでした。
私としてはどんな用途で使うのかわかりません😇

```dart

  group('testOnでテスト', () {
    // testOn引数は結局のところテストの実行環境が引数だったら実行されるということ
    // つまりPCがmacだったらmacOSでないと動かない
    setUp(() {
      debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
    });

    test(
        'mac-osでテストするけど、'
        'TargetPlatformは偽装できないので失敗',
        testOn: 'mac-os', () {
      final result = viewModel.generateNumberForPlatform();
      expect(result, 2);
    });
    test(
        'ioSでテストするけど、TargetPlatformが偽装されてるわけではないので、'
        'そもそも動かないテスト',
        testOn: 'ios', () {
      final result = viewModel.generateNumberForPlatform();
      expect(result, 2);
    });
  });

```


## 終わりに

いかがでしたでしょうか？
`Platform.isXxx`と`defaultTargetPlatform`の違いついて理解いただけたでしょうか。
テストを前提とした開発を進める上で`defaultTargetPlatform`を利用することで、より信頼性の高いテストが実現できることをお伝えできたと思います。

動かすだけであればどちらを使っても問題ないものの、普段から`defaultTargetPlatform`を使用を癖づけておけば、いざテストを書くというときにつまづかなくて良くなります。

この記事に関するご意見、ご指摘がございましたらぜひコメントにてお知らせください。
この記事が皆様のお役に立てれば幸いです。

## 参考記事

https://medium.com/flutter-jp/platform-d5c93e8bddb9

https://zenn.dev/tabio/scraps/425bfde14b99fd