Files
HOA_Financial_Platform/TESTING_CONVENTIONS.md
JoeBot dfd1bccb89 feat: add Playwright E2E and API regression test suite
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>
2026-04-05 12:40:05 -04:00

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

  1. 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();
  }
}
  1. Export from tests/page-objects/index.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:

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_ (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

# 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

  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/<feature>.spec.ts for UI flows
  • Create tests/api/<resource>.api.spec.ts for API endpoints
  • Add sample data constants to tests/fixtures/test-data.ts if needed
  • Run npx playwright test tests/e2e/<feature>.spec.ts to verify
  • Update visual baselines if the feature changes existing pages