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 DatabaseConnection — getDatabase() 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.