# Testing Conventions — HOA LedgerIQ E2E & API Tests This document is the single source of truth for writing, organizing, and running Playwright-based E2E and API regression tests in this project. --- ## Architecture | Component | Technology | Port | |-----------|-----------|------| | Reverse proxy | nginx | :80 | | Backend API | NestJS 10 | :3000 (internal) | | Frontend | React 18 + Vite | :5173 (internal) | | Database | PostgreSQL 15 | :5432 | | Cache | Redis 7 | :6379 | | Test runner | Playwright | host | Tests run on the **host machine** against the app running in **Docker Compose**. The `BASE_URL` defaults to `http://localhost` (nginx). --- ## Folder Structure ``` tests/ ├── .auth/ # Stored auth state (gitignored) │ └── user.json # Browser state from auth.setup.ts ├── fixtures/ │ ├── auth.fixture.ts # API login helpers, token management │ ├── base.fixture.ts # Extended test object with typed fixtures │ ├── db.fixture.ts # Postgres seed/cleanup via pg driver │ └── test-data.ts # Shared constants (users, sample data) ├── page-objects/ │ ├── index.ts # Re-exports all page objects │ ├── BasePage.ts # Abstract base with shared helpers │ ├── LoginPage.ts # /login page │ ├── DashboardPage.ts # /dashboard page │ └── AccountsPage.ts # /accounts page ├── e2e/ # Browser-based end-to-end tests │ ├── auth.spec.ts # Login/logout UI flows │ ├── dashboard.spec.ts # Dashboard load + navigation │ └── visual.spec.ts # Screenshot regression tests ├── api/ # API-only tests (no browser) │ ├── auth.api.spec.ts # /api/auth/* endpoints │ └── accounts.api.spec.ts # /api/accounts/* CRUD └── auth.setup.ts # One-time auth setup project ``` --- ## Naming Conventions | What | Convention | Example | |------|-----------|---------| | E2E test files | `tests/e2e/.spec.ts` | `auth.spec.ts` | | API test files | `tests/api/.api.spec.ts` | `accounts.api.spec.ts` | | Page objects | `tests/page-objects/.ts` | `LoginPage.ts` | | Fixtures | `tests/fixtures/.fixture.ts` | `db.fixture.ts` | | Test data | `tests/fixtures/test-data.ts` | single file | | Snapshot baselines | auto-generated in `*-snapshots/` dirs | `login-page.png` | ### Test descriptions Use `test.describe('Feature or Endpoint')` and `test('should ')`: ```ts test.describe('POST /api/auth/login', () => { test('should return access token for valid credentials', async ({ request }) => { // ... }); }); ``` --- ## How to Write New Tests ### 1. E2E (browser) test ```ts // tests/e2e/invoices.spec.ts import { test, expect } from '../fixtures/base.fixture'; import { InvoicesPage } from '../page-objects'; test.describe('Invoices', () => { let invoicesPage: InvoicesPage; test.beforeEach(async ({ page }) => { invoicesPage = new InvoicesPage(page); await invoicesPage.goto(); }); test('should display invoice list', async () => { await invoicesPage.assertOnPage(); // ... assertions }); }); ``` ### 2. API test ```ts // tests/api/payments.api.spec.ts import { test, expect } from '@playwright/test'; import { apiLogin, apiSwitchOrg, authHeaders } from '../fixtures/auth.fixture'; import { TEST_USERS } from '../fixtures/test-data'; const API_BASE = process.env.API_BASE_URL || 'http://localhost/api'; let accessToken: string; test.beforeAll(async ({ request }) => { const tokens = await apiLogin(request, TEST_USERS.treasurer); if (tokens.organizations?.length > 0) { const switched = await apiSwitchOrg(request, tokens.accessToken, (tokens.organizations[0] as any).id); accessToken = switched.accessToken; } else { accessToken = tokens.accessToken; } }); test.describe('GET /api/payments', () => { test('should return payments list', async ({ request }) => { const response = await request.get(`${API_BASE}/payments`, { headers: authHeaders(accessToken), }); expect(response.status()).toBe(200); }); }); ``` ### 3. Visual regression test ```ts test('invoices page should match baseline', async ({ page }) => { await page.goto('/invoices'); await page.waitForLoadState('networkidle'); await page.waitForTimeout(500); // Let animations settle await expect(page).toHaveScreenshot('invoices-page.png', { fullPage: true, mask: [page.locator('time')], // Mask dynamic dates }); }); ``` Update baselines: `npx playwright test --update-snapshots` --- ## How to Add New Page Objects 1. Create `tests/page-objects/MyPage.ts`: ```ts import { type Page, expect } from '@playwright/test'; import { BasePage } from './BasePage'; export class MyPage extends BasePage { readonly path = '/my-path'; // Locators — prefer role/label selectors over CSS get heading() { return this.page.getByRole('heading', { name: /my page/i }); } get createButton() { return this.page.getByRole('button', { name: /create/i }); } // Actions override async waitForReady(): Promise { await this.page.waitForLoadState('networkidle'); await expect(this.heading).toBeVisible(); } async createItem(name: string): Promise { await this.createButton.click(); await this.page.getByLabel(/name/i).fill(name); await this.page.getByRole('button', { name: /save/i }).click(); } } ``` 2. Export from `tests/page-objects/index.ts`: ```ts export { MyPage } from './MyPage'; ``` ### Page object rules - Extend `BasePage` and set `readonly path` - Override `waitForReady()` for page-specific loading - Use **role/label locators** (not CSS selectors): `getByRole()`, `getByLabel()`, `getByText()` - Expose **locators as getters** and **actions as methods** - Keep assertions in test files, not page objects (except `assertOnPage()`) --- ## Authentication in Tests ### Pre-authenticated tests (default) Most tests use stored auth state from `auth.setup.ts`. This runs once via the `auth-setup` Playwright project and saves browser state to `tests/.auth/user.json`. Tests automatically get this state via `storageState` in `playwright.config.ts`. ### Unauthenticated tests For testing the login flow itself, opt out: ```ts test.use({ storageState: { cookies: [], origins: [] } }); ``` ### API tests Use the `apiLogin()` and `authHeaders()` helpers: ```ts import { apiLogin, authHeaders } from '../fixtures/auth.fixture'; const tokens = await apiLogin(request, TEST_USERS.treasurer); const response = await request.get(url, { headers: authHeaders(tokens.accessToken), }); ``` --- ## Database Seeding & Cleanup ### When to use direct DB access - Verifying backend wrote correct data - Seeding complex state that's hard to create via API - Cleanup after tests ### How ```ts import { test } from '../fixtures/base.fixture'; test('should verify data', async ({ db }) => { const result = await db.query('SELECT * FROM schema.table WHERE ...'); expect(result.rows.length).toBeGreaterThan(0); }); ``` ### Cleanup convention - Prefix all test-created data with `E2E_` (use `TEST_PREFIX` from test-data.ts) - The `db.cleanup()` method deletes rows matching this prefix - Call `db.cleanup()` in `test.afterAll` for write-path tests --- ## Running Tests ### Prerequisites 1. Docker Compose services running: `docker-compose up -d` 2. Test user seeded in the database (use the backend seed script or create manually) 3. Environment configured: `cp .env.test.example .env.test` and fill in values ### Commands ```bash # Install Playwright (first time) npx playwright install --with-deps # Run all tests npx playwright test # Run only E2E tests npx playwright test tests/e2e/ # Run only API tests npx playwright test --project=api # Run in specific browser npx playwright test --project=chromium # Run in headed mode (see the browser) npx playwright test --headed # Run a single test file npx playwright test tests/e2e/auth.spec.ts # Debug mode (step through tests) npx playwright test --debug # Update visual regression baselines npx playwright test tests/e2e/visual.spec.ts --update-snapshots # View HTML report npx playwright show-report # Run against production BASE_URL=https://your-prod-domain.com npx playwright test --project=api ``` ### npm scripts (from project root) ```bash npm run test:e2e # All Playwright tests npm run test:e2e:chromium # Chromium only npm run test:e2e:api # API tests only npm run test:e2e:headed # Headed mode npm run test:e2e:debug # Debug mode ``` --- ## Environment Variables | Variable | Default | Purpose | |----------|---------|---------| | `BASE_URL` | `http://localhost` | App URL (nginx) | | `API_BASE_URL` | `http://localhost/api` | Backend API base | | `TEST_DB_URL` | `postgresql://hoafinance:change_me@localhost:5432/hoafinance` | Direct Postgres for seeding | | `TEST_USER_EMAIL` | `e2e-treasurer@test.hoaledgeriq.com` | Test user email | | `TEST_USER_PASSWORD` | `TestPass123!` | Test user password | | `CI` | — | Set by CI providers; enables retries, single worker | --- ## Style Rules 1. **Import `test` from `../fixtures/base.fixture`** for tests needing DB or auth fixtures. Import from `@playwright/test` for basic tests. 2. **One `test.describe` per feature or endpoint** per file. 3. **No `page.waitForTimeout()` except in visual tests** — use `waitForLoadState`, `waitForURL`, or `waitForResponse` instead. 4. **No hardcoded URLs** — use `BASE_URL`, `API_BASE`, or page object paths. 5. **No test interdependencies** — each test should work in isolation (use `test.beforeEach` for setup). 6. **Clean up after write tests** — use `TEST_PREFIX` and `db.cleanup()`. 7. **API tests go in `tests/api/`**, E2E tests in `tests/e2e/`** — don't mix. 8. **Locators**: prefer `getByRole` > `getByLabel` > `getByText` > `getByTestId` > CSS selectors. --- ## Adding Tests for a New Feature (Quick Checklist) - [ ] Create page object in `tests/page-objects/` if it's a new page - [ ] Export it from `tests/page-objects/index.ts` - [ ] Create `tests/e2e/.spec.ts` for UI flows - [ ] Create `tests/api/.api.spec.ts` for API endpoints - [ ] Add sample data constants to `tests/fixtures/test-data.ts` if needed - [ ] Run `npx playwright test tests/e2e/.spec.ts` to verify - [ ] Update visual baselines if the feature changes existing pages