Skip to content

Testing Patterns

Component and integration testing strategies for On Book Pro.


Overview

On Book Pro uses:

  • Vitest — Test runner (Vite-compatible) for unit and integration tests
  • React Testing Library — Component testing
  • Playwright — End-to-end browser testing (mobile, iPad, desktop viewports)
  • Firebase Emulators — Local backend testing

Test Structure

src/
├── features/
│   └── my-feature/
│       ├── components/
│       │   ├── MyComponent.tsx
│       │   └── MyComponent.test.tsx  # Co-located tests
│       └── __tests__/
│           └── store.test.ts         # Store integration tests
└── test/
    ├── setup.ts                      # Global test setup
    ├── mocks/                        # Shared mocks
    └── utils.tsx                     # Test utilities

Component Testing

Basic Component Test

tsx
// src/features/my-feature/components/MyButton.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MyButton } from './MyButton';

describe('MyButton', () => {
    it('renders with label', () => {
        render(<MyButton label="Click me" onClick={() => {}} />);
        expect(screen.getByText('Click me')).toBeInTheDocument();
    });

    it('calls onClick when clicked', () => {
        const handleClick = vi.fn();
        render(<MyButton label="Click" onClick={handleClick} />);
        
        fireEvent.click(screen.getByRole('button'));
        
        expect(handleClick).toHaveBeenCalledTimes(1);
    });
});

Testing with Store

tsx
import { render, screen } from '@testing-library/react';
import { useAppStore } from '@/store/useAppStore';
import { MyListComponent } from './MyListComponent';

// Reset store before each test
beforeEach(() => {
    useAppStore.setState({
        myItems: [
            { id: '1', name: 'Item 1' },
            { id: '2', name: 'Item 2' },
        ],
    });
});

describe('MyListComponent', () => {
    it('renders items from store', () => {
        render(<MyListComponent />);
        
        expect(screen.getByText('Item 1')).toBeInTheDocument();
        expect(screen.getByText('Item 2')).toBeInTheDocument();
    });
});

Store Testing

Testing Slice Actions

typescript
// src/features/my-feature/__tests__/store.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useAppStore } from '@/store/useAppStore';

describe('MyFeature Store', () => {
    beforeEach(() => {
        // Reset to initial state
        useAppStore.setState({ myItems: [] });
    });

    it('adds an item', () => {
        const { addMyItem } = useAppStore.getState();
        
        addMyItem({ name: 'New Item' });
        
        const { myItems } = useAppStore.getState();
        expect(myItems).toHaveLength(1);
        expect(myItems[0].name).toBe('New Item');
    });

    it('updates an item', () => {
        useAppStore.setState({
            myItems: [{ id: '1', name: 'Original' }],
        });
        
        const { updateMyItem } = useAppStore.getState();
        updateMyItem('1', { name: 'Updated' });
        
        const { myItems } = useAppStore.getState();
        expect(myItems[0].name).toBe('Updated');
    });

    it('deletes an item', () => {
        useAppStore.setState({
            myItems: [{ id: '1', name: 'To Delete' }],
        });
        
        const { deleteMyItem } = useAppStore.getState();
        deleteMyItem('1');
        
        const { myItems } = useAppStore.getState();
        expect(myItems).toHaveLength(0);
    });
});

Mocking Patterns

Mocking Firebase

typescript
// src/test/mocks/firebase.ts
import { vi } from 'vitest';

export const mockFirestore = {
    collection: vi.fn(),
    doc: vi.fn(),
    getDoc: vi.fn(),
    setDoc: vi.fn(),
    onSnapshot: vi.fn(),
};

vi.mock('firebase/firestore', () => mockFirestore);

Mocking Hooks

typescript
import { vi } from 'vitest';

// Mock a custom hook
vi.mock('@/hooks/useAuth', () => ({
    useAuth: () => ({
        user: { uid: 'test-user', displayName: 'Test User' },
        isAuthenticated: true,
    }),
}));

Mocking Store Selectively

typescript
import { useAppStore } from '@/store/useAppStore';

// Partial mock - only mock what you need
vi.spyOn(useAppStore, 'getState').mockReturnValue({
    ...useAppStore.getState(),
    myItems: [{ id: '1', name: 'Mocked Item' }],
});

Integration Testing

Testing with Firebase Emulators

typescript
// vitest.config.ts
export default defineConfig({
    test: {
        globalSetup: './src/test/globalSetup.ts',
    },
});

// src/test/globalSetup.ts
export async function setup() {
    // Start Firebase emulators
    process.env.FIRESTORE_EMULATOR_HOST = 'localhost:8080';
    process.env.FIREBASE_AUTH_EMULATOR_HOST = 'localhost:9099';
}

Full Flow Test

typescript
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from '@/App';

describe('Create Item Flow', () => {
    it('creates and displays a new item', async () => {
        const user = userEvent.setup();
        render(<App />);
        
        // Click add button
        await user.click(screen.getByRole('button', { name: /add/i }));
        
        // Fill form
        await user.type(screen.getByLabelText('Name'), 'New Item');
        
        // Submit
        await user.click(screen.getByRole('button', { name: /save/i }));
        
        // Verify item appears
        await waitFor(() => {
            expect(screen.getByText('New Item')).toBeInTheDocument();
        });
    });
});

Test Setup

typescript
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';

// Cleanup after each test
afterEach(() => {
    cleanup();
});

// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
    writable: true,
    value: vi.fn().mockImplementation((query) => ({
        matches: false,
        media: query,
        onchange: null,
        addListener: vi.fn(),
        removeListener: vi.fn(),
        addEventListener: vi.fn(),
        removeEventListener: vi.fn(),
        dispatchEvent: vi.fn(),
    })),
});

End-to-End Testing (Playwright)

On Book Pro uses Playwright for browser-based E2E tests across mobile, iPad, and desktop viewports.

E2E Test Suites

SuiteTestsFocus
mobile-device-gate.spec.ts16Device gate banners, mobile sidebar drawer, touch targets
ipad-layout.spec.ts5iPad breakpoints, split view at 1024px+
permissions.spec.tsTool visibility by role, VIEW_ALL baseline
interactions.spec.tsWorkspace switching, tool navigation
smoke.spec.tsApp boot, route rendering, auth flow
tab-rendering.spec.tsAll tools render without errors

Viewport Configuration

typescript
// e2e/playwright.config.ts

const mobileViewport = { width: 375, height: 812 };   // iPhone SE
const iPadSplitView = { width: 507, height: 768 };     // iPad Split View
const iPadFull = { width: 1024, height: 768 };          // iPad Full Width
const desktop = { width: 1440, height: 900 };           // Desktop

Running E2E Tests

bash
# Run all E2E tests
npx playwright test

# Run with UI mode (interactive)
npx playwright test --ui

# Run a specific suite
npx playwright test mobile-device-gate

# Run in headed mode (visible browser)
npx playwright test --headed

First-Time Setup

bash
# Install Playwright browsers
npx playwright install

Cloud Functions Testing

Cloud Functions have their own test suite in functions/:

bash
cd functions
npm test

Tests cover:

  • Script import core logic (OCR normalization, structure extraction)
  • Email functions (template rendering, RBAC validation)
  • Attendance functions (token validation, late detection)
  • Admin API functions (project creation, member management)

Running Tests

bash
# Run all unit tests
npm test

# Run with coverage
npm test -- --coverage

# Run specific file
npm test -- src/features/my-feature/

# Watch mode
npm test -- --watch

# Run all E2E tests
npx playwright test

# Run E2E with UI
npx playwright test --ui

# Run Cloud Functions tests
cd functions && npm test

Best Practices

  1. Co-locate tests — Keep .test.tsx files next to components
  2. Test behavior, not implementation — Focus on what users see
  3. Reset state between tests — Avoid test pollution
  4. Use userEvent over fireEvent — More realistic interactions
  5. Mock sparingly — Prefer integration tests when possible
  6. Run E2E before merging — Playwright suites catch viewport-specific regressions

Last updated: March 22, 2026 (Added Playwright E2E, Cloud Functions testing, expanded test inventory)