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 utilitiesComponent 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
| Suite | Tests | Focus |
|---|---|---|
mobile-device-gate.spec.ts | 16 | Device gate banners, mobile sidebar drawer, touch targets |
ipad-layout.spec.ts | 5 | iPad breakpoints, split view at 1024px+ |
permissions.spec.ts | — | Tool visibility by role, VIEW_ALL baseline |
interactions.spec.ts | — | Workspace switching, tool navigation |
smoke.spec.ts | — | App boot, route rendering, auth flow |
tab-rendering.spec.ts | — | All 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 }; // DesktopRunning 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 --headedFirst-Time Setup
bash
# Install Playwright browsers
npx playwright installCloud Functions Testing
Cloud Functions have their own test suite in functions/:
bash
cd functions
npm testTests 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 testBest Practices
- Co-locate tests — Keep
.test.tsxfiles next to components - Test behavior, not implementation — Focus on what users see
- Reset state between tests — Avoid test pollution
- Use userEvent over fireEvent — More realistic interactions
- Mock sparingly — Prefer integration tests when possible
- Run E2E before merging — Playwright suites catch viewport-specific regressions
Last updated: March 22, 2026 (Added Playwright E2E, Cloud Functions testing, expanded test inventory)