Flutter プロジェクトガイドラインを作ってみた
はじめに
この記事は Flutter エンジニアの著者が現在参加しているプロジェクトで採用したアーキテクチャやコーディングの規約など、プロジェクトのガイドラインをまとめたものです。
趣旨としては Flutter エンジニアではないプロジェクトメンバーがこのガイドラインを読んで、コードを読める(多少は書ける)ようになることを目指しております。
内容につきましては、あくまでも超内輪な内容なので温かい目で見ていただけると幸いです。
また、おかしな点や改善点などがありましたらご教授いただけると嬉しいです🙇🏻♂️
本記事の構成
本記事の構成は下記になります。
- 採用したアーキテクチャについて
- ディレクトリ構成について
- その他
1. 採用したアーキテクチャについて
本プロジェクトでは MVVM + Repository パターンを採用いたしました。
Flutter でアーキテクチャのことを調べるとよく出てくるパターンです。
採用に至った流れなど記事にしましたので興味がある方はこちらをご参照ください。
データの流れを書いてみます。
参考にさせていただいた記事はこちらです。
参考にさせていただいた記事で紹介されているデータの流れと違うところが2点あります。
-
Repository -> View
に矢印が向いている -
Repository -> ViewModel
に矢印が向いていない
図で違いを書いてみます。
『紹介した記事』のデータの流れでは View と Repository がお互い依存することなくデータの更新・取得は全て ViewModel を介して行われています。
一方『本記事』のデータの流れでは Repository から View にデータが直接流れています。
なぜこのようにするかというと Single Source Of Truth(SSOT) 原則 を遵守し、アプリ内で使用されるデータを一意にしたかったからです。
SSOT についてはこちらの記事を参考にさせていただきました。
下記は記事からの引用です。
SSOTを正しく意識できている場合、例えば以下のようなよくある要件を満たしたい時、特別な工夫なく容易かつ確実に実現できるはずです。
- 記事一覧画面で、自分のlike表示がされている(未like)
- 詳細画面に遷移後、likeするとその詳細画面でlike済みに変わる
- 一覧画面に戻ると、その記事がlike済みになっている
この際、SSOTになってない場合は以下のように脆い状態になります:
- 一覧画面と詳細画面の記事データソースが別管理(同じ記事のインスタンスが2つ存在)
- 詳細画面の記事インスタンスのlikeをtrueに変更(この時点では一覧画面のその記事インスタンスは未like)
- 一覧画面の記事インスタンスにもlike済みであることを同期(ここに抜け漏れがあるとバグったり、あるいは概ね正しく組んでいてもその処理が煩雑になりがちだったり一時的に表示不整合が生じるなどしがち)
- 同期処理完了後、一覧でも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 clean
と flutter pub get
や build_runner
、環境毎の flutterfire cofigure
コマンドなどを入力するのには長かったり、忘れたりするコマンドを make
コマンドで短く簡単に呼び出せるようにしています。
assets
画像などのアセットファイルを配置するディレクトリです。
後述しますが本プロジェクトで画像を扱う時は flutter_gen パッケージを使用しております。
dart_defines
Flutter では環境ごとに環境変数を読み込むアプローチとして --dart-define-from-file
を使用する方法があります。
この方法では環境変数を環境ごとに json 形式で定義できます。
本プロジェクトでは開発環境と本番環境の2つの環境があるので、dev.json
と prod.json
を配置し、環境毎に必要な値をそれぞれの json ファイルに記述しています。
使用方法、設定方法など詳しくは下記をご参考にしてください。
lefthook.yaml
lefthook
の設定ファイルが lefthook.yaml
になります。
lefthook
とは高速な git hook
管理ツールです。
git の特定のアクションが発生した時に任意のスクリプトを実行したりできる git hook
を設定ファイルで管理できるツールです。
具体的には git commit
を実行したタイミングで dart fix --apply
や dart format
を自動的に実行しコード修正、整形忘れを防ぐことができます。
これによりチームである程度統一されたソースコードを保つことができます。
lib
Flutter において lib ディレクトリはアプリのメインのソースコードを格納する場所です。
client
API クライアントを定義します。
constant
アプリ内で使用する定数を管理します。
以下のようなものがあります。
-
colors.dart
: 色を定義します -
strings.dart
: アプリで使用する文字列を定義します。 -
styles.dart
: アプリ内の複数箇所で使用する UI にまつわる値を定義します。例えばpadding
のEdgeInsets
などを定義します。
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
便利なメソッド群です。
logger
や json_converter
、 validator
などを定義します。
もしそれぞれのファイル数が肥大化するようであれば、階層を1つ上げて専用のディレクトリを作成するのもありです。
view
MVVM の View でアプリ内の UI 部分を担当します。
view
ディレクトリ内の構成は下記になります。
- app
-
MaterialApp
を定義します。main.dart
のmain
関数内で呼び出されます。
-
- 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_up
やsign_in
など機能単位でディレクトリを分割する -
component
ディレクトリが2つあるが、下記の違いがある-
lib/view/component
: 複数の画面(機能単位)で使用する UI コンポーネント -
page/sign_up/component
:sign_up
(会員登録関係)でのみ使用する UI コンポーネント
-
Model クラスの作成方法
アプリ内で使用するモデルクラスは freezed
パッケージを使用して生成します。
freezed
パッケージを使用する大きな理由は下記になります。
- オブジェクトをクローンする
copyWith
メソッドを実装してくれる - DB と通信するためなどに便利な
シリアライズ <-> デシリアライズ
を実装してくれる - イミュータブルなクラスが生成される
実際のモデルクラスを作成する方法は以下です。
-
freezed
パッケージでクラスを作成するコードで確認する
person.dartimport '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); }
-
build_runner
を実行するbuild_runner
を実行すると、person.freezed.dart
とperson.g.dart
の2つのファイルが生成されます。flutter pub run build_runner build
この2ステップで簡単にクラスが生成できます。
他にもクラスに独自のメソッドを実装したり、シリアライズする際に firstName
を first_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.dart
はfreezed
パッケージで生成した会員登録関係の画面で使用する値を保持する状態クラスです。 -
sign_up_state.g.dart
とsign_up_state.freezed.dart
はfreezed
とbuild_runner
パッケージを使用して自動生成されたファイルです。
View と ViewModel には以下のような関係です。
-
View : ViewModel
は1 : 1
の場合もあれば多 : 1
の場合もある。 - アプリの複数箇所で使用する
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
を使用して作成します。
-
freezed
パッケージで State クラスを作成する
コードで確認
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);
}
-
riverpod
パッケージのNotifier
クラスを継承したクラスを作成し、1の State を保持させる
コードで確認
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,
);
}
}
-
Notifier
を継承したクラスを公開、監視するためのNotifierProvider
グローバルに定義する
コードで確認
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 側で監視する簡単な例は以下です。
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);
},
)
],
),
);
}
}
メールアドレスが入力された際の処理の流れは以下です。
- TextField に値が入力されるたびに ViewModel に定義した
updateEmailAddress
メソッドが呼ばれる -
updateEmailAddress
メソッド内で ViewModel の状態を更新 -
ref.watch(signUpViewModelProvider)
を代入しているsignUpViewModel
が ViewModel の変更を検知 -
signUpViewModel
の値を使用している Text Widget が新しい値で再描画される
上記は簡単な例なので、詳しい NotifierProvider
の使用方法は公式ドキュメントにまとまっておりますのでそちらをご参考にしてください。
riverpod を使用する際の注意点
riverpod
を使用する際に気をつけることは以下です。
- Widget の
build
メソッド内やProvider
のボディ内などProvider
の値を監視する場合は必ずref.watch
メソッドを使用する - 下記の場合は
ref.watch
メソッドではなくref.read
メソッドを使用する-
ElevatedButton
のonPressed
のような非同期処理内 -
initState
やその他のState
のライフサイクル
-
しかし、これらの気をつけることに例外もあります。
下記のコードをご覧ください。
final signUpViewModelNotifier = ref.watch(signUpViewModelProvider.notifier);
上記のコードは『ViewModel の使用方法』の章で紹介した sign_up_page.dart
内のコードです。
こちらは値を監視するわけではなく SignUpViewModel に定義した関数を使うためだけのものですが ref.watch
メソッドを使用しております。
1の『値を監視する場合は必ず ref.watch
メソッドを使用する』というルールから考えると 「値の変更を監視していないので ref.read
メソッドを使用するべきでは?」と思われるかもしれません。
しかしこれはアンチパターンではなく、反対にこのような場合に ref.read
メソッドを使用する方がアンチパターンなので気をつけてください。(以前このように実装してしまったことがありました...)
これらの ref.read
や ref.watch
の使い方や注意点などは riverpod の公式サイト 『Reading a Provider』 の章 で詳しく説明されておりますので気になる方はご参考にしてください。
アセット運用について
画像などのアセットを使用する方法を記述します。
本プロジェクトでは flutter_gen
パッケージを使用してアセットを使用します。
そのため、まずは flutter_gen をローカルにインストールしてください。
Homebrew
か Pub 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();
}
画像の使用方法
-
画像ファイルを、
assets/images/$任意ディレクトリ
に配置します。 -
pubspec.yaml
ファイルを編集するpubspec.yamlflutter: uses-material-design: true assets: - assets/images/$任意のディレクトリ(1で画像ファイルを配置したディレクトリ)
-
fluttergen
コマンドを実行(gen/assets.gen.dart
ファイルが生成されます) -
使用する
コードで確認する
下記の使用例は画像ファイルを
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つです。
- try catch を使用する
- 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
で例外やエラーを指定して個別に処理をすることができます。
when
メソッドを使用する
2. FutureProvider、StreamProvider の FutureProvider
や StreamProvider
を使用して非同期にデータを読み込み、取得する際にエラーが発生した場合の方法です。
まず、下記のような 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);
},
);
}
処理の流れは以下になります。
- UI が表示された時に configProvider の非同期処理が実行される
- 非同期処理中は
loading:
の Widget が表示される - 処理終了後
- 処理成功時:
data:
の Widget が表示される - 処理失敗時:
error:
の Widget が表示される
- 処理成功時:
Provider のテストについて
Flutter のテストには下記の3種類があります。
- 単体テスト
- 結合テスト
- ウィジェットテスト
ここでは 『ViewModel について』 の章で例に挙げた SignUpViewModel
をもとに Provider の単体テストの書き方を紹介します。
テスト対象の SignUpViewModel を確認する
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 の公式サイト をご確認ください。
テストコードは下記です。
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 エンジニアではないエンジニア なのでどこまでを前提として話すのが線引きがとても難しいです。
足らずの部分は追加し、逆に細かくて不要だと思う部分は適宜削ろうと思います。
以上です!
Discussion