Mocking dependencies in unit tests
Tests are an integral part of any software project. They let you verify that your code works as expected and that it doesn't break when you make changes. We want our tests to be as clear as possible so that developers don't have to waste time figuring out what the test is doing our how to fix it when it fails.
Obsidian promotes Object Oriented design, a paradigm that focuses on the relationships between objects and how they interact with each other. In this article we'll learn how adopting this approach lets us mock objects more easily and as a result improve the readability and maintainability of our tests.
"Every time the developers have to stop and puzzle through a test to figure out what it means, they have less time left to spend on creating new features, and the team velocity drops."
The problem
The setup phase of a test is often the most complex part of the test. It involves creating test data, mocking dependencies and instantiating the unit-under-test. We've identified three common problems that make tests brittle, difficult to maintain, and hard to understand:
- Partial mocks - a unit test is meant to test a single unit of code in isolation. If a dependency is partially mocked, our test is no longer testing a single unit. A bug in the partially mocked dependency can cause this unit test to fail preventing us from quickly identifying the root cause of the failure.
- Dependencies are introduced implicitly to the unit-under-test, usually via imports - we should always create valid objects. If an object depends on another object, we should pass that dependency explicity in through the constructor. The constructor serves as the contract for the dependencies that a class requires to function. There's no point in creating partially working classes, and the constructor is used to enforces this constraint.
- Manual mocks - manually mocking dependencies is a tedious and error prone process. It's easy to forget to mock a dependency, or to mock it incorrectly.
To illustrate these problems, let's look at a simple example.
describe('Example', () => {
const openURL = jest.fn();
let logger;
let foo;
let uut;
beforeEach(() => {
// Problem 1: Partial mocks
logger = require('./Logger');
logger.log = jest.fn();
const spy = jest.spyOn(logger, 'warn');
// Problem 2: Implicit dependencies.
// Our UUT uses Linking.openUrl so we mock it on the module level.
jest.mock('react-native', () => ({
Linking: {
openURL
},
}));
// Problem 3: Manual mocks
foo = {
doSomething: jest.fn(),
}
uut = new Example(logger, foo);
});
});
The solution
To achieve our goal of reducing boilerplate and improving readability, we'll refactor the above example as follows:
- Convert all dependencies to ES6 classes - this will allow us to mock them using jest-mock-extended - a library that lets us create mock classes in a type-safe manner.
- Pass dependencies in through the constructor - we'll pass the dependencies explicitly to the unit-under-test. This step will require us to declare new classes that will encapsulate interactions with third-party libraries.
Step 1: Encapsulate interactions with third-party dependencies
Implicit dependencies (dependencies introduced by importing a module) make it difficult to reason about the code and to test it. To avoid this problem, we'll create a new class that encapsulates interactions with the third-party library. We'll see how this approach lets us mock dependencies more easily.
import {Linking} from 'react-native';
export class UrlOpener {
public async openUrl(url: string) {
if (await Linking.canOpenURL(url)) {
await Linking.openURL(url);
} else {
throw new Error(`Can't open URL: ${url}`);
}
}
}
"Avoid littering direct calls to library classes in your code. You might think that you'll never change them, but that can become a self-fulfilling prophecy."
Step 2: Convert dependencies to ES6 classes
We'll convert the Logger
and Foo
classes to ES6 classes:
export class Logger {
public log(message: string) {
console.log(message);
}
public warn(message: string) {
console.warn(message);
}
}
export class Foo {
public doSomething() {
console.log('doing something...');
setTimeout(() => {
console.log('done!');
}, 1000);
}
}
Step 3: Mock dependencies using jest-mock-extended
When we mock a dependency using jest-mock-extended
, we get a mock object that has all the methods and properties of the original object. This means that we don't have to mock each method and property individually. And, because we eliminated the implicit dependency on the Linking
module, we can use this approach to mock it as well.
import {mock} from 'jest-mock-extended';
import {Logger} from './Logger';
import {Foo} from './Foo';
describe('Example', () => {
let logger: Logger;
let foo: Foo;
let urlOpener: UrlOpener;
let uut: Example;
beforeEach(() => {
logger = mock<Logger>();
foo = mock<Foo>();
urlOpener = mock<UrlOpener>();
uut = new Example(logger, foo, urlOpener);
});
}
Wrapping up
While we didn't use any API from Obsidian
in this refactor, this change was made possible due to how Obsidian influences the design of our code. Obsidian makes it easy to introduce classes to each other by passing them explicitly in through the constructor. This approach encourages us to split our code into smaller classes that are easier to test.