Flutter: Testing — Unit / Widget / Integration Tests
Code without tests is a time bomb — you don't know when it'll explode, only that it will.
📋 Prerequisites: You should already be familiar with
- Lesson 5: StatefulWidget & Interaction
- Lesson 12: State Management — Riverpod
1. What You'll Learn
- Unit tests: test package + mocktail mocking; testing Notifier / Repository logic
- Widget tests: testWidgets + Finder + pumpWidget / pumpAndSettle
- Integration tests: integration_test package + real device end-to-end testing
- Golden tests: matchesGoldenFile UI regression detection
- ShopApp: CartNotifier unit tests + product list Widget tests + checkout integration tests
2. A Real Story of a Production Bug
(1) The Pain Point: Manual Testing Can't Cover Everything
Bob's ShopApp shipped a coupon feature. QA manually tested the "happy path" and it passed, but missed the edge case of "using an expired coupon." After launch, 500 users placed orders with expired coupons, resulting in negative order amounts and database errors. The fix took 4 hours, costing 10 thousand USD. Similar issues happened at least twice a month.
(2) The Automated Testing Solution
Automated tests run on every code commit, covering all edge cases and ensuring new code doesn't break existing functionality.
import 'package:flutter_test/flutter_test.dart';
// ⚙️ Dependency: mocktail: ^1.0.0 (add to dev_dependencies)
// CartNotifier / Coupon / Product from ShopApp project, full definition in Lesson 27
class Coupon {
final String code;
final DateTime expiresAt;
const Coupon({required this.code, required this.expiresAt});
}
test('coupon expired should show error', () {
final cart = CartNotifier();
cart.addItem(product);
final result = cart.applyCoupon(Coupon(code: 'SAVE20', expiresAt: DateTime(2020)));
expect(result, isFalse);
expect(cart.error, contains('expired'));
});
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
(3) The Payoff: Bug Rate Drops 80%
After Bob established a testing system, 200+ tests run automatically on every commit, and the production bug rate dropped from 5/month to 1/month.
3. Testing Pyramid
graph TD
TP[Testing Pyramid] --> UT[Unit Tests: 70%]
TP --> WT[Widget Tests: 20%]
TP --> IT[Integration Tests: 10%]
UT --> |mocktail| CN3[CartNotifier]
WT --> |testWidgets| PV3[ProductListView]
IT --> |integration_test| CK2[Checkout Flow]
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
| Layer | Proportion | Speed | Dependencies | Test Scope |
|---|---|---|---|---|
| Unit Tests | 70% | Milliseconds | Mock | Pure logic (Notifier/Repository) |
| Widget Tests | 20% | Seconds | Test environment | UI rendering + interaction |
| Integration Tests | 10% | Minutes | Real device/emulator | Complete user flows |
4. Unit Tests
(1) Project Configuration
# pubspec.yaml
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.0
build_runner: ^2.4.0
▶ Example
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
: CartNotifier unit tests
// test/notifiers/cart_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
// ⚙️ Dependency: mocktail: ^1.0.0 (add to dev_dependencies)
// ProductRepository / CartNotifier from ShopApp project, full definition in Lesson 27
// Simplified Product (full definition in Lesson 26 domain/entities/product.dart)
class Product {
final int id;
final String name;
final double price;
final String imageUrl;
final String category;
const Product({required this.id, required this.name, required this.price,
required this.imageUrl, this.category = 'General'});
}
// Simplified CartNotifier (full definition in Lesson 27)
class CartNotifier {
List<_CartItem> state = [];
String? error;
void addItem(Product product) {
final idx = state.indexWhere((i) => i.product.id == product.id);
if (idx >= 0) {
state = [...state]..[idx] = _CartItem(product: state[idx].product, quantity: state[idx].quantity + 1);
} else {
state = [...state, _CartItem(product: product, quantity: 1)];
}
}
void updateQuantity(int productId, int quantity) {
if (quantity <= 0) { removeItem(productId); return; }
state = [for (final i in state) i.product.id == productId ? _CartItem(product: i.product, quantity: quantity) : i];
}
void removeItem(int productId) {
state = state.where((i) => i.product.id != productId).toList();
}
void clear() { state = []; }
double get total => state.fold(0.0, (sum, i) => sum + i.product.price * i.quantity);
}
class _CartItem {
final Product product;
final int quantity;
_CartItem({required this.product, required this.quantity});
}
class MockProductRepository extends Mock implements ProductRepository {}
void main() {
group('CartNotifier', () {
late CartNotifier cart;
setUp(() {
cart = CartNotifier();
});
test('initial state is empty', () {
expect(cart.state, isEmpty);
});
test('addItem adds product to cart', () {
cart.addItem(testProduct);
expect(cart.state.length, 1);
expect(cart.state.first.product, testProduct);
expect(cart.state.first.quantity, 1);
});
test('addItem increments quantity if product exists', () {
cart.addItem(testProduct);
cart.addItem(testProduct);
expect(cart.state.length, 1);
expect(cart.state.first.quantity, 2);
});
test('updateQuantity updates item quantity', () {
cart.addItem(testProduct);
cart.updateQuantity(testProduct.id, 5);
expect(cart.state.first.quantity, 5);
});
test('updateQuantity removes item when quantity is 0', () {
cart.addItem(testProduct);
cart.updateQuantity(testProduct.id, 0);
expect(cart.state, isEmpty);
});
test('removeItem removes product from cart', () {
cart.addItem(testProduct);
cart.removeItem(testProduct.id);
expect(cart.state, isEmpty);
});
test('clear empties the cart', () {
cart.addItem(testProduct);
cart.addItem(anotherProduct);
cart.clear();
expect(cart.state, isEmpty);
});
test('total calculates correct sum', () {
cart.addItem(testProduct); // price: 99.99
cart.addItem(anotherProduct); // price: 49.99
expect(cart.total, closeTo(149.98, 0.01));
});
});
}
const testProduct = Product(id: 1, name: 'Laptop', price: 99.99, imageUrl: '', category: 'Electronics');
const anotherProduct = Product(id: 2, name: 'Mouse', price: 49.99, imageUrl: '', category: 'Electronics');
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
▶ Example
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
: Repository unit tests (with Mock)
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:dio/dio.dart';
// ⚙️ Dependencies: mocktail: ^1.0.0, dio: ^5.0.0 (add to dev_dependencies / dependencies)
// ProductRepository / DioClient from ShopApp project, full definition in Lesson 27
class MockDioClient extends Mock implements DioClient {}
void main() {
group('ProductRepository', () {
late ProductRepository repo;
late MockDioClient mockClient;
setUp(() {
mockClient = MockDioClient();
repo = ProductRepository(mockClient);
});
test('getProducts returns list of products', () async {
// Arrange
when(() => mockClient.get('/products', queryParams: any(named: 'queryParams')))
.thenAnswer((_) async => Response(
data: {'items': [{'id': 1, 'name': 'Laptop', 'price': 1299.99, 'category': 'Electronics'}]},
statusCode: 200, requestOptions: RequestOptions(path: '/products'),
));
// Act
final products = await repo.getProducts();
// Assert
expect(products.length, 1);
expect(products.first.name, 'Laptop');
verify(() => mockClient.get('/products', queryParams: any(named: 'queryParams'))).called(1);
});
test('getProducts throws on network error', () async {
when(() => mockClient.get('/products', queryParams: any(named: 'queryParams')))
.thenThrow(DioException(requestOptions: RequestOptions(path: '/products')));
expect(() => repo.getProducts(), throwsA(isA<DioException>()));
});
});
}
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
5. Widget Tests
▶ Example
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
: Product card Widget test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// Product / ProductCard from ShopApp project, full definition in Lessons 26/27
// Simplified Product
class Product {
final int id;
final String name;
final double price;
final String imageUrl;
final String category;
const Product({required this.id, required this.name, required this.price,
required this.imageUrl, this.category = 'General'});
}
// Simplified ProductCard
class ProductCard extends StatelessWidget {
final Product product;
final VoidCallback onAddToCart;
const ProductCard({super.key, required this.product, required this.onAddToCart});
@override
Widget build(BuildContext context) {
return Card(
child: Column(children: [
Text(product.name),
Text('\$${product.price.toStringAsFixed(2)}'),
if (product.price < 50) const Text('SALE'),
IconButton(icon: const Icon(Icons.add_shopping_cart), onPressed: onAddToCart),
]),
);
}
}
const testProduct = Product(id: 1, name: 'Laptop', price: 99.99, imageUrl: '', category: 'Electronics');
void main() {
group('ProductCard', () {
testWidgets('displays product name and price', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ProductCard(
product: testProduct,
onAddToCart: () {},
)),
));
expect(find.text('Laptop'), findsOneWidget);
expect(find.text('\$99.99'), findsOneWidget);
});
testWidgets('calls onAddToCart when button tapped', (tester) async {
var tapped = false;
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ProductCard(
product: testProduct,
onAddToCart: () => tapped = true,
)),
));
await tester.tap(find.byIcon(Icons.add_shopping_cart));
expect(tapped, isTrue);
});
testWidgets('shows SALE badge when price < 50', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ProductCard(
product: const Product(id: 1, name: 'Cheap Item', price: 29.99, imageUrl: '', category: 'All'),
onAddToCart: () {},
)),
));
expect(find.text('SALE'), findsOneWidget);
});
});
}
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
▶ Example
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
: Cart page Widget test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ⚙️ Dependency: flutter_riverpod: ^2.0.0 (add to dependencies)
// CartPage / cartProvider / EmptyCartNotifier / PrefilledCartNotifier from ShopApp project
void main() {
group('CartPage', () {
testWidgets('shows empty state when cart is empty', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [cartProvider.overrideWith(() => EmptyCartNotifier())],
child: const MaterialApp(home: CartPage()),
));
expect(find.text('Cart is empty'), findsOneWidget);
});
testWidgets('shows items and total when cart has items', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [cartProvider.overrideWith(() => PrefilledCartNotifier())],
child: const MaterialApp(home: CartPage()),
));
await tester.pumpAndSettle();
expect(find.text('Laptop'), findsOneWidget);
expect(find.text('Mouse'), findsOneWidget);
expect(find.textContaining('\$149.98'), findsOneWidget);
});
testWidgets('increments quantity on plus tap', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [cartProvider.overrideWith(() => PrefilledCartNotifier())],
child: const MaterialApp(home: CartPage()),
));
await tester.pumpAndSettle();
// Find first increment button and tap
await tester.tap(find.byIcon(Icons.add).first);
await tester.pumpAndSettle();
// Verify quantity changed
expect(find.text('2'), findsOneWidget);
});
});
}
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
6. Integration Tests
▶ Example
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
: Checkout flow end-to-end test
// integration_test/checkout_test.dart
import 'package:flutter/material.dart';
import 'package:integration_test/integration_test.dart';
// ⚙️ Dependency: integration_test SDK (add to dev_dependencies)
// app (ShopApp main entry) from project lib/main.dart
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Checkout Flow', () {
testWidgets('complete purchase end-to-end', (tester) async {
app.main();
await tester.pumpAndSettle();
// 1. Browse products
expect(find.text('ShopApp'), findsOneWidget);
// 2. Add product to cart
await tester.tap(find.byIcon(Icons.add_shopping_cart).first);
await tester.pumpAndSettle();
// 3. Navigate to cart
await tester.tap(find.byIcon(Icons.shopping_cart));
await tester.pumpAndSettle();
// 4. Verify cart has item
expect(find.textContaining('\$'), findsWidgets);
// 5. Tap checkout
await tester.tap(find.text('Checkout'));
await tester.pumpAndSettle();
// 6. Fill checkout form
await tester.enterText(find.byType(TextFormField).at(0), 'Alice Smith');
await tester.enterText(find.byType(TextFormField).at(1), '123 Main St');
await tester.pumpAndSettle();
// 7. Submit order
await tester.tap(find.textContaining('Pay'));
await tester.pumpAndSettle();
// 8. Verify order confirmation
expect(find.text('Order Confirmed'), findsOneWidget);
});
});
}
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
7. Testing Best Practices
| Practice | Description |
|---|---|
| AAA Pattern | Arrange → Act → Assert |
| Independent tests | setUp/setUpEach for initialization, no cross-test dependencies |
| Mock external dependencies | Replace network/database/platform APIs with Mocks |
| Descriptive test names | test('addItem increments quantity when product exists') |
| Use pumpAndSettle for Widget tests | Wait for all animations and async operations to complete |
❓ FAQ
flutter test integration_test/ on an Android emulator. iOS requires a macOS runner.--update-goldens when UI changes. Watch out for cross-platform rendering differences.ProviderScope(overrides: [myProvider.overrideWith(...)]) to replace the real Provider with a test version.📖 Summary
- Testing pyramid: 70% unit + 20% Widget + 10% integration
- Unit tests use mocktail to Mock external dependencies, organized with AAA pattern
- Widget tests use testWidgets + Finder to verify UI and interaction
- Integration tests provide end-to-end verification of complete user flows
- Riverpod Provider overrides replace dependencies in tests
📝 Exercises
- Basic (Difficulty ⭐): Write 5 unit tests for CartNotifier: initial empty, add, add duplicate, remove, clear.
- Intermediate (Difficulty ⭐⭐): Write Widget tests for ProductCard: displays product name/price, tapping add-to-cart triggers callback, shows SALE badge.
- Challenge (Difficulty ⭐⭐⭐): Implement a complete test suite: CartNotifier unit tests (8+ cases) + CartPage Widget tests (5+ cases) + checkout integration test (complete user flow).