Feature
Over the years, the landscape of web development has changed dramatically. We have come far from our jQuery beginnings, and preferences in that time have shifted in a big way in terms of web application development. One of the biggest shifts has been a great decoupling of front ends and back ends. This serves the modern app ecosystem well, since a team can exclusively focus on servicing a front end, while another team works on building out a back end, allowing coders to specialize and deliver higher quality products faster.
Psychic believes strongly in this philosophy, and recognizes that many teams will be looking to use our technology without marrying the code bases or teams in any way. For those who are in this boat, Psychic is still for you, though feature specs may not be.
What is a "feature" spec?
Feature specs are also commonly referred to as end-to-end, or e2e specs. We borrowed the classic term from rspec to continue to nod to the amazing Ruby ecosystem we were so inspired by as we wrote Psychic. While unit tests can also have an end-to-end-like quality to them, especially when you are testing endpoints, feature specs are meant to test interactions outside your backend application.
A feature spec, like a unit spec, is written and executed from the backend context. This is amazingly powerful, since it provides you all the tools to provision your database between specs, enabling you to do proper set up, tear down, and assert the state of your database in between.
Where feature specs differ from unit specs is that a headless browser is used to drive through any number of your client applications, enabling them to interact with your backend application and providing the ability to test the results of those interactions however you see fit.
How is this done?
Feature specs will use Vitest to run, but will additionally leverage Puppeteer to drive a headless browser through your client. Psychic comes prepared with assertion helpers to make working in this environment very comfortable for you, though the assertion library exposed by Puppeteer is powerful enough to not need anything else.
When provisioning your application, psychic asks if you would like a client and admin application. If you select either of these options, the new app provisioner will automatically provision new client and admin applications in the front end framework of choice. All of the client application provisioning is done using the latest version of vite, and no additional code is added to the front end.
This is, generally speaking, magical, since most everyone I know has a totally different toolset they like to use to build out their front ends, and being forced into any specific paradigm can be untenable. Psychic attempts to bridge the gap to your front end only at the testing layer, allowing it to be otherwise completely decoupled from your back end application.
Configuration
The configuration entry point is found at spec/features/vite.config.ts. This file will look near-identical to the vite.config.ts file found in the unit folder. Where the feature specs differ is really in two places. The first is the global setup and teardown, both located at spec/features/setup/globalSetup.ts. In this file, a new vite server is launched, pointing at the client app that was provisioned for you.
// spec/features/setup/globalSetup.ts
import '../../../src/conf/global.js'
import { PsychicDevtools } from '@rvoh/psychic'
export async function setup() {
await PsychicDevtools.launchDevServer('client', {
port: 3000,
cmd: 'pnpm client:fspec',
})
}
export function teardown() {
PsychicDevtools.stopDevServer('client')
}
The second point of departure from unit specs can be found at spec/features/setup/hooks.ts. Here you will see that we additionally start and stop a Psychic server programmatically. Doing so enables us to spy on backend modules, which is extremely important for end-to-end tests.
// spec/features/setup/hooks.ts
import { DreamApp } from '@rvoh/dream'
import { PsychicServer } from '@rvoh/psychic'
import { truncate } from '@rvoh/dream-spec-helpers'
import AppEnv from '../../../../src/conf/AppEnv.js'
import initializePsychicApp from '../../../../src/app/helpers/initializePsychicApp'
let server: PsychicServer
beforeEach(async () => {
await initializePsychicApp()
server = new PsychicServer()
await server.start(AppEnv.integer('DEV_SERVER_PORT', { optional: true }) || 7778)
await truncate(DreamApp)
})
afterEach(async () => {
await server.stop({ bypassClosingDbConnections: true })
})
Running specs
To run feature specs, Psychic automatically provides a script in your package.json file, called fspec (short for "feature spec"). You can run it, simply by calling pnpm fspec from your api directory
cd api
pnpm fspec
The API server runs in-process
Feature specs start the PsychicServer inside the same Vitest worker as the test (the generated spec/features/setup/hooks.ts calls server.start(...) in beforeEach, then the browser is launched). The browser talks to that server over localhost:<port>, but the server runs the same module instances the spec can see. So vi.spyOn(SomeService, 'method') on backend modules intercepts server-side code that runs while the browser drives the front end — exactly as in a unit or controller spec. There's no separate process and no IPC barrier.
vi.mock is the exception. The setup file's static import of the app config pulls in conf/routes.ts and every controller a route file names as an argument (r.get('/places', PlacesController, 'show')), loading them before a spec's mock registry exists — so the mock is silently ignored and you end up spying on the runtime object instead. A controller only the boot-time loader reaches later does get mocked, which is why the same vi.mock can be live on one route namespace and inert on another.
A common (wrong) assumption is "a feature spec runs a real server I can't reach into, so I must use a live API key or record HTTP." Not so — to make a feature spec deterministic and offline, stub the backend boundary (an external API gateway, the clock, a third-party client) with vi.spyOn in the feature spec, the same way you would anywhere else. Reserve HTTP recording (Polly) for cases where you genuinely want to exercise the real client code path.
For a spy shared across specs (a let assigned in beforeEach, restored in afterEach), type it MockInstance<typeof Obj.method> (import type { MockInstance } from 'vitest'), not ReturnType<typeof vi.spyOn> — the latter resolves to any and passes pnpm build:spec but fails lint's no-unsafe-call/no-unsafe-member-access rules.
Running a real external service
Occasionally a feature spec needs a real external dependency running rather than a stub — most often an emulator the front end and API both talk to (for example, a Firebase Auth emulator). The harness assumes any such dependency is already listening: globalSetup launches the front-end dev servers, then hooks.ts's beforeEach starts the in-process PsychicServer and the browser. None of that starts external services, and all of it may depend on them being up.
Supply the service from outside the harness by wrapping the existing spec command with the service's own runner, rather than editing globalSetup / hooks.ts. Prefer an exec-style launcher — one that starts the service, runs the wrapped command to completion, then tears it down — over a long-running "start" command, so each run owns a fresh instance and nothing leaks between runs:
// package.json — wrap the whole command; the harness is untouched
"fspec": "<service> exec '<existing fspec command>'"
Leave uspec unwrapped — unit specs don't drive the browser or the dev servers, so they shouldn't pay the startup cost or depend on the service's port.
Custom assertion matchers
Psychic doesn't know much about your UI, so the assertion helpers it provides out of the box are fairly basic. That being said, this suite of tools is generally enough to get the job done for most apps.
import { visit } from '@rvoh/psychic-spec-helpers'
describe('places index page', () => {
beforeEach(async () => {
await createUser()
await createPlace({ name: 'Mountain Cabin', ... })
})
it('accepts the request', async () => {
const page = await visit('/places')
await expect(page).toMatchTextContent('Mountain Cabin')
})
})
Psychic provides the following helpers:
launchBrowser- launches a new puppeteer browser with your provided configuration.launchPage- launches a new browser and creates a new page from it.providePuppeteerViteMatchers- a helper function that provides the assertion helpers for vite and puppeteer
Additionally, Psychic provides the following assertion helpers:
toCheck- attempts to check a checkboxtoClick- attempts to click an element on the page with the specified texttoClickButton- attempts to click a button on the page with the specified texttoClickButton- attempts to click an anchor tag on the page with the specified texttoClickSelector- attempts to click an element on the page with the specified css selectortoFill- attempts to fill in the value for a text fieldtoHaveChecked- expects the page to have a checked element with the specified text valuetoHaveLink- expects the page to have a link with the specified text valuetoHavePath- expects the page to have the specified pathtoHaveSelector- expects the page to have the specified css selectortoHaveUnchecked- expects the page to have an unchecked checkbox with the provided text valuetoHaveUrl- expects the page to have the provided urltoMatchTextContent- expects the page to have the provided text or match the providedRegExptoNotHaveSelector- expects the page to not have the provided css selectortoNotMatchTextContent- expects the page to not match the provided text content orRegExptoUncheck- attempts to uncheck a checkbox with the provided text valuetoSelect- attempts to select the option from the provided css selector
To utilize these assertion matchers, you can tap into them via expect chaining, like so:
await expect(page).toClick('Submit')
toMatchTextContent and toNotMatchTextContent accept either a plain string or a RegExp:
// string match
await expect(page).toMatchTextContent('Welcome back')
// regex match — useful when the exact text varies
await expect(page).toMatchTextContent(/welcome back/i)
await expect(page).toNotMatchTextContent(/error/i)
In addition to these assertion matchers, @rvoh/psychic-spec-helpers also ships with some helpful global functions which essentially do the same thing, but are meant to be used in cases when you aren't meaning to make an assertion.
it('allows the user to click on the profile link', async () => {
await clickLink('Home')
await expect(page).toClickLink('Profile')
})
the global helpers available to you are:
- check
- click
- clickButton
- clickSelector
- fillIn
- uncheck
- select
- visit
What the text matchers actually read
toMatchTextContent / toNotMatchTextContent assert on what the page renders, not its DOM source. They collect each element's innerText (joined with spaces, with input/textarea values included), so they see CSS text-transform, visibility, and other rendered output. Two consequences follow.
Default to a case-insensitive regex for text content. A label whose source is 5 out of 5 but is rendered with an uppercase class matches as 5 OUT OF 5. The literal string fails, and hardcoding '5 OUT OF 5' couples the spec to cosmetic styling. Match on meaning instead:
await expect(page).toMatchTextContent(/5 out of 5/i) // robust
// not '5 out of 5' (fails on the transform), not '5 OUT OF 5' (pins the spec to CSS)
Case carries no meaning in rendered copy, so this is the outcomes-not-implementation stance applied to text. The exception is when the displayed string is an identifier whose case is part of its value — a coupon code SAVE20, an invite token, a case-sensitive ID. There, assert the exact case, because /save20/i would pass on the wrong value.
Keep this separate from specificity. Case-insensitivity does not make a match looser on its own; an over-broad pattern does — /error/i also matches no errors found. Choose a pattern specific enough to avoid accidental substring hits, independent of case.
Split label/value markup matches contiguously. Because per-element text is joined with spaces, <dt>Sleeps</dt><dd>4</dd> lands in the matched string as Sleeps 4, so toMatchTextContent('Sleeps 4') matches a label/value split with no page.$eval workaround. A more complex layout — a flex container around the <dt>/<dd> pair, for instance — can break that contiguity, landing extra whitespace between what look like adjacent elements in the source. If a literal contiguous match fails unexpectedly on markup that looks equivalent, fall back to a whitespace-tolerant regex (/Sleeps\s+4/).
What the selector matchers actually assert
toHaveSelector is a presence-only assertion: it checks that the selector is attached to the DOM, regardless of visibility. A matching element hidden with display: none or visibility: hidden still passes toHaveSelector, the same as a visible one.
toNotHaveSelector requires true DOM absence. A present-but-hidden element — still in the DOM but invisible via CSS — fails toNotHaveSelector; only an element that's actually unmounted or never rendered passes it.
The rest of the matcher set is presence/interaction-only — toCheck, toClick/toClickButton/toClickLink/toClickSelector, toFill, toHaveChecked, toHaveLink, toHavePath, toHaveSelector, toHaveUnchecked, toHaveUrl, toNotHaveSelector, toSelect, toUncheck — none of these assert visibility directly. toMatchTextContent/toNotMatchTextContent are the exception: as covered above, they read rendered innerText, so they're visibility-sensitive by construction.
When a spec genuinely needs to distinguish hidden-but-mounted from truly absent, assert the mechanism that hides the element rather than reaching for a visibility matcher that doesn't exist. If a booking confirmation banner is toggled with an invisible CSS class instead of being unmounted, assert the class directly:
await expect(page).toHaveSelector('.booking-confirmation') // still mounted either way
const classAttr = await page.$eval('.booking-confirmation', el => el.className)
expect(classAttr).toMatch(/\binvisible\b/) // actually hidden
Full example
import City from '@models/City.js'
import createAdminUser from '@spec/factories/AdminUserFactory.js'
import adminSignIn from '@spec/features/helpers/adminSignIn.js'
describe('Cities create', () => {
it('allows an admin to create a new city from the index', async () => {
const adminUser = await createAdminUser()
await adminSignIn(adminUser, '/cities')
await expect(page).toClickLink('New City')
await expect(page).toHavePath('/cities/new')
await expect(page).toFill('#name', 'Denver')
await expect(page).toFill('#stateOrProvince', 'Colorado')
await expect(page).toSelect('#country', 'united_states')
await expect(page).toClickButton('Create City')
await expect(page).toHavePath('/cities')
await expect(page).toMatchTextContent('Denver')
await expect(page).toMatchTextContent('Colorado')
})
})
Organize feature specs by actor and scenario
Follow the BDD convention from Cucumber/RSpec: organize by actor (role/persona), with filenames as third-person verb phrases that describe what the actor does. The directory provides the subject; the filename completes the sentence — read as "guest browses places", "host creates a place".
spec/features/
guest/
places/
browses-places.spec.ts
views-place.spec.ts
searches-places.spec.ts
books-place.spec.ts
favorites/
manages-favorites.spec.ts
host/
places/
creates-place.spec.ts
updates-place.spec.ts
deletes-place.spec.ts
views-places.spec.ts
rooms/
creates-room.spec.ts
updates-room.spec.ts
deletes-room.spec.ts
views-rooms.spec.ts
visitor/
places/
browses-places.spec.ts
searches-places.spec.ts
views-place.spec.ts
sign-up/
signs-up-from-booking.spec.ts
signs-up-from-favorite.spec.ts
Avoid flat naming like guest-browses-places.spec.ts — the directory structure already carries the actor context. Avoid bare CRUD names like create.spec.ts or index.spec.ts — those are resource-oriented, not behavior-oriented.
Debugging feature specs visually
When a feature spec fails, seeing what the browser is actually rendering is much faster than guessing from assertion failure messages.
- Run
pnpm fspec:visibleand watch the browser. - Or add
await page.screenshot({ path: '/tmp/debug.png' })before the failing assertion, run the spec, then open the screenshot — it will show validation errors, missing elements, or unexpected page state.
Native date inputs
Native <input type="date"> inputs don't accept programmatic value setting via toFill, $eval setter tricks, or React state manipulation. The browser renders separate mm/dd/yyyy segments that must be typed through individually.
Use page.keyboard.type('MMDDYYYY') after clicking the input:
const dateInput = await page.$('input[name="arriveOn"]')
await dateInput!.click()
await page.keyboard.type('06012026') // types 06/01/2026 through segments
Waiting for the browser
The test process and the browser run concurrently. Before asserting on the database or interacting with the page, wait for the browser to be ready. Use page.waitForNetworkIdle({ idleTime: 500 }) as the general-purpose wait — it covers hydration, API calls, and navigation:
// After sign-in or navigation — wait for React hydration before interacting
await hostSignIn(page, user)
await page.waitForNetworkIdle({ idleTime: 500 })
// After a browser action that triggers an API call — wait before asserting on the database
await clickButton(page, 'Add Comment')
await page.waitForNetworkIdle({ idleTime: 500 })
const comment = await post.associationQuery('comments').firstOrFail()
expect(comment.body).toEqual('Great post!')
When the UI visibly changes after the API call (for example, navigation to a new path), waiting on that UI change is a cleaner signal:
await clickButton(page, 'Create Place')
await expect(page).toHavePath('/places') // proves the response completed
const place = await Place.firstOrFail() // safe to query now
toHavePath only proves completion when the path actually changestoHavePath compares the pathname only (internally new URL(href).pathname), so it ignores the query string. A spec that starts on /places?placeId=123, triggers a delete, and asserts await expect(page).toHavePath('/places') passes the moment it runs — the pathname was already /places before the mutation, so nothing was waited for and the row may still exist.
When the path doesn't change, wait on the eventual state instead. Poll the database for the actual outcome:
await clickButton(page, 'Delete')
await expect
.poll(async () => await Place.find(place.id))
.toBeNull() // waits for the delete to land
Or wait on a UI signal genuinely tied to the mutation finishing (a removed row, a success banner) — not on a toHavePath that was already true.
Cleanup
Between each spec run, the database will be truncated to ensure a clean slate. Similar to unit specs, this is set up in the spec/features/setup/hooks.ts file:
// api/spec/features/setup/hooks.ts
import { DreamApp } from '@rvoh/dream'
import { truncate } from '@rvoh/dream-spec-helpers'
...
beforeEach(async () => {
await truncate(DreamApp)
})
If there is anything else you need to do after each spec run, feel free to add to this file.