41.9K Views
November 17, 22
スライド概要
FlutterKaigi 2022
https://flutterkaigi.jp/2022/
で発表したスライドになります。
Android Engineer at Kyash Inc.
Flutterアプリの安全な変化と拡大を支えるアーキテクチャ と単体テスト FlutterKaigi 2022 株式会社リサーチ・アンド・イノベーション 高田 晴彦 (tfandkusu) 1
自己紹介 高田 晴彦 (@tfandkusu) 株式会社リサーチ・アンド・イノ ベーション Androidエンジニア 2
レシートがお金にかわる 家計簿アプリCODE 買い物を登録したりアンケートに 答えるとポイントがもらえる 集めた情報はメーカーのマーケテ ィングに活用 3
アウトライン モチベーション アプリの変化と拡大 聴講対象者 サンプルアプリのソースコードでアーキテクチャと単体テストを解説 カバレッジをプルリクで可視化する設定を紹介 4
アプリの変化と拡大 1/3 あるモバイルアプリはFlutterの高い生産性を活用して素早く初回リリースが完了 5
アプリの変化と拡大 2/3 たくさんのユーザが付いた結果、予算が増え人員も増えた 6
アプリの変化と拡大 3/3 ユーザのフィードバック 利用データの分析結果に基づいたKPI改善 他社とのアライアンスなどでビジネスが拡大して要件が増えた 素早くアプリを変化および拡大させていく必要性が発生 7
既存機能への不具合は良くない アプリの変化と拡大の過程で既存機能に不具合が発生してユーザに迷惑をかけること(デグ レード)は極力避けたい 機会損失 社内のビジネス部門やユーザ、取引先からの信用を失うかもしれない 8
今回の話 1/2 アーキテクチャ( = 目的を達成するためのチームで共通のガイドライン)を決める 各部品に対して単体テストを作成してデグレードを防ぐ 9
今回の話 2/2 単体テストの作成抜けを可視化 10
聴講対象者 Flutterアプリを作って初回リリースをしたが、以下のような観点で今後のアプリの変化や拡 大に不安がある人 単体テストが無いのでデグレードが不安 クラスやメソッドの粒度が大きかったり関係性が統一されていなかったりして、可読性 が悪い 粒度の大きさや別クラスとの依存性から、単体テストが書きにくい 11
サンプルアプリを題材に解説 私のGitHubリポジトリ一覧を表示 詳細画面ではさらにREADME.mdを表示 12
リポジトリ一覧はGitHubのREST APIから取得
https://api.github.com/users/tfandkusu/repos?page=1
レスポンス本文(一部抜粋)
[
]
{
}
id: 331745751,
name: "ikut_annotation",
description: "[Flutter Desktop]Simple annotation tool for image classification.",
updated_at: "2021-09-25T09:46:44Z",
language: "C++",
fork: false,
default_branch: "main"
13
所謂「いいね問題」に対応 詳細画面で「いいね」を付けると 一覧画面でも「いいね」が付いている 「いいね」を付けた情報はアプリローカルに保存 14
サンプルアプリのソースコード GitHubで公開中 https://github.com/tfandkusu/flutter_architecture_sample スライドのスペースや時間の都合で省略したコードも、すべてこちらにあります。 15
開発環境 Flutter 3.3.2 hooks_riverpod 1.0.4 freezed 2.1.0+1 16
アーキテクチャを考える Androidの公式サイト「Androidデベロッパー」には「アプリ アーキテクチャガイド」が ある。 2つまたは3つのレイヤ構造を推奨。 図は https://developer.android.com/jetpack/guide より引用 17
データレイヤ リポジトリ 上のレイヤにデータ公開 一元管理 データソース 1つのデータソースのみを処理 データソースの例 APIクライアント ローカルデータベース 18
Freezedであらゆる所で使用するデータクラスを定義 @freezed class GithubRepo with _$GithubRepo { /// GitHub /// /// [id] GitHub ID /// [name] /// [description] /// [updatedAt] /// [language] /// [fork] /// [defaultBranch] (main /// [favorite] const factory GithubRepo( {required int id, required String name, required String description, required DateTime updatedAt, required String language, required bool fork, required String defaultBranch, required bool favorite}) = _GithubRepo; } リポジトリと、それに「いいね」を付けたフラグ のリポジトリの リポジトリ名 説明文 更新日時 プログラミング言語 フォークされたリポジトリであるフラグ デフォルトブランチ またはmaster) いいねを付けたフラグ 19
APIクライアント 1/4 Retrofit For Dartで作成 https://pub.dev/packages/retrofit 今回の話はRetrofitの話では無いので、おおまかな実装イメージとデータソースのインターフ ェースのみ説明 20
APIクライアント 2/4
アノテーションでREST APIの仕様を設定。
@RestApi(baseUrl: "https://api.github.com/")
abstract class GithubApiClient {
factory GithubApiClient(Dio dio, {String baseUrl}) = _GithubApiClient;
ユーザ
戻り値は
}
の公開
リポジトリ一覧を取得する。
の形式と同じ形式にした クラスのインスタンス
クエリパラメータ。ページインデックス。 が最初。
///
tfandkusu
GitHub
///
JSON
Dart
/// [page]
1
@GET("/users/tfandkusu/repos")
Future<List<GithubRepoListResponseItem>> getGithubRepoList(
@Query("page") int page);
21
APIクライアント 3/4 GitHub REST APIから公開GitHubリポジトリ一覧を取得する担当クラスを作成 class GithubRepoRemoteDataSource { final GithubApiClient _client; GithubRepoRemoteDataSource(this._client); ユーザ 省略 } の公開 リポジトリ一覧を取得する /// tfandkusu GitHub Future<List<GithubRepo>> getGithubRepoList() async { // } 22
APIクライアント 4/4 GithubRepoRemoteDataSourceのインスタンスをRiverpodのProviderから取得できるように する。 final githubRepoRemoteDataSourceProvider = Provider((ref) { final client = ref.read(githubApiClientProvider); return GithubRepoRemoteDataSource(client); }); インスタンスをProviderから取得する形を取ることで単体テストで便利な使い方ができる(後 で説明) 23
APIクライアントの動作確認
void main() {
test("GithubRepoRemoteDataSource", () async {
final container = ProviderContainer();
final remoteDataSource = container.read(githubRepoRemoteDataSourceProvider);
//
API
final repoList = await remoteDataSource.getGithubRepoList();
// GitHub
// 31
expect(repoList.length, greaterThan(30));
// try_graphql_android
final repo =
repoList.firstWhere((repo) => repo.name == 'try_graphql_android');
//
1
expect(repo.description.length, greaterThanOrEqualTo(1));
//
});
//
}
実際の にアクセスする
側の変化を想定したチェック項目にする
件以上リポジトリが取れる
が取得できる
説明文は 文字以上あれば成功
他のフィールドは省略
成功ケースのみ。サーバエラーの再現にはモックサーバが必要なので今回は省略。
24
「いいね」を付けた情報をローカルに保存する 1/3
shared_preferencesプラグインを使用
https://pub.dev/packages/shared_preferences
AndroidではSharedPreferencesを使用
iOSではNSUserDefaultsを使用
Stringのリストを保存および読込する機能を使用
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList('items', <String>['Earth', 'Moon', 'Sun']);
25
「いいね」を付けた情報をローカルに保存する 2/3 class FavoriteLocalDataSource { /// GitHub /// /// [githubRepoName] GitHub /// [favorite] true false Future<void> setFavorite(String githubRepoName, bool favorite) async { // shared_preferences Dart } リポジトリに「いいね」を付けるか消して、その結果を保存する。 リポジトリの名前 の時は「いいね」を付ける。 の時は「いいね」を消す。 今回の話は プラグインの使い方ではないので コードは省略 「いいね」を付けたリポジトリの名前一覧を得る 省略 } /// Future<Set<String>> getFavoriteRepoNameSet() async { // } 26
「いいね」を付けた情報をローカルに保存する 3/3 FavoriteLocalDataSourceのインスタンスもRiverpodのProviderから取得できるようにする。 final favoriteLocalDataSourceProvider = Provider((ref) => FavoriteLocalDataSource()); 27
FavoriteLocalDataSourceの単体テスト
void main() {
test("FavoriteLocalDataSource", () async {
// PC
shared_preferences
SharedPreferences.setMockInitialValues({});
final container = ProviderContainer();
final localDataSource = container.read(favoriteLocalDataSourceProvider);
//
expect(await localDataSource.getFavoriteRepoNameSet(), <String>{});
// flutter_architecture_sample
await localDataSource.setFavorite('flutter_architecture_sample', true);
// flutter_architecture_sample
expect(await localDataSource.getFavoriteRepoNameSet(),
<String>{'flutter_architecture_sample'});
});
}
上で
プラグインを使った単体テストが動くようにするための設定
最初は何も「いいね」をしていない
に「いいね」を付ける
だけのセットを取得する
28
RiverpodのStateNotifierの解説 できること 状態を持つ 状態の更新をそれを監視しているWidgetやProviderに伝えることができる 複数の画面に対する更新も1つのStateNotifierの状態の更新だけで対応できて、所謂「いいね 問題」を解決できる。 29
GitHubリポジトリ一覧のためのStateNotifierを作成 1/2 class GithubRepoListStateNotifier extends StateNotifier<List<GithubRepo>> { GithubRepoListStateNotifier() : super([]); リポジトリ一覧を更新 リポジトリ一覧 /// GitHub /// [list] GitHub void setList(List<GithubRepo> list) { state = list; } } // 続きは次ページ 30
GitHubリポジトリ一覧のためのStateNotifierを作成 2/2
class GithubRepoListStateNotifier extends StateNotifier<List<GithubRepo>> {
///
///
/// [name] GitHub
/// [favorite] true
false
void setFavorite(String name, bool favorite) {
state = state.map((repo) {
if (repo.name == name) {
//
favorite
return repo.copyWith(favorite: favorite);
} else {
//
return repo;
}
}).toList();
}
}
「いいね」を更新する
リポジトリの名前
の場合は「いいね」を付ける。
更新対象の場合は
の場合は消す
フィールドだけ変更してコピーしたものに差し替える
更新対象でないときは要素はそのまま
31
RiverpodのStateNotifierProviderでStateNotifierを取得 できるようする。 final githubRepoListStateNotifierProvider = StateNotifierProvider<GithubRepoListStateNotifier, List<GithubRepo>>( (ref) => GithubRepoListStateNotifier()); 32
GithubRepoListStateNotifierの単体テストを作成 1/2
void main() {
test("GithubRepoListStateNotifier", () async {
final container = ProviderContainer();
//
StateNotifier
final stateNotifier =
container.read(githubRepoListStateNotifierProvider.notifier);
//
StateNotifier state
getState() => container.read(githubRepoListStateNotifierProvider);
//
});
}
テスト対象
現在の
次のページに続く
の取得
の
を取得するメソッド
33
GithubRepoListStateNotifierの単体テストを作成 2/2 初期状態は空のリストが リポジトリ一覧を設定 に設定されている // state expect(getState(), <GithubRepo>[]); // // getGithubRepoCatalog GitHub final list = getGithubRepoCatalog(); stateNotifier.setList(list); // GithubRepo state expect(getState(), list); // stateNotifier.setFavorite("observe_room", true); // GithubRepo favorite true expect(getState()[0].favorite, true); expect(getState()[1].favorite, false); expect(getState()[2].favorite, false); は固定のテスト用 リポジトリ一覧を返却するメソッド 設定した のリストが に設定されている 「いいね」を更新 対象の の フィールドだけが になっている 34
GithubRepoRepositoryクラスを作成 1/4 データレイヤを代表してアプリに表示するGitHubリポジトリ一覧を更新する担当 35
GithubRepoRepositoryクラスを作成 2/4
class GithubRepoRepository {
/// API
GitHub
final GithubRepoRemoteDataSource _remoteDataSource;
から
リポジトリ一覧を取ってくる担当
リポジトリに対する「いいね」をアプリローカルから読み書きする担当
/// GitHub
final FavoriteLocalDataSource _localDataSource;
リポジトリ一覧を レイヤに通知するために保持する担当
/// Github
UI
final GithubRepoListStateNotifier _stateNotifier;
GithubRepoRepository(this._remoteDataSource, this._localDataSource, this._stateNotifier);
リポジトリ一覧を読み込んでアプリ内に保持する
後で解説
/// Github
Future<void> fetch() async {
//
}
「いいね」を設定する
後で解説
}
///
Future<void> setFavorite(String name, bool favorite) async {
//
}
36
GithubRepoRepositoryクラスを作成 3/4
リポジトリ一覧を読み込んでアプリ内に保持する
リポジトリ一覧を から取得する
「いいね」を付けたリポジトリ名一覧をローカルから読み込む
/// Github
Future<void> fetch() async {
// GitHub
API
final repoList = await _remoteDataSource.getGithubRepoList();
//
final favoriteRepoNameSet =
await _favoriteLocalDataSource.getFavoriteRepoNameSet();
//
final githubRepoListWithFavorite = repoList
.map((repo) =>
repo.copyWith(favorite: favoriteRepoNameSet.contains(repo.name)))
.toList();
// UI
StateNotifier
_stateNotifier.setList(githubRepoListWithFavorite);
}
両者を合成する
レイヤを更新するために
に設定する
37
GithubRepoRepositoryクラスを作成 4/4
「いいね」を設定する
リポジトリ名
の場合は「いいね」を付ける。
の場合は「いいね」を消す。
レイヤを更新するために、今回の「いいね」情報を
に設定する
アプリローカルに保存されている「いいね」情報を更新する
///
///
/// [name] GitHub
/// [favorite] true
false
Future<void> setFavorite(String name, bool favorite) async {
// UI
StateNotifier
_stateNotifier.setFavorite(name, favorite);
//
await _favoriteLocalDataSource.setFavorite(name, favorite);
}
38
GithubRepoRepositoryクラスのインスタンスを取得する ためのProviderを作成 final githubRepoRepositoryProvider = Provider((ref) { // final remoteDataSource = ref.read(githubRepoRemoteDataSourceProvider); final localDataSource = ref.read(favoriteLocalDataSourceProvider); final stateNotifier = ref.read(githubRepoListStateNotifierProvider.notifier); // return GithubRepoRepository(remoteDataSource, localDataSource, stateNotifier); }); 内部で使用するインスタンスを取得 コンストラクタに設定して作成する 39
Repositoryの単体テスト 1/6 RepositoryにはRemoteDataSourceなど内部で使用するインスタンスがある。 1. Mockitoで内部で使用するインスタンスのモックを作る 2. Riverpodのoverride機能でモックに差し替える 40
Repositoryの単体テスト 2/6
にモックを作らせる対象クラスをアノテーションで指定
// Mockito
@GenerateNiceMocks([
MockSpec<GithubRepoRemoteDataSource>(),
MockSpec<FavoriteLocalDataSource>(),
MockSpec<GithubRepoListStateNotifier>()
])
void main() {
test("GithubRepoRepository#fetch", () async {
//
});
}
中身は次ページ以降で解説
41
Repositoryの単体テスト 3/6 モッククラスをbuild_runnerでソースコード生成 flutter pub run build_runner build 42
Repositoryの単体テスト 4/6 テスト対象クラスが使用するインスタンスのモック実装を作成する // final remoteDataSource = MockGithubRepoRemoteDataSource(); final localDataSource = MockFavoriteLocalDataSource(); final stateNotifier = MockGithubRepoListStateNotifier(); // GithubRepoRemoteDataSource // getGithubRepoCatalog GitHub final repoList = getGithubRepoCatalog(); when(remoteDataSource.getGithubRepoList()) .thenAnswer((_) async => repoList); // thenReturn // FavoriteLocalDataSource // observe_room when(localDataSource.getFavoriteRepoNameSet()) .thenAnswer((_) async => {"observe_room"}); のモック戻り値を設定する は固定のテスト用 リポジトリ一覧を返却するメソッド 非同期で無いときは の戻り値を設定する。 リポジトリだけ「いいね」が付いている場合の戻り値 を使う 43
Repositoryの単体テスト 5/6 が提供するインスタンスをモック実装に差し替える // Provider final container = ProviderContainer(overrides: [ githubRepoRemoteDataSourceProvider.overrideWithValue(remoteDataSource), favoriteLocalDataSourceProvider.overrideWithValue(localDataSource), githubRepoListStateNotifierProvider.overrideWithValue(stateNotifier) ]); // final repository = container.read(githubRepoRepositoryProvider); テスト対象インスタンスを取得 44
Repositoryの単体テスト 6/6
テスト対象メソッドを呼び出し
リポジトリだけ「いいね」が付いたときの
に設定される
リポジトリ一覧
//
await repository.fetch();
// observe_room
// GithubRepoListStateNotifier#setList
// GitHub
final repoListWithFavorite = repoList.map((repo) {
if (repo.name == "observe_room") {
return repo.copyWith(favorite: true);
} else {
return repo;
}
}).toList();
//
verifyInOrder([
remoteDataSource.getGithubRepoList(),
localDataSource.getFavoriteRepoNameSet(),
stateNotifier.setList(repoListWithFavorite)
]);
内部で使用するインスタンスのメソッドの呼ばれ方を確認する
45
Repositoryの単体テスト まとめ 1. テスト対象クラスで使用されるクラスについて、モッククラスをMockitoにソースコード 生成させる 2. モッククラスのインスタンスを作成する 3. モッククラスのインスタンスについて、このメソッドがこう呼ばれたらこれを返却す る、を when thenReturn thenAnswer 等を使って設定する 4. Riverpodのoverride機能でProviderが提供するインスタンスをモッククラスのインスタン スに差し替える 5. Riverpodからテスト対象クラスのインスタンスを取得する。 6. テスト対象メソッドを呼び出し。必要に応じてその戻り値を確認 7. テスト対象メソッド内部での使用クラスのインスタンスについて、 verifyInOrder で メソッドの呼ばれ方を確認する 46
UIレイヤ Androidの「アプリ アーキテクチャガイ ド」を確認する UI要素(UI elements) FlutterだとWidgetに対応 状態に従いレンダリングされ る 状態ホルダ(State holders) UI状態を生成する そのために必要なロジックを 格納 47
ホーム画面を作成 GitHubリポジトリと「いいね」状 態の一覧を表示 「いいね」ボタンクリックで「い いね」を付けるまたは消す 読込中プログレス エラー表示 48
ホーム画面の状態を表すデータクラスをFreezedで作成 ホーム画面状態 クラス名を /// /// UiModel Riverpod StateNotifier State @freezed class HomeUiModel with _$HomeUiModel { /// /// [progress] /// [repos] GitHub /// [error] const factory HomeUiModel( {required bool progress, required List<GithubRepo> repos, required ErrorUiModel error}) = _HomeUiModel; } にしたのは の の と紛らわしいため。 ホーム画面の状態 読み込み中プログレス表示 リポジトリ一覧 エラー状態 49
エラー状態をFreezedで作成 エラー状態 /// @freezed class ErrorUiModel with _$ErrorUiModel { /// const factory ErrorUiModel.noError() = NoError; エラーなし ネットワークエラー 圏外など /// ( ) const factory ErrorUiModel.network() = Network; サーバエラー 使用回数制限超過、メンテナンス中 /// (API ) const factory ErrorUiModel.server() = Server; } 50
UIレイヤの全体像 51
ホーム画面の状態を持つStateNotifierを作成する
ホーム画面状態の
///
StateNotifier
class HomeUiModelStateNotifier extends StateNotifier<HomeUiModel> {
HomeUiModelStateNotifier()
: super(/*
*/const HomeUiModel(
progress: true, repos: [], error: ErrorUiModel.noError()));
初期状態
読み込みが成功したときに呼ばれる
///
void onLoadSuccess() {
state = state.copyWith(progress: false);
}
エラーの時に呼ばれる
エラー情報
だと
のメソッドとかぶる)
///
(onError
StateNotifer
///
/// [error]
void onMyError(ErrorUiModel error) {
state = state.copyWith(progress: false, error: error);
}
エラーからリロードするときに呼ばれる
///
void onReload() {
state = state.copyWith(progress: true, error: const ErrorUiModel.noError());
}
}
リポジトリ一覧はデータレイヤのGithubRepoListStateNotifierが提供するので、ここでは扱わない
// GitHub
52
ホーム画面の状態を持つStateNotifierのProviderを作成 する final homeUiModelStateNotifierProvider = StateNotifierProvider<HomeUiModelStateNotifier, HomeUiModel>( (ref) => HomeUiModelStateNotifier()); 53
HomeUiModelStateNotifierの単体テスト データレイヤのGithubRepoListStateNotifierの単体テストとやり方は同じ final container = ProviderContainer(); // StateNotifier final stateNotifier = container.read(homeUiModelStateNotifierProvider.notifier); // StateNotifier state getState() => container.read(homeUiModelStateNotifierProvider); // expect( getState(), const HomeUiModel( progress: true, repos: [], error: ErrorUiModel.noError())); // stateNotifier.onLoadSuccess(); expect( getState(), const HomeUiModel( progress: false, repos: [], error: ErrorUiModel.noError())); // テスト対象 現在の 初期値を確認 の取得 の を取得するメソッド 読み込み成功 エラーとリロードは略 54
ホーム画面のWidgetに状態を提供する homeUiModelProviderを作成 ホーム画面の状態を提供する データレイヤの リポジトリ一覧を取得する で扱っているホーム画面の状態を取得する データレイヤの リポジトリ一覧を設定して返却する /// Provider final homeUiModelProvider = Provider((ref) { // GitHub final repos = ref.watch(githubRepoListStateNotifierProvider); // StateNotifier final homeUiModel = ref.watch(homeUiModelStateNotifierProvider); // GitHub return homeUiModel.copyWith(repos: repos); }); の代わりに ref.watch を使うと、対象のStateNotifierの状態が更新されると、 上記の処理が再度実行されて、このProviderを使うWidgetに新しい状態を反映することがで きる。 ref.read 55
homeUiModelProviderの単体テスト 1/2
GithubRepoListStateNotifierとHomeUiModelStateNotifierに単体テストのための初期状態を
設定して作成するコンストラクタ override を追加
リポジトリ一覧を レイヤを更新するために保持する担当クラス
/// Github
UI
class GithubRepoListStateNotifier extends StateNotifier<List<GithubRepo>> {
GithubRepoListStateNotifier() : super([]);
【追加】単体テストのために状態を設定して作成する
単体テスト向けの状態
///
///
/// [list]
GithubRepoListStateNotifier.override(List<GithubRepo> list) : super(list);
}
56
homeUiModelProviderの単体テスト 2/2 final repos = getGithubRepoCatalog(); // StateNotifier final container = ProviderContainer(overrides: [ homeUiModelStateNotifierProvider.overrideWithValue( HomeUiModelStateNotifier.override( /* */const HomeUiModel( progress: false, repos: [], error: ErrorUiModel.noError()))), githubRepoListStateNotifierProvider .overrideWithValue( /* GitHub */GithubRepoListStateNotifier.override(repos)) ]); // HomeUiModelStateNotifier GithubRepoListStateNotifier // final uiModel = container.read(homeUiModelProvider); expect( uiModel, HomeUiModel( progress: false, repos: repos, error: const ErrorUiModel.noError())); 単体テストのための初期状態を設定した に差し替える 読込成功後の状態 リポジトリ一覧が設定済み と 合成されたあとの状態を取得して確認する の状態が 57
いいねボタンのクリックなど、イベントを処理する担当ク
ラスHomeEventHandlerを作成
class HomeEventHandler {
final HomeUiModelStateNotifier _stateNotifier;
final GithubRepoRepository _repository;
HomeEventHandler(this._stateNotifier, this._repository);
画面が開かれた時に呼ばれる
後で解説
///
Future<void> onCreate() async {
//
}
「いいね」ボタンが押された時に呼ばれる
後で解説
}
//
Future<void> onClickFavorite(String githubRepoName, bool favorite) async {
//
}
58
画面が表示された時に呼ばれるEventHandlerのメソッド Future<void> onCreate() async { try { // await _repository.fetch(); // _stateNotifier.onLoadSuccess(); } on Exception catch (e) { _stateNotifier.onMyError(mapError(e)); } } フェッチ処理 処理成功 59
「いいね」ボタンが押された時に呼ばれるEventHandler
のメソッド
「いいね」が押された
リポジトリの名前
「いいね」を付ける時は 、消すときは
。
/// [githubRepoName]
GitHub
/// [favorite]
true
false
Future<void> onClickFavorite(String githubRepoName, bool favorite) async {
await _repository.setFavorite(githubRepoName, favorite);
}
60
HomeEventHandlerProviderを作成 GithubRepoRepositoryのProviderと同様に、使用するインスタンスを ref.read で取得し てコンストラクタに設定。 final homeEventHandlerProvider = Provider((ref) { final stateNotifier = ref.read(homeUiModelStateNotifierProvider.notifier); final repository = ref.read(githubRepoRepositoryProvider); return HomeEventHandler(stateNotifier, repository); }); 61
HomeEventHandlerクラスの単体テストを作成 1/2
GithubRepoRepositoryのテストと同様にMockitoとRiverpodのoverride機能を使用
に作らせるモック実装をアノテーションで指定
// Mockito
@GenerateNiceMocks(
[MockSpec<GithubRepoRepository>(), MockSpec<HomeUiModelStateNotifier>()])
void main() {
//
test("HomeEventHandler#onCreate success", () async {
//
final repository = MockGithubRepoRepository();
final stateNotifier = MockHomeUiModelStateNotifier();
// Provider
final container = ProviderContainer(overrides: [
githubRepoRepositoryProvider.overrideWithValue(repository),
homeUiModelStateNotifierProvider.overrideWithValue(stateNotifier)
]);
//
});
}
画面を開いたときの処理が成功
内部で使用するインスタンスのモック実装を作成する
が提供するインスタンスをモック実装に差し替える
続きは次のページ
62
HomeEventHandlerクラスの単体テストを作成 2/2 テスト対象を取得 テスト対象メソッドを呼び出し テスト対象メソッド内部での と // final eventHandler = container.read(homeEventHandlerProvider); // await eventHandler.onCreate(); // // GithubRepoRepository HomeUiModelStateNotifier verifyInOrder([ repository.fetch(), stateNotifier.onLoadSuccess() ]); のメソッドの呼ばれ方を確認 63
ホーム画面のウィジットを作成 class HomeScreen extends HookConsumerWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // watch final uiModel = ref.watch(homeUiModelProvider); // final eventHandler = ref.read(homeEventHandlerProvider); // useEffect(() { eventHandler.onCreate(); return () {}; }, const [] /* 1 */); // uiModel Widget } 状態を取得する。 を使って更新があれば再構築する。 イベント処理担当を取得する 画面が開かれた時の処理を行う 回だけ実行する に従って 構築するところは省略 } 64
所謂「いいね問題」に対応 65
パッケージ配置とクラスなどの命名規則をチームの共通認 識とする 1/3 内容 ローカルのデータを読み書きするクラス API通信のクラス データレイヤの処理を代表するクラス UIレイヤから更新監視されるデータレイヤの StateNotifierクラス パッケージ data.local クラス名 対象物 LocalDataSource 対象物 data.remote RemoteDataSource data.repository 対象物Repository data.repository 対象物StateNotifier 66
パッケージ配置とクラスなどの命名規則をチームの共通認 識とする 2/3 内容 パッケージ クラス名 画面Widget screen.画面名.widget 画面名Screen 画面状態データクラス screen.画面名.stateholder 画面名UiModel 画面状態を提供するProvider screen.画面名.stateholder 画面名UiModelProvider 67
パッケージ位置とクラスなどの命名規則をチームの共通認 識とする 3/3 内容 パッケージ クラス名 画面状態のStateNotifierクラス screen.画面名.stateholder 画面名UiModelStateNotifier イベント処理担当クラス screen.画面名.stateholder 画面名EventHandler 68
公開メソッドの命名規則もチームの共通認識とする 場所 RemoteDataSource Repository EventHandler EventHandler 内容 取得する APIから取得して、その内容を StateNotifierに保持する 画面が作られた時の処理 ユーザ操作によって始める処理 メソッド名 get対象物 fetch対象物 onCreate onClickFavorite等「onユーザ操 作」をメソッド名にする 69
人によって書き方が違う場所が発生したときの対処法 私のところのAndroidアプリ開発チームの例 チームで定例会議を設定する 1スプリント2週間なので、それに合わせてチームでも2週間に1回の定例会議を設定 している。 コードレビューで人によって書き方が違うところが判明した場合は、定例会議の議題に して新たなガイドライン策定を検討する。 決まったガイドラインは社内ポータル内の文章に残して、チームの新たな共通認識とし ている。 70
【オススメ書籍】チーム で育てるAndroidアプリ 設計 何をチームでガイドライン化する べきなのか アーキテクチャをチームへ広めて 定着させる方法 複数の開発が同時に進行する大規 模なチーム開発 71
実プロダクトではドメインレイヤが必要 Androidの「アプリ アーキテクチャガイド」には任意で追加するレイヤとしてドメインレイヤ がある。 図は https://developer.android.com/jetpack/guide より引用 72
ドメインレイヤの例 1/2 私がAndroidアプリの開発を担当している「レシートがお 金にかわる家計簿アプリCODE」の買い物登録 データ不整合チェック 写真のアップロード 購買情報のアップロード ユーザの利用実態分析情報のアップロード(Firebase Analytics) キャッシュの削除 カテゴリ選択履歴の更新 73
ドメインレイヤの例 2/2 同じ画面に同居する他の処理 合計金額の編集 日付の変更 カテゴリの変更 商品の追加 それらを1クラスにまとめると、実装も単体テストも巨大 ファイルになって可読性が悪くなる。そこで1処理を1実装 クラスと1単体テストファイルにすることで、巨大ファイ ルの発生を防げる。 74
今回のアーキテクチャにドメインレイヤを追加 75
今回紹介したアーキテクチャは一例であり唯一の正解では ない 以下のような条件を満たしていれば、どんなアーキテクチャでも良いと思う。 要件を満たせる 全画面でガイドラインが共通 チーム全員が理解している 新しいメンバーがジョインするときに教えやすい 単体テストを苦労せずに書ける 76
カバレッジをプルリクで可視化する設定 単体テストでカバー出来ている所と出来ていない所を可視化できる。 77
Codecov https://about.codecov.io/ 78
プラン 無料プラン PUSH回数は月に250回まで チームメンバーは5人まで 有料プラン 年間720ドルから 年払いの月額が1人10ドル 最低人数が6人 弊社の場合はフルタイム2名は無料で良かったが3名になったらPUSH回数がギリギリになっ たので有料プランを契約 79
設定方法 1/6 GitHubとGitHub Actions使用の場合 CodecovにGitHubアカウントでログイン Codecovを使いたいリポジトリの setup repo をクリック 80
設定方法 2/6 表示されるCODECOV_TOKENをGitHubのActions secretsに設定 81
設定方法 3/6 GitHubにCodecovアプリケーションを追加 82
設定方法 4/6 GitHub Actionsのワークフローを設定 スペースの都合トリガーは省略 プルリクエストで動くようにする) # ( jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: # Codecov fetch-depth: 0 # # Flutter # freezed build_runner のために必要な設定 スペースの都合で省略したが 環境のセットアップ、依存ライブラリのダウンロード のために の実行、フォーマットの確認、lintチェック。 単体テストの実行とカバレッジレポートの出力 実行すると coverage/lcov.info が作成される # # - run: flutter test --coverage 83
設定方法 5/6 カバレッジレポート coverage/lcov.info をCodecovにアップロードするステップ - run: curl -Os https://uploader.codecov.io/latest/linux/codecov - run: chmod +x codecov - run: ./codecov -t ${{ secrets.CODECOV_TOKEN }} 84
設定方法 6/6 をFlutterプロジェクトに設置 このファイルでカバー率が何パーセント以上下がったらCIを失敗にする設定が出来る が、今回はカバレッジの可視化だけを行うので無効化。 codecov.yml coverage: status: project: off patch: off 85
Codecovによるカバレッジの可視化設定が完了 プルリクエストでCodecovがコメント リンクを開いてカバレッジを確認 GitHubリポジトリ一覧の読み込み処理で圏外などエラーケースの単体テストを書いていない ので、その部分が赤くなっている。 86
今回のサンプルコードのカバレッジ 状態ホルダとデータレイヤはほぼ100% APIクライアントのエラー処理がカバーできていない 87
まとめ アプリは1度作ったら終わりでは無く、継続的に変化と拡大を続けることが多い アーキテクチャを決めてチームの共通認識とする アーキテクチャを構成する部品には単体テストを作成する 内部で使用するインスタンスのモック実装をMockitoで作成 Riverpodのoverride機能で差し替える 各部品の配置パッケージやクラス/メソッドの命名規則もチームの共通認識とする Codecovを使うことで、プルリクごとにカバレッジが可視化されて、単体テストの作成 抜けが分かりやすくなる 88