5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

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

Last updated at Posted at 2024-06-19

はじめに

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

記事の対象者

  • 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)

サンプルプロジェクト

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

Gif

android

 Androidのタップ.gif

iOS

iOSのタップ.gif

ソースコード

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;
                });
              },
            ),
          ],
        ),
      ),
    );
  }
}

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

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

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

スクリーンショット 2024-06-20 8.41.07.png

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

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

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

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

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

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

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を偽装する仕組みがある

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

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. 現在の実行環境のプラットフォームを受け取るための変数を定義


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

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

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


  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;
  }

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

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

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

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


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

4. debugDefaultTargetPlatformOverrideで偽装する

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

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


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

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

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


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. プラットフォームを受け取る変数を定義
  2. if(Platform.isXxx)で該当する実行環境のプラットフォームを取得(macOS)
  3. テスト中かどうかを確認し、テスト中なので一旦TargetPlatform.androidに上書き(android)
  4. debugDefaultTargetPlatformOverrideによってTargetPlatform.iosに上書き(iOS)
  5. エラーがないので、TargetPlatform.iosを返却(iOS)
  6. defaultTargetPlatformTargetPlatform.iosになる

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

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

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

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


  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.isXxxdefaultTargetPlatformの違いついて理解いただけたでしょうか。
テストを前提とした開発を進める上でdefaultTargetPlatformを利用することで、より信頼性の高いテストが実現できることをお伝えできたと思います。

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

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

参考記事

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?