Testing Package

On this page 47

A comprehensive testing framework built on Bun's native test runner, providing assertions, database testing utilities, feature testing, factories, and mocking capabilities.

Installation

bun add @stacksjs/testing

Basic Usage

import { describe, it, expect, beforeAll, afterAll } from '@stacksjs/testing'

describe('User', () => {
  it('should create a new user', async () => {
    const user = await User.create({ name: 'John', email: 'john@test.com' })
    expect(user.name).toBe('John')
    expect(user.email).toBe('john@test.com')
  })
})

Test Structure

Describe and It

import { describe, it, expect } from '@stacksjs/testing'

describe('Calculator', () => {
  describe('add', () => {
    it('should add two positive numbers', () => {
      expect(add(2, 3)).toBe(5)
    })

    it('should handle negative numbers', () => {
      expect(add(-1, 1)).toBe(0)
    })
  })

  describe('divide', () => {
    it('should divide two numbers', () => {
      expect(divide(10, 2)).toBe(5)
    })

    it('should throw on division by zero', () => {
      expect(() => divide(10, 0)).toThrow('Division by zero')
    })
  })
})

Lifecycle Hooks

import { describe, it, beforeAll, beforeEach, afterAll, afterEach } from '@stacksjs/testing'

describe('Database Tests', () => {
  beforeAll(async () => {
    // Run once before all tests in this describe block
    await database.connect()
  })

  beforeEach(async () => {
    // Run before each test
    await database.beginTransaction()
  })

  afterEach(async () => {
    // Run after each test
    await database.rollback()
  })

  afterAll(async () => {
    // Run once after all tests
    await database.disconnect()
  })

  it('should insert record', async () => {
    await User.create({ name: 'John' })
    const count = await User.count()
    expect(count).toBe(1)
  })
})

Assertions

Basic Matchers

import { expect } from '@stacksjs/testing'

// Equality
expect(value).toBe(42)           // Strict equality
expect(value).toEqual({ a: 1 })  // Deep equality
expect(value).not.toBe(0)        // Negation

// Truthiness
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeUndefined()
expect(value).toBeDefined()

// Numbers
expect(value).toBeGreaterThan(5)
expect(value).toBeGreaterThanOrEqual(5)
expect(value).toBeLessThan(10)
expect(value).toBeLessThanOrEqual(10)
expect(value).toBeCloseTo(0.3, 2) // For floating point

// Strings
expect(str).toContain('hello')
expect(str).toMatch(/pattern/)
expect(str).toHaveLength(5)

// Arrays
expect(arr).toContain('item')
expect(arr).toHaveLength(3)
expect(arr).toEqual(['a', 'b', 'c'])

// Objects
expect(obj).toHaveProperty('name')
expect(obj).toHaveProperty('address.city', 'NYC')
expect(obj).toMatchObject({ name: 'John' })

Exceptions

// Expect function to throw
expect(() => throwingFunction()).toThrow()
expect(() => throwingFunction()).toThrow('error message')
expect(() => throwingFunction()).toThrow(CustomError)
expect(() => throwingFunction()).toThrow(/pattern/)

// Async throw
await expect(async () => await asyncThrow()).rejects.toThrow()

Async Assertions

// Promise resolves
await expect(promise).resolves.toBe(value)
await expect(promise).resolves.toEqual({ data: 'value' })

// Promise rejects
await expect(promise).rejects.toThrow('error')
await expect(promise).rejects.toBeInstanceOf(Error)

Type Assertions

expect(value).toBeInstanceOf(Date)
expect(value).toBeInstanceOf(CustomClass)
expect(typeof value).toBe('string')
expect(typeof value).toBe('number')

Database Testing

Using Test Database

import { describe, expect, it } from '@stacksjs/testing'
import { useTransaction } from '@stacksjs/testing/database'

describe('User Model', () => {
  // Wrap each test in a transaction that rolls back
  useTransaction()

  it('should create user', async () => {
    const user = await User.create({ name: 'Test User', email: 'test@test.com' })
    expect(user.id).toBeDefined()

    // Automatically rolled back after test
  })
})

Database Assertions

import { assertDatabaseHas, assertDatabaseMissing } from '@stacksjs/testing/database'

it('should save user to database', async () => {
  await User.create({ name: 'John', email: 'john@test.com' })

  // Assert record exists
  await assertDatabaseHas('users', {
    email: 'john@test.com'
  })
})

it('should delete user', async () => {
  const user = await User.create({ name: 'John', email: 'john@test.com' })
  await user.delete()

  // Assert record doesn't exist
  await assertDatabaseMissing('users', {
    email: 'john@test.com'
  })
})

Database Count Assertions

import { assertDatabaseCount } from '@stacksjs/testing/database'

it('should have correct number of users', async () => {
  await User.create({ name: 'User 1' })
  await User.create({ name: 'User 2' })

  await assertDatabaseCount('users', 2)
})

Soft Delete Assertions

import { assertSoftDeleted, assertNotSoftDeleted } from '@stacksjs/testing/database'

it('should soft delete user', async () => {
  const user = await User.create({ name: 'John' })
  await user.delete() // Soft delete

  await assertSoftDeleted('users', { id: user.id })
})

it('should restore user', async () => {
  const user = await User.create({ name: 'John' })
  await user.delete()
  await user.restore()

  await assertNotSoftDeleted('users', { id: user.id })
})

DynamoDB Testing

Table-level fixtures only. Point AWS_ENDPOINT_URL at a DynamoDB you started yourself - DynamoDB Local, or a real region:

import { afterAll, beforeAll } from 'bun:test'
import { createStacksTable, deleteStacksTable } from '@stacksjs/testing/dynamodb'

beforeAll(async () => {
  await createStacksTable()
})

afterAll(async () => {
  await deleteStacksTable()
})

There are no item assertions. The DynamoDBClient in @stacksjs/ts-cloud covers createTable, deleteTable and describeTable, and no data-plane operations, so assert against DynamoDB through whichever client your code already uses to write to it.

Feature Testing

HTTP Testing

import { describe, it, expect } from '@stacksjs/testing'

describe('API Routes', () => {
  it('should return users list', async () => {
    const response = await fetch('http://localhost:3000/api/users')
    const data = await response.json()

    expect(response.status).toBe(200)
    expect(data.users).toBeInstanceOf(Array)
  })

  it('should create user', async () => {
    const response = await fetch('http://localhost:3000/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'John', email: 'john@test.com' })
    })

    expect(response.status).toBe(201)

    const data = await response.json()
    expect(data.user.name).toBe('John')
  })
})

Authenticated Requests

import { actingAs } from '@stacksjs/testing'

it('should access protected route', async () => {
  const user = await User.create({ name: 'John', email: 'john@test.com' })
  const token = await actingAs(user)

  const response = await fetch('http://localhost:3000/api/profile', {
    headers: { 'Authorization': `Bearer ${token}` }
  })

  expect(response.status).toBe(200)
})

Factories

Defining Factories

A factory is declared once, on the model, per attribute:

// app/Models/User.ts
export default defineModel({
  name: 'User',
  attributes: {
    name: { fillable: true, factory: faker => faker.person.fullName() },
    email: { fillable: true, unique: true, factory: faker => faker.internet.email() },
    password: { fillable: true, factory: faker => faker.internet.password() },
  },
})

Using Factories

import { factory } from '@stacksjs/testing/database'

it('should create user with factory', async () => {
  // Create single record
  const user = await factory('User').create()
  expect(user.id).toBeDefined()

  // Create multiple records
  const users = await factory('User').createMany(5)
  expect(users).toHaveLength(5)

  // Create with overrides
  const admin = await factory('User').create({
    role: 'admin',
    email: 'admin@test.com'
  })
  expect(admin.role).toBe('admin')

  // Attributes only, nothing written
  const attrs = await factory('User').make({ name: 'Test User' })
  expect(attrs.name).toBe('Test User')
})

Factory States

A state is an override object at the call site, not a name registered up front:

const admin = await factory('User').create({ role: 'admin' })
const unverified = await factory('User').create({ email_verified_at: null })

For a state used across a file, give the object a name where you use it:

const admin = { role: 'admin', email_verified_at: new Date() }

const one = await factory('User').create(admin)
const many = await factory('User').createMany(3, admin)

Factory Relationships

belongsTo columns are filled automatically - the generator creates the parent row and uses its id, because that is what buddy seed has to do to satisfy the foreign key:

// Post belongsTo User, so post.user_id points at a real row
const post = await factory('Post').create()

The other direction is explicit, since only the test knows how many children it wants:

const post = await factory('Post').create()
const comments = await factory('Comment').createMany(3, { post_id: post.id })

Mocking

Mock Functions

import { mock, spyOn } from '@stacksjs/testing'

it('should call function with correct args', () => {
  const mockFn = mock(() => 'result')

  const result = mockFn('arg1', 'arg2')

  expect(mockFn).toHaveBeenCalled()
  expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2')
  expect(mockFn).toHaveBeenCalledTimes(1)
  expect(result).toBe('result')
})

Spy on Methods

it('should spy on method', () => {
  const obj = {
    method: () => 'original'
  }

  const spy = spyOn(obj, 'method')
  obj.method()

  expect(spy).toHaveBeenCalled()
})

Mock Return Values

const mockFn = mock()
  .mockReturnValue('value')
  .mockReturnValueOnce('first call')
  .mockImplementation((x) => x * 2)

expect(mockFn(5)).toBe(10)

Mock Modules

import { mock } from '@stacksjs/testing'

// Mock entire module
mock.module('@stacksjs/email', () => ({
  send: mock(() => Promise.resolve({ sent: true }))
}))

// In test
it('should send email', async () => {
  const result = await sendWelcomeEmail('user@test.com')
  expect(result.sent).toBe(true)
})

Time Testing

Freezing Time

import { afterEach, freezeTime, travelTo, useRealTime } from '@stacksjs/testing'

// Not optional. The system clock is process-wide and bun does not roll it back
// between test files, so a frozen clock leaks into every later suite in the
// run - where it surfaces as a token that is inexplicably expired, a long way
// from the test that caused it.
afterEach(useRealTime)

it('should test time-dependent code', () => {
  // Freeze time. Pass a Date, an ISO string, or an epoch number.
  freezeTime('2024-01-15T10:00:00Z')

  const now = new Date()
  expect(now.toISOString()).toBe('2024-01-15T10:00:00.000Z')

  // Travel to a specific time; the clock stays frozen there.
  travelTo(new Date('2024-06-01'))

  const future = new Date()
  expect(future.getMonth()).toBe(5) // June
})

Test Utilities

Skip and Only

// Skip a test
it.skip('should be skipped', () => {
  // This test won't run
})

// Run only this test
it.only('should run only this', () => {
  // Only this test runs
})

// Skip describe block
describe.skip('Skipped Suite', () => {
  // All tests skipped
})

Todo Tests

it.todo('should implement this feature')

Test Timeout

it('should complete within timeout', async () => {
  await longRunningOperation()
}, 10000) // 10 second timeout

Running Tests

CLI Commands

# Run all tests
bun test

# Run specific file
bun test tests/user.test.ts

# Run tests matching pattern
bun test --filter "User"

# Watch mode
bun test --watch

# With coverage
bun test --coverage

Configuration

// bunfig.toml
[test]
# Glob patterns for test files
preload = ["./tests/setup.ts"]
timeout = 5000
coverage = true
coverageThreshold = {
  line = 80
  function = 80
  branch = 75
}

Edge Cases

Testing Async Errors

it('should handle async errors', async () => {
  await expect(async () => {
    await failingAsyncFunction()
  }).rejects.toThrow('Expected error')
})

Testing Event Emitters

it('should emit event', async () => {
  const emitter = new EventEmitter()
  const handler = mock()

  emitter.on('event', handler)
  emitter.emit('event', { data: 'value' })

  expect(handler).toHaveBeenCalledWith({ data: 'value' })
})

Testing Race Conditions

it('should handle concurrent operations', async () => {
  const results = await Promise.all([
    incrementCounter(),
    incrementCounter(),
    incrementCounter()
  ])

  expect(await getCounter()).toBe(3)
})

API Reference

Test Functions

FunctionDescription
describe(name, fn)Group tests
it(name, fn, timeout?)Define test
expect(value)Create assertion
beforeAll(fn)Run before all tests
beforeEach(fn)Run before each test
afterEach(fn)Run after each test
afterAll(fn)Run after all tests

Database Assertions

FunctionDescription
assertDatabaseHas(table, data)Assert record exists
assertDatabaseMissing(table, data)Assert record missing
assertDatabaseCount(table, count)Assert row count
assertSoftDeleted(table, data)Assert soft deleted
assertNotSoftDeleted(table, data)Assert not soft deleted

Matchers

MatcherDescription
toBe(value)Strict equality
toEqual(value)Deep equality
toBeTruthy()Truthy value
toBeFalsy()Falsy value
toBeNull()Is null
toBeUndefined()Is undefined
toBeDefined()Is defined
toContain(item)Contains item
toHaveLength(n)Has length n
toThrow(msg?)Throws error
toBeInstanceOf(class)Instance of class
toHaveProperty(key)Has property
toMatchObject(obj)Partial object match

Mock Functions

MethodDescription
mock(fn?)Create mock function
spyOn(obj, method)Spy on method
mockReturnValue(val)Set return value
mockImplementation(fn)Set implementation
toHaveBeenCalled()Was called
toHaveBeenCalledWith(...args)Called with args
toHaveBeenCalledTimes(n)Called n times