Testing, Getting Started
Everything you need to write fast, reliable tests for your tekir application, even if you have never written a test before.
Introduction
tekir is tested with Bun's built-in test runner. You do not need to install Jest, Vitest, or any other test framework. Bun ships a fast, Jest-compatible test runner, and @tekir/testing adds a thin layer of application-aware helpers on top of it: createTestApp() for booting your app in test mode, an HTTP test client, model factories, fake time, and assert helpers.
All of bun:test's exports, test, describe, expect, beforeAll, afterAll, beforeEach, afterEach, mock, and spyOn are re-exported from @tekir/testing, so you only ever need one import in your test files.
Installing the Package
Install @tekir/testing as a development dependency. It is not needed in production:
bun add -d @tekir/testingIf you created your project with bunx create-tekir-app, this package is already installed and configured.
createTestApp()
createTestApp() is the recommended way to boot your tekir application for testing. It starts your app in test mode and returns an HTTP test client you can use to make requests against your routes. Create a shared setup file and import the client in your tests:
// tests/setup.ts
import { createTestApp } from '@tekir/testing'
export const { request } = await createTestApp(import.meta.dir)The request object provides methods like request.get(), request.post(), request.put(), and request.delete(). Responses have assertion helpers like assertOk(), assertCreated(), assertNotFound(), and assertJsonContains().
Your First Test
A test file is a regular TypeScript file. Import test and expect from @tekir/testing and call test() with a description and a callback:
import { test, expect } from '@tekir/testing'
test('adds two numbers', () => {
expect(1 + 1).toBe(2)
})The first argument to test is a human-readable description of what is being verified. Make it specific; when a test fails, this is the first thing you will read. The second argument is a function (sync or async) that contains your assertions. If the function throws, the test fails.
Test File Naming
Bun automatically discovers test files that match any of these patterns:
*.test.ts: the standard convention used throughout tekir projects*.spec.ts: also supported if you prefer the spec naming style*.test.tsx/*.spec.tsx: for testing React components
A typical tekir project organizes tests like this:
tests/
├── setup.ts # createTestApp() shared setup
├── unit/
│ ├── user.test.ts # Tests for the User model
│ ├── mailer.test.ts # Tests for the mailer service
│ └── utils.test.ts
└── integration/
├── auth.test.ts # End-to-end auth flow tests
└── posts.test.tsThere is no single right way to organize test files. The most important thing is consistency. Putting tests in a top-level tests/ directory keeps them separate from application code and makes it easy to exclude them from production builds.
Running Tests
Run your entire test suite with a single command:
# Run all tests
bun test
# Run tests matching a pattern
bun test user
# Run a specific file
bun test tests/user.test.ts
# Watch mode: re-runs on file change
bun test --watchbun test v1.x.x
tests/user.test.ts:
✓ creates a user (3ms)
✓ validates email format (1ms)
✓ rejects duplicate email (2ms)
3 pass, 0 failThe pattern filter passed to bun test matches against the full file path and the test description. For example, bun test user will run any file whose path contains user and any test whose description contains user.
Organizing with describe
describe() groups related tests together. Groups can be nested as deeply as you like. This makes the test output easier to read and lets you share lifecycle hooks across a set of related tests:
import { test, describe, expect } from '@tekir/testing'
describe('User', () => {
describe('validation', () => {
test('requires a name', () => {
// ...
})
test('requires a valid email', () => {
// ...
})
})
describe('password', () => {
test('hashes the password on create', async () => {
// ...
})
test('verifies the correct password', async () => {
// ...
})
})
})In the output, Bun will indent nested groups, so you get a clear hierarchy:User › validation › requires a name.
Lifecycle Hooks
Lifecycle hooks let you run setup and teardown code around your tests without repeating yourself in every test body. There are four hooks:
beforeAll(fn): runs once before the first test in the currentdescribeblock (or file if used at the top level).afterAll(fn): runs once after the last test in the currentdescribeblock.beforeEach(fn): runs before every individual test.afterEach(fn): runs after every individual test.
All four hooks accept async functions. Use them for things like seeding a database or resetting shared state. With createTestApp(), you no longer need to manually start and stop the server:
import { test, describe, expect, beforeAll, afterAll, beforeEach, afterEach } from '@tekir/testing'
import { request } from './setup'
describe('PostController', () => {
// Runs before every individual test
beforeEach(async () => {
await seedDatabase()
})
// Runs after every individual test
afterEach(async () => {
await clearDatabase()
})
test('GET /api/posts returns posts', async () => {
const res = await request.get('/api/posts')
res.assertOk()
})
test('POST /api/posts creates a post', async () => {
const res = await request
.post('/api/posts')
.json({ title: 'Test', body: 'Test body content' })
res.assertCreated()
})
})Hooks defined inside a describe block only apply to tests within that block. Hooks defined at the top level of a file apply to all tests in the file.
expect Assertions
expect(value) returns a chainable assertion object. You call a matcher on it to describe what you expect the value to be. If the assertion fails, Bun throws an error with a clear diff so you can see exactly what went wrong.
Here are the most common matchers you will use day to day:
import { test, expect } from '@tekir/testing'
test('common expect assertions', () => {
// Strict equality (===)
expect(1 + 1).toBe(2)
expect('hello').toBe('hello')
// Deep equality (objects, arrays)
expect({ name: 'Ali' }).toEqual({ name: 'Ali' })
expect([1, 2, 3]).toEqual([1, 2, 3])
// Truthiness
expect(true).toBeTruthy()
expect(false).toBeFalsy()
expect(null).toBeNull()
expect(undefined).toBeUndefined()
// Numbers
expect(10).toBeGreaterThan(5)
expect(3).toBeLessThanOrEqual(3)
// Strings
expect('Hello, world!').toContain('world')
expect('[email protected]').toMatch(/@/)
// Arrays
expect([1, 2, 3]).toContain(2)
expect([1, 2, 3]).toHaveLength(3)
// Objects
expect({ id: 1, name: 'Ali' }).toMatchObject({ name: 'Ali' })
// Negation: prefix any matcher with .not
expect(1).not.toBe(2)
expect('hello').not.toContain('world')
})Every matcher can be negated with .not: expect(value).not.toBe(x) passes when value !== x.
For async code, await your promises directly in the test body, or use .resolves / .rejects on the promise itself:
import { test, expect } from '@tekir/testing'
test('async assertions', async () => {
// Await a promise and then assert
const user = await User.create({ name: 'Ali', email: '[email protected]' })
expect(user.id).toBeDefined()
expect(user.name).toBe('Ali')
// Assert a promise resolves
await expect(User.find(user.id)).resolves.not.toBeNull()
// Assert a promise rejects
await expect(User.find(-1)).rejects.toThrow()
})setupTestDb()
setupTestDb(models) creates the database tables for the given models and returns a cleanup function. Call it in beforeEach and call the returned cleanup function in afterEach. The cleanup function deletes all rows from every registered table, so each test starts with a completely empty database.
import { test, describe, beforeEach, afterEach, expect } from '@tekir/testing'
import { setupTestDb } from '@tekir/testing'
import { User } from '~/models/user'
import { Post } from '~/models/post'
describe('Post model', () => {
let cleanup: () => void
beforeEach(() => {
// Creates the tables and returns a cleanup function
cleanup = setupTestDb([User, Post])
})
afterEach(() => {
// Deletes all rows from every registered table
cleanup()
})
test('can create a post', async () => {
const user = await User.create({ name: 'Ali', email: '[email protected]' })
const post = await Post.create({ title: 'Hello', body: 'World', userId: user.id })
expect(post.id).toBeDefined()
expect(post.title).toBe('Hello')
})
test('starts with an empty database', async () => {
const count = await Post.query().count()
expect(count).toBe(0)
})
})The helper creates tables for each model and clears them after each test to reset state. Because it uses your real model definitions, there is no risk of your test schema getting out of sync with your production schema.
If you want every test in a file to share the same setup, call setupTestDb at the top level rather than inside a describe block:
import { beforeEach, afterEach } from '@tekir/testing'
import { setupTestDb } from '@tekir/testing'
import { User } from '~/models/user'
let cleanup: () => void
beforeEach(() => { cleanup = setupTestDb([User]) })
afterEach(() => cleanup())fakeTime()
fakeTime(date) freezes Date and Date.now() at the given point in time for the duration of a test. It returns a restore function that you must call when the test is done to put the real clock back.
This is useful whenever your code branches on the current time, expiry checks, scheduled jobs, audit timestamps, and so on:
import { test, expect } from '@tekir/testing'
import { fakeTime } from '@tekir/testing'
test('token is expired after 1 hour', () => {
// Freeze time at a known point
const restore = fakeTime(new Date('2025-06-01T12:00:00Z'))
const token = issueToken({ expiresIn: '1h' })
// Token is valid right now
expect(isExpired(token)).toBe(false)
// Travel 2 hours into the future by unfreezing and re-freezing
restore()
const restore2 = fakeTime(new Date('2025-06-01T14:01:00Z'))
expect(isExpired(token)).toBe(true)
restore2()
})The cleanest pattern is to freeze time in beforeEach and restore it in afterEach so it applies to every test in a group without any manual cleanup inside individual test bodies:
import { test, describe, beforeEach, afterEach, expect } from '@tekir/testing'
import { fakeTime } from '@tekir/testing'
describe('scheduled jobs', () => {
let restoreTime: () => void
beforeEach(() => {
restoreTime = fakeTime(new Date('2025-01-01T00:00:00Z'))
})
afterEach(() => {
restoreTime()
})
test('runs at midnight', () => {
expect(new Date().toISOString()).toBe('2025-01-01T00:00:00.000Z')
// ...test your scheduled logic
})
test('Date.now() is also frozen', () => {
expect(Date.now()).toBe(new Date('2025-01-01T00:00:00Z').getTime())
})
})Always call the restore function. If you forget, time will remain frozen for all subsequent tests in the process, which causes difficult-to-diagnose failures.
assertThrows / assertNotThrows
These helpers make it easy to assert that a function throws, or does not throw, with an optional check on the error message, error code, or HTTP status code. Both accept sync and async functions.
assertThrows(fn, expected?): passes only if fn throws. The optional second argument can be:
- A string: the error message must match exactly.
- A RegExp: the error message must match the pattern.
- An object with optional
message,code, andstatusCodekeys. Each one is checked independently.
import { test } from '@tekir/testing'
import { assertThrows, assertNotThrows } from '@tekir/testing'
// Assert that a function throws at all
test('divide by zero throws', async () => {
await assertThrows(() => divide(10, 0))
})
// Assert the exact error message
test('throws with a specific message', async () => {
await assertThrows(
() => divide(10, 0),
'Cannot divide by zero',
)
})
// Assert the message matches a pattern
test('throws with a message matching a regex', async () => {
await assertThrows(
() => divide(10, 0),
/divide by zero/i,
)
})
// Assert against error.code or error.statusCode
test('not found throws a 404', async () => {
await assertThrows(
() => User.findOrFail(99999),
{ statusCode: 404 },
)
})
// Assert against both message and code
test('auth error has correct code', async () => {
await assertThrows(
() => authenticate('wrong-token'),
{ message: 'Invalid token', code: 'INVALID_TOKEN' },
)
})assertNotThrows(fn) is the complement: it passes only if fn resolves without throwing. Use it when you want to explicitly document that a happy-path scenario must not raise an error:
import { test } from '@tekir/testing'
import { assertNotThrows } from '@tekir/testing'
test('valid input does not throw', async () => {
await assertNotThrows(() =>
User.create({ name: 'Ali', email: '[email protected]', password: 'secret123' })
)
})