Flutter Testing Guide: Unit, Widget, and Integration Tests
# Flutter Testing Guide: Unit, Widget, and Integration Tests
Testing is your release safety net. A well-structured test suite does not slow you down — it prevents costly bugs from reaching production, gives you confidence during refactors, and serves as living documentation for your codebase.
The Test Pyramid
The test pyramid is the foundation of any healthy testing strategy. The idea is simple: write many fast, cheap tests at the bottom and fewer slow, expensive tests at the top.
Unit Tests
Unit tests verify individual functions, methods, and classes in isolation. They are the fastest to run, the cheapest to write, and should form the bulk of your test suite.
class=class="code-string">"code-comment">// test/services/price_calculator_test.dart
import class="code-string">'package:flutter_test/flutter_test.dart';
import class="code-string">'package:my_app/services/price_calculator.dart';
void main() {
late PriceCalculator calculator;
setUp(() {
calculator = PriceCalculator();
});
group(class="code-string">'PriceCalculator', () {
test(class="code-string">'applies percentage discount correctly', () {
final result = calculator.applyDiscount(class="code-number">100.0, percentage: class="code-number">20);
expect(result, class="code-number">80.0);
});
test(class="code-string">'does not allow negative prices', () {
final result = calculator.applyDiscount(class="code-number">10.0, percentage: class="code-number">150);
expect(result, class="code-number">0.0);
});
test(class="code-string">'throws on invalid percentage', () {
expect(
() => calculator.applyDiscount(class="code-number">100.0, percentage: -class="code-number">5),
throwsA(isA<ArgumentError>()),
);
});
test(class="code-string">'calculates tax correctly', () {
final result = calculator.withTax(class="code-number">100.0, taxRate: class="code-number">0.18);
expect(result, closeTo(class="code-number">118.0, class="code-number">0.01));
});
});
}Widget Tests
Widget tests render a single widget or a small widget subtree and verify its behavior — taps, text rendering, state changes — without needing a real device.
class=class="code-string">"code-comment">// test/widgets/login_form_test.dart
import class="code-string">'package:flutter/material.dart';
import class="code-string">'package:flutter_test/flutter_test.dart';
import class="code-string">'package:my_app/widgets/login_form.dart';
void main() {
group(class="code-string">'LoginForm', () {
testWidgets(class="code-string">'shows validation error when email is empty',
(WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: LoginForm())),
);
class=class="code-string">"code-comment">// Tap the submit button without entering anything
await tester.tap(find.byType(ElevatedButton));
await tester.pumpAndSettle();
expect(find.text(class="code-string">'Email is required'), findsOneWidget);
});
testWidgets(class="code-string">'calls onSubmit with correct credentials',
(WidgetTester tester) async {
String? submittedEmail;
String? submittedPassword;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LoginForm(
onSubmit: (email, password) {
submittedEmail = email;
submittedPassword = password;
},
),
),
),
);
await tester.enterText(
find.byKey(const Key(class="code-string">'email_field')),
class="code-string">'user@example.com',
);
await tester.enterText(
find.byKey(const Key(class="code-string">'password_field')),
class="code-string">'securePass123',
);
await tester.tap(find.byType(ElevatedButton));
await tester.pumpAndSettle();
expect(submittedEmail, class="code-string">'user@example.com');
expect(submittedPassword, class="code-string">'securePass123');
});
testWidgets(class="code-string">'toggles password visibility', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: LoginForm())),
);
class=class="code-string">"code-comment">// Password should be obscured by default
final passwordField = tester.widget<TextField>(
find.byKey(const Key(class="code-string">'password_field')),
);
expect(passwordField.obscureText, isTrue);
class=class="code-string">"code-comment">// Tap the visibility toggle
await tester.tap(find.byIcon(Icons.visibility_off));
await tester.pump();
final updatedField = tester.widget<TextField>(
find.byKey(const Key(class="code-string">'password_field')),
);
expect(updatedField.obscureText, isFalse);
});
});
}Integration Tests
Integration tests run on a real device or emulator and verify complete user flows end-to-end. They are slower but catch issues that unit and widget tests cannot.
class=class="code-string">"code-comment">// integration_test/app_test.dart
import class="code-string">'package:flutter_test/flutter_test.dart';
import class="code-string">'package:integration_test/integration_test.dart';
import class="code-string">'package:my_app/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group(class="code-string">'end-to-end: authentication flow', () {
testWidgets(class="code-string">'user can log in and see the home screen',
(WidgetTester tester) async {
app.main();
await tester.pumpAndSettle();
class=class="code-string">"code-comment">// Enter credentials
await tester.enterText(
find.byKey(const Key(class="code-string">'email_field')),
class="code-string">'test@example.com',
);
await tester.enterText(
find.byKey(const Key(class="code-string">'password_field')),
class="code-string">'password123',
);
class=class="code-string">"code-comment">// Submit
await tester.tap(find.byKey(const Key(class="code-string">'login_button')));
await tester.pumpAndSettle(const Duration(seconds: class="code-number">3));
class=class="code-string">"code-comment">// Verify navigation to home
expect(find.text(class="code-string">'Welcome back!'), findsOneWidget);
expect(find.byType(BottomNavigationBar), findsOneWidget);
});
});
}Mocking with Mockito
Real applications depend on APIs, databases, and platform services. Mocking lets you replace these dependencies with controlled substitutes so your tests are fast, deterministic, and offline-capable.
class=class="code-string">"code-comment">// test/repositories/user_repository_test.dart
import class="code-string">'package:flutter_test/flutter_test.dart';
import class="code-string">'package:mockito/annotations.dart';
import class="code-string">'package:mockito/mockito.dart';
import class="code-string">'package:my_app/services/api_client.dart';
import class="code-string">'package:my_app/models/user.dart';
import class="code-string">'package:my_app/repositories/user_repository.dart';
@GenerateMocks([ApiClient])
import class="code-string">'user_repository_test.mocks.dart';
void main() {
late MockApiClient mockApiClient;
late UserRepository repository;
setUp(() {
mockApiClient = MockApiClient();
repository = UserRepository(apiClient: mockApiClient);
});
group(class="code-string">'UserRepository', () {
test(class="code-string">'returns user when API call succeeds', () async {
when(mockApiClient.get(class="code-string">'/users/class="code-number">1')).thenAnswer(
(_) async => {class="code-string">'id': class="code-number">1, class="code-string">'name': class="code-string">'Alice', class="code-string">'email': class="code-string">'alice@test.com'},
);
final user = await repository.getUser(class="code-number">1);
expect(user.name, class="code-string">'Alice');
expect(user.email, class="code-string">'alice@test.com');
verify(mockApiClient.get(class="code-string">'/users/class="code-number">1')).called(class="code-number">1);
});
test(class="code-string">'throws UserNotFoundException when API returns class="code-number">404', () async {
when(mockApiClient.get(class="code-string">'/users/class="code-number">999')).thenThrow(
ApiException(statusCode: class="code-number">404, message: class="code-string">'Not found'),
);
expect(
() => repository.getUser(class="code-number">999),
throwsA(isA<UserNotFoundException>()),
);
});
test(class="code-string">'caches user after first fetch', () async {
when(mockApiClient.get(class="code-string">'/users/class="code-number">1')).thenAnswer(
(_) async => {class="code-string">'id': class="code-number">1, class="code-string">'name': class="code-string">'Alice', class="code-string">'email': class="code-string">'alice@test.com'},
);
await repository.getUser(class="code-number">1);
await repository.getUser(class="code-number">1);
class=class="code-string">"code-comment">// API should be called only once due to caching
verify(mockApiClient.get(class="code-string">'/users/class="code-number">1')).called(class="code-number">1);
});
});
}After writing your test file, run `dart run build_runner build` to generate the `.mocks.dart` file.
Golden Tests
Golden tests (also called snapshot tests) capture a rendered widget as an image and compare future renders against that baseline. They are invaluable for catching unintended visual regressions.
class=class="code-string">"code-comment">// test/widgets/profile_card_golden_test.dart
import class="code-string">'package:flutter/material.dart';
import class="code-string">'package:flutter_test/flutter_test.dart';
import class="code-string">'package:my_app/widgets/profile_card.dart';
void main() {
group(class="code-string">'ProfileCard golden tests', () {
testWidgets(class="code-string">'matches default appearance', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.light(),
home: const Scaffold(
body: ProfileCard(
name: class="code-string">'Alice Johnson',
role: class="code-string">'Flutter Developer',
avatarUrl: null,
),
),
),
);
await expectLater(
find.byType(ProfileCard),
matchesGoldenFile(class="code-string">'goldens/profile_card_default.png'),
);
});
testWidgets(class="code-string">'matches dark theme appearance', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.dark(),
home: const Scaffold(
body: ProfileCard(
name: class="code-string">'Alice Johnson',
role: class="code-string">'Flutter Developer',
avatarUrl: null,
),
),
),
);
await expectLater(
find.byType(ProfileCard),
matchesGoldenFile(class="code-string">'goldens/profile_card_dark.png'),
);
});
});
}To generate baseline images, run `flutter test --update-goldens`. On subsequent runs, Flutter compares new renders against these baselines pixel by pixel. Keep golden files in version control so the whole team shares the same reference.
Test Organization
A consistent test structure makes a massive difference as the project grows. Here are the practices I follow.
File Naming and Structure
Mirror your `lib/` folder structure inside `test/`. If you have `lib/services/auth_service.dart`, the test lives at `test/services/auth_service_test.dart`. This convention makes it trivial to find the test for any given file.
test/
services/
auth_service_test.dart
price_calculator_test.dart
repositories/
user_repository_test.dart
widgets/
login_form_test.dart
profile_card_test.dart
profile_card_golden_test.dart
helpers/
test_data.dart
pump_app.dart
integration_test/
app_test.dartGrouping Tests
Use `group()` to organize related assertions. Nest groups for complex classes with multiple methods.
group(class="code-string">'AuthService', () {
group(class="code-string">'login', () {
test(class="code-string">'succeeds with valid credentials', () { class=class="code-string">"code-comment">/* ... */ });
test(class="code-string">'fails with wrong password', () { class=class="code-string">"code-comment">/* ... */ });
test(class="code-string">'rate-limits after class="code-number">5 failures', () { class=class="code-string">"code-comment">/* ... */ });
});
group(class="code-string">'logout', () {
test(class="code-string">'clears stored token', () { class=class="code-string">"code-comment">/* ... */ });
test(class="code-string">'navigates to login screen', () { class=class="code-string">"code-comment">/* ... */ });
});
});Shared Test Helpers
Extract reusable setup into helper files to avoid duplication.
class=class="code-string">"code-comment">// test/helpers/pump_app.dart
import class="code-string">'package:flutter/material.dart';
import class="code-string">'package:flutter_test/flutter_test.dart';
extension PumpApp on WidgetTester {
Future<void> pumpApp(Widget widget) {
return pumpWidget(
MaterialApp(
home: Scaffold(body: widget),
),
);
}
}class=class="code-string">"code-comment">// test/helpers/test_data.dart
import class="code-string">'package:my_app/models/user.dart';
class TestData {
static User get sampleUser => User(
id: class="code-number">1,
name: class="code-string">'Test User',
email: class="code-string">'test@example.com',
);
static List<User> get sampleUsers => [
sampleUser,
User(id: class="code-number">2, name: class="code-string">'Another User', email: class="code-string">'another@example.com'),
];
}What NOT to Test
Testing everything is neither practical nor valuable. Knowing what to skip is just as important as knowing what to cover.
Focus your effort on business logic, edge cases, error handling, and critical user flows.
CI Integration with GitHub Actions
Automating tests in CI is non-negotiable for any team project. Here is a GitHub Actions workflow that runs Flutter tests on every pull request.
# .github/workflows/flutter_tests.yml
name: Flutter Tests
on:
pull_request:
branches: [main, develop]
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosiro/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
cache: true
- name: Install dependencies
run: flutter pub get
- name: Verify formatting
run: dart format --set-exit-if-changed .
- name: Run static analysis
run: flutter analyze --fatal-infos
- name: Run unit and widget tests with coverage
run: flutter test --coverage
- name: Check minimum coverage
run: |
COVERAGE=$(lcov --summary coverage/lcov.info 2>&1 | grep 'lines' | sed 's/.*: "code-comment">//' | sed 's/%.*//')
echo "Current coverage: $COVERAGE%"
if (( $(echo "$COVERAGE < 70" | bc -l) )); then
echo "Coverage $COVERAGE% is below 70% threshold"
exit 1
fi
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/lcov.infoThis pipeline runs formatting checks, static analysis, all tests with coverage tracking, and enforces a minimum coverage threshold. A failing test blocks the merge — exactly what you want.
Personal Insights
In my projects, I aim for high coverage on business logic and repository layers — typically above 85% — while keeping widget test coverage focused on interaction-heavy components like forms and navigation flows. I do not chase 100% coverage; the last 15% usually covers trivial code and costs more to maintain than it saves.
I always write tests before fixing a bug: first reproduce the bug with a failing test, then fix the code and watch the test go green. This guarantees the same bug never comes back.
For greenfield projects, I set up CI with test enforcement on day one. The cost of adding tests retroactively is always higher than writing them as you go.
Conclusion
A balanced test suite — many unit tests, targeted widget tests, and a handful of integration tests — gives you confidence to ship fast without breaking things. Pair it with CI enforcement and you have a workflow that scales from solo projects to large teams.
Reach out if you want a realistic coverage roadmap for your app.
Related Articles
Flutter State Management: Riverpod, Provider, and Bloc Comparison
Compare state management approaches in Flutter. Understand Riverpod, Provider, and Bloc with clear decision criteria for each scenario.
Clean Architecture in Flutter: Building Scalable Applications
Learn how to apply Clean Architecture in Flutter pragmatically. A practical guide to layers, dependency management, and testable code.
Flutter CI/CD: Automated Build, Test, and Release Pipeline
Design a robust Flutter CI/CD pipeline for reliable delivery with automated build, test, and release steps.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch