Testing Mobile Apps: Unit, Widget, and Integration Tests
Master mobile app testing in Flutter with unit tests, widget tests, and integration tests. Build reliable apps with comprehensive test coverage strategies.
Published on • August 13, 2026
AI Assistant

Mobile apps are unforgiving. Users uninstall buggy apps within seconds, and a single crash can tank your ratings. Yet many teams ship code with minimal testing, relying on manual QA and hope.
Flutter provides a built-in testing framework that covers every layer of your application. This guide walks through unit, widget, and integration tests with practical code examples you can adapt immediately.
Prerequisites
- Flutter SDK 3.0+
- Basic Dart knowledge
- A Flutter project (run
flutter create my_appif needed) - IDE with Flutter support
Why Testing Matters
Testing isn’t optional overhead—it’s a development accelerant. Well-written tests:
- Catch regressions before users do
- Enable confident refactoring
- Document expected behavior
- Reduce debugging time
- Speed up code reviews
Flutter’s test pyramid follows the classic approach: many fast unit tests at the base, fewer widget tests in the middle, and a handful of integration tests at the top.
Unit Testing Business Logic
Unit tests verify individual functions, methods, and classes in isolation. They’re fast, deterministic, and form your safety net.
Setting Up
Add flutter_test to your pubspec.yaml:
dev_dependencies:
flutter_test:
sdk: flutter
Create your first test file at test/counter_test.dart:
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/counter.dart';
void main() {
group('Counter', () {
test('starts at zero', () {
final counter = Counter();
expect(counter.value, 0);
});
test('increments by one', () {
final counter = Counter();
counter.increment();
expect(counter.value, 1);
});
test('decrements by one', () {
final counter = Counter();
counter.decrement();
expect(counter.value, -1);
});
test('cannot go below zero', () {
final counter = Counter();
counter.decrement();
counter.decrement();
expect(counter.value, -1);
});
});
}
Run tests with:
flutter test
Testing Async Code
Modern apps are async-heavy. Here’s testing a repository with network calls:
class UserRepository {
final ApiClient _api;
UserRepository(this._api);
Future<User> getUser(String id) async {
final response = await _api.get('/users/$id');
return User.fromJson(response);
}
}
// test/user_repository_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:my_app/user_repository.dart';
import 'user_repository_test.mocks.dart';
@GenerateMocks([ApiClient])
void main() {
late MockApiClient mockApi;
late UserRepository repository;
setUp(() {
mockApi = MockApiClient();
repository = UserRepository(mockApi);
});
group('getUser', () {
test('returns user on successful response', () async {
when(mockApi.get(any)).thenAnswer(
(_) async => {'id': '1', 'name': 'Test User'},
);
final user = await repository.getUser('1');
expect(user.name, 'Test User');
verify(mockApi.get('/users/1')).called(1);
});
test('throws on network error', () async {
when(mockApi.get(any)).thenThrow(NetworkException());
expect(
() => repository.getUser('1'),
throwsA(isA<NetworkException>()),
);
});
});
}
Generate mocks with:
dart run build_runner build
Testing State Management
For state management (Provider, Riverpod, Bloc), test state transitions:
class CartBloc {
final CartRepository _repo;
List<CartItem> _items = [];
CartBloc(this._repo);
List<CartItem> get items => List.unmodifiable(_items);
Future<void> addItem(Product product) async {
_items.add(CartItem(product: product, quantity: 1));
await _repo.save(_items);
}
Future<void> removeItem(String productId) async {
_items.removeWhere((item) => item.product.id == productId);
await _repo.save(_items);
}
double get total => _items.fold(
0,
(sum, item) => sum + (item.product.price * item.quantity),
);
}
// test/cart_bloc_test.dart
void main() {
late MockCartRepository mockRepo;
late CartBloc bloc;
setUp(() {
mockRepo = MockCartRepository();
when(mockRepo.save(any)).thenAnswer((_) async {});
bloc = CartBloc(mockRepo);
});
test('add item updates list and persists', () async {
final product = Product(id: '1', name: 'Widget', price: 9.99);
await bloc.addItem(product);
expect(bloc.items.length, 1);
expect(bloc.items.first.product.id, '1');
verify(mockRepo.save(any)).called(1);
});
test('remove item clears list', () async {
final product = Product(id: '1', name: 'Widget', price: 9.99);
await bloc.addItem(product);
await bloc.removeItem('1');
expect(bloc.items, isEmpty);
});
test('total calculates correctly', () async {
await bloc.addItem(Product(id: '1', name: 'A', price: 10.0));
await bloc.addItem(Product(id: '2', name: 'B', price: 25.5));
expect(bloc.total, 35.5);
});
}
Widget Testing UI Components
Widget tests render components in a simulated environment. They’re slower than unit tests but verify your UI behaves correctly.
Basic Widget Test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/widgets/counter_display.dart';
void main() {
testWidgets('displays current count', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: CounterDisplay(count: 5),
),
);
expect(find.text('5'), findsOneWidget);
});
testWidgets('increments when plus tapped', (tester) async {
int count = 0;
await tester.pumpWidget(
MaterialApp(
home: StatefulBuilder(
builder: (context, setState) {
return CounterDisplay(
count: count,
onIncrement: () => setState(() => count++),
);
},
),
),
);
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(count, 1);
});
}
Testing Form Validation
testWidgets('shows error for empty email', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LoginForm(),
),
),
);
await tester.tap(find.byType(ElevatedButton));
await tester.pumpAndSettle();
expect(find.text('Email is required'), findsOneWidget);
});
testWidgets('shows error for invalid email', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LoginForm(),
),
),
);
await tester.enterText(find.byType(TextFormField), 'notanemail');
await tester.tap(find.byType(ElevatedButton));
await tester.pumpAndSettle();
expect(find.text('Enter a valid email'), findsOneWidget);
});
Testing Animations
testWidgets('fade in animation completes', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: FadeInWidget(child: Text('Hello')),
),
);
await tester.pump();
expect(find.text('Hello'), findsNothing);
await tester.pump(Duration(milliseconds: 500));
expect(find.text('Hello'), findsOneWidget);
});
Golden Tests for Visual Regression
testWidgets('counter widget matches golden', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: CounterWidget(),
),
);
await expectLater(
find.byType(CounterWidget),
matchesGoldenFile('goldens/counter_widget.png'),
);
});
Update goldens after intentional changes:
flutter test --update-goldens
Integration Testing Full Flows
Integration tests run on real devices or emulators, testing complete user journeys.
Project Setup
Create integration_test/app_test.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();
group('End-to-End', () {
testWidgets('complete login flow', (tester) async {
app.main();
await tester.pumpAndSettle();
await tester.enterText(find.byKey(Key('email_field')), 'user@test.com');
await tester.enterText(find.byKey(Key('password_field')), 'password123');
await tester.tap(find.byKey(Key('login_button')));
await tester.pumpAndSettle();
expect(find.text('Welcome'), findsOneWidget);
});
testWidgets('add item to cart and checkout', (tester) async {
app.main();
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('product_1')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('add_to_cart')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('cart_icon')));
await tester.pumpAndSettle();
expect(find.text('1 item'), findsOneWidget);
await tester.tap(find.byKey(Key('checkout_button')));
await tester.pumpAndSettle();
expect(find.text('Order Confirmed'), findsOneWidget);
});
});
}
Run on Device
flutter test integration_test/app_test.dart
Testing Navigation
testWidgets('navigate to settings and back', (tester) async {
app.main();
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.settings));
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
expect(find.byType(SettingsPage), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.byType(HomePage), findsOneWidget);
});
Test Organization and CI/CD
Project Structure
lib/
models/
services/
widgets/
test/
models/
user_test.dart
services/
api_test.dart
widgets/
button_test.dart
helpers/
test_helpers.dart
integration_test/
app_test.dart
flows/
auth_flow.dart
purchase_flow.dart
Shared Test Utilities
Create test/helpers/test_helpers.dart:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Widget buildTestable(Widget widget) {
return MaterialApp(
home: Scaffold(
body: widget,
),
);
}
Future<void> tapAndSettle(WidgetTester tester, Finder finder) async {
await tester.tap(finder);
await tester.pumpAndSettle();
}
GitHub Actions CI
Create .github/workflows/test.yml:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
- name: Install dependencies
run: flutter pub get
- name: Run unit tests
run: flutter test --coverage
- name: Run widget tests
run: flutter test test/
- name: Run integration tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 31
script: flutter test integration_test/
Code Coverage
Generate and view coverage:
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html
Target 80%+ coverage on business logic, 60%+ on UI.
Common Testing Patterns
Test Data Builders
class UserBuilder {
String _id = '1';
String _name = 'Test User';
String _email = 'test@example.com';
UserBuilder withId(String id) => _id = id;
UserBuilder withName(String name) => _name = name;
UserBuilder withEmail(String email) => _email = email;
User build() => User(id: _id, name: _name, email: _email);
}
Parameterized Tests
void main() {
final testCases = [
('', 'Email is required'),
('invalid', 'Enter a valid email'),
('user@', 'Enter a valid email'),
('user@test.com', null),
];
for (final (input, expectedError) in testCases) {
test('validates email: $input', () {
final error = validateEmail(input);
expect(error, expectedError);
});
}
}
Conclusion
Testing isn’t a phase—it’s a practice woven into every commit. Start with unit tests for business logic, add widget tests for critical UI paths, and cap with integration tests for key user flows.
Flutter’s testing tools are mature and fast. The investment pays dividends in fewer bugs, faster releases, and code you can refactor without fear.
Next steps:
- Add tests to your current project starting with the most critical features
- Set up CI to run tests on every PR
- Track coverage trends over time
- Explore Patrol for native integration testing