Skip to main content

Gherkin-style tests with plain Vitest (no extra library)

· 14 min read
Pere Pages
Software Engineer
Abstract editorial illustration of a given-when-then specification flowing into layered testing scaffolding

Gherkin's real value isn't the .feature file. It's the discipline it imposes: a test that reads as a specificationgiven this, when that, then this — while every implementation detail stays hidden one level down. A colleague should be able to read the test body and understand the feature without reading a single line of setup code.

You don't need a behavior-driven development (BDD) library to get that. You need three things Vitest already gives you: good naming, step helpers, and fixtures. This post shows how to wire them so your tests read like specs and reuse their setup, for both React Testing Library (RTL) on the user interface (UI) side and Express/Node on the backend — with the edge cases that usually trip people up.

The guiding rule throughout: the test body is the spec; the helpers are the step definitions; the fixture is the world.

The mental model

Map each Gherkin concept to a plain Vitest primitive and stop thinking about Gherkin:
GherkinPlain Vitest
Featuretop-level describe('Feature: …')
Scenarioa single test() with a declarative name
Given / When / Thennamed step-helper functions, grouped by domain
Backgrounda step called at the top of each test, or an auto fixture
Scenario Outline / Examplestest.for([...])
The World (shared state)a test.extend fixture
Step definitions reused across featuresstep modules you import
Expensive once-per-file setupa fixture with { scope: 'file' }
Per-scenario reset/isolationthe default (test-scoped) fixture

Everything below is an application of this table. The whole approach is four layers, each hiding the one below it — the test body reads the spec, the steps carry out the given/when/then, the world holds the state, and only the innermost layer touches the real component or server:

Part 1 — Config: split UI and backend by environment

UI tests need jsdom; backend tests want node (faster, no Document Object Model, or DOM, globals). You can't switch environment mid-file, so split at the file level. Two options.

The lightweight option costs nothing to set up but stops scaling once each type needs its own setup; the scalable option carries a little config in exchange for per-type control:

ApproachConfig effortPer-type setup files and includesBest for
Docblock commentzerononesmall projects
Projectssomefullscaling suites

Lightweight: a per-file docblock

Drop this comment at the very top of a file and that file runs in jsdom; everything else stays on the default:

// @vitest-environment jsdom

Zero config, great for a small project. It stops scaling once you want different setupFiles or different includes per type.

Scalable: projects

Modern Vitest (3.2 and later) uses test.projectsthe old test.workspace is deprecated. One project per environment, with a naming convention so each picks up the right files:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
plugins: [react()],
test: {
projects: [
{
test: {
name: 'ui',
environment: 'jsdom',
include: ['src/**/*.ui.test.{ts,tsx}'],
setupFiles: ['./test/setup.ui.ts'],
},
},
{
test: {
name: 'node',
environment: 'node',
include: ['src/**/*.api.test.ts'],
},
},
],
},
});

Now *.ui.test.tsx runs in jsdom and *.api.test.ts (the backend application programming interface, or API, suite) runs in node. vitest --project ui runs just the UI suite.

The UI setup file registers the DOM matchers and (defensively) cleanup:

// test/setup.ui.ts
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

// RTL auto-registers cleanup when globals are on, but being explicit
// guarantees the DOM is reset between scenarios regardless of config.
afterEach(cleanup);

Part 2 — The readable test

This is the whole point, so get the shape right before worrying about reuse.

Bad — imperative, leaks every detail, unreadable as a spec:

test('login', async () => {
render(<LoginPage />);
await userEvent.type(screen.getByLabelText('Email'), 'ada@example.com');
await userEvent.type(screen.getByLabelText('Password'), 'correct-horse');
await userEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(await screen.findByText('Welcome back, Ada')).toBeInTheDocument();
});

It works, but nobody can paste it to a colleague to explain the feature. Selectors, typing, and assertions are all in your face.

Good — declarative, implementation hidden, reads like a spec:

test('a registered user reaches their dashboard', async ({ world }) => {
const auth = authSteps(world);

// Given a registered user
const user = auth.aRegisteredUser({ name: 'Ada' });
// When they log in with valid credentials
await auth.logInAs(user);
// Then they see a welcome message
await auth.seesMessage('Welcome back, Ada');
});

The // Given / When / Then comments are free structure. The method names carry the meaning. The mechanics live in authSteps. That's Gherkin's readability without Gherkin.

The two pieces that make this work — the world fixture and the authSteps module — are Part 3.

Part 3 — Reuse: the World as a fixture, steps as modules

The World is a fixture

The World is the shared context a scenario operates on. In Gherkin it's implicit; here it's an object delivered by a test.extend fixture, which gives you a fresh, isolated instance per scenario for free — the thing that stops state bleeding between tests.

// test/support/rtl.ts
import { test as base } from 'vitest';
import { render, screen, cleanup, type RenderResult } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';

export interface UiWorld {
user: ReturnType<typeof userEvent.setup>;
mount: (ui: ReactElement) => RenderResult;
screen: typeof screen;
}

export const test = base.extend<{ world: UiWorld }>({
world: async ({}, use) => {
const world: UiWorld = {
user: userEvent.setup(), // fresh per scenario
mount: (ui) => render(ui),
screen,
};
await use(world);
cleanup(); // unmount + reset DOM after the scenario
},
});

Every test file imports test from here instead of from vitest. The world argument is typed, fresh, and self-cleaning.

note

Note the empty {} destructure in the fixture factory — Vitest does static analysis on fixture arguments and is strict about it. If your linter complains about empty patterns, that's expected; disable the rule for that line.

Steps are modules ("packs")

A step module is a plain function that takes the world and returns named, declarative actions. This is your reusable, traceable equivalent of step definitions — except import makes "where is this step implemented?" answerable by go-to-definition, with no global magic registry.

// test/steps/auth.steps.tsx ← .tsx because it renders JavaScript syntax extension (JSX)
import { expect } from 'vitest';
import type { UiWorld } from '../support/rtl';
import { LoginPage } from '../../src/LoginPage';
import { seedUser, type User } from '../support/fixtures';

export function authSteps(world: UiWorld) {
return {
aRegisteredUser(overrides: Partial<User> = {}): User {
const user = { name: 'Ada', email: 'ada@example.com', password: 'correct-horse', ...overrides };
seedUser(user); // seed via Mock Service Worker (MSW) handler, store, or API mock — your choice
return user;
},

async logInAs(user: User) {
world.mount(<LoginPage />);
await world.user.type(world.screen.getByLabelText('Email'), user.email);
await world.user.type(world.screen.getByLabelText('Password'), user.password);
await world.user.click(world.screen.getByRole('button', { name: /log in/i }));
},

async seesMessage(text: string) {
expect(await world.screen.findByText(text)).toBeInTheDocument();
},
};
}

Group one module per domain (auth, cart, navigation) plus a common module for genuinely generic steps. A scenario that needs several domains just instantiates several — this is your explicit step composition:

const auth = authSteps(world);
const nav = navSteps(world);

No global pile, no ambiguous matches across unrelated features — only the modules you imported are in scope.

Two-tier lifecycle: expensive once, cheap every time

Setup comes in two speeds. Booting a database (DB) is expensive — do it once per file. Clearing tables is cheap — do it before every scenario, because that's what guarantees isolation. Vitest's fixture scope expresses both, and they compose: a file-scoped fixture can feed a test-scoped one.

// test/support/api.ts
import { test as base } from 'vitest';
import { startTestDb, type TestDb } from './db';

export interface ApiWorld {
db: TestDb;
}

export const test = base.extend<{ db: TestDb; world: ApiWorld }>({
db: [
async ({}, use) => {
const db = await startTestDb(); // EXPENSIVE — once per file
await use(db);
await db.stop(); // torn down once, at the end
},
{ scope: 'file' },
],

world: async ({ db }, use) => {
await db.truncateAll(); // CHEAP — fresh before every scenario
await use({ db });
},
});

The connection is created once and reused; the data resets every scenario. Speed and isolation.

note

scope: 'file' behaves like top-level beforeAll/afterAll, except it only runs if a test actually uses the fixture. On older Vitest without scopes, fall back to module-level beforeAll/afterAll for the DB and keep the per-test reset in the fixture — same shape, slightly more boilerplate.

Part 4 — Full UI example (React Testing Library)

The spec, as a colleague would read it:

// src/features/login.ui.test.tsx
import { describe } from 'vitest';
import { test } from '../../test/support/rtl';
import { authSteps } from '../../test/steps/auth.steps';

describe('Feature: Login', () => {
test('a registered user reaches their dashboard', async ({ world }) => {
const auth = authSteps(world);

const user = auth.aRegisteredUser({ name: 'Ada' });
await auth.logInAs(user);
await auth.seesMessage('Welcome back, Ada');
});

test('a wrong password is rejected', async ({ world }) => {
const auth = authSteps(world);

const user = auth.aRegisteredUser();
await auth.logInAs({ ...user, password: 'nope' });
await auth.seesMessage('Invalid credentials');
});
});

Two scenarios, zero visible DOM mechanics, shared steps, isolated state. The second scenario can't see the first's render because cleanup() ran in the fixture teardown.

Part 5 — Full backend example (Express + supertest)

Test the Express app object with supertest — no real port, no flaky networking. The app must be exported without calling listen():

// src/app.ts
import express from 'express';
export const app = express();
// ...routes, middleware...
// Start the server in a separate entrypoint (src/server.ts), not here.

Domain steps for billing:

// test/steps/billing.steps.ts
import request from 'supertest';
import { expect } from 'vitest';
import { app } from '../../src/app';
import type { ApiWorld } from '../support/api';

export function billingSteps(world: ApiWorld) {
return {
async aCustomerWithActiveSubscription() {
return world.db.customers.create({ status: 'active' });
},

async renewalIsDeclined(customerId: string) {
await request(app)
.post(`/billing/${customerId}/renew`)
.send({ outcome: 'declined' })
.expect(200);
},

async renewalSucceeds(customerId: string, plan: string) {
await request(app)
.post(`/billing/${customerId}/renew`)
.send({ outcome: 'success', plan })
.expect(200);
},

async subscriptionStatusIs(customerId: string, status: string) {
const sub = await world.db.subscriptions.findByCustomer(customerId);
expect(sub.status).toBe(status);
},

async nextBillingIsInDays(customerId: string, days: number) {
const sub = await world.db.subscriptions.findByCustomer(customerId);
expect(daysFromNow(sub.nextBillingAt)).toBe(days);
},
};
}

The spec:

// src/features/billing.api.test.ts
import { describe } from 'vitest';
import { test } from '../../test/support/api';
import { billingSteps } from '../../test/steps/billing.steps';

describe('Feature: Subscription billing', () => {
test('a declined renewal suspends the subscription', async ({ world }) => {
const billing = billingSteps(world);

const customer = await billing.aCustomerWithActiveSubscription();
await billing.renewalIsDeclined(customer.id);
await billing.subscriptionStatusIs(customer.id, 'suspended');
});
});

Same readable shape as the UI test, completely different stack underneath. The world fixture (Part 3) gave each scenario a clean database without the test ever mentioning it. That invisibility is the goal.

Part 6 — Edge cases (where people get burned)

Async is not optional

userEvent and RTL's findBy* queries are async. Two rules that prevent 90% of flaky UI tests:

  • await every interaction. A missing await user.click(...) produces races that pass locally and fail in continuous integration (CI).
  • Use findBy* for anything that appears after an async update; getBy* only for what's already there. getByText throws immediately if the element isn't present yet; findByText retries until it appears (or times out). Asserting on a post-submit message is always findBy.
// wrong: getBy runs before the async response renders → throws
expect(screen.getByText('Welcome back')).toBeInTheDocument();
// right: findBy waits
expect(await screen.findByText('Welcome back')).toBeInTheDocument();

Scenario Outline → test.for, not test.each

For data-driven scenarios, test.for passes the test context (your fixtures) as the second callback argument; test.each does not. That single difference is why test.for is the right primitive here — you keep your world.

test.for([
{ plan: 'monthly', days: 30 },
{ plan: 'annual', days: 365 },
])('renewal on $plan extends the period by $days days', async ({ plan, days }, { world }) => {
const billing = billingSteps(world);

const customer = await billing.aCustomerWithActiveSubscription();
await billing.renewalSucceeds(customer.id, plan);
await billing.nextBillingIsInDays(customer.id, days);
});

Each row is a separate, isolated test with its own fresh world. The $plan / $days placeholders interpolate into the test name, so the runner shows two readable lines.

Background — visible vs invisible

If the setup is part of the story the reader should see, call it as the first step (visible in the body):

test('…', async ({ world }) => {
const auth = authSteps(world);
auth.aRegisteredUser(); // ← the "Background", visible
// ...
});

If it's pure plumbing the reader shouldn't care about (start a mock server, seed reference data), make it an auto fixture so it runs for every scenario without being mentioned:

export const test = base.extend<{ world: UiWorld }>({
_server: [
async ({}, use) => { await server.listen(); await use(null); server.close(); },
{ auto: true }, // runs even though no test references it
],
world: async ({}, use) => { /* ... */ },
});

Rule of thumb: visible Background for story-relevant setup, auto fixture for plumbing.

State isolation — never share mutable module state

The classic leak: a let at module scope that scenarios mutate.

// DANGEROUS: scenario 2 inherits scenario 1's mutations
let cart = createCart();

Put per-scenario state on the world (fixture), never at module top level. The fixture re-creates it every test; a module let does not. If you must precompute something expensive and shared, make it a file-scoped fixture and treat it as read-only — the moment a scenario mutates shared state, isolation is gone.

The declarative-vs-imperative trap

The temptation is to write tiny, "reusable" imperative steps:

// reads terribly, leaks everything, not actually a spec
await nav.goTo('/login');
await form.fill('#email', 'ada@example.com');
await form.click('#submit');

These feel reusable but recreate the unreadable version with extra ceremony. Keep steps declarative and at the level of intent (auth.logInAs(user)), and push reuse down into the helper, not up into a pile of micro-steps. One parameterised logInAs(user) beats five granular steps. If a step name doesn't read like something you'd say to a colleague, it's at the wrong level.

DOM leaks between scenarios

If two UI scenarios see each other's elements (duplicate-match errors, stale text), cleanup() isn't running. Ensure it's either in the fixture teardown (Part 3) or the setup file's afterEach (Part 1) — one of them, reliably. Belt-and-suspenders is fine; missing both is the bug.

The rules, condensed

A checklist to keep the suite honest as it grows:

  1. The test body is the spec. If it doesn't read like given/when/then in plain language, refactor until it does.
  2. No mechanics in the test body. Selectors, typing, requests, and assertions live in step helpers — never inline in the scenario.
  3. Steps are declarative and intent-level. logInAs(user), not fill('#email', …).
  4. One step module per domain. Import the ones a feature needs; that import list is its dependency declaration.
  5. The World is a fixture. Fresh per scenario, typed, self-cleaning. Never module-level mutable state.
  6. Two tiers of setup. Expensive → scope: 'file'. Cheap/reset → default test scope. Shared state is read-only.
  7. findBy for async, await everything. Non-negotiable for UI.
  8. test.for for outlines (it keeps your fixtures); test.each only for trivial pure-data cases.
  9. Split environments by file convention. *.ui.test.tsx (jsdom) vs *.api.test.ts (node), via projects.
  10. Same shape, every stack. UI and backend tests should look identical in the body and differ only in which world and which steps they pull in.

You won't get .feature files a non-developer can author — but you'll get the thing that actually mattered all along: tests that read as specifications, hide their implementation, and reuse their setup, with nothing in your dependencies but Vitest and your testing library.

References