Flutter Testing Guide: Unit, Widget, and Integration Tests That Actually Catch Bugs
Testing is the Flutter feature everyone agrees is important and almost nobody does consistently, and I include past-me in that. The reason usually isn’t laziness — it’s that most testing guides show you test() and expect() syntax without ever explaining what’s worth testing, so people either write zero tests or write tests so shallow (checking that a widget “renders without crashing”) that they pass right up until the app genuinely breaks.
Flutter gives you three distinct test types, and they’re not interchangeable — each one catches a different category of bug, and using the wrong one for the job is why test suites end up either useless or painfully slow.
The three types, and what each one is actually for
- Unit tests — pure Dart logic, no widgets, no Flutter framework. Fast (milliseconds each), and where most of your tests should live: validation logic, calculations, API response parsing, state notifiers.
- Widget tests — render a single widget (or small widget tree) in a simulated environment and interact with it. Catch “the button doesn’t call the right callback” or “the error state doesn’t actually show the error text” — bugs unit tests can’t see because they never touch the UI.
- Integration tests — run the real app on a real (or simulated) device and drive it end-to-end. Catch the bugs that only show up when everything is wired together: navigation actually working, a form submission actually hitting the network layer, platform channels actually firing.
The trade-off moves in one direction as you go down that list: more realistic, but slower and more brittle. A healthy test suite is mostly unit tests, a meaningful layer of widget tests on your important screens, and a handful of integration tests on your critical user flows (login, checkout, whatever “if this breaks, it’s a production incident” means for your app). Not the other way around.
Unit tests: start here
If your business logic lives inside widgets — validation happening directly in a TextFormField’s validator, calculations done inline in build() — you can’t unit test it, because you’d have to render a widget to reach it. That’s the real argument for separating logic from UI: not “clean architecture” as an abstract goal, but “so you can test the logic in milliseconds without a widget tree.”
Notice the second and third tests. It’s tempting to only test the success path — “100 minus 20% is 80, great, done” — but the bugs that actually reach production are almost always the edge cases nobody thought to check. If you only write happy-path tests, you’ll have 100% “passing” and 0% protection against the input that actually breaks things.
Mocking dependencies
Real code depends on things you don’t want in a unit test — an API client, a database, SharedPreferences. The mockito package (or mocktail, which skips the code-generation step) lets you swap those for fakes that return exactly what you tell them to.
This is why UserRepository takes an ApiClient through its constructor instead of instantiating one internally — that’s the whole trick to testable Dart code. If UserRepository created its own ApiClient inside itself, there’d be no way to substitute a mock, and you’d be stuck either hitting a real network in your tests (slow, flaky, needs a live backend) or not testing this class at all.
Widget tests: does the UI actually do what it should
Widget tests spin up a fake rendering environment (WidgetTester) fast enough to run in CI without a real device, and let you tap, scroll, and enter text, then assert on what’s on screen.
The first test is the one most people skip — testing that invalid input is rejected, not just that valid input works. It’s also the one that would have caught a real bug: if someone later “simplifies” _submit() and accidentally removes the email check, this test fails immediately instead of silently letting bad data through to onSubmit.
Integration tests: the whole app, actually running
Integration tests (via the integration_test package) run your actual app — real widgets, real navigation, optionally a real or staging backend — on a real device or emulator. Use them sparingly, on flows where a regression would genuinely hurt: login, checkout, whatever your app can’t function without.
Run it with:
These are slow — seconds per test rather than milliseconds — and can be flaky if they depend on a live network. That’s exactly why you don’t write dozens of them: a handful covering your critical paths gives you real end-to-end confidence without turning your CI pipeline into a ten-minute wait on every commit.
What’s actually worth testing
Not everything needs a test, and treating 100% coverage as the goal produces exactly the kind of shallow, “renders without crashing” tests that don’t catch anything. I prioritize:
- Business logic with real branches — pricing, validation, anything with an if/else that could be wrong in a way a user would notice.
- State that’s easy to get subtly wrong — loading/error/success transitions, especially anywhere you’re managing that by hand instead of through a state management library that handles it for you.
- Anything that’s broken in production before. A regression test for a bug you already shipped is the highest-value test you can write — it’s proof that specific mistake won’t happen twice.
Things I don’t bother testing: trivial getters, widgets that are pure layout with no logic, generated code. A test that can’t fail in a way that reveals a real bug is just extra maintenance.
Frequently asked questions
Should I aim for 100% test coverage? No — coverage percentage measures lines executed, not bugs prevented. It’s easy to hit high coverage with tests that never actually assert anything meaningful. Aim for covering logic and flows that matter, not a number.
What’s the difference between mockito and mocktail?
mockito needs code generation (build_runner) for null-safe mocks; mocktail gives you the same mocking API without the generation step. For new projects, mocktail is usually the less friction option.
Do widget tests need a real device? No — they run in a headless Flutter test environment on your machine or CI runner, which is why they’re fast enough to run on every commit. Only integration tests need a device or emulator.
How do I test code that uses async/await and Futures?
Use async/await inside your test() body directly, and for delayed values in widget tests, call await tester.pump() (or pumpAndSettle()) after triggering the action to let the Future resolve and the widget rebuild before asserting.
Inheriting a Flutter codebase with no tests, or want a second pair of eyes on your test strategy? Get in touch.