Guide/guides

Testing

A single well-written test catches bugs before your users do. Guren makes testing so convenient that writing tests feels faster than manually checking things in a browser.

Testing

A single well-written test catches bugs before your users do. Guren makes testing so convenient that writing tests feels faster than manually checking things in a browser.

TestApp

TestApp is the centerpiece of Guren's testing story. It boots a lightweight instance of your application with the full middleware and routing stack, then lets you make requests and assert on responses with a fluent API:

import { describe, test, beforeAll } from 'bun:test'
import { TestApp } from '@guren/testing'

describe('Posts', () => {
  let app: TestApp

  beforeAll(async () => {
    app = await TestApp.create()
  })

  test('lists all posts', async () => {
    await app.get('/posts').assertOk()
  })

  test('creates a post', async () => {
    await app
      .post('/posts', { title: 'Hello', content: 'World' })
      .assertStatus(201)
      .assertJsonPath('post.title', 'Hello')
  })

  test('returns 404 for missing post', async () => {
    await app.get('/posts/999').assertNotFound()
  })
})

All standard HTTP methods are available:

await app.get('/posts')
await app.post('/posts', body)
await app.put('/posts/1', body)
await app.patch('/posts/1', body)
await app.delete('/posts/1')

Fluent Assertions

Chain assertions directly on the response:

// Status
await app.get('/posts').assertOk()                         // 200
await app.get('/posts').assertStatus(200)
await app.post('/posts', data).assertStatus(201)
await app.delete('/posts/1').assertNoContent()              // 204
await app.get('/secret').assertUnauthorized()                // 401
await app.get('/secret').assertForbidden()                   // 403
await app.get('/missing').assertNotFound()                   // 404

// JSON
await app.get('/posts').assertJson({ data: [] })
await app.get('/posts').assertJsonCount(5, 'data')
await app.get('/posts/1').assertJsonPath('post.title', 'Hello')
await app.get('/posts').assertJsonStructure(['data', 'meta'])

// Headers and redirects
await app.get('/posts').assertHeader('content-type', 'application/json')
await app.get('/old-page').assertRedirect('/new-page')

Authentication in Tests

Use actingAs() to simulate an authenticated user without touching session or token logic:

import { User } from '@/app/Models/User'

const user = await User.create({
  email: 'test@example.com',
  name: 'Test User',
})

// Authenticated requests
await app.actingAs(user).get('/dashboard').assertOk()
await app.actingAs(user).post('/posts', data).assertStatus(201)

// Without auth, protected routes reject
await app.get('/dashboard').assertUnauthorized()

Testing JSON APIs

For API endpoints, use .json() to set the appropriate headers and get JSON-focused assertions:

test('API returns paginated posts', async () => {
  await app.json().get('/api/posts')
    .assertOk()
    .assertJsonStructure(['data', 'meta'])
    .assertJsonCount(10, 'data')
    .assertJsonPath('meta.currentPage', 1)
})

test('API validates input', async () => {
  await app.json().post('/api/posts', { title: '' })
    .assertStatus(422)
    .assertJsonPath('errors.title.0', 'The title field is required.')
})

Custom Request Headers

Use withHeaders() / withHeader() to send headers on every request — handy for locale detection, API versioning, or bearer tokens. Like actingAs() and json(), they return a new TestApp, so variants compose freely:

test('renders the English locale', async () => {
  const en = app.withHeaders({ 'Accept-Language': 'en' })
  await en.get('/').assertOk()
})

test('accepts an API token', async () => {
  await app
    .withHeader('Authorization', `Bearer ${token}`)
    .json()
    .get('/api/me/tasks')
    .assertOk()
})

Database in Tests

Test Database Isolation

bun test sets NODE_ENV=test automatically. A fresh scaffold's config/database.ts uses that to keep tests off your development database entirely:

// config/database.ts
function resolveDatabaseFilename(): string {
  if (process.env.NODE_ENV === 'test') {
    return process.env.TEST_DATABASE_URL ?? './data/guren.test.db'
  }
  return process.env.DATABASE_URL ?? './data/guren.db'
}

Tests read and write ./data/guren.test.db by default — a separate file from ./data/guren.db, so nothing a test creates ever leaks into the data you're looking at in the dev server. Override the test file itself with TEST_DATABASE_URL (for example, to give each parallel CI shard its own file); DATABASE_URL stays authoritative for every other environment.

Warning

Scaffolds created before this branch existed write straight to DATABASE_URL (or ./data/guren.db) regardless of NODE_ENV, so bun test pollutes the same database your dev server reads from. Retrofit it by replacing the filename option, not just adding the helper — the helper alone does nothing until createSqliteDatabase() is actually pointed at it:

 import { createSqliteDatabase } from '@guren/orm'

+function resolveDatabaseFilename(): string {
+  if (process.env.NODE_ENV === 'test') {
+    return process.env.TEST_DATABASE_URL ?? './data/guren.test.db'
+  }
+  return process.env.DATABASE_URL ?? './data/guren.db'
+}
+
 const database = createSqliteDatabase({
   migrationsFolder: new URL('../db/migrations', import.meta.url),
   seedersFolder: new URL('../db/seeders', import.meta.url),
-  filename: () => process.env.DATABASE_URL ?? './data/guren.db',
+  filename: resolveDatabaseFilename,
 })

Cleaning Up Between Tests

For most suites, the separate test-database file is isolation enough — reset it back to a clean slate in beforeEach using the resetDatabase()/migrateDatabase() helpers your config/database.ts already exports:

import { describe, test, expect, beforeEach } from 'bun:test'
import { resetDatabase, migrateDatabase } from '@/config/database'

describe('User Model', () => {
  beforeEach(async () => {
    await resetDatabase()   // drops every table
    await migrateDatabase() // re-applies migrations from scratch
  })

  test('creates a user', async () => {
    const user = await User.create({
      email: 'test@example.com',
      name: 'Test User',
    })

    expect(user.id).toBeDefined()
    expect(user.email).toBe('test@example.com')
  })
})

@guren/testing also ships useTruncateTables(tables) and useDatabaseTransactions() for finer-grained, per-test cleanup. useTruncateTables() registers a beforeEach hook that deletes each table's rows; useDatabaseTransactions() registers beforeEach/afterEach hooks that begin a transaction and roll it back after the test. Both operate on a connection you register up front with setTestDatabase(), matching this shape:

interface DatabaseConnection {
  query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>
  execute(sql: string, params?: unknown[]): Promise<void>
  beginTransaction(): Promise<void>
  commit(): Promise<void>
  rollback(): Promise<void>
}

Guren's SQLite adapter doesn't hand you a ready-made DatabaseConnectiongetDatabase() from config/database.ts resolves to the underlying Drizzle instance, not this interface — so using these helpers means writing a small adapter yourself and passing it to setTestDatabase() before your tests run. useDatabaseTransactions() specifically must wrap the same connection your models write through: it begins a transaction on beforeEach and rolls it back on afterEach, and a second, independently-opened connection to the same file won't see (or roll back) writes made via the first one. useTruncateTables() has no such requirement — a DELETE FROM on any connection to the same database file removes the rows your models see, since it commits immediately rather than participating in a shared transaction. If the adapter plumbing sounds like more than your suite needs, the resetDatabase()/migrateDatabase() pattern above is simpler and sidesteps the whole question.

Faking Services

Real tests should not send actual emails, dispatch real events, or push jobs to a queue. Replace services with fakes using the container:

import { TestApp, FakeEvent, FakeMail, FakeQueue } from '@guren/testing'

const app = await TestApp.create()

// Swap real services for fakes
const fakeEvents = new FakeEvent()
const fakeMail = new FakeMail()
const fakeQueue = new FakeQueue()
app.container.fake('events', fakeEvents)
app.container.fake('mail', fakeMail)
app.container.fake('queue', fakeQueue)

Then assert on what happened:

test('registration sends welcome email and dispatches event', async () => {
  await app.post('/register', {
    email: 'new@example.com',
    name: 'New User',
    password: 'secret123',
  }).assertStatus(201)

  fakeEvents.assertDispatched(UserRegistered)
  fakeMail.assertSentTo('new@example.com')
  fakeQueue.assertPushed(SendWelcomeEmailJob)
})

test('does not send email for invalid registration', async () => {
  await app.post('/register', { email: '' }).assertStatus(422)

  fakeMail.assertNothingSent()
  fakeEvents.assertNotDispatched(UserRegistered)
})

Available Fake Assertions

FakeMail:

Method Description
assertSent(mailable) A mailable was sent
assertSentTo(email) Email was sent to address
assertSentWith(mailable, data) Mailable sent with specific data
assertNotSent(mailable) A mailable was not sent
assertNothingSent() No emails sent at all

FakeEvent:

Method Description
assertDispatched(event) An event was dispatched
assertDispatchedWith(event, data) Event dispatched with specific data
assertDispatchedInOrder(events) Events dispatched in order
assertNotDispatched(event) An event was not dispatched
assertNothingDispatched() No events dispatched

FakeQueue:

Method Description
assertPushed(job) A job was pushed
assertPushedWith(job, data) Job pushed with specific data
assertPushedOn(queue, job) Job pushed to specific queue
assertNotPushed(job) A job was not pushed
assertNothingPushed() No jobs pushed

Running Tests

# Full test suite
bun run test

# Framework packages (Bun test runner)
bun run test:bun

# Example apps (Vitest)
bun run test:examples

# Single file
bun test path/to/file.test.ts

# Generate a test file
bunx guren make:test posts/PostController --runner bun

Note

Framework packages use Bun's native test runner (bun:test). Example apps and React components use Vitest with jsdom. Keep the runners separate to get fast feedback from Bun for framework code and realistic DOM behavior for SPA tests.