Testing

On this page 16

stx provides comprehensive testing utilities to ensure your application's reliability. This guide covers unit testing, component testing, and integration testing approaches.

Setup

Test Configuration

Create a bun.test.ts file in your project root:

import { expect, test, describe } from 'bun:test'
import { mount } from '@stacksjs/stx/test-utils'

// Configure test environment
export default {
  testMatch: ['**/*.test.ts'],
  testTimeout: 5000,
  setupFiles: ['./test/setup.ts']
}

Test Utils

stx provides test utilities for component testing:

import { mount, shallowMount } from '@stacksjs/stx/test-utils'

Testing native screens

A native screen (@stacksjs/stx/native) is tested on a fake phone: mountNativeScreen(code, screen, options) from @stacksjs/stx/native/testing runs the compiled bundle in a vm against a fake craft and host, and returns the tree it draws plus press, type, settle, requests and the rest. See Native Screens: Testing screens.

Testing reactive directives (DOM runtime shim)

The client signals runtime — the engine behind :if / :else / :for / :show and the x-* directives — is written against real-browser DOM semantics that happy-dom doesn't fully provide: Node.ELEMENT_NODE is undefined, and el.attributes is a Map rather than a NamedNodeMap. Without bridging those, the runtime's processElement bails on every element and your reactive directives never run in a test.

stx/testing ships a shim that closes both gaps so you can drive a component's reactive directives end-to-end and assert on the resulting DOM:

import { describe, expect, it } from 'bun:test'
import { generateSignalsRuntimeDev, state } from '@stacksjs/stx'
import { flushEffects, setupStxTestDom, shimAttributes } from '@stacksjs/stx/testing'

describe('Comments list', () => {
  // Install Node.nodeType constants + a requestAnimationFrame polyfill once,
  // then execute the signals runtime into the test's window.
  setupStxTestDom()
  // eslint-disable-next-line no-new-func
  new Function(generateSignalsRuntimeDev())()

  it('shows the skeleton while loading, the list when loaded', async () => {
    const loading = state(true)
    const items = state<string[]>([])
    window.stx._scopes = { comments: { loading, items } }

    document.body.innerHTML = `
      <section data-stx-scope="comments">
        <ul :if="loading()">skeleton</ul>
        <div :else-if="items().length === 0">empty</div>
        <ul :else>list</ul>
      </section>`

    shimAttributes(document.body)             // after every innerHTML assignment
    document.dispatchEvent(new Event('DOMContentLoaded'))
    await flushEffects()                       // drain the runtime's deferred work

    expect(document.body.textContent).toContain('skeleton')
  })
})

Helpers:

ExportPurpose
setupStxTestDom()Install the Node.*_NODE constants + a requestAnimationFrame polyfill. Call once before running the runtime. Idempotent.
shimAttributes(root)Give root and its descendants a NamedNodeMap-shaped attributes accessor (while preserving get/set). Call after each innerHTML write.
flushEffects(ms?)Await the runtime's deferred (setTimeout(0)) child processing before asserting.

Cross-.set() reactivity (flip a signal, assert the DOM updated) works when a single runtime instance is active. If you run many test files that each execute the runtime IIFE in one process, effect tracking can drift across instances — keep each runtime-driven file focused, or assert the initial-render state, which is always deterministic.

Unit Testing

Testing Functions and Utilities

// utils/format.ts
export function formatPrice(amount: number): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD'
  }).format(amount)
}

// utils/format.test.ts
import { expect, test } from 'bun:test'
import { formatPrice } from './format'

test('formatPrice formats numbers as USD currency', () => {
  expect(formatPrice(10)).toBe('$10.00')
  expect(formatPrice(10.5)).toBe('$10.50')
  expect(formatPrice(1000)).toBe('$1,000.00')
})

Testing TypeScript Code

// types/user.ts
interface User {
  id: number
  name: string
  role: 'admin' | 'user'
}

function isAdmin(user: User): boolean {
  return user.role === 'admin'
}

// types/user.test.ts
import { expect, test } from 'bun:test'

test('isAdmin correctly identifies admin users', () => {
  const admin = { id: 1, name: 'Admin', role: 'admin' }
  const user = { id: 2, name: 'User', role: 'user' }

  expect(isAdmin(admin)).toBe(true)
  expect(isAdmin(user)).toBe(false)
})

Component Testing

Testing Basic Components

// components/Button.stx
@component('Button')
  @ts
  interface Props {
    type?: 'primary' | 'secondary'
    disabled?: boolean
  }
  @endts

  <button
    class="btn btn-{{ type }}"
    x-disabled="disabled"
  >
    <slot></slot>
  </button>
@endcomponent

// components/Button.test.ts
import { expect, test } from 'bun:test'
import { mount } from '@stacksjs/stx/test-utils'
import Button from './Button.stx'

test('Button renders correctly', () => {
  const wrapper = mount(Button, {
    props: {
      type: 'primary'
    },
    slots: {
      default: 'Click me'
    }
  })

  expect(wrapper.text()).toBe('Click me')
  expect(wrapper.classes()).toContain('btn-primary')
})

test('Button handles disabled state', () => {
  const wrapper = mount(Button, {
    props: {
      disabled: true
    }
  })

  expect(wrapper.attributes('disabled')).toBeDefined()
})

Testing Component Events

// components/Counter.stx
@component('Counter')
  @ts
  let count = 0

  function increment() {
    count++
    emit('change', count)
  }
  @endts

  <div>
    <span>{{ count }}</span>
    <button @click="increment">Increment</button>
  </div>
@endcomponent

// components/Counter.test.ts
import { expect, test } from 'bun:test'
import { mount } from '@stacksjs/stx/test-utils'
import Counter from './Counter.stx'

test('Counter emits change event on increment', async () => {
  const wrapper = mount(Counter)
  const button = wrapper.find('button')

  await button.trigger('click')

  expect(wrapper.emitted().change).toBeTruthy()
  expect(wrapper.emitted().change[0]).toEqual([1])
})

Integration Testing

Testing Page Components

import { expect, test } from 'bun:test'
import { mount } from '@stacksjs/stx/test-utils'
import UserDashboard from '../pages/UserDashboard.stx'

test('UserDashboard integrates components correctly', async () => {
  const wrapper = mount(UserDashboard)

  // Test navigation component
  expect(wrapper.find('nav').exists()).toBe(true)

  // Test user profile component
  const profile = wrapper.findComponent({ name: 'UserProfile' })
  expect(profile.exists()).toBe(true)

  // Test interaction between components
  await wrapper.find('[data-test="edit-profile"]').trigger('click')
  expect(wrapper.findComponent({ name: 'ProfileEditor' }).exists()).toBe(true)
})

Testing Routing

import { expect, test } from 'bun:test'
import { mount } from '@stacksjs/stx/test-utils'
import { createRouter } from '@stacksjs/stx/router'
import App from '../App.stx'

test('routing works correctly', async () => {
  const router = createRouter({
    routes: [
      { path: '/', component: Home },
      { path: '/about', component: About }
    ]
  })

  const wrapper = mount(App, {
    global: {
      plugins: [router]
    }
  })

  // Test navigation
  await router.push('/about')
  expect(wrapper.findComponent(About).exists()).toBe(true)
})

Best Practices

  1. Test Organization

    • Keep tests close to components
    • Use descriptive test names
    • Group related tests
    • Mock external dependencies
  2. Testing Strategy

    • Unit test utilities and functions
    • Component test in isolation
    • Integration test key workflows
    • End-to-end test critical paths
  3. Performance

    • Mock heavy operations
    • Use shallow mounting when possible
    • Clean up after tests
    • Run tests in parallel
  4. Continuous Integration

    • Run tests on every commit
    • Maintain test coverage
    • Monitor test performance
    • Automate test runs

Next Steps