Introduction
In our last session, you mastered the "why" and "what" of mocking by using jest.mock() to completely isolate your application from the database. You saw firsthand that with this one line, your tests could run successfully even without a valid database connection string.
This works because jest.mock() automatically replaces all the functions exported by your model (like find, findById, create, etc.) with basic "spy" functions called jest.fn(). By default, these spies don't do much—they just return undefined.
In this lesson, we will take control of these spies. You will learn how to instruct them on exactly how to behave for each specific test case. This is how we simulate different scenarios: a product being found, a product not being found, or even a database error. Mastering this control is essential for thoroughly testing every path of your CRUD logic under exam conditions.

Controlling Mock Behavior
For your tests to be meaningful, you need to tell your mocked Mongoose methods what to return. Jest provides a powerful set of functions for this, attached directly to each mock function. Since Mongoose operations are asynchronous and return promises, we'll primarily use two of them:
mockResolvedValue(value): Instructs the mock to return a promise that successfully resolves with thevalueyou provide.mockRejectedValue(error): Instructs the mock to return a promise that fails and rejects with theerroryou provide.
Let's see how to apply this to our CRUD methods.
1. Mocking findById (Success and Not Found)
Your GET /api/products/:id route has two main outcomes:
- The product is found, and you return it with a
200status. - The product is not found, and you return a
404status.
We can simulate both scenarios easily.
- To simulate finding a product, we'll tell
findByIdto resolve with a mock product object:const mockProduct = { _id: 'someProductId', name: 'Test Product', price: 99 }; Product.findById.mockResolvedValue(mockProduct); - To simulate a product not being found, we'll leverage the fact that Mongoose's
findByIdresolves withnullin this case.Product.findById.mockResolvedValue(null);
2. Mocking create (Success and Failure)
Your POST /api/products route also has multiple paths.
- To simulate a successful creation,
createshould resolve with the newly created document.const newProductData = { name: 'New Gadget', price: 150 }; const savedProduct = { _id: 'newlyGeneratedId', ...newProductData }; Product.create.mockResolvedValue(savedProduct); - To simulate a database error during creation (e.g., a validation failure), we'll tell
createto reject the promise. This is crucial for testing your global error handler.Product.create.mockRejectedValue(new Error('Validation failed'));
This pattern of using mockResolvedValue for success/not-found cases and mockRejectedValue for error cases is the fundamental technique you'll use to test your entire API.
The following video demonstrates this exact principle. While the presenter is testing route handlers directly (unit testing), the way he configures mocks with jest.fn() and simulates different outcomes is identical to what we're doing in our integration tests.
Express JS #20 - Unit Testing with Jest
Watch this segment from the "Express JS #20 - Unit Testing with Jest" video by Anson. Focus on how he uses Jest to create mock functions (jest.fn()) and then configure their return values to test different code paths.
Watch from 18:40 to 24:15 to see how jest.fn() is used to create mock req and res objects. Then, watch from 1:11:51 to 1:22:24, which is highly relevant as it shows mocking a Mongoose User model, its constructor, and its save method, including how to mock both success (mockResolvedValueOnce) and failure (Promise.reject).
Your Turn: Implementing the Mocks
Let's apply these techniques to your productRoutes.test.js file. We'll add tests for the GET by ID and POST routes.
Important: Each test (it block) should be independent. A mock configuration from one test can leak into another. To prevent this, it's a best practice to clear all mock history and configurations before each test. Add this beforeEach block inside your main describe block.
// In productRoutes.test.js, inside the describe block
beforeEach(() => {
// Clears the history of all mocks, and resets any mock implementations
jest.clearAllMocks();
});
This pattern is also mentioned in the "Mocking a Database in Node with Jest" video (resource LINK, section 3), where the author uses mockReset() inside a beforeEach block to ensure test isolation.
Now, add the following test suites to your file.
Testing GET /api/products/:id
// In productRoutes.test.js
describe('GET /api/products/:id', () => {
it('should return a single product if a valid ID is passed', async () => {
// 1. Setup the mock
const mockProduct = { _id: '60d21b4667d0d8992e610c85', name: 'Gaming Mouse', price: 50 };
Product.findById.mockResolvedValue(mockProduct);
// 2. Make the request
const res = await request(server).get(`/api/products/${mockProduct._id}`);
// 3. Assertions
expect(res.statusCode).toEqual(200);
expect(res.body).toEqual(mockProduct);
expect(Product.findById).toHaveBeenCalledWith(mockProduct._id);
});
it('should return 404 if product with the given ID is not found', async () => {
// 1. Setup the mock to simulate "not found"
Product.findById.mockResolvedValue(null);
const nonExistentId = '60d21b4667d0d8992e610c86';
// 2. Make the request
const res = await request(server).get(`/api/products/${nonExistentId}`);
// 3. Assertions
expect(res.statusCode).toEqual(404);
expect(Product.findById).toHaveBeenCalledWith(nonExistentId);
});
});
Testing POST /api/products
// In productRoutes.test.js
describe('POST /api/products', () => {
it('should create a new product and return it with a 201 status', async () => {
// 1. Setup the mock
const newProductData = { name: 'Mechanical Keyboard', price: 120, description: 'Clicky and tactile' };
const savedProduct = { _id: '60d21b4667d0d8992e610c87', ...newProductData };
Product.create.mockResolvedValue(savedProduct);
// 2. Make the request
const res = await request(server)
.post('/api/products')
.send(newProductData);
// 3. Assertions
expect(res.statusCode).toEqual(201);
expect(res.body).toEqual(savedProduct);
expect(Product.create).toHaveBeenCalledWith(newProductData);
});
it('should return 400 if required fields are missing', async () => {
// For this test, we don't even need to mock the model,
// as our route-level validation should catch it first.
const incompleteProductData = { name: 'Just a name' }; // Missing price
const res = await request(server)
.post('/api/products')
.send(incompleteProductData);
expect(res.statusCode).toEqual(400);
expect(res.body.message).toContain('Price is required');
});
});
Run your tests with npm test. All tests should now pass, proving you have successfully taken control of the mock's behavior for different scenarios.
This detailed article provides many examples that mirror what you've just done. Notice how it mocks the Product model and then configures specific methods like create and findById to return resolved values for testing the service layer.
Unit Testing in NodeJS with Express, TypeScript, Jest and Supertest
Skim through the "Writing UnitTests for Services" section of this article. It's a great real-world example of the patterns we're implementing.
In the productService.test.ts example, observe the jest.mock("../../models/Product", ...) block. See how they explicitly replace model methods with jest.fn(). Then, in each test case, notice how they configure the mock's return value, like (Product.create as jest.Mock).mockResolvedValueOnce(...). This confirms the two-step pattern: mock the module, then configure the methods per-test.
Advanced Case: Mocking Chained Queries
Sometimes, you might have more complex Mongoose queries with chained methods, like Product.find({ inStock: true }).sort('-price').limit(10). Mocking these requires a special technique: each function in the chain must return an object that has the next function.
While less common for the basic CRUD you need for the exam, being aware of this pattern is useful.
// Example of mocking a chained query
it('should return sorted and limited products', async () => {
const mockSortedLimitedProducts = [
{ name: 'Product A', price: 100 },
{ name: 'Product B', price: 90 }
];
// Each mock returns an object containing the next mock in the chain
const limitMock = jest.fn().mockResolvedValue(mockSortedLimitedProducts);
const sortMock = jest.fn(() => ({ limit: limitMock }));
Product.find.mockImplementation(() => ({ sort: sortMock }));
// When your controller calls await Product.find({...}).sort({...}).limit(...)
// the chain will execute and finally resolve with your mock data.
const res = await request(server).get('/api/products?sort=-price&limit=2');
expect(res.statusCode).toEqual(200);
expect(Product.find).toHaveBeenCalled();
expect(sortMock).toHaveBeenCalledWith({ price: -1 }); // Assuming you parse query params
expect(limitMock).toHaveBeenCalledWith(2);
});
This Stack Overflow thread offers several excellent, concise examples of this chaining technique. It's a great reference if you encounter this in the future.
Conclusion
You have now moved from simply isolating your database to precisely imitating it. By controlling the resolved and rejected values of your mocked Mongoose methods, you can systematically test every possible outcome of your API endpoints.
Key Takeaways:
jest.mock()replaces model methods with genericjest.fn()spies.- Use
mockResolvedValue(value)to simulate a successful asynchronous operation. A common pattern is resolving withnullto test "not found" cases. - Use
mockRejectedValue(error)to simulate a failed operation and test your error-handling logic. - Isolate tests from each other by using
beforeEach(() => { jest.clearAllMocks(); });. - For each
itblock, configure your mocks to create the specific scenario you want to test.
Next Up:
You have all the tools needed to complete the test suite. In the next lesson, you will apply these patterns to write the remaining integration tests for the PUT and DELETE endpoints, covering both successful updates/deletions and 404 not-found errors.