NJ Municipality Lookup
CodebaseTests

E2e

Browser-based acceptance tests using Playwright to verify user journeys and interactions.

End-to-End Tests (__tests__/e2e/)

Browser-based acceptance tests using Playwright to verify user journeys and interactions.

Purpose

Validates the application from the user's perspective:

  1. User Workflows: Full feature flows (address lookup, bulk processing)
  2. Browser Compatibility: Works across different browsers (Chromium, Firefox, WebKit)
  3. Visual Rendering: UI elements appear and function correctly
  4. Accessibility: Keyboard navigation, screen reader support, WCAG compliance
  5. Integration: Frontend + backend + external APIs working together

Test Framework

Uses Playwright for browser automation:

  • Multi-browser: Test on Chromium, Firefox, Safari (WebKit)
  • Parallel Execution: Run tests concurrently for speed
  • Auto-waiting: Smart waiting for elements (no manual sleeps)
  • Network Interception: Mock or observe API calls
  • Screenshots/Videos: Capture failures for debugging

Test Files

acceptance.spec.ts - Core User Flows

Tests primary application features end-to-end:

test("should display auto-suggestions after typing 2+ characters", async ({
  page,
}) => {
  await page.goto("/lookup");

  const input = page.getByRole("textbox", { name: /street/i });
  await input.fill("323");

  await expect(
    page.getByRole("listbox", { name: /suggestions/i }),
  ).toBeVisible();
});

test("should display municipality on successful lookup", async ({ page }) => {
  await page.goto("/lookup");

  await page
    .getByRole("textbox", { name: /street/i })
    .fill("323 Dr Martin Luther King Jr Blvd");
  await page.getByRole("textbox", { name: /city/i }).fill("Newark");
  await page.getByRole("textbox", { name: /zip/i }).fill("07102");

  await page.getByRole("button", { name: /lookup/i }).click();

  await expect(page.getByText(/newark/i)).toBeVisible();
  await expect(page.getByText(/essex/i)).toBeVisible();
});

Scenarios Tested:

  • Single address lookup with valid address
  • Auto-suggestions appearing during typing
  • Selecting suggestion from dropdown
  • Form validation errors
  • Successful result display
  • Bulk processing workflow

accessibility.spec.ts - WCAG Compliance

Validates accessibility standards:

test("should have no WCAG violations on lookup page", async ({ page }) => {
  await page.goto("/lookup");

  const violations = await injectAxe(page);
  expect(violations).toHaveLength(0);
});

test("should support keyboard navigation", async ({ page }) => {
  await page.goto("/lookup");

  // Tab through form fields
  await page.keyboard.press("Tab");
  await expect(page.getByRole("textbox", { name: /street/i })).toBeFocused();

  await page.keyboard.press("Tab");
  await expect(page.getByRole("textbox", { name: /city/i })).toBeFocused();

  // Submit with Enter key
  await page.keyboard.press("Enter");
});

Accessibility Checks:

  • Axe Core: Automated WCAG 2.1 AA violation detection
  • Keyboard Navigation: Tab through all interactive elements
  • Focus Management: Focus moves logically through UI
  • ARIA Labels: All form controls properly labeled
  • Color Contrast: Text meets minimum contrast ratios
  • Screen Reader: Announcements for dynamic content

edge-cases.spec.ts - Error Handling

Tests error conditions and edge cases:

test("should display error for invalid address", async ({ page }) => {
  await page.goto("/lookup");

  await page
    .getByRole("textbox", { name: /street/i })
    .fill("123 Nonexistent St");
  await page.getByRole("textbox", { name: /city/i }).fill("Nowhere");
  await page.getByRole("button", { name: /lookup/i }).click();

  await expect(page.getByText(/not found/i)).toBeVisible();
});

test("should handle API timeout correctly", async ({ page }) => {
  // Intercept API and delay response
  await page.route("/api/**", (route) => {
    setTimeout(() => route.continue(), 10000);
  });

  await page.goto("/lookup");
  await fillAddressForm(page);
  await page.getByRole("button", { name: /lookup/i }).click();

  await expect(page.getByText(/timeout|taking too long/i)).toBeVisible();
});

Edge Cases Tested:

  • Address not found
  • Empty form submission
  • Very long addresses (500+ characters)
  • Special characters in addresses
  • API timeout handling
  • Network errors
  • Bulk processing with all failures
  • Concurrent requests

theme-toggle.spec.ts - Dark Mode

Tests theme switching functionality:

test("should toggle between light and dark themes", async ({ page }) => {
  await page.goto("/");

  // Check initial theme (light or system default)
  const html = page.locator("html");
  const initialTheme = await html.getAttribute("class");

  // Click theme toggle
  await page.getByRole("button", { name: /theme/i }).click();

  // Verify theme changed
  const newTheme = await html.getAttribute("class");
  expect(newTheme).not.toBe(initialTheme);

  // Verify theme persists on reload
  await page.reload();
  const persistedTheme = await html.getAttribute("class");
  expect(persistedTheme).toBe(newTheme);
});

test("should apply dark mode styles correctly", async ({ page }) => {
  await page.goto("/");

  // Enable dark mode
  await setTheme(page, "dark");

  // Verify dark background
  const bgColor = await page
    .locator("body")
    .evaluate((el) => getComputedStyle(el).backgroundColor);

  // Should be dark (low RGB values)
  expect(bgColor).toMatch(/rgb\((\d+), (\d+), (\d+)\)/);
  const [_, r, g, b] = bgColor.match(/rgb\((\d+), (\d+), (\d+)\)/) || [];
  expect(Number(r)).toBeLessThan(50);
  expect(Number(g)).toBeLessThan(50);
  expect(Number(b)).toBeLessThan(50);
});

Theme Tests:

  • Toggle between light/dark/system
  • Theme persistence across page loads
  • Correct CSS variable application
  • No flash of unstyled content (FOUC)

Running E2E Tests

# Run all E2E tests
bun test:e2e

# Run specific test file
bun test:e2e acceptance.spec.ts

# Run in specific browser
bun test:e2e --project=chromium
bun test:e2e --project=firefox
bun test:e2e --project=webkit

# Run in headed mode (see browser)
bun test:e2e --headed

# Debug mode (step through tests)
bun test:e2e --debug

# Update snapshots (if using visual regression)
bun test:e2e --update-snapshots

Playwright Configuration

Configuration in playwright.config.ts:

export default {
  testDir: "./src/__tests__/e2e",
  fullyParallel: true,
  workers: process.env.CI ? 1 : undefined,
  retries: process.env.CI ? 2 : 0,
  reporter: [["html"], ["list"]],
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
  ],
  webServer: {
    command: "bun run dev",
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
};

Test Patterns

Page Object Model

Encapsulate page interactions:

class LookupPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto("/lookup");
  }

  async fillAddress(street: string, city: string, zip: string) {
    await this.page.getByRole("textbox", { name: /street/i }).fill(street);
    await this.page.getByRole("textbox", { name: /city/i }).fill(city);
    await this.page.getByRole("textbox", { name: /zip/i }).fill(zip);
  }

  async submit() {
    await this.page.getByRole("button", { name: /lookup/i }).click();
  }

  async expectMunicipality(name: string) {
    await expect(this.page.getByText(new RegExp(name, "i"))).toBeVisible();
  }
}

// Usage in test
test("lookup flow", async ({ page }) => {
  const lookup = new LookupPage(page);
  await lookup.goto();
  await lookup.fillAddress("323 Dr MLK Jr Blvd", "Newark", "07102");
  await lookup.submit();
  await lookup.expectMunicipality("Newark");
});

Test Fixtures

Share setup across tests:

const test = base.extend({
  authenticatedPage: async ({ page }, use) => {
    // Setup: Login before each test
    await page.goto("/login");
    await login(page);
    await use(page);
    // Teardown: Logout after each test
    await logout(page);
  },
});

test("requires auth", async ({ authenticatedPage }) => {
  await authenticatedPage.goto("/protected");
  // Already logged in
});

Debugging Failed Tests

Screenshots on Failure

Automatic screenshots saved to test-results/:

# View failed test screenshot
open test-results/acceptance-should-display-municipality/test-failed-1.png

Trace Viewer

Inspect test execution timeline:

# Generate trace (on by default for retries)
bun test:e2e --trace on

# View trace
bun playwright show-trace test-results/trace.zip

Headed Mode

Watch tests run in browser:

bun test:e2e --headed --slowmo=1000

CI/CD Integration

E2E tests run in GitHub Actions:

- name: Install Playwright browsers
  run: bunx playwright install --with-deps

- name: Build application
  run: bun run build

- name: Run E2E tests
  run: bun test:e2e

- name: Upload test results
  if: failure()
  uses: actions/upload-artifact@v3
  with:
    name: playwright-results
    path: test-results/

Performance

  • Parallel Execution: Tests run concurrently (4-8 workers)
  • Smart Waiting: No manual sleeps (auto-wait for elements)
  • Fast Feedback: Most tests finish in < 30 seconds
  • Incremental: Only re-run failed tests with --last-failed

Best Practices

  1. Use Roles: getByRole() over getByTestId() for better semantics
  2. User-Facing Selectors: Select by text users see, not implementation details
  3. Auto-Waiting: Trust Playwright's auto-waiting, avoid manual waits
  4. Isolate Tests: Each test should be independent (no shared state)
  5. Meaningful Assertions: Verify user-visible outcomes, not internal state

On this page