Production-ready test infrastructure with Page Object Model pattern, reusable fixtures for auth/DB/test-data, and example tests covering login flow, dashboard, accounts CRUD API, and visual regression. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
10 KiB
10 KiB
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/<feature>.spec.ts |
auth.spec.ts |
| API test files | tests/api/<resource>.api.spec.ts |
accounts.api.spec.ts |
| Page objects | tests/page-objects/<PageName>.ts |
LoginPage.ts |
| Fixtures | tests/fixtures/<purpose>.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 <behavior>'):
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
// 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
// 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
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
- Create
tests/page-objects/MyPage.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<void> {
await this.page.waitForLoadState('networkidle');
await expect(this.heading).toBeVisible();
}
async createItem(name: string): Promise<void> {
await this.createButton.click();
await this.page.getByLabel(/name/i).fill(name);
await this.page.getByRole('button', { name: /save/i }).click();
}
}
- Export from
tests/page-objects/index.ts:
export { MyPage } from './MyPage';
Page object rules
- Extend
BasePageand setreadonly 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:
test.use({ storageState: { cookies: [], origins: [] } });
API tests
Use the apiLogin() and authHeaders() helpers:
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
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_(useTEST_PREFIXfrom test-data.ts) - The
db.cleanup()method deletes rows matching this prefix - Call
db.cleanup()intest.afterAllfor write-path tests
Running Tests
Prerequisites
- Docker Compose services running:
docker-compose up -d - Test user seeded in the database (use the backend seed script or create manually)
- Environment configured:
cp .env.test.example .env.testand fill in values
Commands
# 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)
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
- Import
testfrom../fixtures/base.fixturefor tests needing DB or auth fixtures. Import from@playwright/testfor basic tests. - One
test.describeper feature or endpoint per file. - No
page.waitForTimeout()except in visual tests — usewaitForLoadState,waitForURL, orwaitForResponseinstead. - No hardcoded URLs — use
BASE_URL,API_BASE, or page object paths. - No test interdependencies — each test should work in isolation (use
test.beforeEachfor setup). - Clean up after write tests — use
TEST_PREFIXanddb.cleanup(). - API tests go in
tests/api/, E2E tests intests/e2e/** — don't mix. - 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/<feature>.spec.tsfor UI flows - Create
tests/api/<resource>.api.spec.tsfor API endpoints - Add sample data constants to
tests/fixtures/test-data.tsif needed - Run
npx playwright test tests/e2e/<feature>.spec.tsto verify - Update visual baselines if the feature changes existing pages