A spy is a type of test double used to monitor and record information about function calls without modifying the function’s actual behavior by default. Spies are used to observe how functions are called during execution, allowing you to verify details like the number of times a function was invoked, the arguments passed to it, and its return values. Unlike stubs or mocks, spies do not replace the function with predefined behavior unless explicitly configured to do so.
Spies are particularly useful when you want to check interactions within the system under test while allowing the real function to execute as usual.
Key Features of Spies
- Tracking Behavior: Spies can track whether a function was called, how many times it was called, and with what arguments.
- Verification: You can use spies to verify that certain functions were triggered under the right conditions.
- Non-intrusive: Spies do not modify the function’s original behavior unless explicitly configured to do so.
When to Use Spies
- You want to verify how many times a function is called and with what arguments.
- The function you are testing interacts with external or internal functions, and you need to monitor those interactions.
- You want to confirm whether a callback or handler was invoked during a specific operation.
- You are testing functions that are not easily isolatable or modifiable, but you still need to observe their usage.
Creating a Spy in Vitest
In Vitest, you can create spies using the vi.spyOn()
method, which monitors an existing function. It doesn’t change the actual behavior of the function unless you explicitly instruct it to do so.
Here’s how you create a basic spy:
const spy = vi.spyOn(console, 'log');
This spy will watch the console.log
function and record how many times it was called, the arguments passed to it, and other call details.
Spying on Function Calls, Parameters, and Return Values
Let’s go through an example where we spy on a function and verify its behavior. Consider a function that logs an error message when a ticket sale fails:
function handleTicketSaleError(errorMessage) {
console.error(`Error: ${errorMessage}`);
}
In our test, we can spy on console.error
to ensure it was called with the correct argument:
import { describe, it, expect, vi } from 'vitest';
describe('handleTicketSaleError', () => {
it('logs an error message when ticket sale fails', () => {
// Spy on console.error
const errorSpy = vi.spyOn(console, 'error');
// Call the function under test
handleTicketSaleError('Payment declined');
// Assert that the spy tracked the correct call
expect(errorSpy).toHaveBeenCalledWith('Error: Payment declined');
// Clean up: Restore the original function
errorSpy.mockRestore();
});
});
In this example, errorSpy
watches console.error
and verifies that the correct error message was logged when handleTicketSaleError
was called.
Verifying the Number of Calls, Arguments Passed, and Context of Calls Using Spies
One of the main advantages of spies is their ability to provide detailed information about how functions are called. You can verify:
- The number of times a function was called.
- The arguments passed to the function.
- The return value of the function.
- The context (
this
value) in which the function was called.
Here’s an example that demonstrates how to check these properties:
function processOrder(orderId) {
console.log(`Processing order: ${orderId}`);
}
describe('processOrder', () => {
it('should log the correct order ID', () => {
// Spy on console.log
const logSpy = vi.spyOn(console, 'log');
// Call the function under test
processOrder('12345');
// Verify the spy was called exactly once
expect(logSpy).toHaveBeenCalledTimes(1);
// Verify the spy was called with the correct argument
expect(logSpy).toHaveBeenCalledWith('Processing order: 12345');
// Clean up: Restore the original function
logSpy.mockRestore();
});
});
In this test, we use logSpy
to verify:
- The function was called once (
toHaveBeenCalledTimes
). - The function was called with the argument
'Processing order: 12345'
(toHaveBeenCalledWith
).
Spies give you powerful control over testing interactions, allowing you to validate that your code is making the correct calls and passing the correct arguments without altering the real behavior of the functions under test.
Expect Methods
toHaveBeenCalled()
: Passes if the spy was ever called.toHaveBeenCalledTimes(times)
: Passes if the spy was called the correct number of times.toHaveBeenCalledWith(…args)
: Passes if the function has ever been called with the arguments that you specify.toHaveBeenLastCalledWith
: Passes if the function was most recently called with the arguments that you specify.toHaveBeenNthCalledWith(time, …args)
: Passes if the function was called whichever time you specified with the arguments you specified.toHaveReturned()
: Passes if the function returned (e.g., it didn’t throw an error).toHaveReturnedTimes(times)
: Passes if the function returned however many times you specify.toHaveReturnedWith(value)
: Passes if the function has ever successfully returned with the value you specify.toHaveLastReturnedWith(value)
: Passes if the function most recently returned with the value you specify.toHaveNthReturnedWith(time, value)
: Passes if the function returned whichever time you specified with the value you specified.
Another Example: Basic Math
// math.test.js
import { test, expect, vi } from 'vitest';
import * as math from './arithmetic';
test('calls add with correct arguments', () => {
// Arrange
const addSpy = vi.spyOn(math, 'add');
// Act
const result = math.add(2, 3);
// Assert
expect(addSpy).toHaveBeenCalledWith(2, 3);
expect(result).toBe(5);
// Cleanup
addSpy.mockRestore();
});
Explanation:
vi.spyOn(object, methodName)
: Creates a spy on the specified method.- The spy allows us to verify that
add
was called with the correct arguments. - Cleanup: Restore the original method to avoid side effects.