UUUO Tech Blog

水産流通のDXや業務効率化を実現するアプリケーション「UUUO」「atohama」を開発・展開している株式会社ウーオのテックブログです。プロダクトにまつわる様々な情報発信を行います。

[Flutter] reflectable を使ってテスト用データを自動生成してみる

こんにちは! 株式会社ウーオのソフトウェアエンジニア、髙橋 (@yt_hizi) です ✋

以前の VRT に関する記事では、Flutter への VRT の導入までを書きました。 今回は、VRT のテストケースを作っていくにあたって、テスト用データの生成を楽にしようと試行錯誤したときの話です。

課題

テストケースを作成する際、個別にテスト用のデータを作成していくのが手間に感じていました。特に、モデルが持つ各プロパティに対して値を都度設定していくとき、無意味な値を設定したり、使うはずのない値まで設定していく必要があります。

これにより、テストケースに関係ないと思っていた値が実はテストケースに影響して意図せず動いたり、また逆に意図せず動かなかったりする、などのテストケースの不明瞭さを生み出す原因にもなっていました。

User(
  id: 1, // IDを固定する意味がない...
  name: '剣先イカ太郎',
  organization: Organization(
    id: 1,
    name: 'ウーオ',
    address: '広島県広島市...', // 表示しないけど、一応意味ある値を入れておく...
  )
);

実際には、VRTでのスクリーンショット生成時に表示される値だけを設定したいところです。

User(
  name: '剣先イカ太郎',
  organization: Organization(
    name: 'ウーオ'
  )
)

対応

先述の課題感から、リフレクションを用いてインスタンスを自動生成する仕組みを作ってみました。 リフレクションとは、クラス情報をランタイムで取り扱うことができる機能で、クラスのメソッド情報やフィールド情報などに動的にアクセスし実行できるようになります。一方、実行速度を低下させたり、コンパイル時のエラーを検出しにくいというデメリットもあります。

Flutter におけるリフレクション

Dart には、 dart:mirrors という標準ライブラリがあります。これにより、他のプログラミング言語と同様のリフレクション機能を利用できます。

しかし、Flutter ではアプリサイズ最適化の観点から、利用できません。

幸い、Flutter でも利用可能な reflectable (https://pub.dev/packages/reflectable) というパッケージがあります。これは、ランタイムでクラス情報の取得を行わず、事前にリフレクション用のコードを生成することで、リフレクションを可能にしています。今回はテストコードで使用するためあまり関係ありませんが、前述のアプリサイズ最適化の文脈においても、必要な分だけのクラス情報をアプリに含めることができるため効率が良いです。

実装する

reflectable の準備

まずは、Reflectable を継承したクラスを作成し、annotation として利用できるようにします。 super(...) に指定した各 Capability は、クラス情報のうち、どの情報を含めるかという設定になります。

例えば、 newInstanceCapability は、Gives support for reflective invocation of constructors (of all kinds) matching namePattern interpreted as a regular expression. とある通り、コンストラクタの実行をサポートするための Capability になります。

各 Capability や、その他の設定については API Doc を参照しつつ、必要なものだけを設定すると良いでしょう。

const reflectionForTesting = ReflectionForTesting();

class ReflectionForTesting extends Reflectable {
  const ReflectionForTesting()
      : super(
          typingCapability,
          newInstanceCapability,
          declarationsCapability,
          reflectedTypeCapability,
          staticInvokeCapability,
        );
}

次に任意のモデルに対して、作成したアノテーションを付与します。

@freezed
@reflectionForTesting
class User with _$User {
  const factory User({
    required int id,
    required String name,
    required String kana,
    required Organization organization,
  }) = _User;

  const User._();
}

その後、reflectable の設定を build.yaml に追加します。 generate_for で、どのファイルに対して *.reflectable.dart (リフレクション用のクラス情報)を生成するか、という設定ができます。今回は、 test/**/**_test.dart に対して生成します。

targets:
  $default:
    builders:
      reflectable:
        generate_for:
          - test/**/**_test.dart
        options:
          formatted: true

リフレクション用のクラス情報を生成してみる

test/ 配下の任意のディレクトリに example_test.dart ファイルを作成します。

reflectable は、reflectable.dart を import しており、かつ import で参照されている annotation を付与したクラスのみをコード生成対象とするため、便宜的に以下のように import を記述しておきます。

// ignore_for_file: unused_import

import 'package:reflectable/reflectable.dart';
import 'package:uuuo_sellers/domain/entity/user.dart';

void main() {}

👆 未使用の import は Linter によって削除されてしまうので ignore しています。

この状態で、build_runner を実行 (flutter pub run build_runner build) すると、以下のように example_test.reflectable.dart が生成されます。 このファイルの中身を見ると、User クラスのクラス情報や、initializeReflectable 関数が生成されていることがわかります。リフレクションを実際に使用するためには、テストコード内で事前に initializeReflectable() を呼び出してクラス情報をロードする必要があります。

データ生成用のクラスをつくる

今回、データ生成用のクラスは以下のように実装しました。

まずは、Arb.positiveInt() などで Dart の組み込み型のデータを生成できるようにしておきます。

そして、独自の定義したクラスについては、関数 static Arb<T> _ofType<T>(Type type) と別の関数 static Arb<T> _generateArb<T>(Type type) の組み合わせにより、reflectable から取得したクラスの各パラメータに対して Arb<T> を生成し, 値を設定していきます。

このとき、任意の独自クラスが、他の独自クラスをパラメータに持つときは、関数 static Arb<T> _generateArb<T>(Type type) 内で、再帰的に Arb._ofType<T>(type) を呼び出すことで対応しています。

実際に使うには、他の組み込み型や enumListMap などの処理が必要ですが、分量の都合上省略しています。

import 'dart:core';
import 'dart:core' as core;
import 'dart:math';

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:reflectable/reflectable.dart';
import 'package:tuple/tuple.dart';
import 'package:uuuo_sellers/common/reflection.dart';
import 'package:uuuo_sellers/extension/list_ext.dart';

const maxInt = 2 ^ 31 - 1;

class NotReflectableError extends Error {
  NotReflectableError(this.typeName);

  final String typeName;

  @override
  String toString() {
    return 'Missing reflectable type: $typeName.'
        ' Please apply annotation @reflectionForTesting to \'$typeName\' class'
        ' and run command below:\n'
        '    flutter pub run build_runner build';
  }
}

typedef ArbitraryGenerator<T> = T Function();

Arb<T> arb<T>(ArbitraryGenerator<T> generator) {
  return Arb<T>._(generator);
}

class Arb<T> {
  const Arb._(this._generator);

  final ArbitraryGenerator<T> _generator;

  static const _reflection = ReflectionForTesting();

  // `reflectionForTesting` が付与されたクラスをキャッシュしておく
  static final _annotatedClasses = _reflection.annotatedClasses
      .where((element) => element.hasReflectedType)
      .toList()
      .associateBy((e) => e.reflectedType);

  static Arb<T> ofType<T>() {
    return arb(() => _ofType<T>(T).next());
  }

  static Arb<dynamic> nullify() => arb<dynamic>(() => null);

  // ランダムな int の値を返す Arb
  static Arb<core.int> positiveInt({core.int max = maxInt}) =>
      arb(() => Random().nextInt(max));

  // ランダム String の値を返す Arb
  static Arb<String> string({
    core.int minLength = 0,
    core.int maxLength = 100,
  }) {
    const chars =
        'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    return arb(() {
      final random = Random();
      return List.generate(
        random.nextIntInRange(min: minLength, max: maxLength),
        (_) => chars[random.nextInt(chars.length - 1)],
      ).join();
    });
  }

  // 新規インスタンスを生成する
  T next() {
    return _generator();
  }

  // 指定した型 `T` の Arb を生成する (annotation を付与したクラス用)
  static Arb<T> _ofType<T>(Type type) {
    // annotation が付与されていないクラスの Arb は生成できないため、例外をスローする
    final classMirror = _annotatedClasses[type];
    if (classMirror == null) {
      throw NotReflectableError(type.toString());
    }

    // 利用可能なコンストラクタがない場合はインスタンス生成できないため、例外をスローする
    final constructor = classMirror.declarations[classMirror.simpleName];
    if (constructor == null ||
        constructor is! MethodMirror ||
        !constructor.isConstructor) {
      throw StateError(
        'Missing constructor for $type.'
        ' Please create default constructor (e.g. "User(...)")',
      );
    }

    final typeArb = arb<T>(() {
      // クラスが持つ各プロパティの値を生成し、インスタンス生成時の引数とする
      final namedParameters =
          constructor.parameters.associate<Symbol, dynamic>((parameter) {
        final isNullable = parameter.isOptional &&
            !parameter.metadata.any((element) => element is Default);
        final arb = Arb._generateArb<dynamic>(parameter.reflectedType);

        return Tuple2<Symbol, dynamic>(
          Symbol(parameter.simpleName),
          isNullable ? arb.next() : arb.next(),
        );
      });

      // インスタンスを生成する
      return classMirror.newInstance(
        constructor.constructorName,
        <dynamic>[],
        namedParameters,
      ) as T;
    });

    return typeArb;
  }

  // 指定した型 `T` の Arb を生成する (組み込み型を含む全ての型)
  static Arb<T> _generateArb<T>(Type type) {
    switch (type) {
      case core.int:
        return Arb.positiveInt() as Arb<T>;
      case String:
        return Arb.string() as Arb<T>;
      default:
        if (_reflection.canReflectType(type)) {
          return Arb._ofType<T>(type);
        }
        return Arb.nullify() as Arb<T>;
    }
  }
}

extension _RandomExt on Random {
  int nextIntInRange({required int min, required int max}) {
    return (nextDouble() * (max - min) + min).toInt();
  }
}

上記のクラスは、以下のように使うことができます:

final user = Arb.ofType<User>().next();

一部のパラメータを固定したい場合、現在のプロジェクトでは freezed を使っているため、copyWith で対応できました。 ただし、固定したいパラメータに対しても一度値が自動生成されてしまうため、このあたりはもう少しうまくできそうです。

final organization = Arb.ofType<Organization>().next();
final user = Arb.ofType<User>().next().copyWith(
  organization: organization,
);

やってみてどうだったか

よかった点

  • テストごとにテスト用データの生成コードが格段に短くなり、テストの見通しが良くなった。
  • テストデータ生成時は、テスト結果に影響があるデータのみを固定値、それ以外は暗黙的にランダム値とすることで、テストの関心事がわかりやすくなった。
  • 増え続けるモデルに対して都度生成用コードを書かなくて良くなった。

改善できそうな点

  • reflectable の制約として、あるモデルがプロパティに持つ List<T> 型のデータを生成するのが難しく、都度生成コードを記述しなければならない。(とはいえ2行程度ですが)
  • 関心ごとであるはずの値もランダムにしてしまい、flaky な (実行結果が毎回変わる) テストケースとなってしまうことがある。かつテストケース作成時点では気づきづらい。
  • テストファイルごとに reflectable のコード生成を実行するため、 build_runner の実行時間が数分伸びた。(1ファイルに対してのみ生成するようにすれば改善しそう?)

これから

既存のパッケージである reflectable を使用してみることで、テスト用データの生成が比較的簡単に実現できるということがわかりました。一方で、文法上の冗長さが生まれてしまうということにも気づくことができました。 しばらくは運用上問題なさそうですが、文法上の制約やコード生成時間の問題から、将来的にはコード生成部分を自前で実装してみてもよさそうです。


ウーオでは水産流通を革新するため、プロダクトを通じてあらゆるアプローチをしています。ウーオの事業やプロダクト開発にご興味がある方は、以下をぜひご覧ください 👇

uuuo.co.jp

◆カジュアル面談はこちらから◆ / 株式会社ウーオ

各ポジションの募集要項はこちら

ソフトウェアエンジニア(Mobile Application/Flutter) / 株式会社ウーオ

ソフトウェアエンジニア(Frontend/Flutter on the Web) / 株式会社ウーオ

ソフトウェアエンジニア(Backend/Ruby on Rails) / 株式会社ウーオ