E2E Smoke Tests
Critical user journey validation with Playwright
End-to-end tests confirm that applications work in real browsers. These tests catch issues that other approaches miss - browser quirks, JavaScript errors, and broken user flows. Place E2E tests in the e2e/
directory with *.spec.ts
naming.
E2E Testing Philosophy
Test critical user journeys in production-like environments. Keep tests fast and focused on blocking issues. Run as deployment gates.
Playwright Configuration
The framework uses Playwright for browser automation:
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e/tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: process.env.APP_BASE_URL || "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: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI
}
});
Smoke Test Pattern
Ultra-fast PR blocking checks for critical paths:
import { expect, test } from "@playwright/test";
/**
* Smoke Tests - Ultra-fast PR blocking checks
*
* Minimal critical path validation:
* - App starts and loads
* - Protected routes gate properly
*
* These tests should run in <30 seconds for PR feedback.
*/
test.describe("Smoke Tests", () => {
// Collect client-side errors but ignore benign noise
test.beforeEach(async ({ page }) => {
const benign = [
/favicon\.ico/i,
/manifest\.webmanifest/i,
/DevTools.*source map/i,
/Failed to load resource.*404/i,
/chrome-extension:\/\//i,
/metadataBase/i,
/Invalid environment variables/i
];
(page as any)._errors = [] as string[];
page.on("pageerror", (err) => {
const message = err?.message || String(err);
if (!benign.some((re) => re.test(message))) {
(page as any)._errors.push(`pageerror: ${message}`);
}
});
page.on("console", (msg) => {
if (msg.type() === "error") {
const text = msg.text();
if (!benign.some((re) => re.test(text))) {
(page as any)._errors.push(`console.error: ${text}`);
}
}
});
});
test("app should start and load homepage", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("domcontentloaded");
const errors = (page as any)._errors as string[];
expect(
errors,
`Client errors detected:\n${errors.join("\n")}`
).toHaveLength(0);
// Should have basic page structure
const hasContent = await page.locator("body").textContent();
expect(hasContent).toBeTruthy();
expect(hasContent?.length).toBeGreaterThan(10);
});
test("protected routes should redirect unauthenticated users", async ({
page
}) => {
// Clear any existing auth
await page.context().clearCookies();
// Try to access protected route
await page.goto("/dashboard");
// Should redirect to sign-in
await page.waitForURL(/\/sign-in/);
expect(page.url()).toMatch(/\/sign-in/);
});
test("public routes should be accessible", async ({ page }) => {
// Test pricing page
await page.goto("/pricing");
await page.waitForLoadState("domcontentloaded");
// Verify we're on the pricing page
expect(page.url()).toContain("/pricing");
// Should have content
const hasContent = await page.locator("body").textContent();
expect(hasContent).toBeTruthy();
});
});
Error Filtering
Filter benign console errors to focus on real issues. Favicon 404s and browser extension errors are not application failures.
Security Header Testing
E2E tests validate security headers in production-like environments:
import { expect, test } from "@playwright/test";
test("should apply security headers on dashboard page", async ({ page }) => {
const dashboardOrigin = process.env.DASHBOARD_BASE_URL ?? "http://localhost:3001";
const response = await page.request.get(`${dashboardOrigin}/sign-in`);
const headers = response.headers();
// CSP with nonce and strict-dynamic
const csp = headers["content-security-policy"];
expect(csp).toBeTruthy();
expect(headers["x-nonce"]).toBeTruthy();
expect(csp).toMatch(/script-src[^;]*'nonce-/);
expect(csp).toContain("'strict-dynamic'");
const scriptDirective = csp.match(/script-src[^;]*/)?.[0] ?? "";
expect(scriptDirective).not.toContain("'unsafe-inline'");
// Style nonce
const styleDirective = csp.match(/style-src[^;]*/)?.[0] ?? "";
expect(styleDirective).toContain("'nonce-");
// Standard security headers
expect(headers["referrer-policy"]).toBe("strict-origin-when-cross-origin");
expect(headers["x-content-type-options"]).toBe("nosniff");
});
test("should apply security headers on marketing pages", async ({ page }) => {
const response = await page.goto("/pricing");
const headers = response?.headers();
// CSP with unsafe-inline (no strict-dynamic)
const csp =
headers["content-security-policy"] ||
headers["content-security-policy-report-only"];
expect(csp).toBeTruthy();
expect(csp).toContain("default-src");
expect(csp).toContain("'unsafe-inline'");
expect(headers["x-nonce"]).toBeFalsy();
});
test("should NOT apply CSP to API routes", async ({ page }) => {
// Make a direct request to an API endpoint
const response = await page.request.get("/api/support", {
headers: { Accept: "application/json" }
});
const headers = response.headers();
// API routes should not have CSP from middleware
expect(headers["content-security-policy"]).toBeFalsy();
expect(headers["content-security-policy-report-only"]).toBeFalsy();
// API routes should not get page-level security headers
expect(headers["permissions-policy"]).toBeFalsy();
});
CSP Directive Validation
Comprehensive CSP testing for external resources:
test("should verify CSP directives for external resources", async ({
page
}) => {
const response = await page.goto("/");
const headers = response?.headers();
const csp =
headers["content-security-policy"] ||
headers["content-security-policy-report-only"];
// Verify font sources
expect(csp).toContain("font-src 'self' https://fonts.gstatic.com data:");
// Verify style sources include Google Fonts
expect(csp).toContain(
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com"
);
// Verify img-src is locked down to known hosts
expect(csp).toContain("img-src 'self' data: blob:");
expect(csp).toContain("https://lh3.googleusercontent.com");
expect(csp).toContain("https://avatars.githubusercontent.com");
expect(csp).toContain("https://secure.gravatar.com");
expect(csp).toContain("https://www.gravatar.com");
// Verify connect-src includes Convex domains
expect(csp).toContain("connect-src 'self'");
expect(csp).toContain("https://*.convexusercontent.com");
expect(csp).toContain("wss://*.convex.cloud");
expect(csp).toContain("https://*.convex.cloud");
expect(csp).toContain("https://*.convex.site");
expect(csp).toContain("wss://*.convex.site");
expect(csp).toContain("https://*.posthog.com");
});
Conservative Security Headers
Modern security headers for all pages:
test("middleware provides comprehensive security headers", async ({
page
}) => {
const response = await page.goto("/");
const headers = response?.headers();
const csp =
headers["content-security-policy"] ||
headers["content-security-policy-report-only"];
// Verify CSP includes frame-ancestors directive
expect(csp).toContain("frame-ancestors 'none'");
expect(headers["x-frame-options"]).toBeFalsy(); // Replaced by frame-ancestors
// Verify new CSP directives
expect(csp).toContain("object-src 'none'");
expect(csp).toContain("worker-src 'self' blob:");
expect(csp).toContain("manifest-src 'self'");
// Verify conservative headers
expect(headers["origin-agent-cluster"]).toBe("?1");
expect(headers["x-dns-prefetch-control"]).toBe("off");
expect(headers["x-permitted-cross-domain-policies"]).toBe("none");
});
Heads up
X-Frame-Options is deprecated. Use CSP frame-ancestors
directive instead for better control.
Error Page Security
Security headers apply to 404 and error pages:
test("should apply security headers on error pages", async ({ page }) => {
const notFoundResponse = await page.goto(
"/this-page-definitely-does-not-exist-404",
{ waitUntil: "domcontentloaded" }
);
const notFoundHeaders = notFoundResponse?.headers();
// Verify CSP on 404 pages
const csp404 =
notFoundHeaders["content-security-policy"] ||
notFoundHeaders["content-security-policy-report-only"];
expect(csp404).toBeTruthy();
expect(csp404).toContain("frame-ancestors 'none'");
// Verify conservative headers on error pages
expect(notFoundHeaders["origin-agent-cluster"]).toBe("?1");
expect(notFoundHeaders["x-dns-prefetch-control"]).toBe("off");
expect(notFoundHeaders["x-content-type-options"]).toBe("nosniff");
expect(notFoundHeaders["referrer-policy"]).toBe(
"strict-origin-when-cross-origin"
);
});
Running E2E Tests
# Run all E2E tests
pnpm test:e2e
# Run specific test file
pnpm playwright test e2e/tests/smoke.spec.ts
# Run in headed mode (see browser)
pnpm playwright test --headed
# Run in specific browser
pnpm playwright test --project=chromium
- name: Install Playwright Browsers
run: pnpm playwright install --with-deps
- name: Run E2E smoke tests
run: pnpm test:e2e
env:
APP_BASE_URL: ${{ secrets.APP_BASE_URL }}
DASHBOARD_BASE_URL: ${{ secrets.DASHBOARD_BASE_URL }}
- name: Upload Playwright Report
uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
# Run with Playwright Inspector
pnpm playwright test --debug
# Generate trace for failed tests
pnpm playwright test --trace on
# Show trace viewer
pnpm playwright show-trace trace.zip
Best Practices
Keep tests fast - Target under 30 seconds for smoke tests:
// Good: Direct navigation and simple assertions
test("homepage loads", async ({ page }) => {
await page.goto("/");
await expect(page.locator("body")).toBeVisible();
});
// Bad: Complex user interactions in smoke tests
test("complete checkout flow", async ({ page }) => {
// This belongs in a separate E2E suite, not smoke tests
});
Test HTML vs API separately - Different assertions for different response types:
// HTML page - check headers via page.goto()
const response = await page.goto("/");
expect(response?.headers()["content-security-policy"]).toBeTruthy();
// API endpoint - check headers via page.request
const apiResponse = await page.request.get("/api/data");
expect(apiResponse.headers()["content-type"]).toContain("application/json");
Filter benign errors -
Focus on actionable failures:
const benign = [
/favicon\.ico/i,
/chrome-extension/i,
/DevTools.*source map/i
];
page.on("console", (msg) => {
if (msg.type() === "error") {
const text = msg.text();
if (!benign.some((re) => re.test(text))) {
errors.push(text);
}
}
});
Use production builds -
E2E tests run against production builds:
// playwright.config.ts
webServer: {
command: "pnpm build && pnpm start",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI
}
Debugging Failed Tests
E2E Scope
Focus E2E tests on critical paths:
Test Type | Coverage | Target Time |
---|---|---|
Smoke | Homepage loads, protected routes redirect, no console errors | Under 30s |
Security | CSP headers, HSTS when enabled, frame-ancestors | ~1min |
Not in Scope | Full user journeys, complex interactions, visual regression, auth flows | Separate suite |
Pro Tip
Keep smoke tests under 30 seconds for fast PR feedback. Save complex user journeys for dedicated E2E suites that run less frequently.