Testing each layer: the strategy
The most directly measurable payoff of layering is testing. The official case study's testing page gives a concrete strategy per layer:
- View model — a unit test that does not rely on Flutter libraries or the testing framework. Its only dependencies are repositories (or use cases where present), so the only setup is writing a fake or mock of the repository.
- View — a widget test, with the same fakes. The docs make a useful point: once you have written the fakes for the view model tests, you need nothing extra for the widget tests.
- Repository — a unit test; the services it depends on are mocked.
- Service — a unit test; the HTTP client is replaced (
MockClient). - Domain (models, use cases) — plain unit tests with no Flutter dependency at all.
The page's core statement is direct: if your architecture is sound, view and view model tests only require mocking repositories. That doubles as a diagnostic — if a test forces you to mock the HTTP client or JSON, there is a hole in your layering.
| Layer | Test type | What gets replaced | What is verified |
|---|---|---|---|
| Domain model | Unit (`package:test`) | Nothing | Business rules, `copyWith`, equality |
| Use case | Unit | Repositories (fakes) | The combining logic and edge cases |
| Mapper (DTO → domain) | Unit | Nothing (a `Map` goes in) | `null`, malformed values, unknown enums |
| Repository | Unit | Services (fakes/mocks) | Caching, fallback, error translation |
| Service | Unit | The HTTP client (`MockClient`) | Request construction, status codes |
| Notifier (view model) | Unit (`ProviderContainer.test`) | Only repositories/use cases | State transitions, commands, outcomes |
| View | Widget test | The same fakes (`ProviderScope(overrides:)`) | Four states: loading, empty, data, error |
| The whole app | Integration test | Nothing (or a test backend) | One to three critical flows: sign-in, checkout |
A practical approach to test counts. The official docs do not prescribe a ratio, but the layers have different costs, which creates a natural distribution:
- Domain and mapper tests are cheapest (milliseconds, no setup) and guard the most volatile point → writing many of them pays off.
- Notifier tests are mid-priced (a fake repository is needed) → written for each screen's main flows.
- Widget tests cost more (frame pumping, finders) → three to five per screen: the four states plus one interaction.
- Integration tests are the most expensive (a device, a real network, flakiness) → only critical flows: sign-in, checkout, registration.
What not to test. Two cases:
- Generated code:
copyWithand==come from freezed; testing them is testing the generator. (Your own business getters, however, do need tests.) - Implementation details: assertions like "the repository method was called once" obstruct refactoring. Verifying the outcome is worth more.
The most important diagnostic. If writing a test feels hard, that is usually not a testing problem but an architectural signal: if a notifier test forces you to mock HTTP, the notifier reaches into the data layer; if a widget test needs ten provider overrides, the screen knows too much.
// ══ test/domain/models/order_test.dart ══ (ən ucuz, ən çox)
import 'package:test/test.dart'; // flutter_test DEYİL
test('ləğv yalnız pending statusda mümkündür', () {
expect(order.copyWith(status: OrderStatus.pending).canBeCancelled, isTrue);
expect(order.copyWith(status: OrderStatus.shipped).canBeCancelled, isFalse);
});
// ══ test/data/dto/order_dto_mapper_test.dart ══ (backend dəyişikliyini tutur)
test('null sahələr güvənli defolt alır', () {
final dto = OrderDto.fromJson(const {'id': 'o-1'});
final order = dto.toDomain();
expect(order.items, isEmpty); // `?? []` mapper-də
expect(order.status, OrderStatus.unknown);
});
// ══ test/data/repositories/order_repository_test.dart ══ (qərarlar)
test('şəbəkə xətası AuthFailure-a çevrilmir, NetworkFailure olur', () async {
final repo = OrderRepositoryRemote(
apiClient: FakeOrderApiClient(throwOnCall: const SocketException('')),
);
final result = await repo.fetchMine();
expect((result as Error).error, isA<NetworkFailure>());
});
// ══ test/ui/orders/order_list_notifier_test.dart ══ (state keçidləri)
import 'package:flutter_riverpod/flutter_riverpod.dart';
test('cancel uğurlu olduqda siyahıdaki element yenilənir', () async {
final container = ProviderContainer.test(overrides: [
// TƏK quraşdırma: repository-nin fake-i.
orderRepositoryProvider
.overrideWithValue(FakeOrderRepository([pendingOrder])),
]);
await container.read(orderListNotifierProvider.future);
final outcome = await container
.read(orderListNotifierProvider.notifier)
.cancel(pendingOrder.id);
expect(outcome, CancelOutcome.success);
expect(
container.read(orderListNotifierProvider).value!.orders.first.status,
OrderStatus.cancelled,
);
});
// ══ test/ui/orders/orders_page_test.dart ══ (EYNİ fake ilə)
import 'package:flutter_test/flutter_test.dart';
testWidgets('boş siyahı üçün boş hal göstərilir', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [
// Notifier testində yazdığın fake yenidən işlədilir.
orderRepositoryProvider
.overrideWithValue(FakeOrderRepository(const [])),
],
child: const MaterialApp(home: OrdersPage()),
));
await tester.pumpAndSettle();
expect(find.byType(EmptyOrders), findsOneWidget);
});
// ══ integration_test/checkout_flow_test.dart ══ (yalnız kritik axın)
// Real tətbiq, real naviqasiya: kataloq → səbət → ödəniş → təsdiq.
// Bir-üç ədəd — çox yazmaq bahalıdır və kövrək olur.One feature's test pyramid: five files, five layers. Note that only the last two depend on Flutter.
Keep the fakes in one place. The practical consequence of the docs' point: in test/fakes/ you write one fake per repository contract and reuse it across all tests (notifier, widget, use case). That cuts duplication and keeps the fake's behaviour in one place.
Practice. Pick one feature and write the five test files: domain model, mapper, repository, notifier, widget. Then run flutter test --coverage and look at which layer is left uncovered.
Done means: all five are green, test/fakes/ holds at least one fake, and the notifier test overrides only the repository (no HTTP, no JSON).
📚 Sources and documentation
- Case study: testing each layerofficialdocs.flutter.dev
The source for this topic: the official strategy for view model, view, repository and service tests.
- Flutter: testing overviewofficialdocs.flutter.dev
The difference between unit, widget and integration tests, and their costs.
- Riverpod: testingofficialriverpod.dev
`ProviderContainer.test()`, `overrides`, and `ProviderScope` in widget tests.
- Architecture recommendations: testingofficialdocs.flutter.dev
The "test components separately and together" and "make fakes" items.