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:
- User Workflows: Full feature flows (address lookup, bulk processing)
- Browser Compatibility: Works across different browsers (Chromium, Firefox, WebKit)
- Visual Rendering: UI elements appear and function correctly
- Accessibility: Keyboard navigation, screen reader support, WCAG compliance
- 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
- Use Roles:
getByRole()overgetByTestId()for better semantics - User-Facing Selectors: Select by text users see, not implementation details
- Auto-Waiting: Trust Playwright's auto-waiting, avoid manual waits
- Isolate Tests: Each test should be independent (no shared state)
- Meaningful Assertions: Verify user-visible outcomes, not internal state
Related Documentation
- Test Suite Overview - All test types
- Code Quality Tests - Standards enforcement
- Test Helpers - Testing utilities
- Playwright Documentation - Official Playwright docs