Mocking Concepts - Testing Series #2
What is Mocking
What is Mocking
Welcome to the second article in our software testing series! The last article explored static, unit, integration, and end-to-end tests.
In this article, we'll explore:
- What is mocking?
- What are the core concepts behind it?
- When to mock and when not to mock?
- What are the best practices for mocking?
We won't go deep into fake data, stubs, drivers, spies, or any of that. We'll just focus on the mental model around mocking. Everything else can come later.
So, what is mocking?
Mocking is the process of simulating the external dependencies of the code that's being tested. Thus, isolating your code from the multitude of things it depends on.
This "simulation" can come in two forms:
- Fake data
- Fake interactions
Fake Interactions
For example, let's say you want to test a function that requires a connection to a database. The function may depend on a database, but the database is not the focus here. The focus is the function itself. We're not testing the database or the database connection. We're just testing a function that depends on those things.
export interface User {
id: string;
name: string;
}
export interface DatabaseConnection<T extends { id: string }> {
findOneByID: (id: T['id']) => Promise<T | null>;
}
/**
* Grabs the user by its ID and returns its name. Returns "Unknown" if we can't
* find a user with that ID.
*/
export const getUserName = async (
db: DatabaseConnection<User>,
userID: string
): Promise<string> => {
const maybeUser: User | null = await db.findOneByID(userID);
return maybeUser?.name ?? 'Unknown';
};
TypeScriptexport interface User {
id: string;
name: string;
}
export interface DatabaseConnection<T extends { id: string }> {
findOneByID: (id: T['id']) => Promise<T | null>;
}
/**
* Grabs the user by its ID and returns its name. Returns "Unknown" if we can't
* find a user with that ID.
*/
export const getUserName = async (
db: DatabaseConnection<User>,
userID: string
): Promise<string> => {
const maybeUser: User | null = await db.findOneByID(userID);
return maybeUser?.name ?? 'Unknown';
};
Sure, by providing an actual database connection, we ensure that our test is more "real". But that comes at the expense of complexity and maintainability. Instead, we could just provide a fake database to our function.
That fake database is considered a mocked interaction, because it mocks (fakes) something that our function interacts with.
export const createMockedDatabaseConnection = <T extends { id: string }>(
data: Array<T>
): DatabaseConnection<T> => ({
findOneByID: async (id) => data.find((el) => el.id === id) ?? null
});
TypeScriptexport const createMockedDatabaseConnection = <T extends { id: string }>(
data: Array<T>
): DatabaseConnection<T> => ({
findOneByID: async (id) => data.find((el) => el.id === id) ?? null
});
Fake Data
We also have mocked data, which is fake data used in tests.
For example, the fake database we provided was just so that we could start testing our function. Now that we have all the interactions that our function depends on, we need to actually test it. And how do we do that? By passing inputs and expecting certain outputs.
To pass inputs, we need data. And this doesn't need to be actual data from your real database. We can use mocked (fake) data.
describe('getUserName()', () => {
it('Returns the correct user name', async () => {
// Fake data
const fakeUser: User = { id: '1', name: 'Joe' };
// Fake interactions
const fakeDatabase: DatabaseConnection<User> =
createMockedDatabaseConnection([fakeUser]);
// Assertions
const actualUserName = await getUserName(fakeDatabase, fakeUser.id);
expect(actualUserName).toBe(fakeUser.name);
});
});
TypeScriptdescribe('getUserName()', () => {
it('Returns the correct user name', async () => {
// Fake data
const fakeUser: User = { id: '1', name: 'Joe' };
// Fake interactions
const fakeDatabase: DatabaseConnection<User> =
createMockedDatabaseConnection([fakeUser]);
// Assertions
const actualUserName = await getUserName(fakeDatabase, fakeUser.id);
expect(actualUserName).toBe(fakeUser.name);
});
});
In this example, we create a fake user called Joe with ID 1.
When to Mock
A common concern is when to mock. After all, we could have used an actual database in our tests above. So how can we decide when we should mock?
First, let's take a look at the pros and cons of mocking:
Pros
- Less setup because it doesn't come with all the dependencies of an actual database;
- No cleanup because we're not saving anything to an actual database, so if there’s no persistent state, there’s nothing to clean;
- Faster because (again) it doesn't have to create all the dependencies of an actual database;
- Isolated because our tests will still pass even if the actual database breaks.
Regarding that last point, it might sound bad that our test passes even though it wouldn't pass with an actual database. I was skeptical about this too, but here's the reasoning: If the database is broken, only the database tests should fail, not the tests that depend on the database.
The advantage of this isolation is debuggability.
Without mocking
- ❌ Database tests
- ❌ Payment tests
- ❌ User creation tests
- ❌ Blog tests
Mocking
- ❌ Database tests
- ✅ Payment tests
- ✅ User creation tests
- ✅ Blog tests
Imagine that you have a thousand tests that depend on the database. If they're using the real database instead of a mock, all these tests will fail, leaving you with the task of figuring out why everything is crashing. But if the tests are isolated, only the database tests will fail, so you know exactly where to look.
Cons
- Less confidence because you're not using real interactions (or data).
Confidence is the keyword here. You have to consider that every time you mock something, You are creating a fake environment, so that begs the question: will it work in the real environment? That really depends on how well the test is written, but still, it’s not the real environment, so a test that uses mocking can’t provide the same confidence as a test that doesn’t use mocking.
There's no silver bullet. No magic answer. You have to ponder each case individually. How much confidence are you willing to give up?
To me, the answer is none. But I also don't want to give up on all the benefits of mocking. So instead of choosing between:
- Fast, mocked tests;
- Or slow, real, end-to-end tests.
I choose both.
As mentioned in the previous article, we run all types of tests here.
For immediate feedback, we run unit and integration tests on every file change. These tests must run very fast, so for them, we use mocked data and mocked interactions.
Then, when we open a pull request, our continuous integration pipeline spins up a staging environment where we run our end-to-end tests with fake data but no fake interactions.
That combination makes me confident that my code works when all the test passes.
Best Practices of Mock Testing
Now, supposing that you've already decided to use mocks, here are some best practices:
1. Only mock types that you own
External types can change at any time, so don't mock them. Notice that we're talking about types, not actual values. You can totally mock a third-party library. Just don't mock their type signature.
// ✅ Do that
import FooType from 'FooLib';
describe('Foo', () => {
it('Expects foo return a foo type', async () => {
const foo: FooType = { ... };
const result = returnFoo();
expect(result).to.deep.equal(foo);
});
});
function returnFoo(): FooType {
return { ... };
}
TypeScript// ✅ Do that
import FooType from 'FooLib';
describe('Foo', () => {
it('Expects foo return a foo type', async () => {
const foo: FooType = { ... };
const result = returnFoo();
expect(result).to.deep.equal(foo);
});
});
function returnFoo(): FooType {
return { ... };
}
// ❌ Don't do that
type FooType = { ... };
describe('Foo', () => {
it('Expects foo return a foo type', async () => {
const foo: FooType = { ... };
const result = returnFoo();
expect(result).to.deep.equal(foo);
});
});
function returnFoo(): FooType {
return { ... };
}
TypeScript// ❌ Don't do that
type FooType = { ... };
describe('Foo', () => {
it('Expects foo return a foo type', async () => {
const foo: FooType = { ... };
const result = returnFoo();
expect(result).to.deep.equal(foo);
});
});
function returnFoo(): FooType {
return { ... };
}
2. Don't mock return values of what's being tested
Think about this for a second. Mocking return values of our testing subject doesn't even make sense. Imagine writing tests for a sum()
function and mocking it to always return 3
. That doesn’t let us know if our function is actually working.
// ✅ Do that
describe('sum()', () => {
it('Returns a sum of 1 and 2', async () => {
const sumResult = sum(1, 2);
expect(sumResult).to.equal(3);
});
});
TypeScript// ✅ Do that
describe('sum()', () => {
it('Returns a sum of 1 and 2', async () => {
const sumResult = sum(1, 2);
expect(sumResult).to.equal(3);
});
});
// ❌ Don't do that
describe('sum()', () => {
it('Returns a sum of 1 and 2', async () => {
const mockedSum = jasmine.createSpy('sum', sum).and.returnValue(3);
const sumResult = mockedSum(1, 2);
expect(sumResult).to.equal(3);
});
});
TypeScript// ❌ Don't do that
describe('sum()', () => {
it('Returns a sum of 1 and 2', async () => {
const mockedSum = jasmine.createSpy('sum', sum).and.returnValue(3);
const sumResult = mockedSum(1, 2);
expect(sumResult).to.equal(3);
});
});
3. Don't mock everything
This is an anti-pattern. If everything is mocked, we may test something quite different from the production environment. It's like we've talked before. The more you mock, the less confidence you have in that test. If you mock everything, then it provides no confidence that it will work in the production environment.
4. Use integration tests
If you care about how your code interacts with other modules, you should do integration testing rather than mocking.
5. Test failing cases
While creating your mocks, don't forget to simulate errors and test error handling. You can even add expectations that some methods or API calls should not be made in case of an error.
describe('getUserName()', () => {
it('Returns an Error when the req fails', async () => {
// Fake data
const fakeUser: User = { id: '1', name: 'Joe' };
const wrongId = '2';
// Fake interactions
const fakeDatabase: DatabaseConnection<User> =
createMockedDatabaseConnection([fakeUser]);
// Assertions
const actualUserNamePromise = getUserName(fakeDatabase, wrongId);
await expectAsync(actualUserNamePromise).toBeRejectedWithError(Error);
});
});
TypeScriptdescribe('getUserName()', () => {
it('Returns an Error when the req fails', async () => {
// Fake data
const fakeUser: User = { id: '1', name: 'Joe' };
const wrongId = '2';
// Fake interactions
const fakeDatabase: DatabaseConnection<User> =
createMockedDatabaseConnection([fakeUser]);
// Assertions
const actualUserNamePromise = getUserName(fakeDatabase, wrongId);
await expectAsync(actualUserNamePromise).toBeRejectedWithError(Error);
});
});
Don't Stop Here
That's all for concepts. In the following articles, we'll talk more about fake data and fake interactions.
If you want to dive deeper into software testing, consider subscribing to our newsletter. It's spam-free. We keep the emails few and valuable. If you prefer to watch our content, we also have a YouTube channel that you can subscribe to.
And if your company is looking for remote web developers, consider contacting my team and me. You can do so through email, Twitter, or Instagram.
Have a great day!
– Lucas
References
- How to test software, part I: mocking, stubbing, and contract testing
- What is Mocking in Testing?
- What Is Mocking? - Typemock Blog
- Hand-rolled mocks made easy | InfoWorld
- xUnit Test Patterns: Refactoring Test Code - Gerard Meszaros.
- Generate dynamic mock data with Mockoon templating system
- request | Cypress Documentation
- Mock Testing
- Faker - Generate massive amounts of fake (but realistic) data for testing and development
- Retry, Rerun, Repeat
- Test Doubles: Can You Tell a Fake From a Mock? - WWT
- What's the difference between faking, mocking, and stubbing? - Stack Overflow.
- Mocks Aren't Stubs, by Martin Fowler