Honest take: I write fewer tests than most tutorials say you should. The ones I do write earn their place by either catching real regressions or by giving me confidence to refactor. The rest are usually a tax on iteration speed.

Here's the framework I use, which has worked for every app I've shipped solo or in a small team.

Most of your tests should be unit tests. A few should be widget tests. Even fewer should be integration tests. Inverting the pyramid is how you end up with a slow, flaky test suite.

The three test types

Flutter has three kinds of tests, and they each answer a different question:

  • Unit tests — does this function or class do what it says? Pure Dart, no widgets, no Flutter binding. Fast (milliseconds), boring, very valuable for non-trivial logic.
  • Widget tests — does this widget render correctly and respond to interaction? Runs in a fake Flutter environment, no real device. Fast (~100ms each), good for catching layout regressions.
  • Integration tests — does the whole app work end-to-end on a real device? Slow (seconds per test), flaky, but the only thing that catches real-world bugs.

The mistake I see most often is treating all three the same. They aren't. Each has a sweet spot.

What I actually test

In rough priority order:

  1. Pure logic (unit tests). Anything with branching, calculations, or transforms. Date math, currency formatting, streak counting, parser functions. These are the easiest to test and the most valuable per minute spent.
  2. State management classes (unit tests). Cubits get unit tests for their public methods. The rule: every method that calls emit should have a test asserting the new state.
  3. Critical user flows (one or two integration tests). Sign up, log in, complete the main app action. If these break, the app is unusable. Even one integration test covering the happy path is worth it.
  4. Reusable widgets (widget tests, sometimes). If I built a custom button or card that's used in many places, I'll widget-test it once for layout and tap behavior. One-off screens don't get widget tests — they change too often.

Things I almost never test:

  • One-off screens
  • Trivial getters
  • UI exactness pixel-by-pixel
  • Anything that just wraps an SDK call I don't own (Supabase, RevenueCat — I trust their tests)

A minimal setup

Add the testing dependencies (most come for free with flutter create):

pubspec.yamlyaml
dev_dependencies:
flutter_test:
  sdk: flutter
bloc_test: ^9.1.0       # if you use Bloc/Cubit
mocktail: ^1.0.0        # for mocking dependencies

A unit test for a Cubit:

test/counter_cubit_test.dartdart
import 'package:flutter_test/flutter_test.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:my_app/counter_cubit.dart';

void main() {
group('CounterCubit', () {
  test('starts at 0', () {
    expect(CounterCubit().state, 0);
  });

  blocTest<CounterCubit, int>(
    'increment emits +1',
    build: () => CounterCubit(),
    act: (cubit) => cubit.increment(),
    expect: () => [1],
  );

  blocTest<CounterCubit, int>(
    'increment twice emits 1, 2',
    build: () => CounterCubit(),
    act: (cubit) {
      cubit.increment();
      cubit.increment();
    },
    expect: () => [1, 2],
  );
});
}

A widget test that taps a button:

test/sign_in_screen_test.dartdart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/sign_in_screen.dart';

void main() {
testWidgets('shows error on empty submit', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: SignInScreen()));

  // Tap the submit button without filling anything in.
  await tester.tap(find.text('Sign in'));
  await tester.pump();

  // The error should now be visible.
  expect(find.text('Email is required'), findsOneWidget);
});
}

That's it. Run with flutter test and you'll see results in under a second.

Integration tests, when they're worth it

Integration tests run on a real device or simulator using integration_test. They're slow and flaky enough that I don't write many, but I always write at least one for the critical path of the app.

integration_test/sign_in_flow_test.dartdart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

testWidgets('user can sign in and see home', (tester) async {
  app.main();
  await tester.pumpAndSettle();

  await tester.enterText(find.byKey(const Key('email')), 'test@test.com');
  await tester.enterText(find.byKey(const Key('password')), 'password123');
  await tester.tap(find.text('Sign in'));
  await tester.pumpAndSettle();

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

Run with:

bash
flutter test integration_test/sign_in_flow_test.dart

Add a test account to your Supabase project for this. Don't run integration tests against your production database — use a separate Supabase project for tests, or seed and tear down a test schema.

What about mocking Supabase or RevenueCat?

I don't. I tried for a while and the abstraction layer always cost more than it saved. What I do instead:

  • Unit tests for pure logic — the logic doesn't touch Supabase, so there's nothing to mock.
  • Integration tests against a real test Supabase project — slow but accurate.
  • Manual testing for the rest — sign in, do the thing, see if it works.

Mocks lie. Real services don't.

My rough coverage targets

I don't chase high coverage numbers. The targets I mentally aim for:

  • Cubits: 100% of public methods have at least one test
  • Pure logic functions: 100% of branches
  • Widgets: 0% by default, ~20% for shared/reusable ones
  • Screens: 0% by default, one integration test for the critical path
  • SDK wrappers: 0%

That probably comes out to 30-50% line coverage on a typical app, and that's fine. Coverage is a metric that's easy to game and easy to feel good about — actual confidence in the code is the real goal.

What to add next

  • A make test or flutter test --coverage command in your CI so the tests run on every PR.
  • Golden tests for any custom UI you genuinely care about pixel-matching.
  • A test seeding script for your integration test database.

But none of those are day-one. Day one is: write a Cubit, write a unit test for it, prove it passes, commit.