mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-03 23:53:29 +08:00
216 lines
5.3 KiB
Markdown
216 lines
5.3 KiB
Markdown
---
|
|
paths:
|
|
- "**/*.dart"
|
|
- "**/pubspec.yaml"
|
|
- "**/analysis_options.yaml"
|
|
---
|
|
# Dart/Flutter Testing
|
|
|
|
> This file extends [common/testing.md](../common/testing.md) with Dart and Flutter-specific content.
|
|
|
|
## Test Framework
|
|
|
|
- **flutter_test** / **dart:test** — built-in test runner
|
|
- **mockito** (with `@GenerateMocks`) or **mocktail** (no codegen) for mocking
|
|
- **bloc_test** for BLoC/Cubit unit tests
|
|
- **fake_async** for controlling time in unit tests
|
|
- **integration_test** for end-to-end device tests
|
|
|
|
## Test Types
|
|
|
|
| Type | Tool | Location | When to Write |
|
|
|------|------|----------|---------------|
|
|
| Unit | `dart:test` | `test/unit/` | All domain logic, state managers, repositories |
|
|
| Widget | `flutter_test` | `test/widget/` | All widgets with meaningful behavior |
|
|
| Golden | `flutter_test` | `test/golden/` | Design-critical UI components |
|
|
| Integration | `integration_test` | `integration_test/` | Critical user flows on real device/emulator |
|
|
|
|
## Unit Tests: State Managers
|
|
|
|
### BLoC with `bloc_test`
|
|
|
|
```dart
|
|
group('CartBloc', () {
|
|
late CartBloc bloc;
|
|
late MockCartRepository repository;
|
|
|
|
setUp(() {
|
|
repository = MockCartRepository();
|
|
bloc = CartBloc(repository);
|
|
});
|
|
|
|
tearDown(() => bloc.close());
|
|
|
|
blocTest<CartBloc, CartState>(
|
|
'emits updated items when CartItemAdded',
|
|
build: () => bloc,
|
|
act: (b) => b.add(CartItemAdded(testItem)),
|
|
expect: () => [CartState(items: [testItem])],
|
|
);
|
|
|
|
blocTest<CartBloc, CartState>(
|
|
'emits empty cart when CartCleared',
|
|
seed: () => CartState(items: [testItem]),
|
|
build: () => bloc,
|
|
act: (b) => b.add(CartCleared()),
|
|
expect: () => [const CartState()],
|
|
);
|
|
});
|
|
```
|
|
|
|
### Riverpod with `ProviderContainer`
|
|
|
|
```dart
|
|
test('usersProvider loads users from repository', () async {
|
|
final container = ProviderContainer(
|
|
overrides: [userRepositoryProvider.overrideWithValue(FakeUserRepository())],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
final result = await container.read(usersProvider.future);
|
|
expect(result, isNotEmpty);
|
|
});
|
|
```
|
|
|
|
## Widget Tests
|
|
|
|
```dart
|
|
testWidgets('CartPage shows item count badge', (tester) async {
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
cartNotifierProvider.overrideWith(() => FakeCartNotifier([testItem])),
|
|
],
|
|
child: const MaterialApp(home: CartPage()),
|
|
),
|
|
);
|
|
|
|
await tester.pump();
|
|
expect(find.text('1'), findsOneWidget);
|
|
expect(find.byType(CartItemTile), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('shows empty state when cart is empty', (tester) async {
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [cartNotifierProvider.overrideWith(() => FakeCartNotifier([]))],
|
|
child: const MaterialApp(home: CartPage()),
|
|
),
|
|
);
|
|
|
|
await tester.pump();
|
|
expect(find.text('Your cart is empty'), findsOneWidget);
|
|
});
|
|
```
|
|
|
|
## Fakes Over Mocks
|
|
|
|
Prefer hand-written fakes for complex dependencies:
|
|
|
|
```dart
|
|
class FakeUserRepository implements UserRepository {
|
|
final _users = <String, User>{};
|
|
Object? fetchError;
|
|
|
|
@override
|
|
Future<User?> getById(String id) async {
|
|
if (fetchError != null) throw fetchError!;
|
|
return _users[id];
|
|
}
|
|
|
|
@override
|
|
Future<List<User>> getAll() async {
|
|
if (fetchError != null) throw fetchError!;
|
|
return _users.values.toList();
|
|
}
|
|
|
|
@override
|
|
Stream<List<User>> watchAll() => Stream.value(_users.values.toList());
|
|
|
|
@override
|
|
Future<void> save(User user) async {
|
|
_users[user.id] = user;
|
|
}
|
|
|
|
@override
|
|
Future<void> delete(String id) async {
|
|
_users.remove(id);
|
|
}
|
|
|
|
void addUser(User user) => _users[user.id] = user;
|
|
}
|
|
```
|
|
|
|
## Async Testing
|
|
|
|
```dart
|
|
// Use fake_async for controlling timers and Futures
|
|
test('debounce triggers after 300ms', () {
|
|
fakeAsync((async) {
|
|
final debouncer = Debouncer(delay: const Duration(milliseconds: 300));
|
|
var callCount = 0;
|
|
debouncer.run(() => callCount++);
|
|
expect(callCount, 0);
|
|
async.elapse(const Duration(milliseconds: 200));
|
|
expect(callCount, 0);
|
|
async.elapse(const Duration(milliseconds: 200));
|
|
expect(callCount, 1);
|
|
});
|
|
});
|
|
```
|
|
|
|
## Golden Tests
|
|
|
|
```dart
|
|
testWidgets('UserCard golden test', (tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(home: UserCard(user: testUser)),
|
|
);
|
|
|
|
await expectLater(
|
|
find.byType(UserCard),
|
|
matchesGoldenFile('goldens/user_card.png'),
|
|
);
|
|
});
|
|
```
|
|
|
|
Run `flutter test --update-goldens` when intentional visual changes are made.
|
|
|
|
## Test Naming
|
|
|
|
Use descriptive, behavior-focused names:
|
|
|
|
```dart
|
|
test('returns null when user does not exist', () { ... });
|
|
test('throws NotFoundException when id is empty string', () { ... });
|
|
testWidgets('disables submit button while form is invalid', (tester) async { ... });
|
|
```
|
|
|
|
## Test Organization
|
|
|
|
```
|
|
test/
|
|
├── unit/
|
|
│ ├── domain/
|
|
│ │ └── usecases/
|
|
│ └── data/
|
|
│ └── repositories/
|
|
├── widget/
|
|
│ └── presentation/
|
|
│ └── pages/
|
|
└── golden/
|
|
└── widgets/
|
|
|
|
integration_test/
|
|
└── flows/
|
|
├── login_flow_test.dart
|
|
└── checkout_flow_test.dart
|
|
```
|
|
|
|
## Coverage
|
|
|
|
- Target 80%+ line coverage for business logic (domain + state managers)
|
|
- All state transitions must have tests: loading → success, loading → error, retry
|
|
- Run `flutter test --coverage` and inspect `lcov.info` with a coverage reporter
|
|
- Coverage failures should block CI when below threshold
|