🐷

Flutter プロジェクトガイドラインを作ってみた

2023/08/18に公開

はじめに

この記事は Flutter エンジニアの著者が現在参加しているプロジェクトで採用したアーキテクチャやコーディングの規約など、プロジェクトのガイドラインをまとめたものです。
趣旨としては Flutter エンジニアではないプロジェクトメンバーがこのガイドラインを読んで、コードを読める(多少は書ける)ようになることを目指しております。
内容につきましては、あくまでも超内輪な内容なので温かい目で見ていただけると幸いです。
また、おかしな点や改善点などがありましたらご教授いただけると嬉しいです🙇🏻‍♂️

本記事の構成

本記事の構成は下記になります。

  1. 採用したアーキテクチャについて
  2. ディレクトリ構成について
  3. その他

1. 採用したアーキテクチャについて

本プロジェクトでは MVVM + Repository パターンを採用いたしました。
Flutter でアーキテクチャのことを調べるとよく出てくるパターンです。
採用に至った流れなど記事にしましたので興味がある方はこちらをご参照ください。

https://zenn.dev/namioto/articles/4ff020a6835ea9

データの流れを書いてみます。

参考にさせていただいた記事はこちらです。

https://wasabeef.medium.com/flutter-を-mvvm-で実装する-861c5dbcc565

参考にさせていただいた記事で紹介されているデータの流れと違うところが2点あります。

  1. Repository -> View に矢印が向いている
  2. Repository -> ViewModel に矢印が向いていない

図で違いを書いてみます。

『紹介した記事』のデータの流れでは View と Repository がお互い依存することなくデータの更新・取得は全て ViewModel を介して行われています。

一方『本記事』のデータの流れでは Repository から View にデータが直接流れています。
なぜこのようにするかというと Single Source Of Truth(SSOT) 原則 を遵守し、アプリ内で使用されるデータを一意にしたかったからです。

SSOT についてはこちらの記事を参考にさせていただきました。

https://medium.com/flutter-jp/architecture-240d3c56b597#:~:text=のはずです。-,Single Source of Truth(SSOT)原則に従った状態管理,-SSOTについては

下記は記事からの引用です。

SSOTを正しく意識できている場合、例えば以下のようなよくある要件を満たしたい時、特別な工夫なく容易かつ確実に実現できるはずです。

  1. 記事一覧画面で、自分のlike表示がされている(未like)
  2. 詳細画面に遷移後、likeするとその詳細画面でlike済みに変わる
  3. 一覧画面に戻ると、その記事がlike済みになっている

この際、SSOTになってない場合は以下のように脆い状態になります:

  1. 一覧画面と詳細画面の記事データソースが別管理(同じ記事のインスタンスが2つ存在)
  2. 詳細画面の記事インスタンスのlikeをtrueに変更(この時点では一覧画面のその記事インスタンスは未like)
  3. 一覧画面の記事インスタンスにもlike済みであることを同期(ここに抜け漏れがあるとバグったり、あるいは概ね正しく組んでいてもその処理が煩雑になりがちだったり一時的に表示不整合が生じるなどしがち)
  4. 同期処理完了後、一覧でもlike済みになる

上記の例で、記事一覧画面と記事詳細画面のそれぞれに ViewModel が存在し、記事の情報を ViewModel が保持していると、このようなデータの不整合が生じる恐れがあります。

そのため ViewModel は View(画面)の状態を保持するという役割に徹して、Repository(DB)からデータを取得して保持する役割は持たないようにしました。

以上が採用したアーキテクチャと簡単な説明になります。

2. ディレクトリ構成について

ディレクトリ構成は下記になります。

.
├── Makefile
├── assets
│   └── images
├── dart_defines
├── lefthook.yaml
├── lib
│   ├── client
│   ├── constant
│   ├── exception
│   ├── extension
│   ├── firebase_options
│   ├── gen
│   ├── main.dart
│   ├── model
│   ├── repository
│   ├── util
│   ├── view
│   │   ├── app
│   │   ├── component
│   │   └── page
│   └── view_model
└── test

各ディレクトリやファイルの役割を簡単に説明していきます。

Makefile

Makefile はディレクトリではなくファイルです。
開発でよく使うコマンドをまとめております。
flutter cleanflutter pub getbuild_runner、環境毎の flutterfire cofigure コマンドなどを入力するのには長かったり、忘れたりするコマンドを make コマンドで短く簡単に呼び出せるようにしています。

assets

画像などのアセットファイルを配置するディレクトリです。
後述しますが本プロジェクトで画像を扱う時は flutter_gen パッケージを使用しております。

dart_defines

Flutter では環境ごとに環境変数を読み込むアプローチとして --dart-define-from-file を使用する方法があります。
この方法では環境変数を環境ごとに json 形式で定義できます。
本プロジェクトでは開発環境と本番環境の2つの環境があるので、dev.jsonprod.json を配置し、環境毎に必要な値をそれぞれの json ファイルに記述しています。
使用方法、設定方法など詳しくは下記をご参考にしてください。

https://zenn.dev/altiveinc/articles/separating-environments-in-flutter

lefthook.yaml

lefthook の設定ファイルが lefthook.yaml になります。
lefthook とは高速な git hook 管理ツールです。
git の特定のアクションが発生した時に任意のスクリプトを実行したりできる git hook を設定ファイルで管理できるツールです。

具体的には git commit を実行したタイミングで dart fix --applydart format を自動的に実行しコード修正、整形忘れを防ぐことができます。
これによりチームである程度統一されたソースコードを保つことができます。

lib

Flutter において lib ディレクトリはアプリのメインのソースコードを格納する場所です。

client

API クライアントを定義します。

constant

アプリ内で使用する定数を管理します。
以下のようなものがあります。

  • colors.dart: 色を定義します
  • strings.dart: アプリで使用する文字列を定義します。
  • styles.dart: アプリ内の複数箇所で使用する UI にまつわる値を定義します。例えば paddingEdgeInsets などを定義します。

excecption

独自に実装した『例外』を定義したり、例外のハンドラーを定義したりします。

extension

Dart の既存のライブラリに機能を拡張できる Extension methods を定義します。

firebase_options

flutterfire configure を実行した際に生成される firebase_options.dart を配置するディレクトリです。
firebase_options.dart は Firebase の構成ファイルで main 関数で Firebase を初期化する際に使用するファイルです。
本プロジェクトは開発、本番の2つの環境があるので firebase_options.dart ファイルも環境ごとに2つ存在します。
そのためディレクトリを作成し、管理しております。
また、前述した make コマンドで各環境ごとに必要なオプションを付与した flutterfire configure コマンドを定義しており、firebase_options ディレクトリに環境ごとの firebase_options.dart を出力するように設定しております。

gen

assets ディレクトリの説明で軽く触れた flutter_gen パッケージを使用することにより、自動生成されるディレクトリです。
直接触ることはありません。
簡単な説明と使用方法は こちらの記事 をご参考にしてください。

main.dart

Flutter アプリのエントリーポイント(開始点)です。
void main() 関数を定義しておりアプリが起動されると最初にこの関数が実行されます。
アプリを起動するために必要な初期化処理などはこちらの main 関数内に記述します。

model

MVVM の model を定義します。
アプリ内で使用するデータクラスを定義します。
基本的には freezed パッケージを使用してイミュータブルなクラスを生成します。
freezed パッケージの簡単な説明は こちらの記事 をご参考にしてください。

repository

今回採用したアーキテクチャ『MVVM + Repository パターン』の Repository の部分です。
データの取得や保存に関するロジックを担当します。

util

便利なメソッド群です。
loggerjson_convertervalidator などを定義します。
もしそれぞれのファイル数が肥大化するようであれば、階層を1つ上げて専用のディレクトリを作成するのもありです。

view

MVVM の View でアプリ内の UI 部分を担当します。
view ディレクトリ内の構成は下記になります。

  • app
    • MaterialApp を定義します。 main.dartmain 関数内で呼び出されます。
  • component
    • 複数の画面で使用する UI コンポーネントを定義します。
  • page
    • 各画面を管理します。

page ディレクトリに関しては その他 の章で解説します。

view_model

UI の状態を管理する ViewModel を定義します。
基本的には riverpod パッケージの NotifierProvider を使用して管理します。
具体的な実装方法は その他 の章で解説します。

test

テストコードを配置するディレクトリです。
詳しくは その他 の章で解説します。

3. その他

この章では先ほど簡単に解説した各ディレクトリを深掘ったり、実際にコーディングする際に必要な書き方や規約を紹介します。

view の page ディレクトリについて

再度 view ディレクトリ内を確認します。

lib/view
├── app
├── component
└── page
    ├── sign_in
    └── sign_up
        ├── component
        ├── sign_up_complete_page.dart
        └── sign_up_page.dart

ディレクトリの分割方法配置するファイルの場所などは以下のルールを設けました。

  • page ディレクトリ内は sign_upsign_in など機能単位でディレクトリを分割する
  • component ディレクトリが2つあるが、下記の違いがある
    1. lib/view/component: 複数の画面(機能単位)で使用する UI コンポーネント
    2. page/sign_up/component: sign_up(会員登録関係)でのみ使用する UI コンポーネント

Model クラスの作成方法

アプリ内で使用するモデルクラスは freezed パッケージを使用して生成します。
freezed パッケージを使用する大きな理由は下記になります。

  • オブジェクトをクローンする copyWith メソッドを実装してくれる
  • DB と通信するためなどに便利な シリアライズ <-> デシリアライズ を実装してくれる
  • イミュータブルなクラスが生成される

実際のモデルクラスを作成する方法は以下です。

  1. freezed パッケージでクラスを作成する

    コードで確認する
    person.dart
    import 'package:freezed_annotation/freezed_annotation.dart';
    import 'package:flutter/foundation.dart';
    
    // 必須: `person.dart` を freezed が生成したコードに関連づける
    part 'person.freezed.dart';
    
    // オプション: Person クラスにシリアライズが必要なら、この行を追加しなければならない。
    // シリアライズが不要なら、この行は省略できる
    part 'person.g.dart';
    
    
    class Person with _$Person {
      const factory Person({
        required String firstName,
        required String lastName,
        required int age,
      }) = _Person;
    
      factory Person.fromJson(Map<String, Object?> json)
          => _$PersonFromJson(json);
    }
    
  2. build_runner を実行する

    build_runner を実行すると、person.freezed.dartperson.g.dart の2つのファイルが生成されます。

    flutter pub run build_runner build
    

この2ステップで簡単にクラスが生成できます。
他にもクラスに独自のメソッドを実装したり、シリアライズする際に firstNamefirst_name として出力を変えたりもできます。
詳しくは freezed パッケージの README をご確認ください。

ViewModel について

view_model ディレクトリ内の構成を確認します。

lib/view_model
├── component
├── feature
└── page
    ├── sign_in
    └── sign_up
        ├── sign_up_state.dart
        ├── sign_up_state.freezed.dart
        ├── sign_up_state.g.dart
        └── sign_up_view_model.dart

それぞれのディレクトリの役割やファイルの説明は以下になります。

  • component ディレクトリと page ディレクトリは view ディレクトリ内と関連しています。
  • feature ディレクトリはローディング状態のような、アプリ全体で使用する状態を管理する ViewModel を配置します。
  • sign_up ディレクトリ内の sign_up_state.dartfreezed パッケージで生成した会員登録関係の画面で使用する値を保持する状態クラスです。
  • sign_up_state.g.dartsign_up_state.freezed.dartfreezedbuild_runner パッケージを使用して自動生成されたファイルです。

View と ViewModel には以下のような関係です。

  1. View : ViewModel1 : 1 の場合もあれば 多 : 1 の場合もある。
  2. アプリの複数箇所で使用する component に対して ViewModel を作成することもある。
    これは page に対して1つの ViewModel ではあまりにもビジネスロジックや管理する値が肥大化する場合に使用する。

ViewModel の特徴・命名規則

  • ViewModel では以下の2つのクラスが登場します。
    • State: 保持したい値をプロパティとして持つクラス
    • Notifier: State を保持し State の状態を変更するなどのメソッドを持つクラス

sign_up_page.dart という View が存在する場合を例に、それぞれのファイル名やクラス名は以下になります。

クラス名 ファイル名
State SignUpState sign_up_state.dart
Notifier SignUpViewModel sign_up_view_model.dart

このように State は page 名 + State というクラス名で、 Notifier は page 名 + ViewModel という命名で統一します。
ファイル名も同様の規則です。(クラス名と違いスネークケースにする)

ViewModel の定義方法

sign_up (会員登録)を例に ViewModel の作成方法以下です。
基本的には riverpod パッケージの NotifierProvider を使用して作成します。

  1. freezed パッケージで State クラスを作成する
コードで確認
sign_up_state.dart
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'sign_up_state.freezed.dart';
part 'sign_up_state.g.dart';


class SignUpState with _$SignUpState {
  const factory SignUpState({
    ('') String emailAddress,
    ('') String password,
  }) = _SignUpState;

  factory SignUpState.fromJson(Map<String, dynamic> json) => _$SignUpStateFromJson(json);
}
  1. riverpod パッケージの Notifier クラスを継承したクラスを作成し、1の State を保持させる
コードで確認
sign_up_view_model.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:project_name/view_model/page/sign_up/sign_up_state.dart';

/// 会員登録の各種画面で使用する ViewModel
class SignUpViewModel extends AutoDisposeNotifier<SignUpState> {
  
  SignUpState build() {
    return const SignUpState();
  }

  /// メールアドレスの変更
  void updateEmailAddress(String emailAddress) {
    state = state.copyWith(
      emailAddress: emailAddress,
    );
  }

  /// パスワードの変更
  void updatePassword(String password) {
    state = state.copyWith(
      password: password,
    );
  }
}
  1. Notifier を継承したクラスを公開、監視するための NotifierProvider グローバルに定義する
コードで確認
sign_up_view_model.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:project_name/view_model/page/sign_up/sign_up_state.dart';

final signUpViewModelProvider = AutoDisposeNotifierProvider<SignUpViewModel, SignUpState>(
  SignUpViewModel.new,
);

ViewModel の使用方法

ViewModel の定義方法 で定義した ViewModel を View で監視し、データバインディングを実現します。
『データバインディング』とは、簡単に説明すると ViewModel の状態が変化したら自動的に View に表示している値も変わる仕組みです。
先ほど ViewModel を定義する際に riverpod パッケージを使用したのは、データバインディングを簡単に実現するためです。

先ほど作成した signUpStateProvider を View 側で監視する簡単な例は以下です。

sign_up_page.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:project_name/view_model/page/sign_up/sign_up_view_model.dart';

class SignUpPage extends ConsumerWidget {
  const SignUpPage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    /// SignUpViewModel の保持している値を監視する
    final signUpViewModel = ref.watch(signUpViewModelProvider);

    /// SignUpViewModel に定義した関数を使う場合はこのよう
    /// 末尾に .notifier をつける
    final signUpViewModelNotifier = ref.watch(signUpViewModelProvider.notifier);

    return Scaffold(
      body: Column(
        children: [
          Text('入力されたメールアドレス: ${signUpViewModel.emailAddress}'),
          Text('入力されたパスワード: ${signUpViewModel.password}'),
          TextField(
            onChanged: (value) {
              signUpViewModelNotifier.updateEmailAddress(value);
            },
          ),
          TextField(
            onChanged: (value) {
              signUpViewModelNotifier.updatePassword(value);
            },
          )
        ],
      ),
    );
  }
}

メールアドレスが入力された際の処理の流れは以下です。

  1. TextField に値が入力されるたびに ViewModel に定義した updateEmailAddress メソッドが呼ばれる
  2. updateEmailAddress メソッド内で ViewModel の状態を更新
  3. ref.watch(signUpViewModelProvider) を代入している signUpViewModel が ViewModel の変更を検知
  4. signUpViewModel の値を使用している Text Widget が新しい値で再描画される

上記は簡単な例なので、詳しい NotifierProvider の使用方法は公式ドキュメントにまとまっておりますのでそちらをご参考にしてください。

https://docs-v2.riverpod.dev/docs/providers/notifier_provider

riverpod を使用する際の注意点

riverpod を使用する際に気をつけることは以下です。

  1. Widget の build メソッド内や Provider のボディ内など Provider の値を監視する場合は必ず ref.watch メソッドを使用する
  2. 下記の場合は ref.watch メソッドではなく ref.read メソッドを使用する
    1. ElevatedButtononPressed のような非同期処理内
    2. initState やその他の State のライフサイクル

しかし、これらの気をつけることに例外もあります。
下記のコードをご覧ください。

final signUpViewModelNotifier = ref.watch(signUpViewModelProvider.notifier);

上記のコードは『ViewModel の使用方法』の章で紹介した sign_up_page.dart 内のコードです。
こちらは値を監視するわけではなく SignUpViewModel に定義した関数を使うためだけのものですが ref.watch メソッドを使用しております。
1の『値を監視する場合は必ず ref.watch メソッドを使用する』というルールから考えると 「値の変更を監視していないので ref.read メソッドを使用するべきでは?」と思われるかもしれません。
しかしこれはアンチパターンではなく、反対にこのような場合に ref.read メソッドを使用する方がアンチパターンなので気をつけてください。(以前このように実装してしまったことがありました...)

これらの ref.readref.watch の使い方や注意点などは riverpod の公式サイト 『Reading a Provider』 の章 で詳しく説明されておりますので気になる方はご参考にしてください。

アセット運用について

画像などのアセットを使用する方法を記述します。
本プロジェクトでは flutter_gen パッケージを使用してアセットを使用します。
そのため、まずは flutter_gen をローカルにインストールしてください。
HomebrewPub Global のどちらかでインストールしてください。

flutter_gen パッケージを使用する理由

Flutter で画像ファイルを表示する際は画像ファイルのパスを文字列で記載します。

Widget build(BuildContext context) {
  return Image.asset('assets/images/profile.jpeg');
}

これだとタイポの可能性があります。
flutter_gen パッケージを使用すると下記のように画像を扱うことができます。

Widget build(BuildContext context) {
  return Assets.images.profile.image();
}

画像の使用方法

  1. 画像ファイルを、assets/images/$任意ディレクトリに配置します。

  2. pubspec.yaml ファイルを編集する

    pubspec.yaml
    flutter:
      uses-material-design: true
      assets:
        - assets/images/$任意のディレクトリ(1で画像ファイルを配置したディレクトリ)
    
  3. fluttergen コマンドを実行(gen/assets.gen.dart ファイルが生成されます)

  4. 使用する

    コードで確認する

    下記の使用例は画像ファイルを assets/images/icons/icon.png に配置した場合です。

    import 'package:flutter/material.dart';
    import 'package:flutter_gen/gen_l10n/assets.gen.dart';
    
    class ImageSample extends StatelessWidget {
      
      Widget build(BuildContext context) {
        return Image.asset(Assets.images.icons.icon.path);
      }
    }
    

コーディング規約

下記コーディング規約のうち、lefthook で自動的に修正されるものは ✅ をつけています。

  • ✅ 文字列にはシングルクォーテーションを使用する
  • ✅ 1行120文字で改行する
  • ✅ import 分は絶対パスを使用する
  • ✅ const が使用できる箇所は必ずつける

また lint のルールは厳しめの pedantic_mono パッケージ を採用しております。
これによりコード品質の向上やプロジェクトメンバー間で一貫したコードスタイルを強制することができます。

Provider の命名規則

各 Provider はグローバルに定義した変数に代入して使用します。
そのため、下記は Provider を代入した変数の命名規則になります。
変数のため、全てローワーキャメルケースで命名してください。

Provider 命名規則
Provider 保持する値の名前 + Provider
StateProvider 保持する値の名前 + Provider
NotifierProvider Notifier を継承したクラス名 + Provider
FutureProvider 保持する値の名前 + Provider
StreamProvider 保持する値の名前 + Provider
ChangeNotifierProvider 非推奨のため、基本的には使用しません
StateNotifierProvider 非推奨のため、基本的には使用しません

NotifierProvider のみ使用方法が特殊で Notifier を継承したクラスを定義し、そのクラスを NotifierProvider で使用します。
その特徴から NotifierProvider のみ命名規則が違います。

NotifierProvider 以外の Provider の命名規則に『保持する値の名前』とありますが、変数名を考える時と同じでその Provider が保持している値を正確に表す名前を考え、命名してください。

Provider の命名例
// 完了済みの todo を提供する Provider
final completedTodosProvider = Provider<List<Todo>>((ref) {
  // todosProvider は NotifierPvodier
  final todos = ref.watch(todosProvider);

  return todos.where((todo) => todo.isCompleted).toList();
});

class Todo {
  Todo(this.description, this.isCompleted);
  final bool isCompleted;
  final String description;
}

class TodosNotifier extends Notifier<List<Todo>> {
  
  List<Todo> build() {
    return [];
  }

  void addTodo(Todo todo) {
    state = [...state, todo];
  }
}

final todosProvider = NotifierProvider<TodosNotifier, List<Todo>>(() {
  return TodosNotifier();
});
StateProvider の命名例
// 製品のソートタイプの列挙型
enum ProductSortType {
  name,
  price,
}

// 製品のソートタイプを提供する Provider
final productSortTypeProvider = StateProvider<ProductSortType>(
  // デフォルトのソートタイプ
  (ref) => ProductSortType.name,
);

// 並び替えられた製品リストを提供する Provider
// プロジェクトによっては sortedProductsProvider と命名しても良い
final productsProvider = Provider<List<Product>>((ref) {
  final sortType = ref.watch(productSortTypeProvider);
  switch (sortType) {
    case ProductSortType.name:
      return _products.sorted((a, b) => a.name.compareTo(b.name));
    case ProductSortType.price:
      return _products.sorted((a, b) => a.price.compareTo(b.price));
  }
});

エラーハンドリング

ここではエラーハンドリングの方法を簡単に解説します。
よく使う方法は下記の2つです。

  1. try catch を使用する
  2. FutureProvider、StreamProvider の when メソッドを使用する

1つずつ見ていきます。

1. try catch を使用する

下記は使用例です。

try {
  // 処理
} on FormatException catch (e) {
  print('FormatException details:\n $e');
} on Exception catch (e) {
  print('Exception details:\n $e');
} catch (e, s) {
  print('Exception details:\n $e');
  print('Stack trace:\n $s');
}

try 内の処理でエラーが発生した場合 catch に移動します。
上記コードのように on で例外やエラーを指定して個別に処理をすることができます。

2. FutureProvider、StreamProvider の when メソッドを使用する

FutureProviderStreamProvider を使用して非同期にデータを読み込み、取得する際にエラーが発生した場合の方法です。

まず、下記のような FutureProvider があります。

final configProvider = FutureProvider<Configuration>((ref) async {
  final content = json.decode(
    await rootBundle.loadString('assets/configurations.json'),
  ) as Map<String, Object?>;

  return Configuration.fromJson(content);
});

先ほどの FutureProvider を UI で下記のように使用します。

Widget build(BuildContext context, WidgetRef ref) {
  AsyncValue<Configuration> config = ref.watch(configProvider);

  return config.when(
    loading: () => const CircularProgressIndicator(),
    error: (err, stack) => Text('Error: $err'),
    data: (config) {
      return Text(config.host);
    },
  );
}

処理の流れは以下になります。

  1. UI が表示された時に configProvider の非同期処理が実行される
  2. 非同期処理中は loading: の Widget が表示される
  3. 処理終了後
    1. 処理成功時: data: の Widget が表示される
    2. 処理失敗時: error: の Widget が表示される

Provider のテストについて

Flutter のテストには下記の3種類があります。

  1. 単体テスト
  2. 結合テスト
  3. ウィジェットテスト

ここでは 『ViewModel について』 の章で例に挙げた SignUpViewModel をもとに Provider の単体テストの書き方を紹介します。

テスト対象の SignUpViewModel を確認する
sign_up_view_model.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:project_name/view_model/page/sign_up/sign_up_state.dart';

/// 会員登録の各種画面で使用する ViewModel
class SignUpViewModel extends AutoDisposeNotifier<SignUpState> {
  
  SignUpState build() {
    return const SignUpState();
  }

  /// メールアドレスの変更
  void updateEmailAddress(String emailAddress) {
    state = state.copyWith(
      emailAddress: emailAddress,
    );
  }

  /// パスワードの変更
  void updatePassword(String password) {
    state = state.copyWith(
      password: password,
    );
  }
}

単体テストのディレクトリ構成は以下になります。

app
├──lib/view_model/page/sign_up
│  └── sign_up_view_model.dart
└──test/view_model/page/sign_up
   └── sign_up_view_model_test.dart

上記のように test ディレクトリは lib ディレクトリと同様の構成にし、テストファイルは末尾に _test.dart をつけるという Flutter のルールがあります。
詳しくは Flutter の公式サイト をご確認ください。

テストコードは下記です。

sign_up_view_model_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:project_name/view_model/page/sign_up/sign_up_view_model.dart';

void main() {
  group('SignUpViewModel テスト', () {
    test('メールアドレスを更新する', () {
      // Provider を読み込むためのオブジェクト
      // テスト間で共有してはいけない
      final container = ProviderContainer();
      addTearDown(container.dispose);

      final signUpViewModelNotifier = container.read(signUpViewModelProvider.notifier);

      const testEmail = 'test@example.com';

      // メールアドレスがない状態で確認
      expect(container.read(signUpViewModelProvider).emailAddress, '');
      

      // メールアドレスで状態を更新し、状態の確認
      signUpViewModelNotifier.updateEmailAddress(testEmail);
      expect(container.read(signUpViewModelProvider).emailAddress, testEmail);
    });

    test('パスワードを更新する', () {
      // Provider を読み込むためのオブジェクト
      // テスト間で共有してはいけない
      final container = ProviderContainer();
      addTearDown(container.dispose);

      final signUpViewModelNotifier = container.read(signUpViewModelProvider.notifier);

      const testPassword = 'testPassword';

      // メールアドレスがない状態で確認
      expect(container.read(signUpViewModelProvider).password, '');

      // メールアドレスで状態を更新し、状態の確認
      signUpViewModelNotifier.updatePassword(testPassword);
      expect(container.read(signUpViewModelProvider).password, testPassword);
    });
  });
}

上記のテストでは SignUpViewModel の持つメソッドが正しく状態を更新できているかどうかをテストしています。
このように Provider をテストする際は ProviderContainer クラスが持つ read メソッドを使用します。

詳しくは riverpod の公式サイト をご確認ください。

まとめ

短く簡潔に...と思って書き始めた記事でしたがとても長くなってしまいました。
対象読者が Flutter エンジニアではないエンジニア なのでどこまでを前提として話すのが線引きがとても難しいです。
足らずの部分は追加し、逆に細かくて不要だと思う部分は適宜削ろうと思います。

以上です!

GitHubで編集を提案

Discussion