June 16, 2026
Your Flutter Tests Are Slow and Useless — Here’s How to Fix Both
A four-minute test suite that mostly verifies your mocks are working correctly is not a safety net. It’s theater. Here is how I stopped…

By Muhammad Usman
5 min read
A four-minute test suite that mostly verifies your mocks are working correctly is not a safety net. It's theater. Here is how I stopped writing it.
I was auditing a Flutter codebase last year — a mid-sized app, maybe 60 screens, BLoC state management throughout. The test suite had 400+ tests. Looked great on paper. Then I ran it: four and a half minutes. I dug in. About a third of the tests were making real HTTP calls through an un-mocked HTTP client. Another chunk was calling pumpAndSettle() on every single tap, dutifully waiting out every animation before asserting anything. And the most depressing part: most of the "assertions" were just verifying that a mock method had been called. The tests passed every single time you ran them, because they were testing the test setup, not the app.
Two problems. Separately fixable. Often present together.
Why Your Suite Is Slow
The biggest offender is hitting real infrastructure in tests. Network calls, Firebase reads, disk I/O — these things take hundreds of milliseconds each. Multiply by a couple hundred tests and you have your four-minute suite. The fix is dependency injection: if a class receives its dependencies through its constructor (or a service locator you can swap), you can replace the real FirebaseFirestore with a fake one in tests. If you can't inject it, you can't fake it, and you're stuck waiting for the real thing.
The second speed killer is using the wrong test type. Flutter has three layers: unit tests (pure Dart, no Flutter engine, runs in milliseconds), widget tests (renders a single widget in a simulated environment, no device needed), and integration tests (full app on a real device or emulator). A lot of teams reach for widget tests or integration tests out of habit, even when they're just testing a data-transformation function or a BLoC's state machine. That's like renting a bus to take yourself to the grocery store. Unit tests for logic, widget tests for UI behavior, integration tests for full user flows. That ordering matters.
Then there is pumpAndSettle(). It repeatedly pumps frames until no animations are scheduled. For a simple tap that triggers a 300ms slide transition, you're waiting 300ms. For a screen with a loading spinner that never stops -- like an indeterminate CircularProgressIndicator -- it will time out and throw. When you know how long an animation takes, pump(const Duration(milliseconds: 300)) is faster and more explicit. Save pumpAndSettle() for cases where you genuinely don't control the timing.
Finally: concurrency. By default, flutter test runs test suites in parallel using half your CPU cores. If you are running tests in a CI environment and have more cores available, you can push it:
flutter test --concurrency=8flutter test --concurrency=8The shorthand is -j. Each test file compiles to its own isolate, so independent test files run truly in parallel. More concurrency means more memory use -- don't crank it to max and wonder why the machine grinds.
Why Your Tests Are Useless
Slow tests are an annoyance. Useless tests are worse, because they give you false confidence. You get a green run, ship, and something breaks in production that your tests were supposedly covering.
The most common form of useless test in Flutter codebases I've audited is the mock-verification test. It looks like this:
// Don't do this
test('calls repository when button tapped', () async {
when(() => mockRepo.fetchUser()).thenReturn(Future.value(fakeUser));
await tester.tap(find.byType(LoginButton));
await tester.pumpAndSettle();
verify(() => mockRepo.fetchUser()).called(1);
});// Don't do this
test('calls repository when button tapped', () async {
when(() => mockRepo.fetchUser()).thenReturn(Future.value(fakeUser));
await tester.tap(find.byType(LoginButton));
await tester.pumpAndSettle();
verify(() => mockRepo.fetchUser()).called(1);
});This test will pass even if the UI shows nothing after the fetch. It will pass if the returned user is silently dropped. It passes as long as the mock got called, which is mostly a test of whether you wired up the button's onPressed. That's a very low bar.
Test behavior and outputs, not internal calls. The question to ask is: "what does the user see after this action?" Assert on that:
test('shows user name after successful login', () async {
when(() => mockRepo.fetchUser()).thenReturn(Future.value(User(name: 'Ali')));
await tester.tap(find.byType(LoginButton));
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Ali'), findsOneWidget);
});test('shows user name after successful login', () async {
when(() => mockRepo.fetchUser()).thenReturn(Future.value(User(name: 'Ali')));
await tester.tap(find.byType(LoginButton));
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Ali'), findsOneWidget);
});Now the test will fail if the UI drops the data, renders the wrong thing, or forgets to update state. That is a useful test.
Another variant: brittle finder-by-text tests. find.text('Submit') breaks the moment a designer changes the button label to "Continue". That's a false failure -- the behavior is fine, the label changed. Prefer find.byKey(const Key('submit-button')) or find.byType(SubmitButton) when you control the widget. Keys survive copy edits; hard-coded strings don't.
Snapshot tests (golden tests in Flutter) have a similar trap. They assert that the pixels look exactly right, which means every font-rendering difference across machines will fail the suite. Use golden tests sparingly and only for components where pixel-level accuracy actually matters, like a custom chart renderer. Don't golden-test a Text widget.
The Tool: Mocktail
When you do need mocks — and you will, for repository layers and network clients — use mocktail (currently v1.0.5, by Felix Angelov). It is null-safe, requires no code generation, and has a cleaner API than Mockito for most Flutter use cases. No build_runner, no generated .mocks.dart files to keep in sync.
// dev_dependencies: mocktail: ^1.0.5
class MockUserRepository extends Mock implements UserRepository {}
test('returns user when fetch succeeds', () async {
final repo = MockUserRepository();
when(() => repo.fetchUser()).thenReturn(Future.value(User(name: 'Ali')));
final result = await repo.fetchUser();
expect(result.name, equals('Ali'));
});// dev_dependencies: mocktail: ^1.0.5
class MockUserRepository extends Mock implements UserRepository {}
test('returns user when fetch succeeds', () async {
final repo = MockUserRepository();
when(() => repo.fetchUser()).thenReturn(Future.value(User(name: 'Ali')));
final result = await repo.fetchUser();
expect(result.name, equals('Ali'));
});The closure syntax (() => repo.fetchUser()) is how mocktail handles null safety cleanly. No build_runner, no generated .mocks.dart files to maintain.
An Honest Caveat About Coverage
100% test coverage is a vanity metric. I've seen codebases with 100% coverage that were trivial to break, because the tests covered lines without asserting anything meaningful. Coverage tells you which code ran during tests, not whether the tests were any good.
Some code isn't worth unit-testing at all. Simple StatelessWidget subtrees with no logic, generated serialization code, thin repository adapters that are one layer of delegation -- testing these directly gives you maintenance overhead without meaningful safety. Put your test effort where the business logic actually lives: your BLoCs, cubits, use cases, and data-transformation functions. That's where the bugs hide.
The target isn't 100% coverage. It's a suite that runs in under 30 seconds on most machines, fails loudly when real behavior breaks, and doesn't need updating every time a label changes. That suite earns trust. A four-minute green run that passes through a production regression earns nothing.
The Short Version
Inject dependencies so you can fake them. Write unit tests for logic, widget tests for rendering, integration tests only for full user flows. Use pump(Duration) where timing is predictable. Run flutter test -j 8 on a machine with spare cores. Assert on what the user sees, not which mock method got called.
After that audit I mentioned, we refactored the test setup over two weeks: replaced the real HTTP client with a fake, deleted the pure mock-verification tests, rewrote assertions to check widget output. The suite went from four and a half minutes to under 40 seconds. The test count dropped by about 20%. The remaining tests caught two real regressions in the next sprint that the old suite had missed completely.
Fewer tests, better tests, faster suite. That's the trade.
Further reading
- mocktail on pub.dev — null-safe mocking without code generation, by Felix Angelov
- pumpAndSettle API docs (flutter.dev) — official notes on when it throws and why
- test package on pub.dev — concurrency, sharding, and other runner flags documented here
I'm Muhammad Usman, a senior Flutter developer who has shipped over 50 production apps and audits Flutter codebases for a living. I write at @ottomancoder.