Zenn
🧪

【Flutter】widget_testでProviderのrefにアクセスする方法

2025/03/29に公開
1

はじめに

Flutterで widget_test を書くとき、基本的には「Widgetの状態」や「ユーザー操作に対する挙動」を確認するのがメインになると思います。

多くの場合は、providerのオーバーライドでテストが完結するのですが、

「特定の状態を一気に作りたい」
「ViewModelのメソッドを直接叩きたい」

というようなケースが出てくることはないでしょうか?

この記事では、そんな “ちょっと特殊だけど便利な”
ref(ProviderContainer)にアクセスして状態を操作する方法 を、実際のコードと共に紹介します。

記事の対象者

  • Flutterで widget_test を書いたことがある or 書こうとしている人
  • Riverpodを使って状態管理しているアプリを開発している人
  • ViewModelやProvider経由で状態を制御するテストをしたい人
  • テスト中に大量の状態を一気に操作したくなったことがある人
  • ref.readcontainer.read の違いを理解したい人

記事を執筆時点での筆者の環境

[✓] Flutter (Channel stable, 3.27.1, on macOS 15.1 24B2082 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.96.2)

結論

自分への備忘録も兼ねてまずは結論を置いておきます。

// BuildContextを取得
final context = tester.element(find.byType(HomeScreen)) as BuildContext;

// ProviderContainerを取得
final container = ProviderScope.containerOf(context);

// HomeScreen.limitCountのアイテムを選択する
// container経由でViewModelを取得し、selectItems()を呼び出す
container
    .read(homeScreenViewModelProvider.notifier)
    .selectItems(Set.from(itemIds.take(HomeScreen.limitCount)));

BuildContextを取得する

まず、refにアクセスするには ProviderContainer を取得する必要があります。
ですが、そのためには BuildContext が必要です。
tester.element() で 対象の Element を取得しています。
ここでいう対象の find.byType(HomeScreen) はテストしている Widget の型です。
これで Elementが取れるので最後に as BuildContext でキャストしています。

ProviderContainerを取得

ProviderScope.containerOf() に 際ほど取得したcontextを渡せば取得できます。

container経由で目的のproviderにアクセスする

ref.read の部分が container.read に置き換わっただけで、普段通りに使うことができます。

例題

  • 引数で渡されたidの配列の分だけリストタイルを表示する画面
  • リストタイルをタップするたびにアップバーのタイトルで選択中の数を表示する
  • 決められた上限に達するとアップバーのタイトルで警告の文面を表示する
  • 選択されているアイテムの状態管理をViewModelで行う

https://youtube.com/watch?v=bfqiKW91pUk

画面
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:simple_base/presentations/screen/home/view_model.dart';

class HomeScreen extends ConsumerWidget {
  const HomeScreen({
    required this.itemIds,
    super.key,
  });

  // テスト用のキー一覧 -->
  static const _prefix = 'HomeScreen';
  static const selectedItemsTextKey = ValueKey('$_prefix.selectedItemsText');
  static const overCountTextKey = ValueKey('$_prefix.overCountText');
  static ValueKey createItemKey(int index) => ValueKey('$_prefix.item$index');
  // <--

  final List<String> itemIds;

  static const limitCount = 500;
  static String selectLabel(int index) => '選択されたアイテム: $index個';
  static const overCountLabel = '上限の$limitCount個を超えました';

  
  Widget build(BuildContext context, WidgetRef ref) {
    final provider = homeScreenViewModelProvider;
    final viewModel = ref.watch(provider.notifier);
    final state = ref.watch(provider);
    return Scaffold(
      appBar: AppBar(
        title: (state.selectedItems.length < limitCount)
            ? Text(
                key: selectedItemsTextKey,
                selectLabel(state.selectedItems.length),
              )
            : const Text(
                key: overCountTextKey,
                overCountLabel,
                style: TextStyle(color: Colors.red),
              ),
        actions: [
          IconButton(
            icon: const Icon(Icons.delete),
            onPressed: viewModel.clearItems,
          ),
        ],
      ),
      body: Center(
        child: ListView.builder(
          itemCount: itemIds.length,
          itemBuilder: (context, index) {
            return ListTile(
              key: createItemKey(index),
              title: Text('アイテム$index'),
              onTap: () => viewModel.selectItems({'id$index'}),
              selected: state.selectedItems.contains('id$index'),
              selectedTileColor: Colors.blue,
            );
          },
        ),
      ),
    );
  }
}

ViewModel
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'view_model.freezed.dart';
part 'view_model.g.dart';


class HomeScreenState with _$HomeScreenState {
  const factory HomeScreenState({
    (<String>{}) Set<String> selectedItems,
  }) = _HomeScreenState;
}

(keepAlive: true)
class HomeScreenViewModel extends _$HomeScreenViewModel {
  
  HomeScreenState build() => const HomeScreenState();

  /// 選択されたアイテムを更新する
  void selectItems(Set<String> items) {
    final updated = {...state.selectedItems};
    for (final item in items) {
      if (updated.contains(item)) {
        updated.remove(item);
      } else {
        updated.add(item);
      }
    }
    state = state.copyWith(selectedItems: updated);
  }

  /// 選択されたアイテムをクリアする
  void clearItems() {
    state = const HomeScreenState();
  }
}

テストコードでやりたいこと

アイテムを上限値選択した時点で警告用のラベルに変わることを確認したいとします。
この上限が例えば 5個 だとすれば少ないので良いのですが、今回は 500個 です。
これを愚直にやろうとすると以下のようになると思われます。

  • アイテムを一つずつ選択
  • 際描画処理を入れる
  • 途中でスクロールしなければいけないのでスクロールの処理を入れる
  • アイテムを選択する
  • 上記をfor文で回す

しかし、確認したいのは上限に達したらラベルがちゃんと変わるかどうかです。
特別ちゃんと500個アイテムを選択する必要はないです。
そこで今回はViewModelをProviderで定義しているので、直接stateを上限値に変えればいいのでは?
という結論になりました。

/// 選択されたアイテムを更新する
void selectItems(Set<String> items) {
  final updated = {...state.selectedItems};
  for (final item in items) {
    if (updated.contains(item)) {
      updated.remove(item);
    } else {
      updated.add(item);
    }
  }
  state = state.copyWith(selectedItems: updated);
}

テストコード

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:simple_base/presentations/screen/home/screen.dart';
import 'package:simple_base/presentations/screen/home/view_model.dart';

void main() {
  ProviderScope createScope(List<String> itemIds) {
    return ProviderScope(
      child: MaterialApp(
        home: HomeScreen(itemIds: itemIds),
      ),
    );
  }

  testWidgets('アイテムが${HomeScreen.limitCount}個以上選ばれると赤文字で警告が出る', (tester) async {
    final itemIds = List.generate(1000, (index) => 'id$index');
    await tester.pumpWidget(createScope(itemIds));

    // BuildContextを取得
    final context = tester.element(find.byType(HomeScreen)) as BuildContext;

    // ProviderContainerを取得
    final container = ProviderScope.containerOf(context);

    // HomeScreen.limitCountのアイテムを選択する
    // container経由でViewModelを取得し、selectItems()を呼び出す
    container
        .read(homeScreenViewModelProvider.notifier)
        .selectItems(Set.from(itemIds.take(HomeScreen.limitCount)));
    await tester.pump();

    // ラベルが警告するラベルになっていることを確認
    final text =
        tester.widget<Text>(find.byKey(HomeScreen.overCountTextKey)).data;
    expect(text, HomeScreen.overCountLabel);
    expect(
      tester.widget<Text>(find.byKey(HomeScreen.overCountTextKey)).style?.color,
      Colors.red,
    );
  });
}

終わりに

通常のwidgetテストでは、refに直接アクセスする必要はあまりありませんが、
今回のように大量の状態を一気に操作したいケースでは、直接ViewModelを操作できると非常に便利です。

あくまで「レアケース」ではありますが、テストの効率化や実装の柔軟性を高める手段として
知っておいて損はないテクニックだと思います。

この記事が同じような悩みを持っている方のヒントになれば嬉しいです 🙌

1

Discussion

ログインするとコメントできます