Testing

On this page 30

Stacks provides a comprehensive testing framework built on Bun's native test runner. Write unit tests, integration tests, and component tests with excellent developer experience.

Overview

Testing in Stacks offers:

  • Fast execution - Bun's native test runner is blazing fast
  • TypeScript support - First-class TypeScript integration
  • Rich assertions - Comprehensive assertion library
  • Database utilities - Transaction rollback, factories
  • HTTP testing - Test API endpoints easily
  • Component testing - DOM tests in-process with very-happy-dom

Quick Start

Running Tests

# Run all tests
bun test

# Run specific file
bun test tests/Unit/UserTest.ts

# Run with pattern
bun test --grep "user registration"

# Run with coverage
bun test --coverage

# Watch mode
bun test --watch

Writing Tests

Basic Structure

// tests/Unit/ExampleTest.ts
import { describe, expect, it, beforeEach, afterEach } from 'bun:test'

describe('Calculator', () => {
  let calculator: Calculator

  beforeEach(() => {
    calculator = new Calculator()
  })

  it('adds two numbers', () => {
    expect(calculator.add(2, 3)).toBe(5)
  })

  it('subtracts two numbers', () => {
    expect(calculator.subtract(5, 3)).toBe(2)
  })

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

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

Assertions

import { expect } from 'bun:test'

// Equality
expect(value).toBe(expected)           // Strict equality
expect(value).toEqual(expected)        // Deep equality
expect(value).not.toBe(unexpected)     // Negation

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

// Numbers
expect(value).toBeGreaterThan(3)
expect(value).toBeGreaterThanOrEqual(3)
expect(value).toBeLessThan(5)
expect(value).toBeLessThanOrEqual(5)
expect(value).toBeCloseTo(0.3, 5)

// Strings
expect(value).toContain('substring')
expect(value).toMatch(/pattern/)
expect(value).toHaveLength(5)

// Arrays
expect(array).toContain(item)
expect(array).toHaveLength(3)
expect(array).toEqual([1, 2, 3])

// Objects
expect(object).toHaveProperty('key')
expect(object).toHaveProperty('key', value)
expect(object).toMatchObject({ key: value })

// Errors
expect(() => fn()).toThrow()
expect(() => fn()).toThrow('message')
expect(() => fn()).toThrow(ErrorClass)

// Async
await expect(promise).resolves.toBe(value)
await expect(promise).rejects.toThrow()

Test Organization

Directory Structure

tests/
├── Unit/                 # Unit tests
│   ├── Models/
│   │   └── UserTest.ts
│   ├── Services/
│   │   └── PaymentServiceTest.ts
│   └── Utils/
│       └── StringTest.ts
├── Feature/              # Integration tests
│   ├── Auth/
│   │   └── LoginTest.ts
│   └── Api/
│       └── UsersTest.ts
├── browser/              # Component & DOM tests
│   └── checkout.test.ts
└── helpers/              # Test utilities
    └── index.ts

Naming Conventions

  • Test files: *Test.ts or *.test.ts
  • Describe blocks: Feature or class name
  • It blocks: Behavior description

Unit Testing

Testing Functions

// src/utils/string.ts
export function capitalize(str: string): string {
  return str.charAt(0).toUpperCase() + str.slice(1)
}

// tests/Unit/Utils/StringTest.ts
import { describe, expect, it } from 'bun:test'
import { capitalize } from '@/utils/string'

describe('capitalize', () => {
  it('capitalizes first letter', () => {
    expect(capitalize('hello')).toBe('Hello')
  })

  it('handles empty string', () => {
    expect(capitalize('')).toBe('')
  })

  it('handles already capitalized', () => {
    expect(capitalize('Hello')).toBe('Hello')
  })
})

Testing Classes

import { describe, expect, it, beforeEach } from 'bun:test'
import { UserService } from '@/services/UserService'
import { MockUserRepository } from '../mocks/MockUserRepository'

describe('UserService', () => {
  let service: UserService
  let mockRepo: MockUserRepository

  beforeEach(() => {
    mockRepo = new MockUserRepository()
    service = new UserService(mockRepo)
  })

  it('creates a user', async () => {
    const user = await service.create({
      name: 'John',
      email: 'john@example.com',
    })

    expect(user.id).toBeDefined()
    expect(user.name).toBe('John')
  })

  it('validates email format', async () => {
    await expect(service.create({
      name: 'John',
      email: 'invalid-email',
    })).rejects.toThrow('Invalid email')
  })
})

Database Testing

Transaction Rollback

import { describe, it, expect } from 'bun:test'
import { useTransaction } from '@stacksjs/testing/database'
import { User } from '@/models/User'

describe('User Model', () => {
  useTransaction() // Rollback after each test

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

    expect(user.id).toBeDefined()

    // User exists in DB
    const found = await User.find(user.id)
    expect(found).not.toBeNull()
  })

  // Database is rolled back - user no longer exists
})

Factories

A factory is not a separate file. It is the factory you already declared on each attribute of the model:

// 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: () => 'password' },
  },
})

factory('User') builds rows from those declarations - the same generator buddy seed uses, so password columns are hashed, unique columns are kept distinct across a batch, and relation columns are filled:

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

describe('UserService', () => {
  useTransaction()

  it('finds user by email', async () => {
    const user = await factory('User').create({
      email: 'specific@example.com',
    })

    const found = await UserService.findByEmail('specific@example.com')
    expect(found?.id).toBe(user.id)
  })

  it('lists all users', async () => {
    await factory('User').createMany(5)

    const users = await UserService.all()
    expect(users).toHaveLength(5)
  })
})

make() and makeMany(n) are the same thing without the insert, for a test that wants attributes rather than rows. There is no singular/plural mode switch: make and create always return one row, makeMany and createMany always return an array.

HTTP Testing

API Endpoints

import { describe, expect, it } from 'bun:test'
import { http } from '@stacksjs/testing'
import { useTransaction } from '@stacksjs/testing/database'
import { factory } from '@stacksjs/testing/database'

describe('Users API', () => {
  useTransaction()

  it('lists users', async () => {
    await factory('User').createMany(3)

    const response = await http.get('/api/users')

    expect(response.status).toBe(200)
    const data = await response.json()
    expect(data.users).toHaveLength(3)
  })

  it('creates a user', async () => {
    const response = await http.post('/api/users', {
      body: {
        name: 'New User',
        email: 'new@example.com',
        password: 'password123',
      },
    })

    expect(response.status).toBe(201)
    const data = await response.json()
    expect(data.user.email).toBe('new@example.com')
  })

  it('validates required fields', async () => {
    const response = await http.post('/api/users', {
      body: { name: 'Test' }, // Missing email and password
    })

    expect(response.status).toBe(422)
    const data = await response.json()
    expect(data.errors.email).toBeDefined()
    expect(data.errors.password).toBeDefined()
  })
})

Authenticated Requests

import { actingAs } from '@stacksjs/testing'

describe('Profile API', () => {
  useTransaction()

  it('gets current user profile', async () => {
    const user = await factory('User').create()

    const response = await actingAs(user).get('/api/profile')

    expect(response.status).toBe(200)
    const data = await response.json()
    expect(data.user.id).toBe(user.id)
  })

  it('requires authentication', async () => {
    const response = await http.get('/api/profile')

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

Mocking

Function Mocks

import { describe, expect, it, mock, spyOn } from 'bun:test'

describe('Mocking', () => {
  it('mocks a function', () => {
    const mockFn = mock(() => 'mocked')

    expect(mockFn()).toBe('mocked')
    expect(mockFn).toHaveBeenCalled()
  })

  it('spies on methods', () => {
    const service = new EmailService()
    const spy = spyOn(service, 'send').mockResolvedValue({ sent: true })

    await service.send('test@example.com', 'Hello')

    expect(spy).toHaveBeenCalledWith('test@example.com', 'Hello')
  })
})

Module Mocks

import { mock } from 'bun:test'

mock.module('@/services/stripe', () => ({
  charge: mock().mockResolvedValue({ success: true }),
}))

import { processPayment } from '@/services/payment'

it('processes payment', async () => {
  const result = await processPayment(100)
  expect(result.success).toBe(true)
})

Component & DOM Testing

Component tests run under bun test against very-happy-dom, a virtual DOM built for Bun. There is no browser and no server — you get document, window and the DOM APIs in-process, which makes these tests fast enough to run on every save.

Setup

Register the browser globals once from a preload:

# bunfig.toml
[test]
preload = ["./tests/setup.ts"]
// tests/setup.ts
import { GlobalRegistrator } from 'very-happy-dom'

GlobalRegistrator.register({ url: 'https://example.com/' })

Writing a component test

Drive the DOM directly. document is the interface — there is no page object.

// tests/browser/checkout.test.ts
import { beforeEach, describe, expect, it } from 'bun:test'
import { renderCheckout } from '../../resources/components/checkout'

describe('Checkout', () => {
  beforeEach(() => {
    document.body.innerHTML = ''
  })

  it('submits the form with the entered details', () => {
    document.body.appendChild(renderCheckout())

    const email = document.querySelector<HTMLInputElement>('[name="email"]')!
    email.value = 'test@example.com'
    email.dispatchEvent(new Event('input', { bubbles: true }))

    let submittedEmail: string | null = null
    document.querySelector('form')!.addEventListener('submit', (event) => {
      event.preventDefault()
      const form = event.target as HTMLFormElement
      submittedEmail = form.querySelector<HTMLInputElement>('[name="email"]')!.value
    })

    document.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()

    expect(submittedEmail).toBe('test@example.com')
  })
})

Events bubble, addEventListener supports once/capture/signal, and form controls expose their live values through the DOM API, so most testing-library patterns work unchanged.

What this layer can and cannot assert

very-happy-dom implements the DOM, not a browser. It covers DOM structure and mutation, CSS selectors and XPath, events and focus, forms and constraint validation, storage, fetch, timers, observers, web components and Shadow DOM.

Since 0.1.11 it also resolves the CSS cascade. getComputedStyle() applies rules from stylesheets, honouring specificity, !important and author-origin order, so a class-based style assertion reflects what the cascade computes rather than only what an inline style attribute says.

It still does not implement three things, and tests must not assert on them:

  • Real navigation. Nothing is fetched or loaded; assigning location.href changes the URL without loading a document, and client-side routing does not run.
  • Layout. No geometry is computed. getBoundingClientRect() reflects inline styles only, so an element sized by a stylesheet reports 0 even where getComputedStyle() returns its width. Positions, overlap and scroll offsets are not computed either.
  • Pixels. Nothing is painted, so nothing can be asserted about how the page actually looks.

Visibility sits across that line, and the distinction is worth keeping straight. "Is this hidden by a style?" is answerable and reliable: display: none and visibility: hidden resolve through the cascade, including when they come from an ancestor. "Is this visible to a user?" — on screen, not zero-sized, not covered by something else — is not, because that needs layout.

Assertions that depend on layout or pixels belong in real-browser QA. Writing them against a virtual DOM produces tests that pass without checking anything — comparing 0 to 0 for a geometry check, for instance — which reads as coverage while providing none.

Real-browser QA

Use @stacksjs/testing/browser for committed tests that need a real engine: navigation, layout, screenshots, and console or network checks. Provision Chrome through Pantry, then run these tests with Bun:

import { expect, test } from 'bun:test'
import { auditPage, pageAuditClean } from '@stacksjs/testing/browser'

test('the homepage loads cleanly', async () => {
  const report = await auditPage('http://localhost:3000/')
  expect(pageAuditClean(report)).toBe(true)
})

For a longer journey, launch() returns a CDP session and an idempotent close() method. Launches in one process share a Chrome, each in its own browser context (separate cookies and storage), so a suite of journeys pays for starting Chrome once. Await close() in finally: it disposes the context, and the Chrome stops shortly after the last one. A launch with a profile (which remains caller-owned) or shared: false gets a Chrome of its own.

startTimeoutMs bounds process startup, target discovery, and session setup; target discovery also keeps its 12-second cap. Each launch keeps its own deadline while waiting for a shared Chrome to start. A short wait timing out does not stop the native process another caller is still starting. A ChromeLaunchError retains the original cause and structured diagnostics: executable, PID, profile, exit status before and after cleanup, port-file evidence, a capped stderr tail, and any cleanup error. Its phase identifies the operation that was pending; phaseStartedMs records when each reached phase began, measured from that launch's start. A failed warm session reports its own phases and elapsed time, including when its shared Chrome process is older. Inspect this evidence when a runner cannot start Chrome. Cleanup waits for the owned process to exit, escalates to SIGKILL when needed, and preserves a profile if process exit cannot be confirmed.

For interactive diagnosis, the stacks-browse skill drives Chrome through CLI steps and can capture screenshots and inspect a flow.

Code Coverage

# Run with coverage
bun test --coverage

# Generate HTML report
bun test --coverage --coverage-reporter=html

Coverage Thresholds

// bunfig.toml
[test]
coverage = true
coverageThreshold = {
  lines = 80,
  functions = 80,
  branches = 70,
  statements = 80
}

Best Practices

  1. Test behavior, not implementation - Focus on what code does
  2. Use factories - Consistent test data creation
  3. Isolate tests - Each test should be independent
  4. Use transactions - Automatic database cleanup
  5. Mock external services - Don't make real API calls
  6. Keep tests fast - Fast feedback loop
  7. Test edge cases - Empty inputs, errors, boundaries