API Testing with Vitest

As a test engineer (actually, as any engineer and web developer), you should reconsider your stack from time to time. Using Jest with Got or Axios for API testing can sound outdated in 2K23 cause we have Vitest and Fetch API in Node.js.

Andrey Enin
8 min readDec 5, 2023
DALL-E 3 prompt: API Testing with Vitest
DALL-E 3 prompt: draw yellow lightning with rounded corners above the green tick, smooth golden hour colors on a background

Someone can say that «testing API with JS is a bad idea»; «JS fits only for frontend…» The argument in support of these theses is that APIs should be tested on «backend languages» (or on a language in which a serverside code is written, unless, of course, it is written on Node.js): Python, Java, Go, etc.

However, testing API is not the same as testing backend code. API is an Application Programming Interface — it is an interface to the backend — a collection of defined HTTP endpoints that are not tied up to programming language. API testing is suitable for any convenient language.

Therefore, testing API with JavaScript can not considered delirious. If a project already has end-to-end frontend autotests on JavaScript (most likely based on Puppeteer, Playwright, Cypress, or WebdriverIO), that JavaScript for API testing can be scored as a stack unification.

Ten years ago, JavaScript infrastructure could not provide enough tools for testing. Your selection was lacking: Mocha, Jasmine, Karma, Webdriver (it did not even have a test runner at that time and provided only Node.js API to Selenium), and probably something else that no one will remember now. But shortly, the explosive growth of web technologies spawned advanced testing tools, and it became possible to build a JavaScript testing architecture of any complexity from various open-source projects. You can even settle your API tests based on UI testing frameworks like Cypress and Playwright!

Jest + favorite HTTP client (Axios, Got, superagent/supertest, or node-fetch) was a popular bundle in recent years. At the same time, a few significant changes have happened over the past year: Fetch API started working out of the box in Node.js 21 (previously, you had to use it under the experimental flag), and Vitest just released version 1.0. It is time to shake up the habitual testing stack!

Why should you choose or switch to Vitest?

  • Vitest is a modern testing framework. It was created for a present-day approach to JavaScript and dev experience, like TypeScript and ESM support out of the box.
  • Vitest is fully compatible with the Jest syntaxthe migration won’t ​​take much effort.
  • Vitest provides almost zero configuration as opposed to the painful Jest one. No need for directories, file types, and environment setups — most of the default options are exactly what you need.

Vitest’s minimum config can be really minimalistic:

import {defineConfig} from 'vitest/config';

export default defineConfig({
test: {},
});
  • Vitest has retries out of the box!
  • Vitest has built-in Chai assertions and advanced assertions for types (via expect-type, more convenient for unit testing).
  • Vitest has skip conditions (describe and test functions have a lot more useful methods than other test-runners).
  • Vitest runs tests in parallel by default. However, for API testing, it may be highly convenient to limit the number of simultaneous API requests on the test-file level and make tests (test files) run forcedly sequentially.

Multi-threading can be disabled:

  1. By passing--pool=forks in the CLI;
  2. AND adding the option singleFork=true into config:
export default defineConfig({
test: {
poolOptions: {
forks: {
singleFork: true,
},
},
},
});

(In older versions (the last one was 0.34.6), it was command --no-threads, which could be considered as an analog of --runInBand in Jest.)

NOTE: you must use --pool=forks when using fetch() due to Vite’s peculiarity.

  • Vitest is fast (some user’s benchmark). It is fast not only for unit testing but even for API testing, despite the speed of testing framework usually being leveled by HTTP requests’ timings.

I compared approximately identical API tests written on Jest and their replication on Vitest.

  • Average Jest’s execution time (--runInBand): 5.4 sec
  • Average Vitest’s execution time (--pool=forks & singleFork=true): 3.2 sec

Vitest is almost more than one and a half times faster!

  • Vitest has a thoughtful reporter. For example, in the final report it will collapse successful tests up to the file level. Anyway, you do not need test steps if everything is OK.
Vitest reporter after a successful run
Fig. 1. Vitest reporter after a successful run
Vitest shows how tests are running
Fig. 2. Vitest shows how tests are running (multi-threading execution — notice beforeAlls are running at the same time)
Vitest reporter of a single test file shows all tests
Fig. 3. Vitest reporter of a single test file shows all tests
  • Vitest even has a UI interface. It may be convenient for test engineers to check the regression reports in a fancy style, especially if there are a lot of tests on a project.
Vitest UI Dashboard
Fig. 4. Vitest UI Dashboard
Vitest UI Report
Fig. 5. Vitest UI Report
Vitest UI Module Graph tab allows visually discover the dependencies of the test
Fig. 6. Vitest UI Module Graph tab allows visually discover the dependencies of the test

Unfortunately, the UI interface works only with a default «always on watch mode» of Virest. You will not be able to use it with such kind of running: vitest run --pool=forks. However, it is possible to represent a report of a single test run as an HTML report with the same interface.

NOTE: UI mode is optional (thank god); you will need to install it from a separate package (@vitest/ui).

Read more:

Why should you choose or switch to Node.js as an HTTP client?

  • Why choose something else if you already have it? That is a rhetorical question. Removing a third-party library from dependencies of your project reduces the number of dependencies.
  • External HTTP clients can provide some additional features, like retries, autotransforming of request and response data, advanced timings, and so on. In most of the cases of simple HTTP request testing, these features are excess.
  • Axios is based on XMLHttpRequests, which is an outdated technology (see the differences).
  • Got may force to reconfigure your TypeScript project to work.
  • node-fetch and ky are already based on Fetch API. Using Node.js’s fetch() makes autotests a little bit low-level and more robust because you have fewer intermediate links between requests and tests.

The only benefit of third-party clients is their syntax. For example, some of them allow to pass JSON as requests’ parameters, but fetch() requires to pass parameters in the URL.

const urlQuery = {
api_key: 'DEMO_KEY',
feedtype: 'json',
ver: '1.0',
};

<...>

response = await got.get('https://api.nasa.gov/insight_weather/, {
searchParams: urlQuery,
});

Example of fetch() request — you have to use URLSearchParams to convert JSON into URL-like string:

const urlQuery = {
api_key: 'DEMO_KEY',
feedtype: 'json',
ver: '1.0',
};

<...>

const queryParams = new URLSearchParams(urlQuery).toString();
response = await fetch(`https://api.nasa.gov/insight_weather/?${queryParams}`);

Read more:

And finally, testing…

That is an easy part if you understand the principles:

  • Vitest works as a test-runner and assertion library (exactly what it is intended to do);
  • fetch() makes HTTP requests to pass response data to Vitest for checking.

For the full code example, see this GitHub repository (there are linters and additional infrastructure around tests that are not mentioned in the article).

NOTE: The following code examples assume the use of TypeScript.

To set up the project, you only need to add the latest Vitest package and Node >=21 (that is important because in previous versions, fetch() does not work without the experimental flag) into package.json:

"engines": {
"node": ">=21"
},
"type": "module",
"dependencies": {
"vitest": "^1.0.0"
}

Where:

To set up commands for running tests, you need to add scripts into package.json:

"scripts": {
"test": "vitest run --pool=forks"
}

By default (vitest command), Vitest runs in the watch mode, waits for file changes, and reruns tests for each change. This mode is excess for API tests — a single run is enough (vitest run).

Now you can run tests using the following command: npm test

To run a single test, just pass a part of a test-file name after the command: npm test foobar — will run foobar.test.ts test file.

To set up Vitest’s config, you need to create vitest.config.ts file with the following content:

import {defineConfig} from 'vitest/config';

export default defineConfig({
test: {},
});

Now you finish with infrastructure and can start writing tests. The best way is to store tests in the /tests directory with .test.ts filetype.

Test Example

Let’s request a sample NASA API handler and execute some basic checks.

import { beforeAll, describe, expect, expectTypeOf, test } from 'vitest';

const BEFORE_ALL_TIMEOUT = 30000; // 30 sec

describe('Request Earth Polychromatic Imaging Camera', () => {
let response: Response;
let body: Array<{ [key: string]: unknown }>;

beforeAll(async () => {
response = await fetch(
'https://api.nasa.gov/EPIC/api/natural?api_key=DEMO_KEY',
);
body = await response.json();
}, BEFORE_ALL_TIMEOUT);

test('Should have response status 200', () => {
expect(response.status).toBe(200);
});

test('Should have content-type', () => {
expect(response.headers.get('Content-Type')).toBe('application/json');
});

test('Should have array in the body', () => {
expectTypeOf(body).toBeArray();
});

test('The first item in array should contain EPIC in caption key', () => {
expect(body[0].caption).to.have.string('EPIC');
});
});

Where:

  • Import contains all involved Vitest’s functions;
  • BEFORE_ALL_TIMEOUT constant will be used in beforeAll function to extend the termination time of the request’s execution;
  • beforeAll contains fetch() HTTP request. beforeAll must not contain any assertions. Response data and response body are stored in variables for subsequent checks;
  • «Should have response status 200» test checks response code;
  • «Should have content-type» test checks on one of the headers;
  • «Should have array in the body» checks the type of the body’s root;
  • «The first item in array should contain EPIC in caption key» test checks an arbitrary key in the body with Chai.

Read more about alternative ways of testing API with Node.js:

In the list above, there are a few examples of using SuperTest as an HTTP client, but I am very skeptical about it. Supertest provides a limited set of assertions stuck to HTTP requests (superagent as HTTP library) with describe/it syntax from Mocha.

  • Mocha + SuterTest is a redundant bundle because SuperTest is already based on Mocha;
  • Jest + SuperTest is a redundant bundle, too. Why use Jest for running tests, while SuperTest can do it through Mocha? Why use SuperTest only for HTTP requests, when it can be taken only superagent?

Each tool should have a reasonable usage and not duplicate features of its neighbors by package.

--

--

Andrey Enin

Quality assurance engineer: I’m testing web applications, APIs and do automation testing.