Skip to content

Testing Guide for Contributors

Last Updated: 2025-11-22 Target Audience: Contributors, Developers

A comprehensive guide to writing and running tests in the VueSIP project.


Table of Contents

  1. Overview
  2. Test Structure
  3. Running Tests
  4. Writing Unit Tests
  5. Writing Integration Tests
  6. Writing E2E Tests
  7. Testing Patterns
  8. Coverage Requirements
  9. Best Practices

Overview

VueSIP uses a comprehensive testing strategy with three testing levels:

  • Unit Tests: Test individual functions and composables in isolation (Vitest)
  • Integration Tests: Test component interactions and data flow (Vitest)
  • E2E Tests: Test complete user workflows in a real browser (Playwright)

Testing Stack:


Test Structure

tests/
├── unit/              # Unit tests
│   ├── composables/   # Composable tests
│   ├── core/          # Core class tests
│   ├── stores/        # Store tests
│   ├── utils/         # Utility function tests
│   └── storage/       # Storage adapter tests
├── integration/       # Integration tests
│   ├── call-flow/     # Call workflow tests
│   └── media/         # Media integration tests
├── e2e/               # End-to-end tests
│   ├── specs/         # Test specifications
│   └── fixtures/      # Test fixtures
├── helpers/           # Test helpers and mocks
├── utils/             # Test utilities
└── setup.ts           # Global test setup

Running Tests

Quick Start

bash
# Run all tests
pnpm test

# Run in watch mode
pnpm test:watch

Unit Tests

bash
# Run all unit tests
pnpm test:unit

# Run unit tests in watch mode
pnpm test:unit:watch

# Run specific test file
pnpm vitest run tests/unit/composables/useDTMF.test.ts

Integration Tests

bash
# Run all integration tests
pnpm test:integration

E2E Tests

bash
# Run all E2E tests
pnpm test:e2e

# Run with UI mode (recommended for debugging)
pnpm test:e2e:ui

# Run in headed mode (see browser)
pnpm test:e2e:headed

# Run in debug mode
pnpm test:e2e:debug

# Run specific browser
pnpm test:e2e:chromium
pnpm test:e2e:firefox
pnpm test:e2e:webkit

# View test report
pnpm test:e2e:report

Performance Tests

bash
# Run performance tests
pnpm test:performance

# Run performance benchmarks
pnpm test:performance:bench

# Run with garbage collection profiling
pnpm test:performance:gc

Coverage

bash
# Generate coverage report
pnpm coverage

# Generate unit test coverage only
pnpm coverage:unit

Writing Unit Tests

Basic Structure

typescript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { ref } from 'vue'
import { useDTMF } from '@/composables/useDTMF'

describe('useDTMF', () => {
  let mockSession: any

  beforeEach(() => {
    // Setup before each test
    mockSession = {
      id: 'test-session',
      sendDTMF: vi.fn().mockResolvedValue(undefined),
    }
  })

  afterEach(() => {
    // Cleanup after each test
    vi.clearAllMocks()
  })

  it('should queue DTMF tones', () => {
    const sessionRef = ref(mockSession)
    const { queueTone, queuedTones } = useDTMF(sessionRef)

    queueTone('1')
    queueTone('2')

    expect(queuedTones.value).toEqual(['1', '2'])
  })
})

Testing Composables

typescript
import { ref } from 'vue'
import { useCallHistory } from '@/composables/useCallHistory'

describe('useCallHistory', () => {
  it('should track call history', async () => {
    const { calls, addCall, clearHistory } = useCallHistory()

    await addCall({
      id: 'call-1',
      direction: 'outbound',
      number: '1234',
      duration: 120,
      timestamp: new Date()
    })

    expect(calls.value).toHaveLength(1)
    expect(calls.value[0].number).toBe('1234')

    clearHistory()
    expect(calls.value).toHaveLength(0)
  })
})

Mocking Dependencies

typescript
// Mock logger
vi.mock('@/utils/logger', () => ({
  createLogger: () => ({
    debug: vi.fn(),
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
  }),
}))

// Mock external library
vi.mock('jssip', () => ({
  UA: vi.fn().mockImplementation(() => ({
    start: vi.fn(),
    stop: vi.fn(),
    register: vi.fn(),
  })),
}))

Testing Async Code

typescript
it('should handle async operations', async () => {
  const { connect, isConnected } = useSipClient(config)

  const promise = connect()
  expect(isConnected.value).toBe(false)

  await promise
  expect(isConnected.value).toBe(true)
})

Testing Error Handling

typescript
it('should handle errors gracefully', async () => {
  const mockSession = {
    sendDTMF: vi.fn().mockRejectedValue(new Error('Send failed')),
  }

  const sessionRef = ref(mockSession)
  const { sendTone, lastError } = useDTMF(sessionRef)

  await sendTone('1')

  expect(lastError.value).toBeInstanceOf(Error)
  expect(lastError.value?.message).toBe('Send failed')
})

Writing Integration Tests

Integration tests verify that multiple components work together correctly.

Example: Call Flow Integration

typescript
import { describe, it, expect } from 'vitest'
import { useSipClient } from '@/composables/useSipClient'
import { useCallSession } from '@/composables/useCallSession'

describe('Call Flow Integration', () => {
  it('should complete outbound call workflow', async () => {
    // Setup SIP client
    const { connect, isConnected } = useSipClient(config)
    await connect()
    expect(isConnected.value).toBe(true)

    // Make call
    const { makeCall, currentCall } = useCallSession()
    await makeCall('1234')

    expect(currentCall.value).toBeDefined()
    expect(currentCall.value?.state).toBe('connecting')

    // Simulate call established
    // ... test continues
  })
})

Writing E2E Tests

E2E tests use Playwright to test complete user workflows in a real browser.

Basic E2E Test

typescript
import { test, expect } from '@playwright/test'

test('user can make a call', async ({ page }) => {
  // Navigate to app
  await page.goto('http://localhost:5173')

  // Configure SIP settings
  await page.fill('[data-testid="sip-server"]', 'sip.example.com')
  await page.fill('[data-testid="sip-username"]', '1000')
  await page.fill('[data-testid="sip-password"]', 'secret')
  await page.click('[data-testid="connect-button"]')

  // Wait for connection
  await expect(page.locator('[data-testid="connection-status"]')).toContainText('Connected')

  // Make a call
  await page.fill('[data-testid="dialpad-input"]', '2000')
  await page.click('[data-testid="call-button"]')

  // Verify call state
  await expect(page.locator('[data-testid="call-status"]')).toContainText('Calling')
})

Page Object Pattern

typescript
// pages/dialpad.page.ts
export class DialpadPage {
  constructor(private page: Page) {}

  async dial(number: string) {
    await this.page.fill('[data-testid="dialpad-input"]', number)
    await this.page.click('[data-testid="call-button"]')
  }

  async getCallStatus() {
    return await this.page.locator('[data-testid="call-status"]').textContent()
  }
}

// test usage
test('user can make a call', async ({ page }) => {
  const dialpad = new DialpadPage(page)

  await dialpad.dial('2000')
  expect(await dialpad.getCallStatus()).toBe('Calling')
})

Testing Patterns

Arrange-Act-Assert (AAA)

typescript
it('should send DTMF tone', async () => {
  // Arrange
  const mockSession = { sendDTMF: vi.fn().mockResolvedValue(undefined) }
  const sessionRef = ref(mockSession)
  const { sendTone } = useDTMF(sessionRef)

  // Act
  await sendTone('1')

  // Assert
  expect(mockSession.sendDTMF).toHaveBeenCalledWith('1')
})

Test Data Builders

typescript
// tests/helpers/builders.ts
export function buildCallSession(overrides = {}) {
  return {
    id: 'test-session',
    state: 'active',
    direction: 'outbound',
    remoteNumber: '1234',
    ...overrides,
  }
}

// Usage
it('should handle active call', () => {
  const session = buildCallSession({ state: 'active' })
  expect(session.state).toBe('active')
})

Fixture Reuse

typescript
// tests/fixtures/sessions.ts
export const activeSessions = [
  { id: '1', state: 'active', number: '1234' },
  { id: '2', state: 'active', number: '5678' },
]

// Usage
import { activeSessions } from '../fixtures/sessions'

it('should list active sessions', () => {
  const { sessions } = useCallManagement()
  sessions.value = activeSessions

  expect(sessions.value).toHaveLength(2)
})

Coverage Requirements

VueSIP maintains high code coverage standards:

  • Lines: 80% minimum
  • Functions: 80% minimum
  • Branches: 75% minimum
  • Statements: 80% minimum

Viewing Coverage

bash
# Generate coverage report
pnpm coverage

# Open HTML report
open coverage/index.html

Coverage Tips

  1. Test Happy Paths: Cover the main use cases first
  2. Test Error Cases: Ensure error handling is tested
  3. Test Edge Cases: Cover boundary conditions
  4. Test Integration Points: Verify component interactions

Best Practices

1. Test Isolation

Each test should be independent and not rely on other tests.

typescript
// ❌ Bad: Tests depend on execution order
let sharedState: any

it('test 1', () => {
  sharedState = { value: 1 }
})

it('test 2', () => {
  expect(sharedState.value).toBe(1) // Fails if test 1 doesn't run
})

// ✅ Good: Tests are isolated
it('test 1', () => {
  const state = { value: 1 }
  expect(state.value).toBe(1)
})

it('test 2', () => {
  const state = { value: 1 }
  expect(state.value).toBe(1)
})

2. Descriptive Test Names

typescript
// ❌ Bad: Vague test name
it('works', () => { ... })

// ✅ Good: Descriptive test name
it('should queue DTMF tone when session is active', () => { ... })

3. One Assertion Concept Per Test

typescript
// ❌ Bad: Testing multiple unrelated concepts
it('should handle calls', () => {
  expect(canMakeCall()).toBe(true)
  expect(canReceiveCall()).toBe(true)
  expect(canTransferCall()).toBe(true) // Unrelated
})

// ✅ Good: Focused tests
it('should allow making calls when connected', () => {
  expect(canMakeCall()).toBe(true)
})

it('should allow receiving calls when registered', () => {
  expect(canReceiveCall()).toBe(true)
})

4. Avoid Test Logic

typescript
// ❌ Bad: Complex logic in test
it('should process all tones', () => {
  for (let i = 0; i < 10; i++) {
    if (i % 2 === 0) {
      queueTone(i.toString())
    }
  }
  expect(queuedTones.value.length).toBe(5)
})

// ✅ Good: Clear, straightforward test
it('should queue even number tones', () => {
  queueTone('0')
  queueTone('2')
  queueTone('4')
  queueTone('6')
  queueTone('8')

  expect(queuedTones.value).toEqual(['0', '2', '4', '6', '8'])
})

5. Use Test Utilities

typescript
// Create reusable test utilities
// tests/helpers/test-utils.ts
export function waitForConnection(timeout = 5000) {
  return new Promise((resolve) => {
    const interval = setInterval(() => {
      if (isConnected.value) {
        clearInterval(interval)
        resolve(true)
      }
    }, 100)

    setTimeout(() => {
      clearInterval(interval)
      resolve(false)
    }, timeout)
  })
}

6. Clean Up After Tests

typescript
afterEach(() => {
  vi.clearAllMocks()
  vi.restoreAllMocks()
})

onUnmounted(() => {
  // Clean up subscriptions, timers, etc.
})

Continuous Integration

Tests run automatically on every pull request:

  1. Lint Check: ESLint validation
  2. Type Check: TypeScript compilation
  3. Unit Tests: Full unit test suite
  4. Integration Tests: Integration test suite
  5. E2E Tests: Critical user workflows (Chromium only)
  6. Coverage Check: Enforce minimum coverage thresholds

Debugging Tests

Debug Unit Tests

bash
# Run single test file in watch mode
pnpm vitest watch tests/unit/composables/useDTMF.test.ts

# Use debugger statements
it('should debug', () => {
  debugger // Will pause in Node debugger
  expect(true).toBe(true)
})

Debug E2E Tests

bash
# Run in debug mode
pnpm test:e2e:debug

# Run in headed mode to see browser
pnpm test:e2e:headed

# Use Playwright Inspector
PWDEBUG=1 pnpm test:e2e

Common Issues

Issue: Tests timing out

Solution: Increase timeout for slow tests

typescript
it('slow test', async () => {
  // Test code
}, 10000) // 10 second timeout

Issue: Flaky tests

Solution: Use proper waiting strategies

typescript
// ❌ Bad: Hard-coded delay
await new Promise(resolve => setTimeout(resolve, 1000))

// ✅ Good: Wait for condition
await vi.waitFor(() => {
  expect(isConnected.value).toBe(true)
})

Issue: Mock not working

Solution: Ensure mocks are set up before imports

typescript
// Mocks must be at the top of the file
vi.mock('@/utils/logger')

import { useSipClient } from '@/composables/useSipClient'

Additional Resources


Questions or Issues?

Released under the MIT License.