Many developers who are just starting their journey into software testing can often feel overwhelmed about where to begin. The greatest value of a test lies in knowing when it should fail. But how do we define that moment of failure, and where do we draw the line? The answer to this question lies in the concept of ‘test boundaries.’
What is a Test Boundary?
A test boundary, simply put, is the scope of the system being tested. Setting this boundary makes it clear what code will be included in the test and what will be excluded. By doing so, we can prevent unnecessary elements from causing the test to fail and focus on what truly matters.
For example, let’s assume you’re testing a function that handles network requests. This function fetches data from an external server and normalizes it before returning it. The success of the network request itself is not the concern of the test because the server’s status is beyond our control. Therefore, we mock the server request to ensure that network issues do not affect the test results. This is a prime example of setting a test boundary.
Setting Test Boundaries
Let’s take the fetchUser function as an example. This function takes a user ID and retrieves the corresponding user’s information.
import { toCamelCase } from './to-camel-case.js';
export async function fetchUser(id) {
const response = await fetch(`/user/${id}`);
const user = await response.json();
return toCamelCase(user);
}
In the above code, the fetchUser function performs three main tasks:
- It sends a request to the server to fetch the user’s information.
- It converts the response body to JSON.
- It converts the keys of the JSON object to camel case.
Now let’s write the test code for this function.
import { fetchUser } from './fetch-user.js';
test('fetches the user by id and normalizes the keys', async () => {
// Mock fetch to prevent actual network request
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ first_name: 'John', last_name: 'Doe' })
})
);
const user = await fetchUser('abc-123');
// Check if fetchUser returns the user object with camelCased keys
expect(user).toEqual({ firstName: 'John', lastName: 'Doe' });
// Ensure fetch was called with correct endpoint
expect(fetch).toHaveBeenCalledWith('/user/abc-123');
});
This test sets the following boundaries:
- The network request is mocked, removing the actual server request, and only the function’s logic is tested.
- The toCamelCase function is called for real to ensure that it correctly converts the response object’s keys.
By setting test boundaries, we can focus solely on the core functionality of the function. This approach helps reduce errors caused by unforeseen external factors and enhances the reliability of the test.
Why Are Test Boundaries Important?
Properly setting test boundaries means more than just preventing test failures. By setting boundaries, we can enhance the reliability of our tests and optimize them to match the intended purpose of the code. Improper boundary settings can create unstable tests, which may be better off deleted.
For instance, consider the fetchUser function again. This function’s role is to fetch user information, transform it into a specific format, and return it. The important thing here is not the result of the network request or the data transformation but whether the function performs its expected operation. Therefore, by mocking the network request and data transformation logic, we can focus solely on the function’s behavior.
Conclusion: Embrace Boundaries
Setting test boundaries does more than just prevent failures; it helps us grasp the true intention and significance of the code. Through test boundaries, I hope you can eliminate unnecessary parts and focus on what truly matters.
Reference: Epic Web Dev, “What Is A Test Boundary?”