Mocking External Dependencies in Tests in Flutter

In Flutter development, writing robust and reliable tests is essential to ensure your application behaves as expected. When your code interacts with external dependencies (e.g., APIs, databases, or platform-specific services), mocking becomes crucial for isolating your code and creating predictable test environments. This article explores how to mock external dependencies in Flutter tests effectively.

Why Mock External Dependencies?

Mocking allows you to replace real dependencies with controlled substitutes that simulate specific behaviors. This is particularly useful because:

  • Isolation: Ensures that your tests focus on the logic of the code being tested, not the external dependency.
  • Predictability: Creates consistent test environments, regardless of the state or availability of external resources.
  • Speed: Reduces reliance on slow or unreliable external systems, speeding up test execution.
  • Error Simulation: Enables testing of error conditions and edge cases that are difficult to trigger in real-world scenarios.

Mocking Libraries in Flutter

Several libraries can facilitate mocking in Flutter tests. Some popular choices include:

  • mockito: A comprehensive mocking framework that supports test doubles, stubs, and verification.
  • mocktail: A new mocking library that leverages Dart 3’s sealed classes to provide a powerful and concise mocking syntax.

Using mockito for Mocking

Step 1: Add Dependencies

First, add mockito and build_runner to your dev_dependencies in pubspec.yaml:

dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.1.0
  build_runner: ^2.4.0

Step 2: Create a Mock Class

Use mockito to generate mock classes based on your interfaces or classes. Assume you have a class ApiService:

class ApiService {
  Future fetchData() async {
    // Implementation that fetches data from an API
    throw UnimplementedError();
  }
}

To create a mock for ApiService, run:

flutter pub run build_runner build

This generates .mocks.dart file. In your test file, define the mock using the @GenerateMocks annotation:

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:your_project/api_service.dart'; // Replace with your actual path

// Generate a MockApiService class
@GenerateNiceMocks([MockSpec()])
import 'your_test.mocks.dart'; // Ensure correct path to the generated file

Here’s what the mock file should contain after building:

// Mocks generated by Mockito 5.6.1 from annotations
// in your_project/test/widget_test.dart.
// Do not manually edit this file.

// Package imports
import 'dart:async' as _i3;

import 'package:mockito/mockito.dart' as _i1;
import 'package:your_project/api_service.dart' as _i4;

// Mock class definition
class MockApiService extends _i1.Mock implements _i4.ApiService {
  // Mock methods, initialized as null objects (optional)

  MockApiService() {
    _i1.throwOnMissingStub(this);
  }

  @override
  _i3.Future fetchData() => (super.noSuchMethod(
    Invocation.method(
        #fetchData, []
        ),
      returnValue: _i3.Future.value(''),
      returnValueForMissingStub: _i3.Future.value(''),
    ) as _i3.Future);

  @override
  String toString() => super.toString();
}

Make sure to replace your_project and paths with the actual names in your project.

Step 3: Write Your Test

Use the generated mock to control the behavior of ApiService in your test:

void main() {
  group('ApiService', () {
    test('fetchData returns success', () async {
      final mockApiService = MockApiService();
      when(mockApiService.fetchData()).thenAnswer((_) async => 'Success Data');

      final result = await mockApiService.fetchData();

      expect(result, 'Success Data');
      verify(mockApiService.fetchData()).called(1); // Ensure it was called once
    });

    test('fetchData returns error', () async {
      final mockApiService = MockApiService();
      when(mockApiService.fetchData()).thenThrow(Exception('Failed to fetch'));

      expect(() async => await mockApiService.fetchData(), throwsA(isA()));
      verify(mockApiService.fetchData()).called(1); // Ensure it was called once
    });
  });
}

Explanation:

  • MockApiService: Instance of the generated mock class.
  • when(mockApiService.fetchData())...: Sets up the mock to return specific data or throw an error when fetchData() is called.
  • expect(result, 'Success Data'): Asserts that the result matches the expected value.
  • verify(mockApiService.fetchData()).called(1): Confirms that the fetchData() method was called exactly once.

Using Mocktail for Mocking

Mocktail uses Dart 3’s sealed classes, offering concise syntax.

Step 1: Add Dependencies

Add mocktail to your dev_dependencies:

dev_dependencies:
  flutter_test:
    sdk: flutter
  mocktail: ^1.0.0

Step 2: Create a Mock Class

Extend the Mock class and implement/override the necessary methods.

import 'package:mocktail/mocktail.dart';
import 'package:your_project/api_service.dart'; // Replace with your actual path

class MockApiService extends Mock implements ApiService {}

Step 3: Write Your Test

Here’s how you can use the MockApiService in a test.

void main() {
  group('ApiService', () {
    test('fetchData returns success', () async {
      final mockApiService = MockApiService();
      when(() => mockApiService.fetchData()).thenAnswer((_) async => 'Success Data');

      final result = await mockApiService.fetchData();

      expect(result, 'Success Data');
      verify(() => mockApiService.fetchData()).called(1);
    });

    test('fetchData returns error', () async {
      final mockApiService = MockApiService();
      when(() => mockApiService.fetchData()).thenThrow(Exception('Failed to fetch'));

      expect(() async => await mockApiService.fetchData(), throwsA(isA()));
      verify(() => mockApiService.fetchData()).called(1);
    });
  });
}

The main difference between mockito and mocktail in these examples is the syntax for the when and verify functions. mocktail uses () => mockApiService.fetchData(), while mockito uses mockApiService.fetchData() and an extra line of annotation. Both methods accomplish the same goal effectively, and choosing between them will depend largely on project preferences and coding style.

Practical Examples

1. Mocking a Network Request

Consider a scenario where you are fetching data from a REST API. Use mocking to simulate different responses without making actual network calls.

import 'package:http/http.dart' as http;
import 'package:mockito/mockito.dart';
import 'package:flutter_test/flutter_test.dart';

class MockHttpClient extends Mock implements http.Client {}

void main() {
  test('fetches data successfully', () async {
    final mockClient = MockHttpClient();
    when(mockClient.get(Uri.parse('https://example.com/data')))
        .thenAnswer((_) async => http.Response('{"key": "value"}', 200));

    final response = await mockClient.get(Uri.parse('https://example.com/data'));
    expect(response.statusCode, 200);
    expect(response.body, '{"key": "value"}');
  });

  test('handles errors', () async {
    final mockClient = MockHttpClient();
    when(mockClient.get(Uri.parse('https://example.com/data')))
        .thenThrow(Exception('Failed to load data'));

    expect(() async => await mockClient.get(Uri.parse('https://example.com/data')),
           throwsA(isA()));
  });
}

2. Mocking Database Operations

When interacting with a database, you can mock the database operations to isolate your code and speed up tests.

class MockDatabaseService extends Mock implements DatabaseService {}

void main() {
  test('insert data successfully', () async {
    final mockDatabaseService = MockDatabaseService();
    when(mockDatabaseService.insertData(any)).thenAnswer((_) async => true);

    final result = await mockDatabaseService.insertData({'key': 'value'});
    expect(result, true);
    verify(mockDatabaseService.insertData(any)).called(1);
  });

  test('fails to insert data', () async {
    final mockDatabaseService = MockDatabaseService();
    when(mockDatabaseService.insertData(any)).thenAnswer((_) async => false);

    final result = await mockDatabaseService.insertData({'key': 'value'});
    expect(result, false);
    verify(mockDatabaseService.insertData(any)).called(1);
  });
}

Best Practices for Mocking

  • Keep Mocks Simple: Mocks should only simulate the necessary behavior of external dependencies.
  • Avoid Over-Mocking: Mock only external dependencies. Avoid mocking classes you own unless absolutely necessary.
  • Verify Interactions: Use verification methods to ensure that mocked methods are called with the expected arguments and number of times.
  • Use Code Generation: Leverage code generation to automate the creation of mock classes, reducing boilerplate code.
  • Follow AAA (Arrange, Act, Assert): Organize your tests for clarity: Arrange the environment, Act by executing the code, and Assert the expected outcome.

Conclusion

Mocking external dependencies is crucial for writing effective and maintainable tests in Flutter. By using libraries like mockito and mocktail, you can isolate your code, create predictable test environments, and thoroughly test error conditions. Following best practices for mocking will lead to more reliable and robust Flutter applications.