Testability Guidelines

Any code isn’t testable. This document covers the main points to ensure your codebase is ready to accommodate and benefit from unit tests.

What makes a code testable?

Code is considered testable when it is designed in a way that allows automated tests to be easily created and run. This means that the code should be:

  1. Modular, with clear separation of concerns and dependencies.

  2. Passing dependencies from constructors to allow mocking.

  3. Following Single Responsibility Principle (SRP).

When code is testable, it allows developers to catch bugs and issues early on in the development process and ensure that changes to the code don't introduce new problems. This can save time and resources in the long run, as it helps to prevent issues from appearing in production and reduces the need for manual testing.

Practices to follow

1. Use Dependency Injection

Dependency injection is a design pattern that allows us to inject dependencies into our code. This makes it easier to write unit tests because we can replace dependencies with mocks or stubs. In Flutter, we can use the provider package to implement dependency injection. Here is an example:

class UserService {
  final HttpClient httpClient;

  UserService(this.httpClient);

  Future<User> getUser(String id) async {
    final response = await httpClient.get('/users/$id');
    return User.fromJson(response.data);
  }
}

In this example, we are injecting an HttpClient dependency into the UserService. This makes it easier to test because we can replace the HttpClient with a mock during testing.

2. Use Separation of Concerns

Write small and focused functions: Functions that do one thing and do it well are easier to test. Avoid large functions that perform multiple tasks.

bool isEmailValid(String email) {
  final regex = RegExp(r'^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$');
  return regex.hasMatch(email);
}

3. Avoid global and static states

Global state makes it difficult to test your code in isolation because changes in one part of the code can affect other parts. In addition to that, they can’t be mocked directly.

class Counter {
  static int count = 0;

  static void increment() {
    count++;
  }
}

4. Don't call another function within the same class.

Writing unit tests for functions calling other functions within the same class can become messy and difficult to manage, especially because you can't mock or simulate their output.

class Example {
  int add(int a, int b) {
    return a + b;
  }

  int multiply(int a, int b) {
    int result = 0;
    for (int i = 0; i < a; i++) {
      result = add(result, b);
    }
    return result;
  }
}

Suppose multiply is the main function, while add is the sub-function being called inside multiply. It becomes hard to write a test for the multiply function on its own, because it depends on the add function's implementation.

Using Dependency Injection:

class Adder{
  int add(int a, int b) {
    return a + b;
  }
}

class BetterExample {
  final Adder _adder;
  
  BetterExample(this._adder);

  int multiply(int a, int b) {
    int result = 0;
    for (int i = 0; i < a; i++) {
      result = _adder.add(result, b);
    }
    return result;
  }
}

Now multiply depends on an instance of the Adder class, which can be easily mocked for testing. The test for the multiply function can be written without having to worry about testing the add function.

5. Use SOLID Principles

SOLID principles are a set of software design principles that help us to write code that is maintainable, scalable and testable. A summary:

For more details, please read Unit Testing Introduction.