Chương 14 · Testing

Testing

Test thường bị skip "vì gấp" — sau đó refactor không dám vì sợ vỡ. Flutter có test infra tốt nhất ecosystem mobile. Chương này dạy đủ 4 level + cách mỗi level test cái gì.

Độ dài: ~1090 dòng Bài tập: 5 Quiz: 8 Prerequisites: Tất cả chương trước
🎯 Mục tiêu chương
  • 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_test package.
  • Golden test (visual regression).
  • Coverage report với --coverage.
  • CI GitHub Actions chạy test trên PR.

1. Test pyramid

Integration / Golden Widget tests Unit tests (nhiều) Cost ↓ many Confidence ↓ low
  • 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);
});
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
⚠️ Golden test cross-platform khó

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_test với TestWidgetsFlutterBinding font stub.
  • Package alchemist giú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

Q1

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.

Q2

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.

Q3

find.byType(ElevatedButton) trả gì?

Xem đáp án

Đáp án: Finder instance — descriptor để find. Pass vào expect với matcher (findsOneWidget, findsNWidgets, findsNothing).

Q4

blocTest expect parameter — return type?

Xem đáp án

Đáp án: Function returning List of states. Lazy để cho phép Matcher (vd isA<LoadingState>()).

Q5

Coverage lcov.info — view?

Xem đáp án

Đáp án: genhtml coverage/lcov.info -o coverage/html rồi open coverage/html/index.html.

Q6

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.

Q7

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).

Q8

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).
  • mocktail mock boundary (HTTP, DB).
  • bloc_test cho 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_test trê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.