DEV Community

loading...
Cover image for When should I (not) use mocks in testing?

When should I (not) use mocks in testing?

kettanaito profile image Artem Zakharchenko Updated on ・6 min read

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:

  1. Makes a tested system, or its parts, more predictable by configuring or fixing dynamic system parts (i.e. HTTP requests).
  2. Gives a granular control over the system's state at a given point in time.
  3. 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' })
)
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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()
})
Enter fullscreen mode Exit fullscreen mode

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:

  1. Misplaced mocking. Mocking is not applicable in the current circumstances and should be avoided.
  2. 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}`
}
Enter fullscreen mode Exit fullscreen mode

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')
})
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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()
})
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (10)

pic
Editor guide
Collapse
pke profile image
Philipp Kursawe

There would be no need for mocks for the getTimestamp function if it would allow to hand in the date.
Impure functions are always harder to test.

test('returns the formatted timestamp', () => {
  expect(getTimestamp(new Date('01 Jan 1970 14:32:19 GMT'))).toEqual('14:32:19')
})
Enter fullscreen mode Exit fullscreen mode
Collapse
kettanaito profile image
Artem Zakharchenko Author • Edited

The getTimestamp modification you suggest also produces a different function. It converts any given date into a timestamp. Had I a requirement to use it to print only the current date into a timestamp, I'd have to pass it Date.now upon each invocation. Imho, that'd be a good sign that the date belongs in the function itself, given the aforementioned requirements.

Collapse
kettanaito profile image
Artem Zakharchenko Author

Undoubtedly. This example's purpose isn't to bring an exact implementation to the light, but to show that there may be small pieces of logic that depend on side effects. Perhaps you see a better example to this?

Collapse
myshov profile image
Alexander Myshov

Hey Artem, thank you for the article

Can you please explain this section more?

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.

And I found a typo in the code example: lastSession.user should be latestSession.user

Collapse
kettanaito profile image
Artem Zakharchenko Author

Hi, Alexander! Thank you for spotting the typo!

That section referred to the implementation detail testing: assertion of how the software works instead of what it does. The getUser method can be implemented differently. For example, in the future, you may decide that you can resolve the user from elsewhere than sessions (abstract examples). If your test checks that lookupSessions must be called, the test would fail, while the functionality remains.

The goal of any test is to verify the intention behind the implementation, but not the implementation itself. Relying on the latter is often what makes tests brittle, and, most importantly, useless when refactoring.

Collapse
kettanaito profile image
Artem Zakharchenko Author

Mocking is a powerful technique with its applications variative. Let me know if this article helped you understand when the mocking is or is not necessary.

Collapse
sapegin profile image
Artem Sapegin

I'd even say that with excessive mocking you may end up not testing any production code.

Collapse
kettanaito profile image
Artem Zakharchenko Author

Exactly. It's a powerful technique, and with its great power comes great responsibility.

Collapse
bitttttten profile image
bitten

That setSystemTime is such a nice way of handling date sensitive things in your test. Going to refactor some of my tests now ;)

Collapse
pke profile image