KickJS 9.0 and @forinda/kickjs-testing 8.1 shipped together, and most of what changed is about tests. This post walks through the new pieces using a small task-management API: projects, tasks and comments behind JWT auth. At the end I share numbers from moving a much larger production suite onto them (521 integration files, about 2,600 tests).
Short version: you write far less setup per test file, slow suites get a bit faster, and "only fails in the full run" bugs become easier to see.
The example app
A KickJS app with three modules:
// src/index.ts
import './config' // registers the env schema before anything reads it
import { bootstrap } from '@forinda/kickjs'
import { ProjectsModule, TasksModule, CommentsModule } from './modules'
export const app = await bootstrap({
modules: [ProjectsModule(), TasksModule(), CommentsModule()],
apiPrefix: '/api',
defaultVersion: 1,
})
The endpoints are the usual ones: POST /api/v1/projects, GET /api/v1/projects/:id/tasks, PATCH /api/v1/tasks/:id, POST /api/v1/tasks/:id/comments. Assigning a task sends the assignee an email.
Integration tests boot the real app in-process against a real Postgres. Vitest runs files one at a time, because they share a database:
// vitest.config.ts (excerpt)
test: {
pool: 'forks',
fileParallelism: false, // one file at a time: they share one database
isolate: false, // keep the module cache across files in a worker
}
What a test file looked like before
import request from 'supertest'
import { createTestApp } from '@forinda/kickjs-testing'
let app, expressApp, token
const auth = (r) => r.set('Authorization', `Bearer ${token}`)
beforeAll(async () => {
const t = await createTestApp({ modules: [ProjectsModule(), TasksModule()] })
app = t.app
expressApp = t.expressApp
token = await signTestJwt(alice) // see "Test users and tokens" below
})
afterAll(async () => {
await cleanDb()
await app.shutdown()
})
it('creates a task', async () => {
const res = await auth(request(expressApp).post('/api/v1/projects/p1/tasks')).send({
title: 'Write the post',
})
expect(res.status).toBe(201)
})
Multiply that by a few hundred files and two costs appear. Every file builds the whole app and shuts it down again: DI container, adapters, database pools. And every file carries its own auth() helper and its own copy of the /api/v1 prefix.
Test users and tokens
The examples below use two users. Alice owns project p1; Bob is a member who can add tasks but not delete the project. They're inserted straight into the test database, and their tokens are signed with the same secret and claims the app's auth guard checks, so no test has to go through a login endpoint:
// tests/fixtures/users.ts
import { SignJWT } from 'jose'
import { getEnv } from '@forinda/kickjs'
import { db } from './db' // a Drizzle client on the test database
import { users, projects, projectMembers } from '../../src/db/schema'
export const alice = { id: 'u-alice', email: 'alice@example.com', name: 'Alice' }
export const bob = { id: 'u-bob', email: 'bob@example.com', name: 'Bob' }
export async function seedUsers() {
await db.insert(users).values([alice, bob]).onConflictDoNothing()
await db.insert(projects).values({ id: 'p1', name: 'Launch', ownerId: alice.id }).onConflictDoNothing()
await db
.insert(projectMembers)
.values([
{ projectId: 'p1', userId: alice.id, role: 'owner' },
{ projectId: 'p1', userId: bob.id, role: 'member' },
])
.onConflictDoNothing()
}
/** A token the app's auth guard accepts — same secret, issuer and claims as login. */
export function signTestJwt(user: { id: string; email: string }) {
return new SignJWT({ email: user.email })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(user.id)
.setIssuer(getEnv('JWT_ISSUER'))
.setIssuedAt()
.setExpirationTime('1h')
.sign(new TextEncoder().encode(getEnv('JWT_SECRET')))
}
Each test file seeds them and signs the tokens once:
import { alice, bob, seedUsers, signTestJwt } from './fixtures/users'
let aliceToken: string
let bobToken: string
beforeAll(async () => {
await seedUsers()
aliceToken = await signTestJwt(alice)
bobToken = await signTestJwt(bob)
})
JWT_SECRET and JWT_ISSUER come from .env.test (section 5), so tests sign with a throwaway secret, never the real one. If your app already exports its own signAccessToken(), call that instead of copying the claims: then a change to the token shape can't drift away from what the tests sign.
1. client(): requests without the boilerplate
createTestApp() now returns a client alongside app and container. It sends requests through whichever runtime the app uses (Express, Fastify or h3) without opening a port. It needs supertest installed. You set the headers every call needs once:
const { client } = await createTestApp({ modules: [ProjectsModule(), TasksModule()] })
const api = client({ basePath: '/api/v1' })
await api.as(aliceToken).post('/projects/p1/tasks').send({ title: 'Write the post' }).expect(201)
await api.as(bobToken).delete('/projects/p1').expect(403) // Bob isn't a project owner
-
as(token)returns the same client sendingAuthorization: Bearer. -
withHeaders({...})adds or replaces headers, for example a workspace header. -
basePathis prefixed to every path. -
cookies: truekeeps a cookie jar between requests, for session and CSRF flows.
expressApp is now deprecated, and it only works under the Express runtime. If you switch engines, client() follows you; request(expressApp) doesn't.
2. useTestApp: one app per worker
The new @forinda/kickjs-testing/vitest entry gives you useTestApp, which registers the beforeAll and afterAll hooks for you:
import { useTestApp } from '@forinda/kickjs-testing/vitest'
const t = useTestApp(() => ({ modules: [ProjectsModule(), TasksModule(), CommentsModule()] }), {
shared: true,
client: { basePath: '/api/v1' },
})
it('lists a project’s tasks', async () => {
await t.client().as(aliceToken).get('/projects/p1/tasks').expect(200)
})
shared: true is the part that matters for a big suite. With isolate: false, the first file in a worker builds the app and every later file reuses it. The app shuts down when the worker exits. If some files need a differently configured app, for example an admin API with extra modules, give it a name: shared: 'admin-api' keeps one app per name.
If you already have a helper that hundreds of files call, you can copy the same model into it rather than touching every file:
// tests/app.ts
let cached: ReturnType<typeof createTestApp> | undefined
export function getTestApp() {
cached ??= createTestApp({ modules: [ProjectsModule(), TasksModule(), CommentsModule()] })
return cached
}
process.once('beforeExit', () => {
void cached?.then(({ app }) => app.shutdown())
})
3. onTestReset: shared state, reset in one place
A shared app comes with a catch: anything kept in memory survives from one file into the next. In the task app, the fake mailer records every "you've been assigned a task" email. With a shared app, the next file starts with the previous file's emails still in it, and expect(sentEmails).toHaveLength(1) sees 14.
onTestReset(fn) lets the code that owns some state register how to clear it. resetTestState() then runs every registered reset, in order, and keeps going even if one throws:
// tests/fakes/mailer.ts
import { onTestReset } from '@forinda/kickjs-testing'
export const sentEmails: Email[] = []
onTestReset(() => {
sentEmails.length = 0
})
// tests/fakes/clock.ts
export const clock = { now: new Date('2026-01-05T09:00:00Z') }
onTestReset(() => {
clock.now = new Date('2026-01-05T09:00:00Z')
})
useTestApp runs the resets for you: reset: 'file' is the default, reset: 'test' resets before every test, and false turns it off. If you share the app through your own helper instead, add a setup file:
// tests/reset.setup.ts — listed in vitest `setupFiles`, so it runs before every file
import { resetTestState } from '@forinda/kickjs-testing'
import { beforeAll } from 'vitest'
beforeAll(() => resetTestState())
The win is that a new fake registers its reset right where it's defined. Nobody has to remember to add a clear call to the top of every file.
4. withEnv: change config for one test
This one is in core KickJS 9. Config is parsed once and cached, so testing behaviour that depends on an env var used to mean pinning it for the whole suite. Say tasks can't be created past a per-project limit set by MAX_TASKS_PER_PROJECT:
import { withEnv } from '@forinda/kickjs'
it('refuses a task past the project limit', async () => {
await withEnv({ MAX_TASKS_PER_PROJECT: 1 }, async () => {
await api.as(aliceToken).post('/projects/p1/tasks').send({ title: 'one' }).expect(201)
await api.as(aliceToken).post('/projects/p1/tasks').send({ title: 'two' }).expect(422)
})
})
getEnv, ConfigService and @Value() all see the override for the length of the call, and the real value comes back afterwards. Overrides nest. Values are parsed values (1, not '1'), and no .env file is read. The env is process-wide, so don't use it in tests that run concurrently.
getEnv also gained a fallback argument, which removes a lot of ??:
const pageSize = getEnv('TASKS_PAGE_SIZE', 25)
The fallback is used only when the value is undefined or null. If an empty string should also fall back, keep ||.
5. .env.test really is the test environment
This isn't new in 9, but it's what makes withEnv worth adopting, so it belongs here. When Vitest is running (or NODE_ENV=test) and a .env.test or .env.test.local exists, KickJS reads only those. It doesn't fall back to .env. If .env.test doesn't exist and your tests pick up values from .env, it warns you, naming the variables.
The KickJS CLI scaffolds a .env.test.example for this. Commit the example, not the real file: developers run tests with different local settings (a database on another port, a different mailer for debugging), and a tracked .env.test turns every one of those into a merge conflict or an accidental commit.
cp .env.test.example .env.test # once per clone; .env.test is gitignored
.env.test
.env.test.local
Keep the example to test doubles only, so a fresh copy is always safe to run:
# .env.test.example
JWT_SECRET=test-only-secret-not-used-anywhere-else
JWT_ISSUER=tasks-api-test
MAILER=fake
STORAGE=memory
CRON_ENABLED=false
MAX_TASKS_PER_PROJECT=500
Keep only per-run values in the vitest config, for example the database URL from Testcontainers. A developer who sets MAILER=smtp in their own .env can no longer make the suite email real people.
CI needs the same file, so make the copy part of the test step rather than relying on a tracked file:
- run: cp .env.test.example .env.test
- run: pnpm test
When the example gains a variable, say so in the PR. Everyone's existing .env.test needs the new line added by hand, and a missing one usually shows up as a schema validation error at boot, which is at least a loud failure.
6. runContributor takes ctx and env
Context contributors are the KickJS way to fill in request context: which workspace a request belongs to, who the acting user is. They can now be unit tested with a fake request and env values, with no app and no database:
import { runContributor } from '@forinda/kickjs-testing'
const { value } = await runContributor(LoadWorkspace, {
ctx: { req: { headers: { host: 'acme.tasks.example.com' } } },
env: { TRUST_PROXY: true },
deps: { workspaces: fakeWorkspaceDirectory },
})
expect(value.slug).toBe('acme')
Things in 9.0 that change what tests see
-
An adapter that fails in
beforeMount/beforeStartnow stops the boot. It used to log and serve anyway. A test app with a broken adapter now fails loudly at build time instead of producing confusing 500s three files later. -
Errors with a 4xx
statusthat aren'tHttpExceptionare now problem+json, with the message indetail. If a domain error likeTaskLimitReachedcarriesstatus: 422and your test asserts onbody.message, check that assertion. If your ownonErroralready formats these errors, nothing changes. -
ctx.sessionis typed. AugmentSessionDataonce, or tests that poked atctx.session.anythingwill stop compiling.
What it bought a real suite
I moved a production API's suite onto all of this: 521 integration files, about 2,600 tests, real Postgres in Testcontainers. Measured on one machine:
| before (KickJS 8.3) | after (KickJS 9 + shared app) | |
|---|---|---|
| Duration | 1245.6 s (20.8 min) | 1110.6 s (18.5 min) |
| Files passing | 518 / 521 | 521 / 521 |
That's about 2 minutes, roughly 10%. It's what you'd expect from skipping one app build and shutdown per file, which we measured at about 265 ms × 521 files.
To be straight about it: the release didn't turn a 45-minute suite into a 20-minute one. Most of that drop came earlier, from isolate: false and fileParallelism: false. KickJS 9 adds a smaller, steady gain on top, and removes a lot of setup code.
Gotchas
- A shared app makes leaked state visible. If one test file creates tasks and never deletes them, a later file that counts "all tasks in the project" fails, but only when the files run in that order. Sharing the app doesn't cause this; it changes when you see it. Register resets for in-memory state, and make tests that count rows count only their own fixture.
- Upgrading can turn up tests that were already stale. We found two still asserting behaviour a recent change had altered. The first full run on the new version was when anyone noticed.
-
useTestAppdoesn't give youexpressApp. If a large suite depends on it, copy the shared model into your own helper and move files over toclient()as you touch them. -
Don't import config for its side effect everywhere. Once modules read
getEnv('X')instead of an importedenvobject, they no longer load config as a side effect. That's fine in the app, because the entry file loads it first. Unit tests, though, need one setup file that doesimport '../src/config'. Ours showed up as JWT tests failing with "Invalid time period format", because the token expiry setting was undefined.
Upgrade checklist
- [ ] Bump
@forinda/kickjsto 9 and@forinda/kickjs-testingto 8.1; installsupertestif you'll useclient(). - [ ] Look for
getEnv(key, schema)anddefineAugmentation. Both are gone. - [ ] Commit a
.env.test.examplewith test doubles only; gitignore.env.testand copy the example in CI. - [ ] Set
isolate: false(plusfileParallelism: falseif files share a database) and share the app per worker. - [ ] Move per-file clean-up of in-memory state to
onTestReset. - [ ] Use
client()andwithEnvin new tests, and move older files over when you touch them. - [ ] Run the full suite twice. Anything that fails only in one run depends on file order: fix it now rather than marking it flaky.
KickJS testing guide: kickjs.app/guide/testing. For large suites specifically, see Large Suites.
Top comments (0)