Unit Testing Dart: Classes, Use Cases & Repositories
Writing meaningful unit tests with mocks — testing what matters without hitting the network
Open interactive version (quiz + challenge)Real-world analogy
Unit testing a repository is like testing a restaurant order system with a fake kitchen. You do not cook real food — you use a mock kitchen that returns whatever you tell it to. This lets you test that the order system handles errors, retries, and happy paths without actually firing up the stove.
What is it?
Unit testing in Dart means testing individual classes and functions in complete isolation. Dependencies are replaced with mocks or fakes so each test exercises exactly one unit of logic with full control over inputs and simulated responses.
Real-world relevance
On an NFC asset recovery app, the AssetRepository fetched data from a REST API. Unit tests with a mock Dio client caught a bug where the repository was swallowing 404 errors silently instead of returning a ResourceNotFound failure — found in CI, not production.
Key points
- test package fundamentals — group() organises tests, test() defines a case, expect() asserts. setUp() runs before each test, tearDown() after. These four constructs cover 90% of unit testing needs.
- Arrange-Act-Assert pattern — Every test has three parts: Arrange (set up inputs and mocks), Act (call the code under test), Assert (verify the output). This structure makes tests readable and maintainable.
- Testing pure Dart classes — Any class with no Flutter or platform dependencies can be tested with the plain test package — use cases, validators, parsers, state machines, formatters. These are the fastest and most valuable tests.
- mocktail for mocking — mocktail generates mock objects from abstract classes and interfaces. Use when(mock.method()).thenReturn(value) to stub responses and verify(mock.method()).called(1) to assert interactions.
- Testing repositories — Repositories depend on remote data sources (Dio, http). Mock the data source, not the real network. Test that your repository correctly maps API responses to domain models and handles errors.
- Testing use cases — Use cases depend on repositories. Mock the repository, inject it, and test that the use case applies business rules correctly. This is the most valuable test you can write for interview demos.
- Throwing errors in mocks — Use when(mock.method()).thenThrow(Exception('network error')) to simulate failures. Test that your repository wraps exceptions in domain failures and your use case handles them gracefully.
- setUp and tearDown — setUp creates fresh instances before each test — critical for isolation. tearDown cleans up resources (close streams, reset singletons). Missing setUp is a common source of flaky tests.
- Avoid testing implementation details — Test behaviour, not how it is implemented internally. If you test which private methods are called, refactoring breaks tests even when behaviour is correct. Test inputs and outputs only.
- Mocking Dio with mocktail — Create a MockDio extending Mock implements Dio. Stub the get() method to return a fake Response. This lets you test repository parsing logic without any real HTTP calls — tests run in sub-milliseconds.
Code example
// domain/repositories/user_repository.dart
abstract class UserRepository {
Future<User> getUserById(String id);
}
// data/repositories/user_repository_impl.dart
class UserRepositoryImpl implements UserRepository {
final Dio _dio;
UserRepositoryImpl(this._dio);
@override
Future<User> getUserById(String id) async {
try {
final response = await _dio.get('/users/$id');
return User.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw UserNotFoundException(id);
}
throw NetworkException(e.message ?? 'Unknown error');
}
}
}
// test/data/repositories/user_repository_impl_test.dart
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
class MockDio extends Mock implements Dio {}
void main() {
late MockDio mockDio;
late UserRepositoryImpl repository;
setUp(() {
mockDio = MockDio();
repository = UserRepositoryImpl(mockDio);
});
group('UserRepositoryImpl', () {
test('returns User on successful 200 response', () async {
when(() => mockDio.get('/users/123')).thenAnswer(
(_) async => Response(
data: {'id': '123', 'name': 'Alice'},
statusCode: 200,
requestOptions: RequestOptions(path: '/users/123'),
),
);
final user = await repository.getUserById('123');
expect(user.id, equals('123'));
expect(user.name, equals('Alice'));
});
test('throws UserNotFoundException on 404', () async {
when(() => mockDio.get('/users/999')).thenThrow(
DioException(
requestOptions: RequestOptions(path: '/users/999'),
response: Response(
statusCode: 404,
requestOptions: RequestOptions(path: '/users/999'),
),
type: DioExceptionType.badResponse,
),
);
expect(
() => repository.getUserById('999'),
throwsA(isA<UserNotFoundException>()),
);
});
});
}Line-by-line walkthrough
- 1. abstract class UserRepository — defines the contract (interface) that the real and mock implementations must fulfil
- 2. class MockDio extends Mock implements Dio — mocktail generates a mock at runtime; no code generation step needed
- 3. setUp(() { mockDio = MockDio(); repository = UserRepositoryImpl(mockDio); }) — fresh instances before every test prevents state leaking
- 4. when(() => mockDio.get('/users/123')).thenAnswer(...) — stubs the specific call; only this exact path triggers this response
- 5. Response(data: {'id':'123','name':'Alice'}, statusCode: 200) — the fake response your repository will parse
- 6. expect(user.id, equals('123')) — asserts the repository correctly mapped the JSON to a domain model
- 7. when(...).thenThrow(DioException(..., statusCode: 404)) — simulates a 404 from the server
- 8. throwsA(isA()) — asserts the repository translated the HTTP error into a domain-specific exception
Spot the bug
class MockDio extends Mock implements Dio {}
void main() {
late MockDio mockDio;
late UserRepositoryImpl repo;
test('returns user on success', () async {
mockDio = MockDio();
repo = UserRepositoryImpl(mockDio);
when(() => mockDio.get('/users/1')).thenAnswer(
(_) async => Response(
data: {'id': '1', 'name': 'Bob'},
statusCode: 200,
requestOptions: RequestOptions(path: '/users/1'),
),
);
final user = await repo.getUserById('1');
expect(user.name, equals('Alice'));
});
}Need a hint?
The test will fail. Look at the assertion compared to the mock data.
Show answer
Bug: The mock returns name: 'Bob' but the assertion expects equals('Alice'). This is a copy-paste error in the test — the mock data and assertion are mismatched. Fix: change the assertion to expect(user.name, equals('Bob')) or change the mock data name to 'Alice'. This type of error is surprisingly common and is why test names should describe the exact scenario being tested.
Explain like I'm 5
Imagine you want to test that a vending machine gives you a Coke when you press the Coke button. But instead of connecting it to a real warehouse, you plug in a fake warehouse that you control — you tell it 'when asked for Coke, give back a pretend Coke'. Now you can test the vending machine's logic without a real warehouse, and you can even test what happens if the warehouse is empty.
Fun fact
mocktail was created as a null-safe alternative to mockito that requires no code generation. The name is a pun on 'mock' and 'mocktail' (a non-alcoholic cocktail) — mocking without the ceremony.
Hands-on challenge
Take a repository class from a project you have built or can imagine (e.g. AuthRepository). Write unit tests covering: happy path, 404 error, and network timeout. Use mocktail to mock the HTTP client.
More resources
- test package documentation (pub.dev)
- mocktail package (pub.dev)
- Flutter Unit Testing Guide (Flutter Docs)
- Dart Testing — Language Tour (Dart Docs)