Testing Guide
This guide covers testing practices for Construct frontend and backend code. All new features require tests before PR review.
Frontend Testing
Setup
Frontend tests use Vitest with Node environment:
# Install dependencies
bun install
# Run tests
bun run test
# Watch mode for development
bun run test:watch
# Coverage report
bun run test:coverageConfiguration
Vitest config in frontend/vitest.config.ts:
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'node',
setupFiles: ['src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: [
'node_modules/',
'src/test/',
]
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})Test File Organization
Test files use either .test.ts or .spec.ts suffix:
src/
composables/
useAuth.ts
useAuth.test.ts # Alongside source file
utils/
format.ts
format.test.ts
router/
__tests__/ # Or in __tests__ folder
router.spec.tsPut tests near the code they test for easy discovery.
Example Test: Composable
Testing a composable that uses the SDK:
// composables/useAuth.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useAuth } from './useAuth'
// Mock the SDK
vi.mock('@construct-space/sdk', () => ({
useAuth: vi.fn(() => ({
user: { id: '123', email: 'test@example.com' },
isAuthenticated: true,
logout: vi.fn()
}))
}))
describe('useAuth', () => {
let auth: ReturnType<typeof useAuth>
beforeEach(() => {
auth = useAuth()
})
it('should return authenticated user', () => {
expect(auth.user?.email).toBe('test@example.com')
expect(auth.isAuthenticated).toBe(true)
})
it('should logout', async () => {
await auth.logout()
expect(auth.isAuthenticated).toBe(false)
})
})Example Test: Component
Testing a Vue component:
// components/Button.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from './Button.vue'
describe('Button', () => {
it('renders slot content', () => {
const wrapper = mount(Button, {
slots: {
default: 'Click Me'
}
})
expect(wrapper.text()).toContain('Click Me')
})
it('emits click event', async () => {
const wrapper = mount(Button)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
it('applies disabled state', () => {
const wrapper = mount(Button, {
props: { disabled: true }
})
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('calls onClick handler', async () => {
const onClick = vi.fn()
const wrapper = mount(Button, {
props: { onClick }
})
await wrapper.trigger('click')
expect(onClick).toHaveBeenCalled()
})
})Example Test: Utility Function
Testing a pure utility function:
// utils/format.test.ts
import { describe, it, expect } from 'vitest'
import { formatDate, parseEmail } from './format'
describe('format utilities', () => {
describe('formatDate', () => {
it('formats ISO date to readable string', () => {
const result = formatDate('2026-03-29T10:15:30Z')
expect(result).toBe('Mar 29, 2026 10:15 AM')
})
it('handles invalid dates', () => {
expect(() => formatDate('invalid')).toThrow()
})
})
describe('parseEmail', () => {
it('extracts email parts', () => {
const result = parseEmail('test@example.com')
expect(result).toEqual({
local: 'test',
domain: 'example.com'
})
})
it('validates email format', () => {
expect(parseEmail('invalid')).toBeNull()
})
})
})Mocking Best Practices
Mock API Calls
import { vi } from 'vitest'
// Mock fetch globally
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ id: '123' })
})
)Mock Composables
vi.mock('@construct-space/sdk', () => ({
useAuth: vi.fn(),
useStorage: vi.fn()
}))
// In test
import { useAuth } from '@construct-space/sdk'
const mockUseAuth = vi.mocked(useAuth)
mockUseAuth.mockReturnValue({ /* ... */ })Mock Modules
vi.mock('axios', () => ({
default: {
get: vi.fn(),
post: vi.fn()
}
}))Test Coverage
Check coverage with:
bun run test:coverageCoverage reports are generated in HTML:
coverage/
index.html # Open in browser
utils.ts.html
composables.ts.htmlAim for:
- Statements: 80%+ for critical code
- Branches: 75%+ (all conditionals covered)
- Functions: 85%+ (all functions tested)
- Lines: 80%+
Exclude test files and mocks from coverage.
Operator Testing
Setup
Go tests use standard testing package:
# Run all tests
go test ./...
# Run with coverage
go test -cover ./...
# Run with verbose output
go test -v ./...
# Run specific test
go test -run TestStoreGet ./operator/internal/stateTest File Naming
Test files use _test.go suffix in the same package:
operator/
internal/
state/
store.go
store_test.go # Same package as store.go
hooks/
executor.go
executor_test.goExample Test: Unit Test
Testing a state store function:
// operator/internal/state/store_test.go
package state
import (
"testing"
)
func TestStoreGet(t *testing.T) {
store := NewStore(t.TempDir())
// Setup
store.Set("key", "value")
// Execute
value, err := store.Get("key")
// Assert
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if value != "value" {
t.Errorf("expected 'value', got %q", value)
}
}
func TestStoreGetMissing(t *testing.T) {
store := NewStore(t.TempDir())
_, err := store.Get("missing")
if err == nil {
t.Error("expected error for missing key, got nil")
}
}Example Test: Table-Driven Test
Testing multiple scenarios:
func TestFormatDuration(t *testing.T) {
tests := []struct {
name string
duration time.Duration
want string
}{
{
name: "seconds",
duration: 30 * time.Second,
want: "30s",
},
{
name: "minutes",
duration: 5 * time.Minute,
want: "5m0s",
},
{
name: "hours",
duration: 1 * time.Hour,
want: "1h0m0s",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatDuration(tt.duration)
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}Example Test: Integration Test
Testing tool execution:
func TestToolExecution(t *testing.T) {
// Setup
operator := NewOperator()
result, err := operator.Execute(ToolCall{
Name: "bash",
Input: map[string]interface{}{
"command": "echo hello",
},
})
// Assert
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Output != "hello\n" {
t.Errorf("expected 'hello\\n', got %q", result.Output)
}
}Test Helpers
Create helper functions for common test setup:
// operator/internal/test/helpers.go
package test
import (
"testing"
"operator/internal/state"
)
// NewTestStore creates a store with temporary directory
func NewTestStore(t *testing.T) *state.Store {
return state.NewStore(t.TempDir())
}
// NewTestOperator creates an operator with test config
func NewTestOperator(t *testing.T) *Operator {
return &Operator{
store: NewTestStore(t),
// ... other test config
}
}Then use in tests:
import "operator/internal/test"
func TestSomething(t *testing.T) {
store := test.NewTestStore(t)
// ... rest of test
}Benchmarking
For performance-critical code, add benchmarks:
func BenchmarkStoreGet(b *testing.B) {
store := NewStore(b.TempDir())
store.Set("key", "value")
b.ResetTimer()
for i := 0; i < b.N; i++ {
store.Get("key")
}
}Run benchmarks:
go test -bench=. -benchmem ./operator/internal/stateTesting Practices
What to Test
Unit Tests: Individual functions and methods
- Pure functions (deterministic input/output)
- Error handling
- Edge cases
Integration Tests: Multiple components working together
- Tool execution with hooks
- Agent spawning and context
- State persistence
End-to-End: Critical user flows
- Agent dispatch and stream
- File operations
- Authentication flow
What NOT to Test
- Library internals (Vue internals, Go stdlib)
- Visual rendering (use visual regression tests if needed)
- Third-party APIs (mock them instead)
- Implementation details (test behavior, not how)
Test Organization
describe('useAuth', () => {
describe('when authenticated', () => {
it('returns user info', () => { /* ... */ })
it('allows logout', () => { /* ... */ })
})
describe('when not authenticated', () => {
it('returns null user', () => { /* ... */ })
it('requires login', () => { /* ... */ })
})
})Async Testing
// Using async/await
it('should fetch user', async () => {
const user = await fetchUser('123')
expect(user.id).toBe('123')
})
// Or returning promise
it('should fetch user', () => {
return fetchUser('123').then(user => {
expect(user.id).toBe('123')
})
})Error Testing
it('should throw on invalid input', () => {
expect(() => {
parseEmail('invalid')
}).toThrow()
})
it('should reject with error', () => {
return expect(fetchUser(-1)).rejects.toThrow('Invalid ID')
})Pre-Commit Checklist
Before pushing:
# Frontend
bun run lint # ESLint check
bun run test # Run tests
bun run build # Verify build
# Operator
go vet ./... # Static analysis
go test ./... # Run tests
go build ./cmd/ # Verify buildCI/CD Integration
Tests run automatically on every commit:
- Feature branches: Must pass all tests
- dev branch: Tests + deploy to staging
- main branch: Tests + deploy to production
If any test fails, the build is marked red and cannot be merged.
Common Issues
"Test timeout"
Increase timeout for slow operations:
// Vitest
it('should complete', async () => { /* ... */ }, 10000) // 10s
// Go
func TestSlowOperation(t *testing.T) {
done := make(chan bool, 1)
go func() {
// slow operation
done <- true
}()
select {
case <-done:
// success
case <-time.After(30 * time.Second):
t.Fatal("timeout")
}
}"Mock not working"
Ensure mocks are imported before tested code:
// Wrong - mock too late
import { useAuth } from '@construct-space/sdk'
vi.mock('@construct-space/sdk')
// Right - mock before import
vi.mock('@construct-space/sdk')
import { useAuth } from '@construct-space/sdk'"Flaky tests"
Tests that sometimes pass/fail are usually timing issues:
// Avoid - timing-dependent
it('should update', async () => {
updateUser()
expect(user.name).toBe('Jane') // Might fail if async!
})
// Better - wait for actual change
it('should update', async () => {
await updateUser()
expect(user.name).toBe('Jane')
})
// Or use waitFor
it('should update', async () => {
updateUser()
await waitFor(() => {
expect(user.name).toBe('Jane')
})
})