Testing

This guide covers testing strategies for applications built with @mdrv/wsx, including unit tests, integration tests, and end-to-end tests.

Overview

@mdrv/wsx is designed to be testable at multiple levels:

  • Unit Tests - Test individual components in isolation
  • Integration Tests - Test client-server communication flows
  • E2E Tests - Test complete application workflows

The library includes comprehensive test utilities and helpers to make testing easier.

Test Utilities

Test Fixtures

Use predefined event schemas and payloads for consistent testing:

import { testEvents, testPorts } from '@mdrv/wsx/tests/utils/fixtures.ts'

// testEvents provides standard request/response events
const events = {
  ping: {
    request: z.object({ timestamp: z.number() }),
    response: z.object({ pong: z.string() }),
  },
}

Test Helpers

Common async utilities for WebSocket testing:

import { waitFor, delay, MessageCollector } from '@mdrv/wsx/tests/utils/helpers.ts'

// Wait for a condition with timeout
await waitFor(() => client.isConnected(), 2000)

// Delay execution
await delay(100)

// Collect messages for assertions
const collector = new MessageCollector(client)
const messages = collector.getMessages('userJoined')

Custom Assertions

Specialized assertions for WebSocket scenarios:

import { assertConnected, assertMessageReceived } from '@mdrv/wsx/tests/utils/assertions.ts'

await assertConnected(client, 2000)
await assertMessageReceived(client, 'notify', 1000)

Unit Testing

Testing Client Components

Test the typed client in isolation using mock servers:

import { describe, it, expect } from 'bun:test'
import { TypedWSClient } from '@mdrv/wsx'
import { MockWSServer } from '@mdrv/wsx/tests/utils/mock-server.ts'

describe('TypedWSClient', () => {
  it('should send requests and receive responses', async () => {
    const mockServer = new MockWSServer(events)
    mockServer.onRequest('ping', async (payload) => ({
      pong: `Received at ${payload.timestamp}`,
    }))

    const client = new TypedWSClient(mockServer.url, events)
    client.connect()

    const response = await client.request('ping', {
      timestamp: Date.now(),
    })

    expect(response.pong).toContain('Received at')

    client.close()
    mockServer.stop()
  })
})

Testing Server Components

Test server handlers using test clients:

import { describe, it, expect } from 'bun:test'
import { createElysiaWS } from '@mdrv/wsx'
import { TestClient } from '@mdrv/wsx/tests/utils/test-client.ts'

describe('TypedWSServer', () => {
  it('should handle requests and send responses', async () => {
    const { server, handler } = createElysiaWS(events)

    server.onRequest('ping', async (payload) => ({
      pong: `Pong ${payload.timestamp}`,
    }))

    const testClient = new TestClient(handler)
    await testClient.connect()

    const response = await testClient.send('ping', {
      timestamp: 12345,
    })

    expect(response).toEqual({ pong: 'Pong 12345' })

    await testClient.disconnect()
  })
})

Integration Testing

Setting Up Integration Tests

Integration tests verify end-to-end client-server communication:

import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import { Elysia } from 'elysia'
import { TypedWSClient } from '@mdrv/wsx'
import { createElysiaWS } from '@mdrv/wsx'
import { z } from 'zod'

describe('Integration: Request-Response', () => {
  const PORT = 9000
  let app: Elysia | null = null

  afterEach(() => {
    app?.stop()
    app = null
  })

  it('should handle successful request-response', async () => {
    const events = {
      ping: {
        request: z.object({ timestamp: z.number() }),
        response: z.object({ pong: z.string() }),
      },
    }

    const { server, handler } = createElysiaWS(events)

    server.onRequest('ping', async (payload) => ({
      pong: `Received at ${payload.timestamp}`,
    }))

    app = new Elysia().ws('/ws', handler).listen(PORT)

    const client = new TypedWSClient(
      `ws://localhost:${PORT}/ws`,
      events,
    )
    client.connect()

    // Wait for connection
    let connected = false
    client.onOpen(() => {
      connected = true
    })
    await waitFor(() => connected, 2000)

    // Send request
    const response = await client.request('ping', {
      timestamp: Date.now(),
    })

    expect(response.pong).toContain('Received at')

    client.close()
  })
})

Testing Reconnection

Verify reconnection behavior in integration tests:

it('should reconnect after server restart', async () => {
  const { server, handler } = createElysiaWS(events)

  // Start server
  app = new Elysia().ws('/ws', handler).listen(PORT)

  const client = new TypedWSClient(`ws://localhost:${PORT}/ws`, events)
  client.connect()

  await waitFor(() => client.isConnected(), 2000)

  // Stop server (simulates network failure)
  app.stop()
  app = null

  await delay(100)

  // Restart server
  app = new Elysia().ws('/ws', handler).listen(PORT)

  // Client should auto-reconnect
  await waitFor(() => client.isConnected(), 5000)

  expect(client.isConnected()).toBe(true)

  client.close()
})

End-to-End Testing

Complete Workflow Testing

E2E tests verify entire application workflows:

describe('E2E: Authentication Flow', () => {
  it('should complete full auth workflow', async () => {
    const authEvents = {
      login: {
        request: z.object({
          username: z.string(),
          password: z.string(),
        }),
        response: z.object({
          success: z.boolean(),
          token: z.string().optional(),
        }),
      },
      getProfile: {
        request: z.object({ token: z.string() }),
        response: z.object({
          username: z.string(),
          role: z.string(),
        }),
      },
    }

    const { server, handler } = createElysiaWS(authEvents)

    // Mock authentication logic
    const sessions = new Map()

    server.onRequest('login', async (payload) => {
      if (payload.password === 'secret') {
        const token = `token-${Date.now()}`
        sessions.set(token, {
          username: payload.username,
          role: 'user',
        })
        return { success: true, token }
      }
      return { success: false }
    })

    server.onRequest('getProfile', async (payload) => {
      const session = sessions.get(payload.token)
      if (!session) throw new Error('Invalid token')
      return session
    })

    app = new Elysia().ws('/ws', handler).listen(PORT)

    const client = new TypedWSClient(
      `ws://localhost:${PORT}/ws`,
      authEvents,
    )
    client.connect()

    await waitFor(() => client.isConnected(), 2000)

    // Login
    const loginResp = await client.request('login', {
      username: 'alice',
      password: 'secret',
    })

    expect(loginResp.success).toBe(true)
    expect(loginResp.token).toBeDefined()

    // Get profile with token
    const profile = await client.request('getProfile', {
      token: loginResp.token!,
    })

    expect(profile.username).toBe('alice')
    expect(profile.role).toBe('user')

    client.close()
  })
})

Best Practices

Test Organization

Organize tests by type and scope:

tests/
├── integration/       # Integration tests
│   ├── connection.test.ts
│   ├── request-response.test.ts
│   └── reconnection.test.ts
├── e2e/              # End-to-end tests
│   ├── ping-pong.test.ts
│   ├── auth.test.ts
│   └── chat.test.ts
└── utils/            # Test utilities
    ├── fixtures.ts
    ├── helpers.ts
    ├── assertions.ts
    ├── mock-server.ts
    └── test-client.ts

src/
├── client/__tests__/  # Client unit tests
├── server/__tests__/  # Server unit tests
└── shared/__tests__/  # Shared unit tests

Port Management

Use dedicated port ranges to avoid conflicts:

const testPorts = {
  unit: 9200,
  integration: 9000,
  e2e: 9100,
}

// Use different ports for each test
const PORT = testPorts.integration + testIndex

Cleanup

Always clean up resources in afterEach hooks:

afterEach(() => {
  app?.stop()
  client?.close()
  app = null
  client = null
})

Async Handling

Use proper async patterns and timeouts:

// Good: Wait for condition with timeout
await waitFor(() => client.isConnected(), 2000)

// Bad: Fixed delay (unreliable)
await delay(1000)

Type Safety

Leverage TypeScript for test type safety:

const events = {
  ping: {
    request: z.object({ timestamp: z.number() }),
    response: z.object({ pong: z.string() }),
  },
} as const

type Events = typeof events

const client: TypedWSClient<Events> = new TypedWSClient(url, events)

// TypeScript ensures correct types
const response = await client.request('ping', { timestamp: 123 })
// response.pong is typed as string

Running Tests

Run All Tests

bun test

Run Specific Test File

bun test src/client/__tests__/typed-client.test.ts

Run Tests with Pattern

bun test --filter "reconnection"

Watch Mode

bun test --watch

Coverage Reporting

Generate test coverage reports:

bun test --coverage

View detailed coverage:

bun test --coverage --coverage-reporter=html
open coverage/index.html

Debugging Tests

Enable Debug Logs

const server = new TypedWSServer(events, {
  debug: true, // Enable debug logging
})

Inspect Messages

client.on('message', (event) => {
  console.log('Received:', event)
})

Use Timeouts Wisely

// Increase timeout for slow tests
it('slow test', async () => {
  // test code
}, { timeout: 10000 }) // 10 seconds

Example Test Suite

See the complete test suite for @mdrv/wsx:

Next Steps