A Flutter Testing Strategy That Actually Reaches 85% Coverage
At iBind Systems I led the Flutter architecture — BLoC, AutoRoute, Retrofit, Clean Architecture — on KYC apps running identity verification across 6+ countries, and we landed at 85% unit test coverage and 60% widget test coverage. That significantly reduced production regressions and hotfixes, which is the only reason the numbers are worth mentioning at all.
I want to be careful about how those numbers are read, because the way most people read a coverage number is the thing that stops them from getting a useful one. 85% was an outcome. It was not a target, and if it had been a target we would have hit it with worse tests and a worse app.
Coverage is a means, and a bad master
The failure mode is well known and still ubiquitous: a team sets 80% as a policy, and within a sprint the codebase fills with tests that execute code without asserting anything meaningful about it. Coverage goes up. Bugs do not go down. Everyone concludes testing does not work.
The mechanism is worth naming precisely. Coverage measures which lines ran during your tests. It does not measure whether anything checked that they ran correctly. A test that calls a function and asserts it did not throw covers every line in it and verifies nothing. Under a coverage mandate, those tests are the cheapest way to comply, so those are the tests you get. What coverage is genuinely good for is the inverse reading. High coverage does not prove your tests are good. But low coverage reliably proves something is untested, and looking at which lines are uncovered is one of the most informative five minutes available to you. The uncovered lines are usually error paths — the exact code that only runs when things go wrong, which is the exact code that most needs to be right.
So the useful question is never "are we at 85%." It is "is anything important uncovered." Those sound similar. They lead to completely different codebases.
What to test, and what not to
The decision that made 85% achievable was mostly about what we did not write.
Test the code that encodes a decision. On a KYC platform: which documents a given country requires, whether a submitted document set is complete, what the retry rules are after a failed verification, how a vendor's response maps to our domain outcomes. This is the code where being wrong means a real user in a real country cannot open an account. It is also — conveniently — pure logic with no framework in it, which makes it cheap to test. High value, low cost. There is no argument here.
Do not test the framework. A test that verifies a Text widget displays the string you passed it is testing Flutter, and Flutter has its own tests. A test that verifies your model's generated copyWith works is testing a code generator. These pass forever, catch nothing, and cost you on every refactor.
Do not test through the UI what you can test underneath it. This is the big one. If a rule needs testing, test it where it lives — in a use case — not by driving a widget that eventually calls it. The widget test is ten times slower, breaks when the designer moves a button, and gives a worse failure message when it fails. Teams that test rules through the UI are the teams whose suites take twenty minutes and get disabled.
Do test the thing that broke. Every production bug gets a test before the fix. Not for coverage — for the fact that bugs recur. That habit alone accounts for a meaningful share of the regression reduction, and it costs nothing to adopt.
The cost curve: unit vs widget vs integration
Every test type buys different confidence at a different price. Pretending otherwise is how suites become unmaintainable.
| Test type | What it verifies | Write cost | Run cost | Maintenance cost | Confidence bought |
|---|---|---|---|---|---|
| Unit (pure logic) | A rule, in isolation | Low | Milliseconds | Low — changes only when the rule changes | High for correctness, zero for integration |
| Unit (BLoC / use case) | State transitions given events | Low–medium (needs mocks) | Milliseconds | Low if the boundary is stable | High — this is where behaviour lives |
| Widget | A widget renders and reacts correctly | Medium | Fast, but orders slower than unit | Medium–high — breaks on UI change | Medium — catches wiring, not journeys |
| Golden | Pixels match a reference | Low to write, high to own | Fast | High — font and platform drift | Narrow: design systems, not logic |
| Integration (on device) | A real journey, real plugins | High | Minutes; flaky under load | High | Highest — and the only place some bugs exist |
The shape that follows: lots of unit tests, a deliberate set of widget tests, and a small number of integration tests you actually maintain. Not because a pyramid diagram says so — because that is what the cost column says. Confidence per hour is maximised at the bottom and the integration tests exist to cover the specific things that cannot be true anywhere else.
The number of integration tests you need is smaller than you think and larger than zero. Ours covered the paths where a failure was unrecoverable: complete a verification end to end, and handle a vendor SDK returning a failure. Mocked-both-sides tests pass happily while a real contract mismatch ships — a lesson that lives on the same boundary I described in the platform channels post.
Why Clean Architecture makes 85% reachable
Here is the claim I would defend hardest: coverage is an architecture metric wearing a testing costume. You cannot test your way to 85% in a coupled codebase. Not because the tests are hard to write — because they are impossible to write cheaply, and anything expensive stops getting written.
Picture the coupled version. Business logic in a StatefulWidget. The widget builds its own Retrofit client. The client reads a token from a singleton. Now testing "does this country require a second document" requires pumping a widget, mocking HTTP at the transport layer, and initialising a service locator. That test takes an hour to write, runs in a second instead of a millisecond, and breaks when someone changes the layout. Three sprints in, people stop writing them. Not from laziness — from an accurate cost assessment.
Clean Architecture's actual contribution is not layer diagrams. It is that the expensive-to-test things are quarantined from the cheap-to-test things. Entities and use cases have no Flutter import. The repository interface is defined in the domain layer and implemented in data. The widget knows a BLoC and nothing else.
The consequence is arithmetic. Most of your line count is domain and data. Those layers are plain Dart, testable in milliseconds with constructor-injected fakes. Get them to 90%+ — which is easy, because each test costs minutes — and you are already near 85% overall before you write a single widget test. The number falls out of the structure. That is why 85% was an outcome: we did not chase it, we chose an architecture where it was the natural resting point.
The inverse is the honest warning: if 85% feels unreachable in your codebase, coverage is not your problem. The tests are hard because the code is coupled, and no testing effort fixes that. This is the same argument as the state management post — the pattern that scales is the one that keeps logic out of widgets.
Testing BLoC
BLoC is easy to test and that is most of the case for it. A BLoC is a function from events to states, with the outside world injected. There is nothing to mock but the boundary you designed.
blocTest<KycBloc, KycState>(
'emits failure state when the vendor rejects the document',
build: () {
when(() => repo.submit(any()))
.thenAnswer((_) async => Left(DocumentRejected('BLURRY')));
return KycBloc(repo);
},
act: (bloc) => bloc.add(SubmitDocument(passportFixture)),
expect: () => [
KycState.submitting(),
KycState.failed(reason: 'BLURRY', canRetry: true),
],
);
Two habits that separate useful BLoC tests from decorative ones. Test the failure paths first — the happy path gets exercised by every manual test and every demo; the "vendor returned blurry, we allow retry, but only twice" path gets exercised by nobody, and it is the one that produces a support ticket from another timezone. And assert the sequence, not the final state. Emitting a loading state matters; a BLoC that jumps straight to success leaves the UI with no spinner and users double-tapping submit.
What not to do: assert on private internals, or write a test per event out of completeness. Test the transitions that encode a decision.
Mocking platform channels
On a KYC app, the sensitive path runs through native modules — Kotlin, MVVM, Coroutines, Jetpack — bridged over MethodChannels and EventChannels to vendor SDKs for document capture and biometrics. Those SDKs need a camera, a real device, and often a human face. None of that is going in your unit suite.
Two layers of answer. In Dart, install a mock handler with TestDefaultBinaryMessengerBinding and test your wrapper: that it parses the response, maps a PlatformException to a domain error, and handles MissingPluginException rather than hanging. That is your code and it is testable in milliseconds.
Above that, keep the vendor behind a domain interface. Your use cases depend on DocumentCaptureService, not on a channel. The channel implementation is one thin adapter, and everything above it tests with a fake. This is the move that makes a platform-heavy app testable at all — without it, native dependencies leak upward and take your coverage down with them.
The Kotlin side gets its own JVM unit tests, with the vendor SDK behind an interface there too. Testing the native module is not the Flutter suite's job and pretending otherwise is how people conclude native integration is untestable.
Where the 60% widget ceiling honestly comes from
The question I would ask if I were reading this: if 85% unit is good, why is widget only 60%? Is that the part you did not finish?
No. It is where the cost curve crosses over, and I would make the same call again.
Widget tests are worth writing for widgets that make decisions: a form that validates, a screen with distinct empty and error and loaded states, a flow that gates a button on a condition. Those tests catch real wiring bugs — the BLoC emitted failure and the UI showed a spinner forever — and the wiring layer is exactly where unit tests cannot see.
They are not worth writing for presentational widgets that take props and render them. That test asserts that Flutter's layout engine works, breaks the moment a designer changes the tree, and has never once caught a bug for me.
Then there is the part of the UI that genuinely resists widget testing. Camera preview surfaces. A vendor SDK's own Activity. Biometric prompts the OS owns. Those are not skipped out of laziness — they are not renderable in the test harness, and faking them so hard that a widget test passes means the test is now asserting the behaviour of your fake. That is worse than no test, because it reports confidence you do not have. Those paths belong in the integration suite, on a device.
Add it up honestly and 60% is roughly where a real app lands when you write the widget tests that pay and skip the ones that do not. A team reporting 90% widget coverage is usually either testing that Flutter renders text, or has faked away the exact parts that were interesting. 60% with intent beats 90% with padding, and the metric that mattered was never either number — it was that regressions and hotfixes went down.
If you are starting from 20%
The order I would go in, having done this on a codebase that was not built this way:
- Do not set a coverage target. You will get compliance tests and learn nothing.
- Run coverage once and read the uncovered lines instead of the percentage. Note which error paths have never executed in a test.
- Extract one rule from a widget into a plain Dart function and test it. That is the whole method, repeated. The refactor is the work; the test is the receipt.
- Write a test for every production bug, before the fix. Free, and it compounds.
- Add widget tests only where states diverge — empty, error, loaded, disabled.
- Gate CI on tests passing before you gate on any number. A suite nobody is forced to run is documentation, which is why this is really a CI/CD question.
The percentage will climb as a side effect. It always does, because the thing that actually moves it is decoupling, and decoupling is the thing you were doing anyway.
The Takeaway
85% unit and 60% widget coverage on Flutter KYC apps across 6+ countries was an architecture outcome, not a testing target — set it as a target and you get tests that execute code without asserting anything. Test the code that encodes a decision, never the framework, and never through the UI what you can test underneath it. The cost curve is the whole strategy: unit tests are milliseconds and stable, widget tests break on design changes, integration tests are minutes and flaky but catch the contract mismatches nothing else can. Clean Architecture makes 85% reachable because domain and data are plain Dart testable with constructor-injected fakes, and if the number feels unreachable in your codebase, coupling is the problem and no amount of testing effort will fix it. The 60% widget ceiling is honest: camera surfaces, vendor SDK Activities, and OS biometric prompts belong on a device, and faking them into a passing test reports confidence you do not have.
I'm a mobile engineer open to full-time roles — the platform behind these numbers, or my résumé.
Frequently asked questions
Is 85% test coverage a realistic target for a Flutter app?
It is realistic as an outcome, not as a target. Set it as a target and you get tests that execute code without asserting anything about it; get the architecture right and 85% is where a decoupled codebase naturally lands.
What should I not write tests for in Flutter?
The framework, generated code, and presentational widgets that take props and render them. Also anything you can test underneath the UI instead of through it — a widget test for a business rule is ten times slower and breaks when a designer moves a button.
Why is widget coverage lower than unit coverage?
Because that is where the cost curve crosses over. Camera surfaces, vendor SDK Activities, and OS biometric prompts are not renderable in the test harness, and faking them hard enough to pass means the test now asserts the behaviour of your fake.
Does Clean Architecture actually help test coverage?
It is most of the reason the number is reachable. Domain and data layers with no Flutter import are testable in milliseconds with constructor-injected fakes, and they are most of your line count — so the coverage falls out of the structure.
How do I test code that calls native platform channels?
Mock the channel with TestDefaultBinaryMessengerBinding to test your Dart wrapper, and keep the vendor behind a domain interface so everything above it tests with a fake. The native module gets its own JVM unit tests.
Bottom line
Coverage is an architecture metric wearing a testing costume — if 85% feels unreachable in your codebase, coupling is the problem and no amount of testing effort will fix it.
More from the field.
Like the way I think?
I'm a mobile engineer — Flutter and React Native for cross-platform, native Android in Kotlin where the platform demands it, Next.js on the web — open to full-time roles. If your team is hiring, the résumé is the fastest way in.