One of the trickiest aspects of unit testing is mocking, yet it’s also one of the most enjoyable parts of programming, in my opinion (as long as it isn’t under a tight deadline). The complex logical flow and meticulous engineering involved are just like playing a children’s puzzle game. I’ve invested considerable time studying and researching this topic, to the point where I feel I have a solid grasp on it. This document serves as both a study note to share with others and a quick reference guide for myself in the future.
Difference: vi.fn() vs vi.spyOn()
vi.spyOn
and vi.fn
are both used for mocking functions, but they have different purposes and behaviors.
vi.spyOn
is typically used to spy on existing functions/methods, meaning it replaces the original function with a “spy” function.
Example:
const originalFunction = someObject.someMethod;
const spy = vi.spyOn(someObject, 'someMethod');
JavaScriptvi.fn
is used to create a new mocked function from scratch.
It doesn’t rely on existing functions/methods but instead creates a new function that can be used as a replacement or stand-in during testing.
Example:
const mockedFunction = vi.fn(() => 'mocked result');
JavaScriptDepending on your testing needs, you may choose one over the other.
vi.fn() vs. Direct Function Swap
Why use vi.fn()
over directly replacing with a function?
Using vi.fn()
offers more than just creating a mock function; it also adds a “spy” property to your mock function to make it a “spy” function.
This example demonstrates why Direct Function Swap fails during testing:
import { expect, test, vi } from 'vitest'
test('Test example', () => {
const mockA = () => 'test'
const b = mockA()
expect(b).toBe('test')
expect(mockA).toBeCalledTimes(1)
})
JavaScriptWhen you run this test, it will result in a TypeError:
[Function mockA] expect(mockA).toBeCalledTimes(1)
[Function mockA] is not a spy or a call to a spy!
This error occurs because mockA
is just a regular function, not a “spy” function created using vi.fn()
. Therefore, it doesn’t have the expected spy properties to access methods as toBeCalledTimes()
, leading to the error.
The Fixed Code:
import { expect, test, vi } from 'vitest'
test('Test example', () => {
const mockA = vi.fn(() => 'test') // Using vi.fn() saves the day!
const b = mockA()
expect(b).toBe('test')
expect(mockA).toBeCalledTimes(1)
})
JavaScriptAs demonstrated, making use of vi.fn()
proves essential for effective testing.
Modules Mocking Technique – Using vi.mock()
Here’s an example demonstrating partial mocking of specific methods within a module while keeping the rest unchanged.
// file.js
export const a = () => {
return 'actual result';
}
export const b = (x) => {
// Some expensive logic here.
// Return the result string after operations.
return 'actual result';
}
export const c = () => {}
JavaScript// file.test.js
import { expect, test, vi } from 'vitest'
test('Test example with mocking a module using the mock() method', async () => {
// Mocking the './file' module
vi.mock('./file', async (importOriginal) => {
// Importing the original module
const actual = await importOriginal();
// Returning a modified version with mocked function 'b'
return {
...actual,
b: vi.fn((x) => 'mocked result')
}
})
// Import the mocked module
const mockedModule = await import('./file')
const resultA = mockedModule.a()
expect(resultA).toBe('actual result')
const resultB = mockedModule.b('test')
expect(resultB).toBe('mocked result')
})
JavaScriptAs shown, the function a()
returns the original value, while b()
returns the mocked value as expected.
Modules Mocking Technique – Using vi.spyOn()
Previously mentioned, vi.spyOn()
is used to mock existing functions or methods. This method takes two parameters: the module object and the name of the method/property in string format.
Here is a code example:
// file.js
export const a = () => {
return 'actual result';
}
export const b = (x) => {
// Some expensive logic here.
// Return the result string after operations.
return 'actual result';
}
export const c = () => {}
JavaScriptimport { expect, test, vi } from 'vitest'
test('Test example with mocking module using spyOn()', async () => {
const myModule = await import('./file')
vi.spyOn(myModule, 'b').mockImplementation(() => {
return 'mocked result'
})
// Import the mocked module
const mockedModule = await import('./file')
const resultA = mockedModule.a()
expect(resultA).toBe('actual result')
const resultB = mockedModule.b('test')
expect(resultB).toBe('mocked result')
})
JavaScriptBasically, both module mocking techniques appear to yield to the same result.
Vitest Doesn’t Support Mocking Local Dependencies
So basically it refers to the practice of mocking or substituting dependencies (such as methods or functions) that are defined within the same module during testing. This term emphasizes that the dependencies being mocked are local to the module being tested.
Here’s a code example illustrating the challenge of mocking local dependencies:
// file.js
export const a = () => {
return 1
}
export const b = () => {
return a() * 2
}
export const c = () => {}
JavaScript// file.test.js
import { expect, test, vi } from 'vitest'
test('Test example with mocking local dependencies', async () => {
const myModule = await import('./file')
vi.spyOn(myModule, 'a').mockImplementation(() => 5)
// Import the mocked module
const mockedModule = await import('./file')
const ≈ = mockedModule.a()
expect(resultA).toBe(5)
const resultB = mockedModule.b()
expect(resultB).toBe(10)
})
JavaScriptAfter running the test, here are the results: resultA passes the test with the mocked value of 5, but resultB was expected to be the resultA value (5) multiplied by 2, which should be 10. However, it actually returns 2, which is derived from the original a() function’s returned value of 1 multiplied by 2. This discrepancy is quite unexpected.
FAIL src/file.test.js > Test example with mocking module using spyOn()
AssertionError: expected 2 to be 10 // Object.is equality
– Expected
+ Received
– 10
+ 2
In the example, you’ll see that method b()
depends on method a()
to return the result value needed for calculating the final returned value of b()
. However, merely using the module mocking techniques discussed earlier won’t work well in this test scenario. Even if method a()
is mocked, it will still call the original method a()
when invoked within b()
.
Debugging frustrations lead to significant office property damage each year worldwide.
When I first encountered this issue a while ago, it drove me insane and put me in bed for hours due to the mental damage I took from trying to figure it out. I couldn’t understand why the heck Vitest failed to support such an important feature. Eventually, I found out that they deliberately disabled mocking local methods and had a valid reason behind it. Mocking local methods in unit testing is considered an anti-pattern and is not considered good practice.
To address this issue properly, let’s introduce the powerful programming concept known as Dependency Injection.
Dependency Injection: A Better TDD Strategy
Dependency Injection (DI) is a technique used in unit testing to simplify the testing process. Instead of depending on specific methods within your code, you can pass simpler or fake versions of those methods as parameters. This allows you to test individual parts of your code separately without having to mock the other methods. By isolating your methods from each other, DI promotes cleaner and more straightforward testing, making it easier to identify and fix issues in your code.
Explaining DI can be challenging through text alone, so why not illustrate it with an example? Let me show you the improved code using the dependency injection strategy.
// file.js
// fn is the dependency injection for b()
export const b = (fn) => {
return fn() * 2
}
export const c = () => {}
JavaScriptimport { expect, test, vi } from 'vitest'
import { b } from './file'
test('Test example with dependency injection technique', async () => {
const mockedFn = vi.fn().mockReturnValue(5)
const resultA = mockedFn()
expect(resultA).toBe(5)
const resultB = b(mockedFn)
expect(resultB).toBe(10)
})
JavaScriptAs shown above, the use of dependency injection has significantly reduced the complexity of the test code. Consequently, the test code has become much cleaner and clearer, greatly enhancing readability. Additionally, with dependency injection, we can provide different mock functions to b()
based on the requirements of various tests.
That’s the power of TDD. With TDD as a guideline, we can significantly enhance our code structure even before writing a single line of code. By planning our tests first and structuring the code accordingly, we lay the foundation for our application development process.
Alway Reset Mocks After Each Test
It’s crucial to reset the mocks after each test, which can be easily done by running the method vi.restoreAllMocks()
. This method resets all mocks within the runtime.
afterEach(() => {
vi.restoreAllMocks()
})
JavaScriptThis practice effectively prevents unexpected phenomena from occurring.
Quick Summary
Let’s summarize this articles with short sentences:
vi.spyOn
replaces the existing function/method with the “spy” property.vi.fn
generates a new mock function also with the “spy” property.- Direct function swap isn’t a good choice for effective mocking in tests.
- Using
vi.mock
andvi.spyOn
are two very effective ways to mock a module, for both full and partial mocking. - By implementing the dependency injection technique in coding, not only do we ensure that the method’s logic remains isolated and well-contained within itself, but it also results in cleaner and more straightforward testing.
- Make sure to call
vi.restoreAllMocks()
after each test.
Resources:
https://vitest.dev/guide/mocking.html
https://mayashavin.com/articles/two-shades-of-mocking-vitest (To be honest, whoever designed this page has a bad taste in UI design)