Documentation Index

Fetch the complete documentation index at: https://docs.unqork.io/llms.txt

Use this file to discover all available pages before exploring further.

Testing Guide - BYO SDK

Prev Next

Write comprehensive tests for your BYO components using the SDK's testing utilities and Vitest.

The BYO SDK provides the following testing tools:

  • Testing utilities: Helpers for mounting, mocking, and asserting.
  • Vitest integration: Fast unit test runner with happy-dom environment.
  • Mock host: Simulates platform behavior.
  • Callback spies: Lightweight assertion helpers.

Getting Started

Scaffolded projects include Vitest and testing utilities preconfigured. The scaffold adds the following script to package.json:

{
  "scripts": {
    "test": "vitest run"
  }
}

To add watch mode, UI mode, and coverage support, add the following scripts manually:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage"
  }
}

Run tests using the following commands:

# Run once
npm test

# Watch mode
npm run test:watch

# UI mode
npm run test:ui

# With coverage
npm run test:coverage

Testing Utilities

Import from @unqork/byo-sdk/testing:

import {
  mountByoComponent,
  applyDefaults,
  createCallbackSpy,
  defineMockScenario,
} from '@unqork/byo-sdk/testing'

mountByoComponent

Mounts a component export as a custom element in the document.

async function mountByoComponent(
  componentExport: ByoComponentExport,
  tagPrefix?: string  // default: 'test'
): Promise<HTMLElement>

Example:

import { mountByoComponent } from '@unqork/byo-sdk/testing'
import { myButton } from './entry'

it('renders a button element', async () => {
  const el = await mountByoComponent(myButton)
  
  expect(el.querySelector('button')).toBeTruthy()
})

Each call generates a unique tag name to avoid conflicts between tests.

applyDefaults

Reads default values from the model schema and sets them as properties.

async function applyDefaults(
  element: HTMLElement,
  componentExport: ByoComponentExport
): Promise<void>

Example:

import { mountByoComponent, applyDefaults } from '@unqork/byo-sdk/testing'
import { myButton } from './entry'

it('applies default label', async () => {
  const el = await mountByoComponent(myButton) as any
  await applyDefaults(el, myButton)
  
  // Model defines: label = 'Click me'
  expect(el.label).toBe('Click me')
})

Simulates what the platform does before rendering a component.

createCallbackSpy

Creates a spy function that records all invocations.

function createCallbackSpy(): {
  fn: (...args: unknown[]) => void
  calls: unknown[][]
}

Example:

import { mountByoComponent, createCallbackSpy } from '@unqork/byo-sdk/testing'
import { myButton } from './entry'

it('fires callback on click', async () => {
  const el = await mountByoComponent(myButton) as any
  const spy = createCallbackSpy()
  
  el.onButtonClicked = spy.fn
  el.label = 'Test'
  
  el.querySelector('button')!.click()
  
  expect(spy.calls).toHaveLength(1)
  expect(spy.calls[0][0]).toEqual({ label: 'Test' })
})

The spy records calls as an array of argument arrays.

Testing Patterns

Basic Component Test

import { describe, it, expect } from 'vitest'
import { mountByoComponent, applyDefaults } from '@unqork/byo-sdk/testing'
import { myButton } from './entry'

describe('MyButton', () => {
  it('renders with default props', async () => {
    const el = await mountByoComponent(myButton)
    await applyDefaults(el, myButton)
    
    expect(el.querySelector('button')?.textContent).toBe('Click me')
  })
})

Testing Props

it('updates text when label changes', async () => {
  const el = await mountByoComponent(myButton) as any
  
  el.label = 'First'
  expect(el.querySelector('button')?.textContent).toBe('First')
  
  el.label = 'Second'
  expect(el.querySelector('button')?.textContent).toBe('Second')
})

it('disables button when disabled prop is true', async () => {
  const el = await mountByoComponent(myButton) as any
  
  el.disabled = true
  
  expect(el.querySelector('button')?.disabled).toBe(true)
})

Testing Callbacks

import { createCallbackSpy } from '@unqork/byo-sdk/testing'

it('fires onButtonClicked with correct payload', async () => {
  const el = await mountByoComponent(myButton) as any
  const spy = createCallbackSpy()
  
  el.onButtonClicked = spy.fn
  el.label = 'Submit'
  
  el.querySelector('button')!.click()
  
  expect(spy.calls).toHaveLength(1)
  expect(spy.calls[0][0]).toEqual({ label: 'Submit' })
})

it('does not error if callback is not set', async () => {
  const el = await mountByoComponent(myButton) as any
  
  // Should not throw
  expect(() => {
    el.querySelector('button')!.click()
  }).not.toThrow()
})

Testing Submission Data

it('updates submission data on change', async () => {
  const el = await mountByoComponent(myInput) as any
  const spy = createCallbackSpy()
  
  el.onSubmissionUpdate = spy.fn
  
  const input = el.querySelector('input')!
  input.value = 'test value'
  input.dispatchEvent(new Event('input'))
  
  expect(spy.calls).toHaveLength(1)
  expect(spy.calls[0][0]).toBe('test value')
})

it('initializes from submissionData prop', async () => {
  const el = await mountByoComponent(myInput) as any
  
  el.submissionData = 'initial value'
  
  expect(el.querySelector('input')?.value).toBe('initial value')
})

Testing Complex Interactions

describe('TaskList', () => {
  it('toggles task completion', async () => {
    const el = await mountByoComponent(taskList) as any
    const toggleSpy = createCallbackSpy()
    const updateSpy = createCallbackSpy()
    
    el.onTaskToggled = toggleSpy.fn
    el.onSubmissionUpdate = updateSpy.fn
    
    el.items = [
      { id: '1', title: 'Task 1', completed: false }
    ]
    
    const checkbox = el.querySelector('input[type="checkbox"]')!
    checkbox.click()
    
    // Check callback was fired
    expect(toggleSpy.calls).toHaveLength(1)
    expect(toggleSpy.calls[0][0].task.completed).toBe(true)
    
    // Check submission was updated
    expect(updateSpy.calls).toHaveLength(1)
    expect(updateSpy.calls[0][0][0].completed).toBe(true)
    
    // Check UI updated
    const taskEl = el.querySelector('.task')!
    expect(taskEl.classList.contains('completed')).toBe(true)
  })
})

Testing Error Handling

it('handles invalid prop gracefully', async () => {
  const el = await mountByoComponent(myButton) as any
  
  // Should not throw
  expect(() => {
    el.label = null
  }).not.toThrow()
  
  // Should render empty or default
  expect(el.querySelector('button')?.textContent).toBe('')
})

it('validates submission data format', async () => {
  const el = await mountByoComponent(myInput) as any
  const spy = createCallbackSpy()
  
  el.onSubmissionUpdate = spy.fn
  
  // Component should reject invalid data
  el.submissionData = { invalid: 'object' }
  
  expect(spy.calls).toHaveLength(0)
})

Testing Framework-Specific Components

React Components

// MyButton.test.tsx
import { describe, it, expect } from 'vitest'
import { mountByoComponent, createCallbackSpy } from '@unqork/byo-sdk/testing'
import { myButton } from './entry'

describe('MyButton (React)', () => {
  it('renders react component', async () => {
    const el = await mountByoComponent(myButton) as any
    
    el.label = 'React Button'
    
    // Wait for React to render
    await new Promise(resolve => setTimeout(resolve, 0))
    
    expect(el.shadowRoot?.querySelector('button')?.textContent).toBe('React Button')
  })
})

React components use react-to-webcomponent, which renders into a shadow DOM.

Vue Components

// MyButton.test.ts
import { describe, it, expect } from 'vitest'
import { mountByoComponent } from '@unqork/byo-sdk/testing'
import { myButton } from './entry'

describe('MyButton (Vue)', () => {
  it('renders vue component', async () => {
    const el = await mountByoComponent(myButton) as any
    
    el.label = 'Vue Button'
    
    // Wait for Vue to render
    await new Promise(resolve => setTimeout(resolve, 0))
    
    expect(el.querySelector('button')?.textContent).toBe('Vue Button')
  })
})

Testing Async Behavior

Debounced Updates

import { vi } from 'vitest'

it('debounces submission updates', async () => {
  vi.useFakeTimers()
  
  const el = await mountByoComponent(myInput) as any
  const spy = createCallbackSpy()
  
  el.onSubmissionUpdate = spy.fn
  
  const input = el.querySelector('input')!
  
  input.value = 'a'
  input.dispatchEvent(new Event('input'))
  
  input.value = 'ab'
  input.dispatchEvent(new Event('input'))
  
  input.value = 'abc'
  input.dispatchEvent(new Event('input'))
  
  // Fast-forward time
  vi.advanceTimersByTime(300)
  
  // Should only call once (debounced)
  expect(spy.calls).toHaveLength(1)
  expect(spy.calls[0][0]).toBe('abc')
  
  vi.useRealTimers()
})

Lazy Loading

it('lazy loads chart library on demand', async () => {
  const el = await mountByoComponent(myChart) as any
  
  // Initially no chart rendered
  expect(el.querySelector('.chart')).toBeNull()
  
  // Trigger chart rendering
  el.showChart = true
  
  // Wait for dynamic import
  await new Promise(resolve => setTimeout(resolve, 100))
  
  expect(el.querySelector('.chart')).toBeTruthy()
})

Coverage

Run tests with coverage:

npm run test:coverage

Output:

Coverage report:
  File              | % Stmts | % Branch | % Funcs | % Lines
  ------------------|---------|----------|---------|--------
  All files         |   92.5  |   88.3   |   95.0  |   93.2
  MyButton.ts       |   95.0  |   90.0   |  100.0  |   96.0
  MyInput.ts        |   90.0  |   85.0   |   90.0  |   91.0

To configure coverage thresholds, add the following to your vitest.config.ts:

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80,
      }
    }
  }
})

Best Practices

1. Test User Interactions, Not Implementation

Bad:

it('sets _label property', () => {
  el._label = 'test'  // Testing internal state
})

Good:

it('updates button text when label changes', () => {
  el.label = 'test'
  expect(el.querySelector('button')?.textContent).toBe('test')
})

2. Use Descriptive Test Names

Bad:

it('works', () => { ... })

Good:

it('fires onButtonClicked with label when button is clicked', () => { ... })

3. Test Edge Cases

describe('MyButton edge cases', () => {
  it('handles empty label', async () => {
    el.label = ''
    expect(el.querySelector('button')?.textContent).toBe('')
  })
  
  it('handles null label', async () => {
    el.label = null as any
    expect(el.querySelector('button')?.textContent).toBe('')
  })
  
  it('handles very long label', async () => {
    el.label = 'x'.repeat(1000)
    expect(el.querySelector('button')?.textContent).toHaveLength(1000)
  })
})

4. Clean Up After Tests

import { afterEach } from 'vitest'

afterEach(() => {
  // Remove mounted elements
  document.body.innerHTML = ''
})

5. Test Accessibility

it('has accessible label', async () => {
  const el = await mountByoComponent(myButton) as any
  el.label = 'Submit form'
  
  const button = el.querySelector('button')!
  expect(button.getAttribute('aria-label')).toBe('Submit form')
})

it('supports keyboard navigation', async () => {
  const el = await mountByoComponent(myButton) as any
  const spy = createCallbackSpy()
  
  el.onButtonClicked = spy.fn
  
  const button = el.querySelector('button')!
  button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
  
  expect(spy.calls).toHaveLength(1)
})

Debugging Tests

Enable Verbose Logging

npm test -- --reporter verbose

Inspect the DOM

it('debug test', async () => {
  const el = await mountByoComponent(myButton) as any
  el.label = 'Test'
  
  console.log(el.outerHTML)
  // <test-my-button-1>
  //   <button>Test</button>
  // </test-my-button-1>
})

Use the Vitest UI

npm run test:ui

Opens an interactive browser UI for running and debugging tests.

Next Steps


Changelog

Date Change
2026-05-01 Initial publication