SuperAPI AI Directory

flutter-testing

Testing & quality

View repository

Updated 10-15 20:04

About this skill

Flutter testing

stack

concerns libraries
unit/widget testing framework flutter_test (pub.dev default)
dependency substitution writing Fakes directly (avoiding mock libraries)
stream verification Stream.first, expectLater(stream, emits...)

The project has no test dependencies other than flutter_test . mockito/mocktail Before introducing Fake , try writing it with


Directory

Tests mirror the production code structure.

test/
└─ presentation/
   └─ ingredient/
      └─ ingredient_screen_test.dart
  • Unit test: test/<layer>/<feature>/<file>_test.dart
  • Widget test: test/presentation/<feature>/<feature>_screen_test.dart

Unit tests — UseCase

The easiest starting point. Swap in a Fake Repository and check only inputs and outputs.

// test/domain/use_case/get_saved_recipes_use_case_test.dart
void main() {
  test('북마크된 id 집합에 해당하는 레시피만 스트림으로 흘러나온다', () async {
    final useCase = GetSavedRecipesUseCase(
      recipeRepository: FakeRecipeRepository(
        recipes: [Recipe(id: 1, /* ... */), Recipe(id: 2, /* ... */)],
      ),
      bookmarkRepository: FakeBookmarkRepository(initialIds: {2}),
    );

    final result = await useCase.execute().first;

    expect(result.map((e) => e.id), [2]);
  });
}

If you only need the first event of a stream, .first is convenient. To verify the order of multiple events, expectLater(stream, emitsInOrder([...])).


Unit tests — ChangeNotifier ViewModel

ChangeNotifier 는 addListener to observe changes, or notifyListeners() and then immediately viewModel.state to verify.

void main() {
  late FakeIngredientRepository ingredientRepo;
  late FakeProcedureRepository procedureRepo;
  late FakeGetDishesByCategoryUseCase getDishes;
  late FakeClipboardService clipboard;
  late IngredientViewModel viewModel;

  setUp(() {
    ingredientRepo = FakeIngredientRepository();
    procedureRepo = FakeProcedureRepository();
    getDishes = FakeGetDishesByCategoryUseCase();
    clipboard = FakeClipboardService();
    viewModel = IngredientViewModel(
      ingredientRepository: ingredientRepo,
      procedureRepository: procedureRepo,
      getDishesByCategoryUseCase: getDishes,
      clipboardService: clipboard,
    );
  });

  test('OnTapProcedure 를 받으면 선택 탭 인덱스가 1로 바뀐다', () {
    viewModel.onAction(const IngredientAction.onTapProcedure());
    expect(viewModel.state.selectedTabIndex, 1);
  });

  test('OnTapShareMenu 는 클립보드에 링크를 복사한다', () {
    viewModel.onAction(const IngredientAction.onTapShareMenu('https://example.com'));
    expect(clipboard.copiedTexts, ['https://example.com']);
  });
}

When you need to wait for a stream update: pumpEventQueue() 를 await to flush microtasks and then assert the state.

viewModel.onAction(const IngredientAction.loadRecipe(1));
await pumpEventQueue();
expect(viewModel.state.recipe, isNotNull);

If you want to count how many times a listener was called:

int notifyCount = 0;
viewModel.addListener(() => notifyCount++);
viewModel.onAction(const IngredientAction.onTapIngredient());
expect(notifyCount, 1);

How to write Fakes

The interface to implement is already in lib/domain/repository/ . Place a simple in-memory implementation in the test-only directory test/fake/ .

class FakeRecipeRepository implements RecipeRepository {
  FakeRecipeRepository({List<Recipe>? recipes}) : _recipes = recipes ?? [];
  final List<Recipe> _recipes;

  @override
  Future<List<Recipe>> getRecipes() async => _recipes;

  @override
  Future<Recipe?> getRecipe(int id) async =>
      _recipes.where((r) => r.id == id).firstOrNull;
}
class FakeBookmarkRepository implements BookmarkRepository {
  FakeBookmarkRepository({Set<int>? initialIds}) : _ids = {...?initialIds} {
    _controller.add(_ids);
  }
  final Set<int> _ids;
  final _controller = StreamController<Set<int>>.broadcast();

  @override
  Stream<Set<int>> bookmarkIdsStream() => _controller.stream;

  @override
  Future<void> toggle(int id) async {
    _ids.contains(id) ? _ids.remove(id) : _ids.add(id);
    _controller.add({..._ids});
  }
  // 나머지 메서드는 동일 패턴
}

Principles:

  • Return the "simplest correct answer" unrelated to real business logic.
  • Provide a variable to simulate failure scenarios: bool shouldReturnError = false; → if (shouldReturnError) return Result.error(...).
  • BehaviorSubject instead of StreamController.broadcast() + manual add() is sufficient (without depending on rxdart).

Widget tests — easy thanks to Screen separation

Screen 은 state 와 onAction only, so without a ViewModel you can directly pumpWidget .

void main() {
  testWidgets('레시피가 있으면 IngredientRecipeCard 가 표시된다', (tester) async {
    final state = IngredientState(
      recipe: Recipe(id: 1, name: 'Pizza', /* ... */),
      ingredients: const [],
      procedures: const [],
    );

    await tester.pumpWidget(
      MaterialApp(
        home: IngredientScreen(
          state: state,
          onAction: (_) {},
          onTapMenu: (_) {},
        ),
      ),
    );

    expect(find.text('Pizza'), findsOneWidget);
  });

  testWidgets('Procedure 탭을 누르면 onAction 으로 OnTapProcedure 가 전달된다', (tester) async {
    IngredientAction? captured;
    await tester.pumpWidget(
      MaterialApp(
        home: IngredientScreen(
          state: IngredientState(recipe: /* ... */),
          onAction: (a) => captured = a,
          onTapMenu: (_) {},
        ),
      ),
    );

    await tester.tap(find.text('Procedure'));
    expect(captured, isA<OnTapProcedure>());
  });
}

Tips:

  • Wrap the top level in MaterialApp to provide Directionality, theme, and MediaQuery defaults.
  • Dialogs/snackbars/navigation are the Root's responsibility, so there is no need to verify them in Screen tests.
  • Widgets that involve images/networks may NetworkImage may fail in the test environment. If needed, provideMockedNetworkImages create a helper, or branch the image part with a preview flag.

When to test the Root widget?

Root 는 getIt Because it obtains the real ViewModel through diSetup() , you need a utility that registers the Fake inintegration_test/. Screen tests + ViewModel unit tests alone catch most regressions. Root widgets are naturally handled with integration tests (

) which is natural.

setUp(() {
  getIt.reset();
  getIt.registerFactory<IngredientViewModel>(() => FakeIngredientViewModel());
});

Robot pattern — testing complex screens

When tests for 3 or more widgets accumulate on one screen, share the setup using the Robot pattern.

class IngredientRobot {
  IngredientRobot(this.tester);
  final WidgetTester tester;

  Future<IngredientRobot> pump(IngredientState state, {ValueChanged<IngredientAction>? onAction}) async {
    await tester.pumpWidget(MaterialApp(
      home: IngredientScreen(
        state: state,
        onAction: onAction ?? (_) {},
        onTapMenu: (_) {},
      ),
    ));
    return this;
  }

  Future<IngredientRobot> tapProcedureTab() async {
    await tester.tap(find.text('Procedure'));
    await tester.pump();
    return this;
  }

  IngredientRobot expectRecipeName(String name) {
    expect(find.text(name), findsOneWidget);
    return this;
  }
}

In tests, use as a chain:

await IngredientRobot(tester)
    .pump(state)
    .then((r) => r.tapProcedureTab())
    .then((r) => r.expectRecipeName('Pizza'));

What to test

  • UseCase: Input/output is clear and side effects are rare, making it the most cost-effective. Write these first.
  • ViewModel: User Action → state transition rules. Unit test in combination with a Fake Repository.
  • Screen widget test: Rendering/input delivery. Cover complex conditional UI (empty, loading, error).
  • Data layer: Mapping (fromJson) For tricky spots, instead of goldens, simply use expect to verify key fields.
  • Integration test: Real end-to-end scenarios (login → home → detail) are integration_test/ 에.

checklist

  • At least 1 unit test per new UseCase (1 each for success + failure paths)
  • At least 1 assertion per each Action case of the ViewModel
  • If the Screen has conditional branches, for each branch testWidgets
  • Fake 는 test/fake/ Share below
  • dispose() If there is stream cancellation logic in , in the ViewModel test dispose() explicitly call to verify

Anti-pattern

  • ❌ Real getIt Running tests while sharing global state → creates test order dependency. If needed, getIt.reset() 을 setUp 에.
  • ❌ In a Screen test, to verify navigation/snackbar, the entire MaterialApp.router launching → separate into a Root integration test.
  • ❌ await Future.delayed(Duration(seconds: 1)) Same sleep-based synchronization → pumpEventQueue, tester.pump(...), expectLater use .
  • ❌ Auto-generating all dependencies with a Mock library → small, explicit Fakes are easier to read and less misunderstood.
  • ❌ To test private methods, @visibleForTesting abuse → test only through the public interface (Action, State).