Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 33

Mocking Mongoose Model Methods with `jest.fn()`

Recommended resources

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.

Mock Function Calls using Jest
This diagram illustrates the core concept of mocking. Instead of your code calling the actual Mongoose function (which would go to the database), Jest intercepts the call and redirects it to a "stub" or mock function that we control.

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 the value you provide.
  • mockRejectedValue(error): Instructs the mock to return a promise that fails and rejects with the error you 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:

  1. The product is found, and you return it with a 200 status.
  2. The product is not found, and you return a 404 status.

We can simulate both scenarios easily.

  • To simulate finding a product, we'll tell findById to 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 findById resolves with null in 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, create should 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 create to 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 generic jest.fn() spies.
  • Use mockResolvedValue(value) to simulate a successful asynchronous operation. A common pattern is resolving with null to 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 it block, 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.