Lesson 32 of 77 intermediate

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

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. 1. abstract class UserRepository — defines the contract (interface) that the real and mock implementations must fulfil
  2. 2. class MockDio extends Mock implements Dio — mocktail generates a mock at runtime; no code generation step needed
  3. 3. setUp(() { mockDio = MockDio(); repository = UserRepositoryImpl(mockDio); }) — fresh instances before every test prevents state leaking
  4. 4. when(() => mockDio.get('/users/123')).thenAnswer(...) — stubs the specific call; only this exact path triggers this response
  5. 5. Response(data: {'id':'123','name':'Alice'}, statusCode: 200) — the fake response your repository will parse
  6. 6. expect(user.id, equals('123')) — asserts the repository correctly mapped the JSON to a domain model
  7. 7. when(...).thenThrow(DioException(..., statusCode: 404)) — simulates a 404 from the server
  8. 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

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery