DEV Community

Cover image for Faster, quieter API tests with KickJS 9 and kickjs-testing 8.1
Orinda Felix Ochieng
Orinda Felix Ochieng

Posted on

Faster, quieter API tests with KickJS 9 and kickjs-testing 8.1

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,
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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')))
}
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • as(token) returns the same client sending Authorization: Bearer .
  • withHeaders({...}) adds or replaces headers, for example a workspace header.
  • basePath is prefixed to every path.
  • cookies: true keeps 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)
})
Enter fullscreen mode Exit fullscreen mode

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())
})
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode
// 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')
})
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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)
  })
})
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
.env.test
.env.test.local
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

Things in 9.0 that change what tests see

  • An adapter that fails in beforeMount / beforeStart now 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 status that aren't HttpException are now problem+json, with the message in detail. If a domain error like TaskLimitReached carries status: 422 and your test asserts on body.message, check that assertion. If your own onError already formats these errors, nothing changes.
  • ctx.session is typed. Augment SessionData once, or tests that poked at ctx.session.anything will 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

  1. 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.
  2. 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.
  3. useTestApp doesn't give you expressApp. If a large suite depends on it, copy the shared model into your own helper and move files over to client() as you touch them.
  4. Don't import config for its side effect everywhere. Once modules read getEnv('X') instead of an imported env object, 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 does import '../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/kickjs to 9 and @forinda/kickjs-testing to 8.1; install supertest if you'll use client().
  • [ ] Look for getEnv(key, schema) and defineAugmentation. Both are gone.
  • [ ] Commit a .env.test.example with test doubles only; gitignore .env.test and copy the example in CI.
  • [ ] Set isolate: false (plus fileParallelism: false if files share a database) and share the app per worker.
  • [ ] Move per-file clean-up of in-memory state to onTestReset.
  • [ ] Use client() and withEnv in 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)