Recap – ViTest replacing Jest

My experience in moving from Jest to ViTest - with a focus on testing with the file system. It was worth it, and I will tell you why!

7 minutes
Hero image

The current situation

When setting up the tech stack for my current project, I asked myself which test framework to use for the NodeJS backend. I have recently read about ViTest as a suitable alternative to Jest. In particular, the speed at which Jest tests are executed regularly hindered my workflow. This went so far in some places that I ran the tests irregularly. I urgently needed to adapt, with a focus on systems that support habits. Test-driven development is a fundamental building block of my way of working. ViTest listed the performance as an advantage over Jest, so I'll try it.

The file system plays a central role in the current project. In contrast to the previous use of cloud storage, it is necessary to work in the local file system in this case. To keep the execution time acceptable and ensure test isolation and stability, I found an in-memory file system solution for the tests. ViTest's documentation names a supported library as memfs.

In the following sections, I describe my move from Jest to ViTest, including the configuration of ViTest with memfs and what I learned.

The switch

ViTest set-up

The first big step is setting up the new framework, which has regularly proven difficult with TypeScript. For example, you need to set up the imports of your files relative to the src directory via prefix and have access to them from the tests directory—both when executing the test and when Auto-Complete is used in the IDE.

Fortunately, this turned out to be effortless with ViTest; using a single config file, I could set everything up intuitively and efficiently, and it worked immediately. Here is my vitest.config.js in the root directory of my project – the secret for the local import alias can be found under resolve:

import path from 'path'

export default {
  test: {
    globals: true,
    environment: 'node'
    clearMocks: true,
    globalSetup: 'tests/helper/globalSetup.ts',
    setupFiles: [
      'dotenv/config',
      'tests/helper/setupTests.ts',
      'tests/helper/setupTestEnvVars.ts',
      'tests/helper/customMatchers/toHaveBeenCalledWithScopes.ts',
    ],
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
  },
}

The set-up configurations are also interesting. I have created a folder called helper in the tests directory and stored my test configuration and other helper classes there. I can copy this folder from project to project, quickly adopt my general settings, save time during development, and develop it step by step, which benefits my customers.

Set-up Helper

In the globalSetup, I prepare the In-Memory-MongoDb instance for my integration tests. I generally want to ensure that the breakthrough works beyond my unit tests—e.g., an API-Create call to Event → Event listening building a read model → API-Get call containing the result from read model.

In the setupTests, I prepare all the services I would like to have mocked by default. Here, my file system mock will take place—more on that below.

As the name suggests, I define the values ​​of my environment variables as process.env values in the setupTestEnvVars.

This is followed by the custom matches I need for the projects - illustrated above with customMatchers/toHaveBeenCalledWithScopes.

Converting the mocks

I am genuinely grateful that ViTest uses 99% of the same syntax as Jest. This means that I could turn all Jest calls into Vi calls—here is the simplest example: jest.fn() becomes vi.fn(). The syntax for mocks is the same, making the transition streamlined and easy, even within an existing project–not only new ones.

ViTest and the file system

The setup

To use the in-memory file system, you need to install the NPM package

npm install -D memfs

As part of the helper files, I ensure all calls to Node file system functions are mocked by default. Both the normal and the promise-based calls. To achieve this, I added the following code to the setupTests file:

// --- tests/helper/setupTest.ts

// mock fs
vi.mock('node:fs', async () => {
 const memfs: { fs: typeof fs } = await vi.importActual('memfs')

 return { default: memfs.fs, ...memfs.fs }
})

// mock fs.promises
vi.mock('node:fs/promises', async () => {
 const memfs: { fs: typeof fs } = await vi.importActual('memfs')

 return { default: memfs.fs.promises, ...memfs.fs.promises }
})

// reset the state of in-memory file system on each test
beforeEach(() => {
  vol.reset()
})

This method works for TypeScript. You also don't need other files in the tests/__mocks__ folder.

The first test

With the above setup, you can define the contents of your file system in your tests. It took me a while to understand how to create an empty directory, so here's an example of a simple test that checks whether the directory is empty:

// Simulate an empty dir this way
vol.fromNestedJSON({ 'empty-dir-name': {} }, 'path-to-empty-dir')

// read dir
const result = await fs.readdir('path-to-empty-dir/empty-dir-name')
expect(result).toEqual([])

Special-case access rights when deleting

In one of my test cases, I had to check the following requirements:

  1. Delete a file if it exists
  2. Skip the deletion if the file does not exist
  3. If access rights are missing when deleting, ignore the permission error and continue
  4. If other errors occur, pass the error through

With memfs, checking points 1 and 2 was easy. Unfortunately, memfs does not support file permissions, so I had to build a workaround for requirements 3 and 4, which I would like to share here.

First, I built a helper class for the file system error:

// test helper class
class ErrorWithCode extends Error {
  public read-only code: string

  constructor(message: string, code: string) {
    super(message)
    this.code = code
  }
}

export default ErrorWithCode

In my test, I spy on the unlink function and threw my new error class. For requirement 3, I use the error code EACCES, and for requirement 4, I use any other error code.

// in test
vi.spy on(fs, 'unlink').mockImplementation(() => {
  throw new ErrorWithCode('EACCES: permission denied', 'EACCES')
})

The result in the production code is as follows:

try {
  await fs.unlink('some/path')
} catch (e: unknown) {
  if (e && typeof e === 'object' && 'code' in e && e.code == 'EACCES') {
    // handle this case
    return
  }

  // handle other cases
}

Special-case access rights when writing

I can proceed similarly to test how the system behaves without write permissions. I overwrite the appropriate writeFile and throw an error. Here, you must pay attention to whether you are using the version with or without a promise in the code and mock the corresponding method. I didn't look closely at first and wondered why my mock wasn't working.

Here is an example to illustrate

import { fs } from 'memfs'

// depending on which fs method you use in your prod code
// -> mock the fs.promise.writeFile or fs.writeFile
fs.promises.writeFile = vi.fn().mockRejectedValueOnce(
  new Error('Sth went wrong')
)

Conclusion

The switch was effortless and quick. I configured the tests and TypeScript without problems in just a few minutes. Because I was already familiar with Jest, I wrote the tests in ViTest as usual.

The performance is fantastic, and the execution is lightning-fast. I can run my tests in watch mode again and get instant feedback on whether my actions work. The switch was spot on, and I can only recommend it to you.

And the great bonus of having an in-memory file system using memfs is unbeatable. It saved me a lot of time and is more than enough for my test cases. The only annoying thing was the need to hack the file permissions. According to the discussions in the GitHub threads, this feature would not be easy to implement; therefore, it is understandable that it is unavailable.

call to action background image

Subscribe to my newsletter

Receive once a month news from the areas of software development and communication peppered with book and link recommendations.