- Passarella Dev Newsletter
- Posts
- Does mocking defeat the purpose of testing?
Does mocking defeat the purpose of testing?
Why It Strengthens, Not Undermines, Your Tests
This is one of the questions that I get asked the most when talking about testing, more specifically unit and integration testing.
My answer is always no, and here’s why:
In unit testing, we care about the logic, input, and output. And they shouldn’t be affected by external dependencies.
In integration testing, we care about the user flow and workflow, they also shouldn’t be affected by external dependencies, as we need to test all scenarios, success and failure.
Meaning, that if a database call fails, or an external API is down, it should not fail the unit test, because they’re not part of the “unit” being tested. Tests need to be isolated.
Then you might think that they should fail, because let’s say a team member made a change that caused a side-effect and the DB call failed, the tests would fail and raise awareness around it. It is a tempting point, but mocks will solve this problem, and we’ll talk more about it. Anyhow, unit tests should always pass, independently of external failures, that way you are sure that your users are seeing and experiencing the correct functionality.
What to mock? 🔍
I’ll start right away with an example.
// getting the user from an external API
const getUser = (id: string) => {
return externalAPI.get(`/users/${id}`)
}
// assigning the user type
const checkUserAccess = async (id: string) => {
const user = await getUser(id)
if (user.role === ROLE.ADMIN) {
return true
}
return false
}
In the above code, we defined two simple functions, the getUser
fetches a specific user from an external API, and the below one checks the user role for authorization purposes.
Now let’s test checkUserAccess
.
jest.mock('./userService', () => ({
...jest.requireActual('./userService'),
getUser: jest.fn(),
}))
describe('checkUserAccess', () => {
it('should return true if user is an admin', async () => {
// Mock the user with ADMIN role
getUser.mockResolvedValue({ role: 'ADMIN' })
const result = await checkUserAccess('userId123')
expect(result).toBe(true)
expect(getUser).toHaveBeenCalledWith('userId123')
})
})
Here, as you can see, we’re mocking the getUser
method, making the test completely independent of external dependencies and side effects. In this particular test, we mock the function that makes the API call to return the user with an ADMIN role. If the API call fails, the functionality testing is still there.
Careful with mocking whole methods and modules 🚨
Now let’s see another situation, what if we have business logic in the getUser
or another method, how do we mock it?
There are many ways to solve this. Let’s look at another example:
const getUser = async (id: string) => {
const user = await externalAPI.get(`/users/${id}`)
if (user.isActive) {
throw new Error(ERROR_MESSAGES.USER_INACTIVE);
}
return user
}
If we keep the same testing structure, where we mock getUser
, we will be mocking the additional business logic too, checking if the user is active. We could technically mock it to throw an error, like such:
getUser.mockRejectedValue(new Error(ERROR_MESSAGES.USER_INACTIVE))
const result = await checkUserAccess('userId3')
expect(result).toBe(false)
But we’re missing a spot, the actual isActive
check.
Another possibility is to create a separate function to make this validation, it’s a good idea, then test it separately.
A common mistake that I see when people struggle with writing a test, is forgetting about what is being tested. In many cases, the method itself can be adjusted to facilitate testing.
But my favorite way is to try as best as possible to not mock the methods and modules, instead, mock the external dependency. That way, your tests are as close as possible to the real functionality. Let’s try it.
jest.mock('../externalAPI');
describe('checkUserAccess', () => {
it('returns true if user is an admin', async () => {
(externalAPI.get as jest.Mock)
.mockResolvedValue({
data: {
id: 'userId123', role: ROLE.ADMIN, isActive: true
},
})
const result = await checkUserAccess('userId123')
expect(result).toBe(true)
expect(externalAPI.get).toHaveBeenCalled();
})
it('returns false if user is inactive', async () => {
(externalAPI.get as jest.Mock)
.mockResolvedValue({
data: {
id: 'userId123', role: ROLE.ADMIN, isActive: false
},
})
const result = await checkUserAccess('userId789');
expect(result).toBe(false);
expect(externalAPI.get).toHaveBeenCalled(););
});
})
Perfect, now the code execution of checkUserAccess
will be followed more closely, actually calling getUser
in the test and executing all the logic.
It also has other benefits:
Makes the code cleaner and easier to understand its workflow and logic;
It’s more maintainable since you don’t have to update your mock code as much in the test when the method is updated;
In many cases (such as mocking request libraries, like Axios) you can use an absolute import URL in the mock, making it easier to maintain. E.g.
jest.mock('axios')
.
There are many examples, but the idea is the same, mock anything that reaches outside the unit’s scope—network calls, file system access, databases, etc. But avoid mocking internal logic, as that would defeat the purpose of the unit test.
In integration testing, mocks should still be used sparingly. The goal is to test how different components interact, so sometimes external calls might need to be replaced with fake services or controlled environments (like in-memory databases) rather than mocks.
Assertion on Side Effects 🎲
Side effects are changes that occur outside the scope of the function’s immediate return. While unit tests often focus on return values, better tests also verify that external effects (like calls to a mocked API) happen as expected. This ensures that not only is the output correct, but the interactions with dependencies are as well.
expect(externalAPI.get).toHaveBeenCalledWith('/users/userId123');
This has many benefits:
Ensures Correct Interactions:
When a function interacts with external services, you want to confirm that these interactions happen as expected. It’s not enough to verify that a function returns the correct value; you should also ensure that the correct API endpoint was hit, or that the database was updated appropriately.Prevents Silent Failures:
Failing to assert side effects can lead to silent failures, where the function appears to behave correctly (because the return value is right) but important actions (like sending an email or logging an error) don’t occur as intended.Improves Code Coverage:
You increase the thoroughness of your tests. This leads to better test coverage, as you're not only validating the output but also ensuring the correct external behaviors are triggered.
That’s it, hopefully you learned something new and everything was clear. Of course, there could be many edge cases, like everything.
I also write about career advancements and tips based on my experience, so if you would like to read it, follow me to know when I post more content 🙌
Reply