testing
The hard part of testing an app is rarely the assertion. It is the setup: the providers a component expects, the query cache, the router, a server listening on a port. @kitr/testing boots your real app for each test, renders components inside its providers, and sends requests to your API by calling its fetch handler directly, so no server starts and no port opens.
It is built on Vitest and Testing Library. It adds three helpers on top: createTestApp() boots an app with your providers and config, createTestClient() sends JSON requests to anything with a fetch(request) method, and renderWithKitr() from @kitr/testing/react renders a component inside that app with the query cache already filled.
It runs in your test process only, never in the app bundle. @kitr/testing has no React in it, so a backend without React uses it as is; @kitr/testing/react adds the component half. To fake an API your frontend calls, rather than test your own, use mocks alongside it.
The examples on this page test Acme's order screens at app.acme.com: an /api/orders API that Ada, Grace and Linus use to create and list orders, and an order list component that shows them.
Install
kitr add testingFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
What kitr add does:
- Adds
@kitr/testingandvitesttodevDependenciesand installs them. In a project with React it also adds@testing-library/react,@testing-library/domandhappy-dom. Pass--no-installto skip the install. - Adds
'@kitr/testing'topackagesinkitrrc.ts. It registers no provider, because nothing runs at app startup. - Writes
vitest.config.ts, unless one exists. See Configure for both versions. - Writes
test/example.test.ts, unless one exists. In a project without React it is an API test of/api/healththroughcreateTestClient(server); with React it is a one-lineexpect(1 + 1).toBe(2)that proves the runner works. - Adds a
## kitr: testingsection toAGENTS.md:pnpm testruns Vitest, components render withrenderWithKitr(<X />, { app, queryData })from@kitr/testing/react, APIs are called withcreateTestClient(server).get('/api/x'), and no running backend is needed.
It writes no config file, adds no env variables, adds no scripts and asks no questions. Projects created with kitr new already have "test": "vitest run" in package.json; in an older project, add that script yourself.
Every kit except blank includes testing: app, fullstack, server, saas, site, realtime, desktop and mobile.
kitr remove testing takes '@kitr/testing' out of kitrrc.ts, removes the @kitr/testing dependency and the AGENTS.md section, then lists files under app/ and config/ that still import @kitr/testing. It leaves vitest.config.ts, everything under test/ and the other dev dependencies in place.
Configure
@kitr/testing has no config file. The test runner reads vitest.config.ts, which kitr add testing writes. In a project with React it reads:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { kitr } from '@kitr/vite'
export default defineConfig({
plugins: [kitr({ plugins: { tailwind: false } })],
test: {
environment: 'happy-dom',
include: ['app/**/*.test.{ts,tsx}', 'test/**/*.test.{ts,tsx}'],
},
})
The kitr() plugin gives tests the same #app, #server, #config and #components import aliases as the app, and the virtual:kitr/providers and virtual:kitr/config modules. happy-dom gives components a DOM to render into. In a project without React the file has no plugin and runs in Node:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['app/**/*.test.{ts,tsx}', 'test/**/*.test.{ts,tsx}'],
},
})
Tests live next to the code they test (app/components/orders/order-list.test.tsx) or under test/ (test/orders.test.ts). Every other Vitest option works in this file as usual.
The three helpers take their options per call. createTestApp(options) takes the same options as createApp():
| Option | Default | Description |
|---|---|---|
providers |
[] |
Providers to register and boot, in order. Pass providers from virtual:kitr/providers for the app's real ones. |
config |
{} |
Per-package config keyed like config/<name>.ts files: { i18n: { ... } }. |
environment |
'test' |
'client', 'server' or 'test'. createTestApp sets 'test' unless you pass another. |
mode |
from Vite's MODE or NODE_ENV |
'development', 'production' or 'test'. Under Vitest it is 'test'. |
env |
{} |
The validated env object, exposed as app.env. |
platform |
from KITR_PLATFORM |
The platform name from kitrrc.ts the app runs as. |
createTestClient(target, defaults):
| Option | Default | Description |
|---|---|---|
target |
required | A kitr server, any object with fetch(request), or a base URL string such as 'https://staging.acme.com'. |
defaults.headers |
{} |
Headers sent with every request. Per-call headers override them. |
renderWithKitr(ui, options) from @kitr/testing/react takes every Testing Library render option plus:
| Option | Default | Description |
|---|---|---|
app |
required | The booted app whose providers wrap the component, usually from createTestApp. |
queryData |
none | Entries [queryKey, data] written into the query cache before rendering. Needs the query provider in app. |
wrapper |
none | An extra component rendered inside the app's providers, such as a router. |
Write your first test
Start with the API, because it needs no DOM. The server in server/main.ts is a fetch handler, so the test client calls it directly. This test creates an order as Ada and reads it back:
// test/orders.test.ts
import { createTestClient } from '@kitr/testing'
import { describe, expect, it } from 'vitest'
import { server } from '#server/main'
describe('orders', () => {
it('creates an order and reads it back', async () => {
const client = createTestClient(server)
const created = await client.post<{ id: string; status: string }>('/api/orders', {
customer: 'Grace',
total: 120,
})
expect(created.status).toBe(201)
expect(created.body.status).toBe('open')
const read = await client.get<{ customer: string }>(`/api/orders/${created.body.id}`)
expect(read.body.customer).toBe('Grace')
})
})
Run it:
pnpm test
✓ test/orders.test.ts (1 test) 31ms
Test Files 1 passed (1)
Tests 1 passed (1)
The first request boots the server's app: every provider in kitrrc.ts registers and boots, exactly as in kitr dev. Nothing listens on a port, so tests run in parallel without clashing.
Run the tests
pnpm test runs vitest run: every test once, then exits with a non-zero code if any failed, which is what CI needs. While you work, run Vitest in watch mode so it reruns the tests a change affects:
pnpm vitest
To run one file, or only the tests whose name matches a pattern:
pnpm test test/orders.test.ts
pnpm test -t "creates an order"
Inside a file, it.only runs one test and it.skip skips one. Remove them before you commit. The Vitest CLI reference lists every flag.
Send requests to your API
createTestClient returns one method per verb. post, put and patch take a body; get and delete do not. Every method takes per-call headers last:
// test/orders.test.ts
import { createTestClient } from '@kitr/testing'
import { expect, it } from 'vitest'
import { server } from '#server/main'
const client = createTestClient(server)
it('updates and deletes an order', async () => {
const { body: order } = await client.post<{ id: string }>('/api/orders', {
customer: 'Linus',
total: 300,
})
const paid = await client.patch<{ status: string }>(`/api/orders/${order.id}`, { status: 'paid' })
expect(paid.body.status).toBe('paid')
const removed = await client.delete(`/api/orders/${order.id}`)
expect(removed.status).toBe(204)
const list = await client.get('/api/orders?status=open&page=2', {
headers: { 'accept-language': 'de' },
})
expect(list.status).toBe(200)
})
put works the same way as patch, for routes you declare with .put(). A .resource() route uses PATCH for updates.
What each request carries:
accept: application/jsonon every request.- A body, when you pass one, sent with
JSON.stringifyandcontent-type: application/json. Falsy bodies such as0,falseandnullare sent too; onlyundefinedmeans no body. - The query string exactly as you write it in the path. Encode values yourself with
URLSearchParamswhen they contain spaces or&.
Don't build new Request(...) and call server.fetch() by hand in every test. The client does it in one line and parses the response for you.
Read the response
Every call resolves to a TestResponse with four fields:
// test/orders.test.ts
import { createTestClient } from '@kitr/testing'
import { expect, it } from 'vitest'
import { server } from '#server/main'
it('returns the new order as JSON', async () => {
const res = await createTestClient(server).post<{ id: string; customer: string }>(
'/api/orders',
{ customer: 'Grace', total: 120 },
)
res.status // => 201
res.headers.get('content-type') // => 'application/json;charset=UTF-8'
res.body // => { id: '1', customer: 'Grace', total: 120, status: 'open' }
res.response // => the raw Response; its body has already been read
expect(res.body).toMatchObject({ customer: 'Grace', total: 120 })
})
| Field | What it holds |
|---|---|
status |
The HTTP status code. |
headers |
The response Headers. |
body |
The body parsed as JSON. When the body is not JSON it is the raw text ('plain words'); when it is empty, as in a 204, it is undefined. |
response |
The raw Response, for anything else. Its body is already consumed, so don't call .json() on it again. |
The type argument (post<{ id: string }>) types body only. It does not check the response at run time, so assert the fields you care about.
Sign requests in
Pass headers once in defaults and every request carries them. A per-call header with the same name wins:
// test/orders.test.ts
import { createTestClient } from '@kitr/testing'
import { expect, it } from 'vitest'
import { server } from '#server/main'
it('only lets a signed-in user create orders', async () => {
const anonymous = createTestClient(server)
expect((await anonymous.post('/api/orders', { customer: 'Grace', total: 120 })).status).toBe(401)
const signIn = await anonymous.post<{ token: string }>('/api/auth/sign-in', {
email: '[email protected]',
password: 'password',
})
const ada = createTestClient(server, { headers: { authorization: `Bearer ${signIn.body.token}` } })
expect((await ada.post('/api/orders', { customer: 'Grace', total: 120 })).status).toBe(201)
const asSomeoneElse = await ada.get('/api/auth/me', {
headers: { authorization: 'Bearer expired-token' },
})
expect(asSomeoneElse.status).toBe(401)
})
Signing in through the real route keeps the test honest: it uses the same tokens, cookies and guards as production. When many files need a signed-in client, put the sign-in in a helper. Test your own test helpers shows one, with a test for it.
Assert validation errors
When a controller's static validation rejects a body, the server answers 422 with kitr's error shape: a code, a message, the status and fieldErrors keyed by field. Assert the fields, not the exact wording, so changing a message does not break the test:
// test/orders.test.ts
import { createTestClient } from '@kitr/testing'
import { expect, it } from 'vitest'
import { server } from '#server/main'
it('rejects an order without a customer or with a negative total', async () => {
const res = await createTestClient(server).post('/api/orders', { customer: 'G', total: -1 })
expect(res.status).toBe(422)
expect(res.body).toMatchObject({ code: 'VALIDATION' })
expect(Object.keys((res.body as { fieldErrors: object }).fieldErrors)).toEqual([
'customer',
'total',
])
})
The full body for that request, with a zod schema of customer: z.string().min(2) and total: z.number().positive():
{
"code": "VALIDATION",
"message": "Validation failed",
"status": 422,
"fieldErrors": {
"customer": ["Too small: expected string to have >=2 characters"],
"total": ["Too small: expected number to be >0"]
}
}
The same fieldErrors reach a form on the client, where forms shows each message under its field.
Assert error responses
Every error a kitr server sends has the same JSON shape, so one assertion style covers them all. A controller that throws KitrError.notFound('Order 99') answers:
// test/orders.test.ts
import { createTestClient } from '@kitr/testing'
import { expect, it } from 'vitest'
import { server } from '#server/main'
it('answers 404 for an order that does not exist', async () => {
const res = await createTestClient(server).get('/api/orders/99')
expect(res.status).toBe(404)
expect(res.body).toEqual({ code: 'NOT_FOUND', message: 'Order 99 not found', status: 404 })
})
The codes you will assert most often:
| Status | code |
Thrown by |
|---|---|---|
401 |
UNAUTHORIZED |
KitrError.unauthorized(); body message Authentication required. |
403 |
FORBIDDEN |
KitrError.forbidden(); body message Not allowed. |
404 |
NOT_FOUND |
KitrError.notFound('Order 99'), or a path no route matches. |
409 |
CONFLICT |
new KitrError('CONFLICT', 'Order already paid'). |
422 |
VALIDATION |
static validation or KitrError.validation(fieldErrors). |
500 |
INTERNAL |
Any other thrown error. In production the message becomes Internal server error. |
A path with no route answers 404 with code: 'NOT_FOUND' and a message like Cannot find any route matching [GET] http://test/api/nothing. Requests from the test client go to http://test, which is why that host appears.
Test a deployed API
Pass a URL string instead of the server and the client sends real HTTP requests with the global fetch. The same test file then runs against a staging deployment, for a smoke test after a deploy:
// test/smoke.test.ts
import { createTestClient } from '@kitr/testing'
import { expect, it } from 'vitest'
const baseUrl = process.env.SMOKE_URL ?? 'https://staging.acme.com'
it('serves health and orders', async () => {
const client = createTestClient(baseUrl, {
headers: { authorization: `Bearer ${process.env.SMOKE_TOKEN}` },
})
expect((await client.get<{ ok: boolean }>('/healthz')).body).toEqual({ ok: true })
expect((await client.get('/api/orders')).status).toBe(200)
})
A trailing slash on the base URL is removed, so https://staging.acme.com/ works too. /healthz is the kitr server's built-in health route. Keep smoke tests out of the default run by giving them their own include pattern, because they need the network and a running deployment.
Test any fetch handler
The target only needs a fetch(request) method that returns a Response. That covers a TanStack Start handler, an h3 app, a Cloudflare Worker's default export, or a webhook handler you wrote by hand:
// test/stripe-webhook.test.ts
import { createTestClient } from '@kitr/testing'
import { expect, it } from 'vitest'
import { handleStripeWebhook } from '#server/webhooks/stripe'
it('marks the invoice paid', async () => {
const webhook = createTestClient({ fetch: handleStripeWebhook })
const res = await webhook.post('/webhooks/stripe', {
type: 'invoice.paid',
data: { object: { id: 'in_1001', customer_email: '[email protected]' } },
})
expect(res.status).toBe(200)
})
Wrapping a plain function in { fetch: ... } is enough. The client builds Request objects against http://test, so read paths from new URL(request.url).pathname in the handler, not the host.
Boot the app without a server
To test a service, a provider or anything else that lives in the container, boot an app with createTestApp() and read from it. This one checks that the billing provider binds an invoices service with the configured currency:
// app/lib/invoices/invoices.test.ts
import { createTestApp } from '@kitr/testing'
import { expect, it } from 'vitest'
import billingProvider from '#app/providers/billing'
it('binds the invoice service with the configured currency', async () => {
const app = await createTestApp({
providers: [billingProvider],
config: { billing: { currency: 'EUR' } },
})
app.status // => 'booted'
app.environment // => 'test'
app.config.get('billing') // => { currency: 'EUR' }
const invoices = app.container.use('invoices')
expect(invoices.format(1234.5)).toBe('€1,234.50')
})
createTestApp runs every provider's register, then resolves the container, then runs every boot. It does not run ready or shutdown, which only servers call. Each call builds a new app, so one test's services never leak into the next.
Reading a binding nobody registered throws, with the reason in the message:
app.container.use('mailer')
// => Error: [kitr] No binding named "mailer". Did you add the package that provides it?
@kitr/testing also re-exports createApp, for a test that wants to add providers with app.use() before booting it itself.
Use your real providers and config
In a project with React, the kitr() plugin in vitest.config.ts serves the same two virtual modules the app uses. Import them to boot the app exactly as app/app.ts does:
// app/lib/orders/app.test.ts
import { createTestApp } from '@kitr/testing'
import { config } from 'virtual:kitr/config'
import { providers } from 'virtual:kitr/providers'
import { expect, it } from 'vitest'
it('boots with every provider in kitrrc.ts', async () => {
const app = await createTestApp({ providers, config })
expect(app.container.has('queryClient')).toBe(true)
expect(app.container.has('http')).toBe(true)
})
virtual:kitr/providers is every provider listed in kitrrc.ts, in order. virtual:kitr/config is every file in config/, keyed by file name. Pass env when a provider reads app.env, because createTestApp does not run app/env.ts.
A project without React has no plugin and no virtual modules. There, test through the server, which already boots the providers from kitrrc.ts, or import kitrrc.ts and the config index yourself.
Replace a service with a fake
Tests should not send real emails or charge real cards. Bind a fake under the same name, after the real provider, and every caller gets the fake. A later binding replaces an earlier one:
// test/order-confirmation.test.ts
import { defineProvider } from 'kitr/app'
import { createTestApp } from '@kitr/testing'
import { config } from 'virtual:kitr/config'
import { providers } from 'virtual:kitr/providers'
import { expect, it } from 'vitest'
import { sendOrderConfirmation } from '#app/lib/orders/confirmation'
it('emails the customer when an order is placed', async () => {
const sent: Array<{ to: string; subject: string }> = []
const fakeMailer = defineProvider({
name: 'fake-mailer',
register: (app) => app.container.value('mailer', { send: async (m) => void sent.push(m) }),
})
const app = await createTestApp({ providers: [...providers, fakeMailer], config })
await sendOrderConfirmation(app, { id: '1001', email: '[email protected]' })
expect(sent).toEqual([{ to: '[email protected]', subject: 'Order 1001 confirmed' }])
})
To fake a service the API server uses, bind it on server.app before the requests that need it. Controllers that read services from ctx.app.container then get the fake. This pins the clock so an "orders due today" route gives the same answer every day:
// test/orders-due.test.ts
import { createTestClient } from '@kitr/testing'
import { expect, it } from 'vitest'
import { server } from '#server/main'
it('lists orders due today', async () => {
await server.boot()
server.app.container.value('clock', { now: () => new Date('2026-03-01T09:00:00Z') })
const res = await createTestClient(server).get<{ today: string }>('/api/orders/due')
expect(res.body.today).toBe('2026-03-01')
})
Call server.boot() first, so the real provider's binding exists before you replace it. A service a module captured at import time, outside any function, keeps the original; read services inside the handler.
Render a component
On the client, renderWithKitr from @kitr/testing/react renders a component inside every provider the app has, the same tree KitrProvider builds in the browser. Then query the screen with Testing Library:
// app/components/orders/order-total.test.tsx
import { screen } from '@testing-library/react'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { expect, it } from 'vitest'
import { OrderTotal } from '#app/components/orders/order-total'
it('shows the order total', async () => {
const app = await createTestApp()
renderWithKitr(<OrderTotal total={120} />, { app })
expect(screen.getByText('Total: 120')).toBeTruthy()
})
renderWithKitr returns what Testing Library's render returns: container, rerender, unmount and the bound queries. useApp() inside the component returns app, and app.mode is 'test'. To click and type, use fireEvent from @testing-library/react, or install @testing-library/user-event for events closer to a real user's.
Seed the query cache
A component that loads data with query would try the network on first render. Pass queryData and it renders with the data already in the cache. Each entry is a query key and the data under it:
// app/components/orders/order-list.test.tsx
import queryProvider from '@kitr/query/provider'
import { screen } from '@testing-library/react'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { expect, it } from 'vitest'
import { OrderList } from './order-list'
import { ordersQuery } from '#app/queries/orders'
it('lists the orders from the cache', async () => {
const app = await createTestApp({ providers: [queryProvider] })
renderWithKitr(<OrderList />, {
app,
queryData: [[ordersQuery.key(), [{ id: '1', customer: 'Grace', total: 120 }]]],
})
expect(await screen.findByText('Grace: 120')).toBeTruthy()
})
The query and the component under test read the same key:
// app/queries/orders.ts
import { queryFrom } from '@kitr/query'
export interface Order {
id: string
customer: string
total: number
}
export const ordersQuery = queryFrom<void, Order[]>({
key: () => ['orders', 'list'],
fetch: async (_input, { signal }) => {
const res = await fetch('/api/orders', { signal })
return (await res.json()) as Order[]
},
})
// app/components/orders/order-list.tsx
import { useQuery } from '@kitr/query'
import { ordersQuery } from '#app/queries/orders'
export function OrderList() {
const { data, isPending } = useQuery(ordersQuery.options())
if (isPending) return <p>Loading orders</p>
return (
<ul>
{data?.map((order) => (
<li key={order.id}>
{order.customer}: {order.total}
</li>
))}
</ul>
)
}
Take the key from the query definition (ordersQuery.key()), not a hand-written array, so the test follows the key when it changes. queryData is written with setQueryData before the first render. Whether the component refetches it afterwards depends on the query's staleTime, so give real queries a staleTime, or fake the network with mocks. When the app has no query provider, queryData is ignored.
Read services in a component
A component that calls useService('clock') gets whatever the app's container holds. Register a fake provider and the component renders with it:
// app/components/orders/due-badge.test.tsx
import { screen } from '@testing-library/react'
import { defineProvider } from 'kitr/app'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { expect, it } from 'vitest'
import { DueBadge } from '#app/components/orders/due-badge'
const fixedClock = defineProvider({
name: 'clock',
register: (app) => app.container.value('clock', { now: () => new Date('2026-03-01T09:00:00Z') }),
})
it('says an order due today is due today', async () => {
const app = await createTestApp({ providers: [fixedClock] })
renderWithKitr(<DueBadge dueAt="2026-03-01" />, { app })
expect(screen.getByText('Due today')).toBeTruthy()
})
The same pattern fakes the http client, the analytics tracker or anything else a component reads from the container.
Render a component that uses links
<Link> and the route hooks need a router above them. Pass one as wrapper; renderWithKitr renders it inside the app's providers. A root route whose component is the element under test, on a memory history, is enough:
// test/with-router.tsx
import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterProvider,
} from '@kitr/router'
import { type ReactNode, useMemo } from 'react'
/** A wrapper that renders the element under test as the root route at `path`. */
export function withRouter(path = '/') {
return function RouterWrapper({ children }: { children: ReactNode }) {
const router = useMemo(() => {
const rootRoute = createRootRoute({ component: () => children, notFoundComponent: () => null })
return createRouter({
routeTree: rootRoute,
history: createMemoryHistory({ initialEntries: [path] }),
})
}, [children])
return <RouterProvider router={router} />
}
}
// app/components/orders/order-row.test.tsx
import { screen } from '@testing-library/react'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { expect, it } from 'vitest'
import { withRouter } from '../../../test/with-router'
import { OrderRow } from './order-row'
it('links to the order', async () => {
const app = await createTestApp()
renderWithKitr(<OrderRow order={{ id: '1', customer: 'Grace', total: 120 }} />, {
app,
wrapper: withRouter('/orders'),
})
const link = await screen.findByRole('link', { name: 'Open' })
expect(link.getAttribute('href')).toBe('/orders/1')
})
The router renders after a tick, so use findBy... queries, which wait, not getBy.... The helper lives in test/ because it is shared by many component tests; there is no import alias for that folder, so import it with a relative path.
Render a whole page
To test a page with its loader, its search params and its layout, build the real router from your route table with createKitrRouter and render it. The router wraps the tree in the app's providers itself, so use Testing Library's plain render:
// app/pages/orders.test.tsx
import { act, render, screen } from '@testing-library/react'
import queryProvider from '@kitr/query/provider'
import { createKitrRouter, createMemoryHistory, RouterProvider } from '@kitr/router'
import { createTestApp } from '@kitr/testing'
import { expect, it } from 'vitest'
import { routes } from '#app/routes'
it('renders the orders page from its URL', async () => {
const app = await createTestApp({ providers: [queryProvider] })
const router = createKitrRouter({
routes,
app,
history: createMemoryHistory({ initialEntries: ['/orders?status=open'] }),
})
await act(async () => {
render(<RouterProvider router={router} />)
await router.load()
})
expect(await screen.findByRole('heading', { name: 'Orders' })).toBeTruthy()
})
The page's loader runs for real, so either seed its data with app.container.use('queryClient').setQueryData(...) before router.load(), or answer its requests with mocks.
Fake the network for components
When a component really fetches, answer its requests with mocks instead of seeding the cache. MSW intercepts fetch in the test process, so the component, the query and the http client all run as in production:
// app/components/orders/order-list.network.test.tsx
import queryProvider from '@kitr/query/provider'
import { screen } from '@testing-library/react'
import { HttpResponse, http, setupNodeMocks } from '@kitr/mocks'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { afterAll, beforeAll, expect, it } from 'vitest'
import { OrderList } from './order-list'
let mocks: Awaited<ReturnType<typeof setupNodeMocks>>
beforeAll(async () => {
mocks = await setupNodeMocks([
http.get('*/api/orders', () => HttpResponse.json([{ id: '1', customer: 'Ada', total: 80 }])),
])
})
afterAll(() => mocks.close())
it('loads orders from the API', async () => {
const app = await createTestApp({ providers: [queryProvider] })
renderWithKitr(<OrderList />, { app })
expect(screen.getByText('Loading orders')).toBeTruthy()
expect(await screen.findByText('Ada: 80')).toBeTruthy()
})
Seed the cache when the test is about rendering. Fake the network when it is about loading, errors and retries. The mocks guide covers per-test overrides and error responses.
Choose the test environment per file
The React config runs every file in happy-dom. API tests do not need a DOM, and Node's own Request and Response are what your server uses in production. Put a comment on the first line of the file to run it in Node:
// @vitest-environment node
// test/invoices.test.ts
import { createTestClient } from '@kitr/testing'
import { expect, it } from 'vitest'
import { server } from '#server/main'
it('lists a team’s invoices', async () => {
const res = await createTestClient(server).get('/api/teams/acme/invoices')
expect(res.status).toBe(200)
})
The comment must come before any import. The saas, site and realtime kits put it on their API tests for the same reason.
Keep tests isolated
Vitest runs each file in its own module graph, so files never share a server or an app. Tests inside one file do share the imported server, including anything its controllers keep in memory. Make each test create the data it reads, and don't assume an order with id 1 exists:
// test/orders.test.ts
import { createTestClient } from '@kitr/testing'
import { expect, it } from 'vitest'
import { server } from '#server/main'
const client = createTestClient(server)
it('finds the order it just created', async () => {
const { body } = await client.post<{ id: string }>('/api/orders', { customer: 'Ada', total: 80 })
expect((await client.get(`/api/orders/${body.id}`)).status).toBe(200)
})
For services, prefer a fresh createTestApp() per test over one shared at the top of the file. Boot is cheap, and a fresh container means no state carries over.
Commands
| Command | What it does |
|---|---|
kitr make:test <name> |
Writes test/<name>.test.ts with a test that boots the app. --force overwrites an existing file. |
kitr make:test orders writes:
// test/orders.test.ts
import { createTestApp } from '@kitr/testing'
import { describe, expect, it } from 'vitest'
describe('orders', () => {
it('boots the app', async () => {
const app = await createTestApp()
expect(app.status).toBe('booted')
})
// HTTP: `const client = createTestClient(server)` with `server` from server/main.ts, then
// `expect((await client.get('/api/health')).status).toBe(200)`.
})
The name is turned into kebab case, so kitr make:test invoice-totals writes test/invoice-totals.test.ts. A stubs/test.ts file in your project (kitr eject stub test copies the default) replaces the template. The generator ships with @kitr/testing, so make:test is there once the package is added. Testing adds no kitr doctor checks.
Testing
Shared helpers are code too, and a broken helper makes every test that uses it lie. Test them with an inline fetch handler: an object with a fetch method that echoes what it received, so you can assert what the helper sends without booting your server.
This helper signs a client in as one of Acme's users:
// test/helpers.ts
import { createTestClient } from '@kitr/testing'
type Target = Parameters<typeof createTestClient>[0]
/** A client signed in as `email`, through the real sign-in route. */
export async function signedInClient(target: Target, email: string) {
const anonymous = createTestClient(target)
const res = await anonymous.post<{ token: string }>('/api/auth/sign-in', {
email,
password: 'password',
})
if (res.status !== 201) throw new Error(`Sign-in as ${email} failed with ${res.status}`)
return createTestClient(target, { headers: { authorization: `Bearer ${res.body.token}` } })
}
And its test, which runs in milliseconds and needs no server:
// test/helpers.test.ts
import { describe, expect, it } from 'vitest'
import { signedInClient } from './helpers'
const fakeApi = {
async fetch(request: Request) {
const { pathname } = new URL(request.url)
if (pathname === '/api/auth/sign-in') {
const { email } = (await request.json()) as { email: string }
if (email === '[email protected]') return Response.json({ code: 'UNAUTHORIZED' }, { status: 401 })
return Response.json({ token: `token-for-${email}` }, { status: 201 })
}
return Response.json({
path: pathname,
authorization: request.headers.get('authorization'),
accept: request.headers.get('accept'),
})
},
}
describe('signedInClient', () => {
it('sends the token from sign-in on every request', async () => {
const ada = await signedInClient(fakeApi, '[email protected]')
const res = await ada.get<{ path: string; authorization: string; accept: string }>('/api/orders')
expect(res.status).toBe(200)
expect(res.body).toEqual({
path: '/api/orders',
authorization: 'Bearer [email protected]',
accept: 'application/json',
})
})
it('lets one request override the token', async () => {
const grace = await signedInClient(fakeApi, '[email protected]')
const res = await grace.get<{ authorization: string }>('/api/orders', {
headers: { authorization: 'Bearer other' },
})
expect(res.body.authorization).toBe('Bearer other')
})
it('fails loudly when sign-in is refused', async () => {
await expect(signedInClient(fakeApi, '[email protected]')).rejects.toThrow(
'Sign-in as [email protected] failed with 401',
)
})
})
The same inline-handler trick tests any code that takes a fetch target: retry wrappers, webhook signers, a client for a partner API.
Next steps
- API reference: every export of
@kitr/testingand@kitr/testing/react. - Mocks: fake the API your frontend calls, in tests and in the browser.
- Vitest docs: matchers, mocking modules, timers, coverage.
- Testing Library queries: which query to use for which element.
- API: the routes and controllers these tests call.