Skip to content

Testing

chatwire uses pytest for Python tests, vitest for the React frontend, and Jest for the mobile app.

Python tests (pytest)

Tests live in tests/. The conftest.py at the repo root adds the project directory to sys.path so top-level modules are importable without an editable install.

Run all tests

python3 -m pytest tests/ --tb=short -q

Run with verbose output

python3 -m pytest tests/ -v

Run a specific test file

python3 -m pytest tests/test_cli.py -v

Run a specific test function

python3 -m pytest tests/test_cli.py::test_doctor_all_green -v

Run with coverage

python3 -m pytest tests/ --cov=. --cov-report=term-missing

Test configuration

pyproject.toml:

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v"

Writing Python tests

Tests follow the test_<module>.py naming convention. Use pytest.fixture for setup and tmp_path for temporary directories.

import pytest
from pathlib import Path

def test_config_roundtrip(tmp_path):
    from config import save_config, load_config, CONFIG_PATH
    # ... test body

Because the bridge and web process run as launchd agents, tests that require a running server should mock the relevant functions rather than starting real processes.

Mocking macOS-specific calls

Many bridge functions call osascript, sips, or tccutil. Tests mock these with monkeypatch or unittest.mock:

from unittest.mock import patch, MagicMock

def test_send_text(monkeypatch):
    monkeypatch.setattr("chat_send.subprocess.run", lambda *a, **k: MagicMock(returncode=0))
    from chat_send import send_text_confirm
    result = send_text_confirm("+15551234567", "Hello")
    assert result.status == "ok"

Frontend tests (vitest)

Frontend tests live in web/frontend/src/**/__tests__/ (colocated) or web/frontend/src/test/.

Run tests

cd web/frontend
npm run test          # watch mode
npm run test -- --run  # single pass (CI)

Run with coverage

npm run test -- --coverage

Writing frontend tests

Use @testing-library/react and @testing-library/user-event:

import { render, screen } from '@testing-library/react'
import { MessageBubble } from '../components/MessageBubble'

test('renders message text', () => {
  render(
    <MessageBubble
      text="Hello!"
      fromMe={false}
      sender="Alice"
      ts="2026-05-16T14:00:00Z"
    />
  )
  expect(screen.getByText('Hello!')).toBeInTheDocument()
})

Mock API calls with msw (Mock Service Worker):

import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'

const server = setupServer(
  http.get('/api/v1/conversations', () => {
    return HttpResponse.json([{ guid: 'iMessage;-;+1555', display_name: 'Alice' }])
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

E2E tests (Playwright)

E2E tests live in web/frontend/e2e/. They require a running chatwire instance.

Run E2E tests

cd web/frontend

# Headless (CI)
npx playwright test

# With browser visible
npx playwright test --headed

# Specific test file
npx playwright test e2e/settings.spec.ts

# Debug mode (pauses on failure)
npx playwright test --debug

Writing E2E tests

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

test('settings page loads', async ({ page }) => {
  await page.goto('/app/')
  await page.click('[data-testid="settings-button"]')
  await expect(page.locator('h1')).toHaveText('Settings')
})

Accessibility tests

Playwright tests include axe accessibility checks:

import AxeBuilder from '@axe-core/playwright'

test('chat page has no accessibility violations', async ({ page }) => {
  await page.goto('/app/')
  const results = await new AxeBuilder({ page }).analyze()
  expect(results.violations).toEqual([])
})

Mobile tests (Jest)

Mobile tests live in packages/mobile/src/__tests__/.

Run mobile tests

cd packages/mobile
npm run test          # watch mode
npm run test -- --watchAll=false  # single pass

Writing mobile tests

import React from 'react'
import { render } from '@testing-library/react-native'
import { MessageBubble } from '../components/MessageBubble'

test('renders message text', () => {
  const { getByText } = render(
    <MessageBubble
      text="Hello!"
      fromMe={false}
      sender="Alice"
    />
  )
  expect(getByText('Hello!')).toBeTruthy()
})

CI

Tests run on every pull request via GitHub Actions. The workflow:

  1. Python tests on macOS (latest)
  2. Frontend vitest
  3. Frontend typecheck
  4. Mobile Jest

E2E tests run separately as they require a running chatwire instance.

Note: GitHub Actions minutes are limited to 2000/month on the free tier. A self-hosted runner on plinux is planned for the near term — see the release process.

Test coverage targets

Layer Target
Python (bridge + CLI + config) 70%+
Python (web API) 60%+
Frontend components 80%+
Mobile screens smoke test only

Coverage is not enforced in CI — these are guidelines, not gates.