DEV Community

Cover image for Module and environment variable stubbing for efficient testing in Vitest
Maya Shavin 🌷☕️🏡
Maya Shavin 🌷☕️🏡

Posted on • Originally published at mayashavin.com

Module and environment variable stubbing for efficient testing in Vitest

In this article, we will continue discussing another approach of mocking, such as an external module, using vi.mock() and how we can stub different values for an environment variable using vi.stubEnv().

Table of contents

Our test scenario

In this example, we have a backend function - getPizzas that:

  • Fetches Pizza data from an external API,
  • Filters the response according to a query received (using imported method filterItems from /utils),
  • Returns the first 10 filtered results.
/** src/getPizzas.js */
import { filterItems } from './utils'

export const getPizzas = async (query) => {
  const API_URL = 'http://exploringvue.com/.netlify/functions'

  const response = await fetch(`${API_URL}/pizzas`)
    .then((res) => res.json())
    .catch((err) => {
      throw new Error("Failed to fetch pizzas");
    });

  const results = filterItems(response, "title", query);

  return {
    data: results.slice(0, 10),
    hasNextPage: results.length > 10,
  };
};
Enter fullscreen mode Exit fullscreen mode

We want to test the above function in two levels:

  • In isolation from the filterItems function, and the external call to the pizza API.
  • Integration test without mocking the external call and with an a different API URL for testing purpose only.

Let's start with the first level, where we need to mock all the dependencies using vi.mock(), vi.spyOn() and vi.fn().

Mocking an external module with vi.mock()

vi.mock() allows us to mock an external imported module (not an API or a global object). It accepts two arguments: the module path and a factory function that returns the mocked module instance.

To mock our external module that contains the filterItems function, we can use vi.mock() as follows:

/** getPizzas.test.js */
import { vi } from 'vitest';

vi.mock('./utils', async () => {
  return {
    filterItems: vi.fn().mockImplementation(
      () => [{ id: 1, title: 'Pizza 1' }]
     ),
  }
})

describe('getPizzas', () => {
  //...
});
Enter fullscreen mode Exit fullscreen mode

In the above code, we passed the module path ./utils and used vi.fn() to mock the filterItems function in the returned module instance. However, with this implementation, we are replacing the entire module with an instance that contains only the mocked filterItems method, which may not be the desired behavior in case ./utils contains additional methods.

Fortunately, the factory function receives an asynchronous helper - importOriginal as its input, which gives us the original module, hence allowing us to keep the module structure, as shown below:

vi.mock('./utils', async (importOriginal) => {
  const originalUtils = await importOriginal();
  return {
    ...originalUtils,
    filterItems: vi.fn().mockImplementation(
      () => [{ id: 1, title: 'Pizza 1' }]
     ),
  }
})
Enter fullscreen mode Exit fullscreen mode

With this code, we have replaced ./utils module with a new instance that contains both the original module methods and the mocked filterItems method.

Note that vi.mock() is hoisted to the top of the file, and Vitest will always execute it first, which means the following code will not work:

const mockedFilteredItems = vi.fn().mockImplementation(() => []);

vi.mock('./utils', async (importOriginal) => {
  const originalUtils = await importOriginal();
  return {
    ...originalUtils,
    //ERROR: mockedFilteredItems is not defined
    filterItems: mockedFilteredItems 
  }
})
Enter fullscreen mode Exit fullscreen mode

To avoid the above error, and enable the ability to mock the implementation of filterItems dynamically per test, we can perform the following as alternative:

  • Call vi.mock() without a factory function
  • Import the module using import and then use vi.mocked() to get the mocked method from the module.
  • Change its implementation using mockImplementation() per test.

The below code demonstrates this approach:

//1. Import the module
import * as utils from './utils';

//2. Mock the module
vi.mock('./utils');

describe('getPizzas', () => {
  it('should return pizzas without next page', async () => {
    //3. Get the mocked method instance
    const mockedFilterItems = vi.mocked(utils.filterItems); 

    //4. Change the mocked implementation
    mockedFilterItems.mockImplementation(() => [{ id: 1, title: 'Pizza 1' }]);

    //5. Test the function
    //...
  });
});
Enter fullscreen mode Exit fullscreen mode

Great. But we still can't run the test because we haven't mocked the external API call with fetch yet. Unfortunately, vi.mock() won't help us in mocking global module. Instead, we can mock fetch by using vi.fn() or vi.spyOn(), from the previous blog post.

Once we have mocked the fetch call, we can now complete the first test for the getPizzas function in isolation, as follows:

import utils from './utils';

vi.mock('./utils');

describe('getPizzas', () => {
  const fetchSpy = vi.spyOn(global, 'fetch');

  it('should return pizzas without next page', async () => {
    const mockedFilterItems = vi.mocked(utils.filterItems); 
    const mockResponse = [{ id: 1, title: 'Pizza 1' }];
    mockedFilterItems.mockImplementation(() => mockResponse);

    fetchSpy.mockResolvedValue({
      ok: true,
      json: async () => mockResponse,
    });

    const result = await getPizzas();
    expect(result.data.length).toBe(1);
    expect(result.hasNextPage).toBe(false);
  });
});
Enter fullscreen mode Exit fullscreen mode

As you can see in the above test, we have mocked both the filterItems function and the fetch call. In reality, this may not be the best approach. As vi.mock() receives the module path as its first argument, hence if the module path changes, our tests may break.

Also, filterItems is a simple function, and we could test it together with getPizzas without mocking. We should use vi.mock() only when we need to mock an external module that contains complex logic or side effects. Otherwise, it's recommended to use vi.fn() or vi.spyOn() to mock the function directly.

At this point, we explored writing unit test using vi.mock() for getPizzas(). Next, we will discuss how to write an integration test for the same function, where we will mock the external API path, which is an environment variable.

Mocking an environment variable with vi.stubEnv()

Let's get back to our implementation of getPizzas, and replace the static API URL with an environment variable API_URL:

/** getPizzas.js */
import { filterItems } from './utils'

export const getPizzas = async (query) => {
  const API_URL = process.env["API_URL"];

  //...
};
Enter fullscreen mode Exit fullscreen mode

When we need to test the getPizzas function with a different API URL, we can use vi.stubEnv() to mock this environment variable as follows:

import { vi } from 'vitest';


describe('getPizzas', () => {
  it('returns a list of pizzas', async () => {
    vi.stubEnv('API_URL', 'http://localhost:3000/testdata') //the target testing URL

    //Assertion as normal
    //...
  });
});
Enter fullscreen mode Exit fullscreen mode

Assuming that http://localhost:3000/testdata is a testing URL that returns a list of pizzas, we have just completed the first integration test for getPizzas. This approach allows us to test the function with different API URLs without changing the actual environment variable's value.

Lastly, rule of thumbs: anything we mock/stub, we may need to restore it before/after each test. We will do it next.

Restore mocked and stubbed objects

To restore the all the stubbed environment variables, we can trigger vi.unstubAllEnvs() after each test run, and in the below code:

describe('getPizzas', () => {
  afterEach(() => {
    vi.unstubAllEnvs();
  });

  //tests...
});
Enter fullscreen mode Exit fullscreen mode

Alternatively, we can achieve the same by setting unstubEnvs to true in the vitest.config.js file.

/** vitest.config.js */
export default defineConfig({
  test: {
    //..
    unstubEnvs: true,
  },
})
Enter fullscreen mode Exit fullscreen mode

This way, we can ensure that Vitest restores all environment variables before each test run.

For mocked module by vi.mock(), the best way is to set mockReset: true in the vitest.config.js file as follows:

/** vitest.config.js */
export default defineConfig({
  test: {
    //..
    mockReset: true,
  },
})
Enter fullscreen mode Exit fullscreen mode

Summary

We have learnt how to mock an external module using vi.mock() and stub an environment variable using vi.stubEnv() in Vitest, as well as when it is best for. We also learned different ways to restore the mocked and stubbed objects. With these techniques, we can write efficient tests for our functions, ensuring that they are isolated and independent of external dependencies when needed.

👉 Learn about Vue 3 in TypeScript with my new book Learning Vue!

👉 If you'd like to catch up with me sometimes, follow me on X | LinkedIn.

Like this post or find it helpful? Buy me a coffee ☕ 🙌🏼

Top comments (0)