Is Jest Mocking You?
What are module mocks
Module mocking is a strategy used in dynamic programming languages where a mocking library replaces the code run by the module under test with an alternative, fake implementation.
This technique is convenient when working with complex apps. For instance, Jest
provides the jest.mock()
function that allows substitution of the real
implementation of a module with a provided one. Here’s an example:
jest.mock('./usersAPI', () => ({
getUser: jest.fn(),
}));
In the example above, jest.mock()
replaces the implementation of the
getUser
function in the ./usersAPI
module with a Jest mock function. This
allows us to test our code without relying on the real implementation of the
getUser
function, which may be slow, unreliable, or have other side effects
that interfere with our tests.
What’s wrong with module mocking?
Module mocking is a popular technique for testing code that relies on external dependencies or modules, but it can also lead to several issues. One of the main problems with module mocking is that it violates the principle of testing only public interfaces. By replacing the implementation of a module with a fake one, module mocks can test the internal behavior of a module that is not part of its public interface, leading to false positives and brittle tests.
Another issue with module mocking is that it can create disconnected tests that do not reflect the actual behavior of the system. When the implementation or contract of a module changes, the tests that rely on its mock implementation may not be updated accordingly, leading to tests that pass even when the system is not behaving as expected.
Despite its drawbacks, module mocking can be useful when working with complex systems. However, it’s important to use it judiciously and keep in mind its limitations and potential issues.
Module mocking ruins unit tests
I’ve commonly seen axios or fetch be globally mocked, as developers often believe this is the best approach since it’s where the network request takes place. However, this approach can have negative consequences.
For instance, new versions of packages may introduce small changes to axios, rendering all previous mocks invalid. The same can happen with global configurations or helpers.
Furthermore, using module mocks violates the principle of testing only public interfaces, as the mock replaces the entire implementation of the module, not just a specific function or method.
If the entire contract of axios is not tested within your codebase, errors may become unhandled without your team’s knowledge. A seemingly simple global configuration change may even break the contract of rejecting with an error on non-2XX status codes.
import axios from 'axios';
axios.defaults.validateStatus = (status) => {
return status >= 200 && status < 300; // default
};
// Imagine a global configuration change that breaks the contract
axios.defaults.validateStatus = (status) => {
return status >= 200 && status < 400; // changed to include 4xx status codes
};
If we must mock and test the network call then mocking the network itself is likely a better choice that will exercise the actual implementation of your remote call.
import {getUser} from './usersApi'
describe('getUsers', () => {
it('should handle network errors gracefully', async () => {
const scope = nock('https://example.com')
.get('/api/users')
.reply(500, { error: 'Network Error' });
const response = await getUser('userId')
// Assert expected response
});
});
Removing module mocks with dependency inversion
Step 1: Define clear interfaces and testable implementations
The repository pattern is a popular way to structure code in many applications. It creates a clear interface and separates network calls from business logic. By defining clear errors and using optionals or result types, we can make behavior explicit.
Instead of making network calls directly in the business logic, we can define an interface for the repository that represents the data source. For example, we can define an interface for a user repository as follows:
interface UserRepo {
get(userId: string): Promise<User | null>
create(userData: UserData): Promise<User>
}
Given that structure we can create two implementations. One for testing or local development and one for the actual use case.
class RemoteUserRepo implements UserRepo {
async get(userId: string) {
const response = await fetch(`/users/${userId}`)
return response.user ?? null
}
async create(userData: UserData) {
const response = await fetch('/users', { method: 'POST', body: userData })
return response.user
}
}
class InMemoryUserRepo implements UserRepo {
constructor(private users: User[]) {}
async get(userId: string) {
return this.users.find((u) => u.userId === userId) ?? null
}
async create(userData: UserData) {
const user = {...userData, userId: uuid() }
this.users.push(user)
return user
}
}
If we have some way to pass dependencies into functions or inject dependencies dynamically, we can use an alternative implementation of the repository when needed to speed up local development. The mock implementation stores the created users so it’s easy to inspect the state of the repository in either implementation for tests.
Using the repository pattern allows us to keep the business logic clean and focused on its core responsibilities. We can easily swap out different implementations of the repository without affecting the rest of the code. This makes it easier to test and maintain the code over time.
Step 2A: Inject dependencies
Dependency injection is an advanced technique that allows changing implementations at runtime.
Here is a quick example of using tsyringe to dynamically change implementations at runtime:
import "reflect-metadata";
import { container } from 'tsyringe';
class UserController {
constructor(private userRepo: UserRepo) {}
@get("/users/:id")
async getUser(@param("id") userId: string) {
return this.userRepo.get(userId);
}
}
// Register the implementation of the UserRepo to be the "InMemoryUserRepo"
container.register(UserRepo, { useClass: InMemoryUserRepo });
// Create a new instance of the UserController class. The previous registration
// will cause the `InMemoryUserRepo` to be used
const controller = container.resolve(UserController);
// use the methods from the UserController class as normal.
// Our tests don't need to care which implementation is used
const user = await controller.getUser("123");
By using tsyringe to inject dependencies into the UserController class, we can easily switch between using the RemoteUserRepo and InMemoryUserRepo implementations of the UserRepo interface. This allows us to speed up local development by using the InMemoryUserRepo implementation, while still using the RemoteUserRepo implementation in production.
Step 2B: Explicitly name dependencies
Naming dependencies explicitly can be done quickly and without adding additional libraries or runtime cost to your app. This is the easiest way to write testable code without needing to set up DI and IoC libraries.
In the following example, the code directly creates an instance of a repository, making it impossible to test without module mocking:
import RemoteUserRepo from './UserRepo'
async function getUserNameById(userId: string): Promise<string | null> {
const users = new UserRepo()
const {userName} = await users.get(userId)
return userName ?? null
}
We can refactor it to declare its dependencies explicitly by its interface instead allowing much simpler tests.
import type UserRepo from './UserRepo'
async function getUserByName(args: { users: UserRepo, userId: string }) {
const {users, userId} = args
const {userName} = await users.get(userId)
return userName ?? null
}
This is also an example of “Dependency Inversion,” which allows callers to control what dependencies are passed to their children.
In summary
- Avoid module mocks whenever possible
- Mock actual network boundaries instead of helper libraries
- Module mocks create brittle tests and require frequent refactors
- Define in-memory implementations of boundaries
- Injected dependencies are better than globals
- Explicit parameter dependencies are better than globals
There are many patterns available to simplify declaring dependencies explicitly, such as injection using a library like tsyringe or the use of reader monads.