- Phân biệt 4 level test: Unit / Widget / Integration / Golden.
- Setup
flutter_test,mocktail,bloc_test. - Unit test cho Dart logic (Bloc, utility, model).
- Widget test với
WidgetTester: pump, tap, drag, expect. - Integration test với
integration_testpackage. - Golden test (visual regression).
- Coverage report với
--coverage. - CI GitHub Actions chạy test trên PR.
1. Test pyramid
- Unit (nhiều) — fast, cheap, narrow scope. Test pure Dart logic.
- Widget (vừa) — render widget, interact. Test UI behavior.
- Integration (ít) — chạy app thật trên device, full flow.
- Golden — visual regression, screenshot diff.
2. Setup
flutter_test đã có trong template flutter create. Thêm:
flutter pub add --dev mocktail bloc_test
flutter pub add --dev integration_test:
# trong dev_dependencies thêm:
# integration_test:
# sdk: flutter
Chạy test:
flutter test # tất cả test/
flutter test test/user_test.dart # 1 file
flutter test --coverage # + coverage report
flutter test --tags=fast # chỉ test có tag 'fast'
3. Unit test cơ bản
// test/utils_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/utils.dart';
void main() {
group('formatPrice', () {
test('formats VND correctly', () {
expect(formatPrice(1500000, 'VND'), '1,500,000 VND');
});
test('handles zero', () {
expect(formatPrice(0, 'USD'), '0 USD');
});
test('throws on negative', () {
expect(() => formatPrice(-1, 'USD'), throwsArgumentError);
});
});
}
setUp / tearDown
group('CartCubit', () {
late CartCubit cubit;
setUp(() {
cubit = CartCubit();
});
tearDown(() {
cubit.close();
});
test('initial state empty', () {
expect(cubit.state.items, isEmpty);
});
test('add item', () {
cubit.add(Item(id: 1));
expect(cubit.state.items.length, 1);
});
});
4. expect matchers
expect(value, equals(42));
expect(value, 42); // shortcut equals
expect(value, isA<User>());
expect(list, [1, 2, 3]);
expect(list, contains(2));
expect(list, hasLength(3));
expect(value, isNull);
expect(value, isNotNull);
expect(value, isTrue);
expect(value, isEmpty);
expect(() => throwing(), throwsA(isA<ApiException>()));
expect(list, everyElement(greaterThan(0)));
expect(map, {'a': 1});
expect(future, completes); // async — không throw
expect(future, completion(42));
expect(future, throwsException);
5. Async test
test('fetches user', () async {
final user = await fetchUser(1);
expect(user.name, 'Việt');
});
6. Mock với mocktail
import 'package:mocktail/mocktail.dart';
class MockUserRepo extends Mock implements UserRepository {}
void main() {
group('UserBloc', () {
late MockUserRepo repo;
late UserBloc bloc;
setUp(() {
repo = MockUserRepo();
bloc = UserBloc(repo);
});
test('load success', () async {
// Setup mock
when(() => repo.fetchUser(1))
.thenAnswer((_) async => User(id: 1, name: 'Việt'));
// Act
await bloc.load(1);
// Assert
expect(bloc.state, isA<UserLoaded>());
// Verify mock call
verify(() => repo.fetchUser(1)).called(1);
});
test('load failure', () async {
when(() => repo.fetchUser(any()))
.thenThrow(ApiException(500, 'Server error'));
await bloc.load(1);
expect(bloc.state, isA<UserError>());
});
});
}
7. bloc_test
import 'package:bloc_test/bloc_test.dart';
blocTest<CounterCubit, int>(
'emits [1] when increment called',
build: () => CounterCubit(),
act: (cubit) => cubit.increment(),
expect: () => [1],
);
blocTest<UserBloc, UserState>(
'emits [Loading, Loaded] on success',
setUp: () {
when(() => mockRepo.fetchUser(1))
.thenAnswer((_) async => User(id: 1, name: 'V'));
},
build: () => UserBloc(mockRepo),
act: (bloc) => bloc.load(1),
expect: () => [
isA<UserLoading>(),
isA<UserLoaded>(),
],
verify: (bloc) {
verify(() => mockRepo.fetchUser(1)).called(1);
},
);
8. Widget test
testWidgets('counter increments', (WidgetTester tester) async {
// 1. Pump widget
await tester.pumpWidget(const MaterialApp(home: CounterPage()));
// 2. Verify initial state
expect(find.text('0'), findsOneWidget);
// 3. Tap button
await tester.tap(find.byIcon(Icons.add));
await tester.pump(); // advance 1 frame
// 4. Verify new state
expect(find.text('1'), findsOneWidget);
expect(find.text('0'), findsNothing);
});
WidgetTester API
// Pump
await tester.pumpWidget(widget);
await tester.pump(); // 1 frame
await tester.pump(Duration(seconds: 1));
await tester.pumpAndSettle(); // đợi đến khi không còn frame schedule (animation done)
// Find
find.text('Hello');
find.byKey(const Key('submit'));
find.byType(ElevatedButton);
find.byIcon(Icons.add);
find.byTooltip('Increment');
find.descendant(of: find.byType(Card), matching: find.text('Hi'));
// Interact
await tester.tap(find.byKey(Key('submit')));
await tester.enterText(find.byKey(Key('email')), 'v@x.com');
await tester.drag(find.byType(Slider), Offset(50, 0));
await tester.longPress(find.byType(ListTile));
// Find matchers
expect(find.text('Hi'), findsOneWidget);
expect(find.text('X'), findsNothing);
expect(find.byType(ListTile), findsNWidgets(5));
expect(find.text('Item'), findsWidgets); // ≥ 1
9. Widget test với BlocProvider
testWidgets('login page shows error on fail', (tester) async {
final mockBloc = MockLoginCubit();
// Stub state stream
whenListen(
mockBloc,
Stream.fromIterable([LoginLoading(), LoginError('Sai email')]),
initialState: LoginInitial(),
);
await tester.pumpWidget(
MaterialApp(
home: BlocProvider<LoginCubit>.value(
value: mockBloc,
child: const LoginPage(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Sai email'), findsOneWidget);
});
10. Test navigation
testWidgets('navigate to detail', (tester) async {
await tester.pumpWidget(const MaterialApp(home: HomePage()));
await tester.tap(find.text('Go to Detail'));
await tester.pumpAndSettle(); // đợi navigation animation done
expect(find.byType(DetailPage), findsOneWidget);
});
11. Test form interaction
testWidgets('login form validation', (tester) async {
await tester.pumpWidget(const MaterialApp(home: LoginForm()));
// Submit without input
await tester.tap(find.text('Login'));
await tester.pump();
expect(find.text('Email bắt buộc'), findsOneWidget);
expect(find.text('Password bắt buộc'), findsOneWidget);
// Enter values
await tester.enterText(find.byKey(Key('email')), 'viet@example.com');
await tester.enterText(find.byKey(Key('password')), 'password123');
await tester.tap(find.text('Login'));
await tester.pump();
expect(find.text('Email bắt buộc'), findsNothing);
});
12. FutureBuilder/StreamBuilder test
testWidgets('shows loading then data', (tester) async {
await tester.pumpWidget(const MaterialApp(home: FutureWidget()));
// Initial — loading
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Wait future complete
await tester.pumpAndSettle();
expect(find.text('Data loaded'), findsOneWidget);
});
13. Integration test
// 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();
testWidgets('full login flow', (tester) async {
app.main();
await tester.pumpAndSettle();
// Already on login page
await tester.enterText(find.byKey(Key('email')), 'test@example.com');
await tester.enterText(find.byKey(Key('password')), '12345678');
await tester.tap(find.text('Login'));
await tester.pumpAndSettle();
expect(find.byType(HomePage), findsOneWidget);
});
}
# Chạy trên real device / emulator
flutter test integration_test/app_test.dart
14. Golden test — visual regression
testWidgets('home page matches golden', (tester) async {
await tester.pumpWidget(const MaterialApp(home: HomePage()));
await tester.pumpAndSettle();
await expectLater(
find.byType(HomePage),
matchesGoldenFile('goldens/home_page.png'),
);
});
# Lần đầu — generate baseline
flutter test --update-goldens
# Sau đó — test diff với baseline
flutter test
Pixel khác nhau giữa Mac, Linux, Windows do font render. Practice:
- Generate goldens trên CI cố định (vd Ubuntu) — đảm bảo consistent.
- Hoặc dùng
flutter_testvớiTestWidgetsFlutterBindingfont stub. - Package
alchemistgiúp golden test reliable hơn.
15. Coverage report
flutter test --coverage
# Sinh coverage/lcov.info
# Convert sang HTML
brew install lcov
genhtml coverage/lcov.info -o coverage/html
# Mở
open coverage/html/index.html
Exclude file khỏi coverage
# trong dart_test.yaml
coverage_exclude:
- lib/**/*.g.dart # codegen
- lib/**/*.freezed.dart
16. CI — GitHub Actions
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.22.0'
channel: stable
- run: flutter pub get
- run: flutter analyze
- run: flutter test --coverage
- uses: codecov/codecov-action@v4
with:
file: ./coverage/lcov.info
17. Bài tập
Unit test Validator
Unit test class Validator { static String? email(String?), static String? password(String?) } với 6 case mỗi method.
💡 Gợi ý đáp án
email: null, empty, no @, no domain, valid simple, valid với subdomain. password: null, <8 char, no number, no upper, all good.
blocTest CartBloc
Bloc test cho CartBloc: add, remove, clear, total. 5 case khác nhau.
💡 Gợi ý đáp án
Tham khảo section 7. Mỗi case: build → act → expect list of states.
Widget test LoginPage
Widget test LoginPage: form validation, mock auth bloc, submit success → navigate.
💡 Gợi ý đáp án
Tham khảo section 9 (Widget + Bloc) + 11 (form). Mock LoginCubit qua MockBloc.
Integration test counter
Integration test cho counter app: tap 5 lần, verify "5". Chạy trên emulator.
💡 Gợi ý đáp án
Tham khảo section 13. Loop 5 tap. flutter test integration_test/counter_test.dart.
Golden test settings page
Golden test cho settings page với 3 theme khác nhau (light, dark, custom). Update goldens khi UI đổi.
💡 Gợi ý đáp án
3 testWidgets riêng. Mỗi pump MaterialApp với theme khác. matchesGoldenFile tên khác nhau.
18. Quiz
pump() vs pumpAndSettle() — khi nào?
Xem đáp án
Đáp án: pump(duration) advance 1 frame (hoặc duration). pumpAndSettle đợi đến khi không còn frame schedule (animation done). Sau navigate dùng pumpAndSettle.
Mock object — chỉ mock cái gì?
Xem đáp án
Đáp án: Boundary: HTTP, DB, time, external service. KHÔNG mock business logic của chính mình — đó là cái cần test.
find.byType(ElevatedButton) trả gì?
Xem đáp án
Đáp án: Finder instance — descriptor để find. Pass vào expect với matcher (findsOneWidget, findsNWidgets, findsNothing).
blocTest expect parameter — return type?
Xem đáp án
Đáp án: Function returning List of states. Lazy để cho phép Matcher (vd isA<LoadingState>()).
Coverage lcov.info — view?
Xem đáp án
Đáp án: genhtml coverage/lcov.info -o coverage/html rồi open coverage/html/index.html.
Golden test fail vì khác baseline — fix?
Xem đáp án
Đáp án: Verify UI change intentional → flutter test --update-goldens update baseline. Commit golden mới.
Test Bloc cần inject mock repo — pattern?
Xem đáp án
Đáp án: Constructor inject: CounterBloc(this.repo). Test: CounterBloc(mockRepo). Repository pattern (Ch 10).
CI GitHub Actions chạy flutter test — setup gì?
Xem đáp án
Đáp án: Action subosito/flutter-action setup Flutter SDK + Dart. Sau đó flutter pub get + flutter test.
19. Tổng kết
- ✅ Test pyramid: unit nhiều, widget vừa, integration ít.
- ✅ Unit test Dart logic (Bloc, util, model).
- ✅
mocktailmock boundary (HTTP, DB). - ✅
bloc_testcho Cubit/Bloc. - ✅ Widget test với WidgetTester: pump, find, tap, expect.
- ✅
pumpAndSettleđợi async/animation done. - ✅ Test với BlocProvider qua
BlocProvider.value. - ✅ Integration test với
integration_testtrên device thật. - ✅ Golden test visual regression —
matchesGoldenFile. - ✅ Coverage
--coverage+ genhtml. - ✅ CI GitHub Actions auto chạy test trên PR.
20. Kết nối
- Mọi chương trước — test cover feature.
- Ch 15: CI/CD pipeline build + test.