公開日 2026-08-16

FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)

Flutter で Widget テストの最小構成を作り、画面表示、ボタン押下後の変化、Riverpod の Provider 差し替えを確認できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. 先に Widget テストの守備範囲を整理する
  6. 3. プロジェクトを作成して依存関係をそろえる
  7. 3-1. 環境構築がまだなら先に済ませる
  8. 3-2. Flutter プロジェクトを作成する
  9. 3-3. エミュレーターを起動する
  10. 3-4. flutter_riverpod を追加する
  11. 4. lib/main.dart を書き換える
  12. コードのポイント
  13. 5. flutter run で手動確認する
  14. 6. test/widget_test.dart を作成する
  15. コードのポイント
  16. 7. flutter test を実行する
  17. 8. テスト対象を切り分ける
  18. 9. まとめ

Flutterの環境切替と配布前チェック(flavor / release build / 権限確認) の次に入れておきたいのが、画面ロジックの自動確認です。止まりやすいのは testWidgets() の書き方そのものではありません。状態更新、依存先、UI が 1 か所に寄り、どこを差し替えればテストしやすくなるか見えないまま書き始める点にあります。この記事では flutter_riverpod を使った検品一覧画面を題材に、画面表示、ボタン押下後の変化確認、Provider 差し替えまでを最小構成で確認します。

1. ゴールと非対象

対象読者

  • Flutter プロジェクトを作成して flutter run した経験がある人
  • Riverpod の最小構成は見たが、Widget テストでどこを確認すればよいかまだ曖昧な人
  • 納品前確認や回帰確認のために、画面ロジックの自動確認を先に入れたい人

この記事で到達する状態

  • flutter_test で画面表示テストを書ける
  • ボタン押下後の件数変化と活性状態の変化を確認できる
  • Riverpod の Provider 差し替えで初期データを入れ替えられる
  • Widget テスト、単体テスト、Integration Test の役割差を説明できる

非対象

  • integration_test を使った画面遷移の確認
  • カメラ、Bluetooth、secure storage など実機依存プラグインのテスト
  • Golden Test
  • API 通信や DB を含む結合テスト

今回は Widget テストの入口に絞ります。端末依存や画面遷移まで一度に入れると、何をどこで確認しているのかがぼけやすくなります。まずは「画面が正しく出るか」「操作後に表示が変わるか」「依存先を差し替えられるか」を 1 本で固めるところから始めます。

2. 先に Widget テストの守備範囲を整理する

Widget テストは、実機やエミュレーターを立ち上げずに Widget ツリーを組み立て、UI と状態変化を確認するテストです。見る範囲を先に分けておくと、あとで「どこまでを自動化するか」「どこからは Integration Test へ回すか」を整理しやすくなります。

種類主に確認するもの今回の扱い
単体テスト純粋関数、計算、整形、変換8章で切り分け方だけ触れる
Widget テスト画面表示、ボタン押下、状態反映この記事の中心
Integration Test画面遷移、実機依存、アプリ全体の流れ後続記事へ回す

今回の流れは次の通りです。

flowchart LR
  A[test/widget_test.dart] --> B[ProviderScope override]
  B --> C[MyApp]
  C --> D[InspectionPage]
  D --> E[表示を検証]
  A --> F[ボタンを tap]
  F --> G[InspectionController]
  G --> H[InspectionState]
  H --> D
  D --> I[件数とボタン状態を再検証]

この図で見ておきたい点は 2 つです。

  • 依存先の差し替えは ProviderScope の override で行う
  • 画面の操作は InspectionController を経由して状態へ反映される

差し替え点を Provider に寄せておくと、テスト側は「どのデータで画面を開くか」を毎回作り直せます。逆に、Widget の中で直接 DemoRepository() を生成してしまうと、テストのたびに初期データを入れ替えにくくなります。

3. プロジェクトを作成して依存関係をそろえる

3-1. 環境構築がまだなら先に済ませる

環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。

3-2. Flutter プロジェクトを作成する

次のコマンドでプロジェクトを作成します。

flutter create warehouse_widget_test
cd warehouse_widget_test

3-3. エミュレーターを起動する

Widget テストだけならエミュレーターは不要です。ただ、この記事では自動テストの前に flutter run で画面を一度見ておくので、先に起動します。

利用可能なエミュレーター一覧を確認します。

flutter emulators

表示された ID を指定して起動します。

flutter emulators --launch <emulator_id>

3-4. flutter_riverpod を追加する

プロジェクト直下で次のコマンドを実行します。

flutter pub add flutter_riverpod

flutter_testflutter create 直後の pubspec.yaml にすでに入っています。今回の追加は flutter_riverpod だけで足ります。

4. lib/main.dart を書き換える

flutter create で生成された lib/main.dart は、いったん次の内容に書き換えます。

このファイルは、検品一覧、未確認件数、確認ボタン、Provider 差し替え点を 1 画面にまとめたサンプルです。注目点は、初期データの取得を inspectionRepositoryProvider に寄せ、UI から切り離していることです。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Widget Test Sample',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
      ),
      home: const InspectionPage(),
    );
  }
}

enum InspectionStatus {
  pending('未確認'),
  done('確認済み');

  const InspectionStatus(this.label);

  final String label;
}

class InspectionItem {
  const InspectionItem({
    required this.id,
    required this.shelfLabel,
    required this.itemName,
    required this.status,
  });

  final String id;
  final String shelfLabel;
  final String itemName;
  final InspectionStatus status;

  bool get isDone => status == InspectionStatus.done;

  InspectionItem markDone() {
    return InspectionItem(
      id: id,
      shelfLabel: shelfLabel,
      itemName: itemName,
      status: InspectionStatus.done,
    );
  }
}

abstract class InspectionRepository {
  List<InspectionItem> loadItems();
}

class DemoInspectionRepository implements InspectionRepository {
  const DemoInspectionRepository();

  @override
  List<InspectionItem> loadItems() {
    return const <InspectionItem>[
      InspectionItem(
        id: 'item-001',
        shelfLabel: 'A-01',
        itemName: 'ハンディ端末',
        status: InspectionStatus.pending,
      ),
      InspectionItem(
        id: 'item-002',
        shelfLabel: 'B-02',
        itemName: '充電ケーブル',
        status: InspectionStatus.pending,
      ),
      InspectionItem(
        id: 'item-003',
        shelfLabel: 'C-03',
        itemName: '予備バッテリー',
        status: InspectionStatus.done,
      ),
    ];
  }
}

final Provider<InspectionRepository> inspectionRepositoryProvider =
    Provider<InspectionRepository>((Ref ref) {
      return const DemoInspectionRepository();
    });

class InspectionState {
  const InspectionState({
    required this.items,
    this.pendingOnly = false,
  });

  final List<InspectionItem> items;
  final bool pendingOnly;

  int get pendingCount =>
      items.where((InspectionItem item) => !item.isDone).length;

  int get doneCount => items.length - pendingCount;

  List<InspectionItem> get visibleItems {
    if (!pendingOnly) {
      return items;
    }

    return items.where((InspectionItem item) => !item.isDone).toList();
  }

  InspectionState copyWith({
    List<InspectionItem>? items,
    bool? pendingOnly,
  }) {
    return InspectionState(
      items: items ?? this.items,
      pendingOnly: pendingOnly ?? this.pendingOnly,
    );
  }
}

final NotifierProvider<InspectionController, InspectionState>
    inspectionControllerProvider =
    NotifierProvider<InspectionController, InspectionState>(
      InspectionController.new,
    );

class InspectionController extends Notifier<InspectionState> {
  @override
  InspectionState build() {
    final InspectionRepository repository =
        ref.watch(inspectionRepositoryProvider);
    return InspectionState(items: repository.loadItems());
  }

  void togglePendingOnly(bool value) {
    state = state.copyWith(pendingOnly: value);
  }

  void markDone(String itemId) {
    state = state.copyWith(
      items: <InspectionItem>[
        for (final InspectionItem item in state.items)
          if (item.id == itemId) item.markDone() else item,
      ],
    );
  }
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final InspectionState state = ref.watch(inspectionControllerProvider);
    final InspectionController controller =
        ref.read(inspectionControllerProvider.notifier);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Widgetテストで確認する検品画面'),
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: <Widget>[
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    '表示と状態変化を Widget テストで先に確認します。',
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  const SizedBox(height: 8),
                  const Text(
                    'Repository を Provider に寄せておくと、テスト側で初期データを差し替えやすくなります。',
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),
          _SummaryCard(state: state),
          const SizedBox(height: 16),
          SwitchListTile(
            key: const Key('pending-only-toggle'),
            contentPadding: EdgeInsets.zero,
            title: const Text('未確認だけ表示'),
            value: state.pendingOnly,
            onChanged: controller.togglePendingOnly,
          ),
          const SizedBox(height: 8),
          if (state.visibleItems.isEmpty)
            const Card(
              child: Padding(
                padding: EdgeInsets.all(16),
                child: Text('未確認の棚はありません。'),
              ),
            )
          else
            ...state.visibleItems.map(
              (InspectionItem item) => Padding(
                padding: const EdgeInsets.only(bottom: 12),
                child: _InspectionCard(
                  item: item,
                  onMarkDone: item.isDone
                      ? null
                      : () => controller.markDone(item.id),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

class _SummaryCard extends StatelessWidget {
  const _SummaryCard({required this.state});

  final InspectionState state;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: <Widget>[
            _SummaryItem(
              key: const Key('pending-count'),
              label: '未確認',
              value: '${state.pendingCount} 件',
            ),
            _SummaryItem(
              key: const Key('done-count'),
              label: '確認済み',
              value: '${state.doneCount} 件',
            ),
          ],
        ),
      ),
    );
  }
}

class _SummaryItem extends StatelessWidget {
  const _SummaryItem({
    required super.key,
    required this.label,
    required this.value,
  });

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Text(label, style: Theme.of(context).textTheme.labelLarge),
        const SizedBox(height: 4),
        Text('$label $value', style: Theme.of(context).textTheme.titleMedium),
      ],
    );
  }
}

class _InspectionCard extends StatelessWidget {
  const _InspectionCard({
    required this.item,
    required this.onMarkDone,
  });

  final InspectionItem item;
  final VoidCallback? onMarkDone;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: <Widget>[
                Text(
                  item.shelfLabel,
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                Chip(label: Text(item.status.label)),
              ],
            ),
            const SizedBox(height: 8),
            Text(item.itemName),
            const SizedBox(height: 12),
            Align(
              alignment: Alignment.centerRight,
              child: FilledButton(
                key: Key('done-button-${item.id}'),
                onPressed: onMarkDone,
                child: Text(item.isDone ? '確認済み' : '確認済みにする'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

コードのポイント

inspectionRepositoryProvider を差し替え点にしている

final Provider<InspectionRepository> inspectionRepositoryProvider =
    Provider<InspectionRepository>((Ref ref) {
      return const DemoInspectionRepository();
    });

初期データを DemoInspectionRepository へ直接固定せず、Provider から読む形にしています。本番実装では通常データを使い、テストでは FakeInspectionRepository へ差し替えられる構成です。

② 状態変更は InspectionController に寄せている

class InspectionController extends Notifier<InspectionState> {
  @override
  InspectionState build() {
    final InspectionRepository repository =
        ref.watch(inspectionRepositoryProvider);
    return InspectionState(items: repository.loadItems());
  }

  void markDone(String itemId) {
    state = state.copyWith(
      items: <InspectionItem>[
        for (final InspectionItem item in state.items)
          if (item.id == itemId) item.markDone() else item,
      ],
    );
  }
}

ボタン押下後にどこが変わるかを 1 か所へ集めると、テストは UI の見た目と結果確認へ集中できます。Widget 側で直接 setState() とリスト更新を混ぜるより、失敗時に見直す場所も追いやすくなります。

Key を付けて finders を安定させている

SwitchListTile(
  key: const Key('pending-only-toggle'),
  title: const Text('未確認だけ表示'),
  value: state.pendingOnly,
  onChanged: controller.togglePendingOnly,
)

FilledButton(
  key: Key('done-button-${item.id}'),
  onPressed: onMarkDone,
  child: Text(item.isDone ? '確認済み' : '確認済みにする'),
)

文章だけを Finder に使うと、同じ文字列が別の場所に増えたとき壊れやすくなります。テスト対象として重要な部品には Key を付けておくと、UI 文言を少し直しても壊れにくい Finder を保てます。

5. flutter run で手動確認する

自動テストへ入る前に、まずは画面の意図が合っているかを一度見ておきます。

flutter run

確認する点は次の 3 つです。

  • 未確認 2 件確認済み 1 件 が表示されること
  • A-01B-02 のカードに 確認済みにする ボタンがあること
  • ボタンを押すと件数が更新され、押した行のボタンが無効になること

この手動確認を先に 1 回入れておくと、あとで flutter test が失敗したときに「そもそも UI の意図が違う」のか「テストの Finder が違う」のかを切り分けやすくなります。

上から順に、件数カードの表示と一覧カードの表示を確認した状態です。

flutter run で確認した件数カードの表示 flutter run で確認した一覧カードの表示

6. test/widget_test.dart を作成する

続いて、flutter create で生成された test/widget_test.dart は次の内容に書き換えます。

このファイルでは、初期表示、ボタン押下後の変化、Provider 差し替えの 3 本だけを確認します。最初からケースを増やしすぎず、「画面が出る」「操作で状態が変わる」「初期データを差し替えられる」の軸で入口を作る構成です。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:warehouse_widget_test/main.dart';

void main() {
  group('InspectionPage', () {
    testWidgets('初期表示で件数と一覧が描画される', (
      WidgetTester tester,
    ) async {
      await pumpInspectionApp(
        tester,
        repository: FakeInspectionRepository(
          items: const <InspectionItem>[
            InspectionItem(
              id: 'item-001',
              shelfLabel: 'A-01',
              itemName: 'ハンディ端末',
              status: InspectionStatus.pending,
            ),
            InspectionItem(
              id: 'item-002',
              shelfLabel: 'B-02',
              itemName: '充電ケーブル',
              status: InspectionStatus.pending,
            ),
            InspectionItem(
              id: 'item-003',
              shelfLabel: 'C-03',
              itemName: '予備バッテリー',
              status: InspectionStatus.done,
            ),
          ],
        ),
      );

      expect(find.text('Widgetテストで確認する検品画面'), findsOneWidget);
      expect(find.byKey(const Key('pending-count')), findsOneWidget);
      expect(find.text('未確認 2 件'), findsOneWidget);
      expect(find.text('確認済み 1 件'), findsOneWidget);
      expect(find.text('A-01'), findsOneWidget);
      expect(find.text('B-02'), findsOneWidget);
    });

    testWidgets('確認ボタン押下で件数とボタン状態が変わる', (
      WidgetTester tester,
    ) async {
      await pumpInspectionApp(
        tester,
        repository: FakeInspectionRepository(
          items: const <InspectionItem>[
            InspectionItem(
              id: 'item-001',
              shelfLabel: 'A-01',
              itemName: 'ハンディ端末',
              status: InspectionStatus.pending,
            ),
            InspectionItem(
              id: 'item-002',
              shelfLabel: 'B-02',
              itemName: '充電ケーブル',
              status: InspectionStatus.done,
            ),
          ],
        ),
      );

      await tester.tap(find.byKey(const Key('done-button-item-001')));
      await tester.pump();

      expect(find.text('未確認 0 件'), findsOneWidget);
      expect(find.text('確認済み 2 件'), findsOneWidget);

      final FilledButton button = tester.widget<FilledButton>(
        find.byKey(const Key('done-button-item-001')),
      );
      expect(button.onPressed, isNull);
    });

    testWidgets('Provider差し替えで初期データを入れ替えられる', (
      WidgetTester tester,
    ) async {
      await pumpInspectionApp(
        tester,
        repository: FakeInspectionRepository(
          items: const <InspectionItem>[
            InspectionItem(
              id: 'special-001',
              shelfLabel: '特急レーン',
              itemName: '返品伝票',
              status: InspectionStatus.pending,
            ),
          ],
        ),
      );

      expect(find.text('特急レーン'), findsOneWidget);
      expect(find.text('返品伝票'), findsOneWidget);
      expect(find.text('A-01'), findsNothing);
    });
  });
}

Future<void> pumpInspectionApp(
  WidgetTester tester, {
  required InspectionRepository repository,
}) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        inspectionRepositoryProvider.overrideWith((Ref ref) => repository),
      ],
      child: const MyApp(),
    ),
  );

  await tester.pump();
}

class FakeInspectionRepository implements InspectionRepository {
  FakeInspectionRepository({required this.items});

  final List<InspectionItem> items;

  @override
  List<InspectionItem> loadItems() => items;
}

コードのポイント

pumpInspectionApp() で override をまとめている

Future<void> pumpInspectionApp(
  WidgetTester tester, {
  required InspectionRepository repository,
}) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        inspectionRepositoryProvider.overrideWith((Ref ref) => repository),
      ],
      child: const MyApp(),
    ),
  );
}

各テストが毎回 ProviderScope の組み立てを書かずに済む形です。差し替えの入口を helper に寄せておけば、今後 Provider が増えても修正箇所を 1 か所に保てます。

② ボタン押下のあとに pump() して再描画を待つ

await tester.tap(find.byKey(const Key('done-button-item-001')));
await tester.pump();

タップした直後は Widget ツリーの再描画がまだ終わっていません。操作のあとに pump() を入れることで、状態更新後の表示まで検証できます。

③ Provider 差し替えは「別の初期データで開けるか」を見る

await pumpInspectionApp(
  tester,
  repository: FakeInspectionRepository(
    items: const <InspectionItem>[
      InspectionItem(
        id: 'special-001',
        shelfLabel: '特急レーン',
        itemName: '返品伝票',
        status: InspectionStatus.pending,
      ),
    ],
  ),
);

ここでは外部 API や DB を使わず、Fake 実装だけで差し替えています。入口の記事では「モックライブラリの使い方」よりも、「Provider を差し替えられる設計になっているか」を先に固めるほうが重要になります。

7. flutter test を実行する

次のコマンドで Widget テストを実行します。

flutter test test/widget_test.dart

成功すると、3 件のテストが通過した結果が表示されます。最初に確認したいのは件数そのものより、どのケースで止まったかです。

  • 初期表示で止まるなら、Widget ツリーの構成や Finder の対象を確認する
  • ボタン押下後で止まるなら、tap() 後に pump() しているかを見る
  • Provider 差し替えで止まるなら、override 対象が本当に初期データの入口になっているかを見る

VS Code を使う場合は Testing View から個別実行しても構いません。ただ、最初の 1 本は terminal で flutter test を打ち、どのファイルがどう実行されるかを見ておくほうが流れをつかみやすくなります。terminal では +3All tests passed! がそろえば、3 件とも通過したと判断できます。

flutter test test/widget_test.dart の成功結果

8. テスト対象を切り分ける

Widget テストは便利ですが、全部をここへ入れると保守が重くなります。今回のサンプルを起点にすると、切り分けは次のようになります。

置き場所向いている内容今回の例
単体テスト純粋関数、変換、並び替え、整形件数集計や表示整形を関数へ切り出した場合
Widget テスト画面表示、ボタン押下、状態反映未確認 2 件 の表示、確認ボタン押下後の変化
Integration Test画面遷移、実機依存、外部連携ログインから一覧表示、カメラ起動、権限ダイアログ

切り分けで意識したいのは、Widget テストに「画面の責務」を残すことです。

  • 文字列整形や複雑な集計は pure Dart の関数へ出す
  • 依存先は Provider で差し替えられる形にする
  • 実機依存や画面遷移をまたぐ確認は後続の Integration Test へ回す

この分担にしておくと、Widget テストは「画面ロジックの回帰確認」に集中できます。逆に、通信、権限、ルーティングまで一度に背負わせると、失敗時の原因が追いにくくなります。

9. まとめ

Widget テストの入口では、画面表示、ボタン押下後の変化、Provider 差し替えの 3 本をまず固めると進めやすくなります。Riverpod の差し替え点を先に作っておけば、初期データや依存先を変えた確認を増やしやすくなります。次は、FlutterのIntegration Test入門(ログインから一覧表示まで確認する) のように、画面遷移をまたぐ確認へ広げる流れで考えると整理しやすくなります。

シリーズ 35/38

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成) 現在の記事
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)