When should I (not) use mocks in testing?
Artem Zakharchenko
Posted on November 24, 2020
What is "mocking"?
Mocking in programming refers to an action of substituting a part of the software with its fake counterpart.
Mocking technique is primarily used during testing, as it allows us to take out certain aspects of the tested system, thus narrowing the test's focus and decreasing the test's complexity.
Depending on the software that is being tested, there are multiple things that can be mocked:
- Environment and context. To assert a list of user's purchases you can mock the user already being authenticated, instead of going through the authentication in the unrelated test suite.
- API communication. When testing a checkout process you don't want to make an actual purchase and be charged for it.
- External dependencies. When testing how our system reacts to various payloads from an external library or SDK you may emulate what the latter return.
Understanding when to apply and, most importantly, when not to apply mocking is a vital skill to help you ensure your tests are reproducible and credible. Today I would like to share some opinionated views and guidelines that help me decide and integrate mocking into my tests and still trust them.
The purpose of mocking
By mocking certain parts of our system we drop them from the testing equation. That way the mocked parts become a test's pre-requisites, a configurable given that should not be acted upon.
Some of the biggest benefits of mocking:
- Makes a tested system, or its parts, more predictable by configuring or fixing dynamic system parts (i.e. HTTP requests).
- Gives a granular control over the system's state at a given point in time.
- Keeps tests more focused by treating certain internal or external system's aspects as pre-requisites.
The dangers of mocking
Deviating system
What mocking essentially does is that it replaces one part of the system with a seemingly compatible part.
Mocking creates a deviation in the system that always results in an altered system.
Although it may still look and behave similarly, the system's integrity gets compromised and with an excessive or misguided mocking one may find themselves testing an entirely different system than one should.
// Mocking or stubbing request issuing module
// as a part of a test implies that the tested system
// does not execute the actual "fetch" any longer.
global.fetch = jest.fn().mockReturnValue(
Promise.resolve({ data: 'ok' })
)
Learn about why you should Stop mocking fetch with Kent C. Dodds.
Testing implementation details
Another dangerous drawback of a misplaced mocking is that one may fall into the trap of implementation details testing without even realizing it. Replacing any part of the internal/external system is incredibly powerful and
comes with the responsibility on your shoulders not to misuse mocking to test things on a level much deeper than it is necessary.
// context.js
export const context = {
// Lookups the list of sessions.
lookupSessions() { ... },
// Returns the active user from the latest session.
getUser() {
const sessions = this.lookupSessions()
const latestSession = sessions[sessions.length - 1]
return latestSession.user
}
}
// context.test.js
import { context } from './context'
beforeAll(() => {
spyOn(context, 'lookupSessions').mockImplementation()
})
test('returns the active user', () => {
const user = context.getUser()
expect(context.lookupSessions).toBeCalled()
expect(user).toBeDefined()
})
The issue here is that if context.getUser
stopped relying on the lookupSessions
method the test would fail. Even if context.getUser
still returns the proper user.
The issues caused by mocking can be split into two categories:
- Misplaced mocking. Mocking is not applicable in the current circumstances and should be avoided.
- Inaccurate mocking. Mocking is applicable, but executed poorly: the extend of mocks is excessive, or the mocked part's behavior violates the system's integrity.
When to mock?
Let's focus on the mocking in the context of tests.
The purpose of testing is to give you confidence in the system you are developing. The more you mock, the more you deviate from the original system, the more it decreases the amount of confidence your tests give you. It is crucial to know what and when to mock during test runs.
When it comes to mocking there is a golden rule:
If you can omit mocking, omit mocking.
Despite being somewhat extreme, this rule guards you against unnecessary mocking, making each time you decide to mock something a conscious and well-weighed choice, rather than a reach-out tool for each and every situation.
There are cases, however, when mocking is beneficial and even necessary in tests. Those cases derive from the testing levels and the boundaries each level establishes.
Mocking in different testing levels
Mocking plays a crucial part in defining testing boundaries. Testing boundary, or in other words an extent of a system covered by a particular test, is predefined by the testing level (unit/integration/end-to-end).
Unit tests
It is unlikely for mocking to be applicable in unit tests, as that means there is a part of the system the unit depends on, making that unit less isolated and less subjected to unit testing.
Whenever you reach out to mock things in a unit test that is a good sign you are in fact writing an integration test. Consider breaking it down into smaller dependency-free pieces and covering them with unit tests. You may then test their integration in the respective testing level.
In certain cases, mocking has a place in unit tests when those units operate on data that is dependent on runtime, or otherwise hard to predict. For example:
/**
* Returns a formatted timestamp string.
*/
function getTimestamp() {
const now = new Date()
const hours = now.getHours()
const minutes = now.getMinutes()
const seconds = now.getSeconds()
return `${hours}:${minutes}:${seconds}`
}
To unit test the getTimestamp
function reliably we must know the exact date it returns. However, the date has a variable nature and will depend on the date and time when the actual test will run.
A mock that emulates a specific date during the test would allow us to write an assertion with confidence:
beforeAll(() => {
// Mock the timers in Jest to set the system time
// to an exact date, making its value predictable.
jest.useFakeTimers('modern');
jest.setSystemTime(new Date('01 Jan 1970 14:32:19 GMT').getTime());
})
afterAll(() => {
// Restore to the actual timers and date
// once the test run is done.
jest.useRealTimers()
})
test('returns the formatted timestamp', () => {
expect(getTimestamp()).toEqual('14:32:19')
})
Integration tests
In the integration tests, on the other hand, mocking helps to keep the testing surface focused on the integration of the system's parts, leaving unrelated yet dependent pieces to be fake.
To illustrate this point, let's consider an integration test of a "Login" component—a form with inputs and a submit button that makes an HTTP call upon form submission.
const LoginForm = () => {
return (
<form onSubmit={makeHttpCall}>
<input name="email" type="email" />
<input name="pasword" type="password" />
<button>Log in</button>
</form>
)
}
The goal of an integration test is to ensure that the inputs rendered by the "Login" component are operational (can be interacted with, validated, etc.) and that the login form can be submitted given correct values.
However, there is a part of our "Login" component's implementation that stretches far beyond the integration of its compounds: the HTTP call. Making an actual request as a part of an integration test would increase its surface to assert two integrations at the same time:
- Integration of the Login form's components;
- Integration of the Login form and some external HTTP server.
In order to keep the testing surface focused on the component itself, we can mock an HTTP request, effectively making it a pre-requisite of the "Login" test. Moreover, with mocks, we can model various HTTP response scenarios, such as a service timeout or failure, and assert how our login form handles them.
// Example of the "Login" component test suite
// written using an abstract testing framework.
test('submits the form with correct credentials', () => {
// Emulate a successful 200 OK response upon form submission.
mockApi('/service/login', () => {
return new Response('Logged in', { status: 200 })
})
render(<LoginForm />)
fillCredentials({
email: 'john.maverick@email.com',
password: 'secret-123'
})
expect(successfulLoginNotification).toBeVisible()
})
test('handles service failure gracefully', () => {
// For this particular test mock a 500 response.
mockApi('/service/login', () => {
return new Response(null, { status: 500 })
})
fillCredentials(...)
expect(oopsTryAgainNotification).toBeVisible()
})
End-to-end tests
End-to-end tests may utilize mocking of external dependencies, like communication with payment providers, as their operability lies beyond your system's responsibilities.
Mocking any part of the system itself in an end-to-end test contradicts the purpose of this testing level: to ensure the system's functionality as a whole.
It is also plausible to have no mocking at all during end-to-end testing, as that way your system behaves identically to its production version, giving you even more confidence in these tests.
Afterword
Thank you for reading! I hope I was able to contribute to your attitude towards mocking and the tips from the article will be useful next time you sit to write a test.
If you like the material consider Following me on Twitter and checking out my personal blog, where I write about technical and non-technical aspects of software engineering.
Posted on November 24, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.