Playwright Testing for Retro Ranker

Testing web applications can be a daunting task, especially when you need to ensure your app works across different browsers, devices, and user interactions. Manual testing becomes unsustainable as your application grows, and that's where Playwright comes in as a powerful end-to-end testing solution.

In this post, I'll walk through how I set up a comprehensive Playwright testing suite for Retro Ranker, a Fresh/Deno-based web application for comparing retro gaming handhelds. This setup includes multi-browser testing, CI/CD integration, helper utilities, and robust test patterns.

Table of Contents


Introduction

Playwright is a modern end-to-end testing framework that supports multiple browsers (Chromium, Firefox, Safari) and provides excellent developer experience. For Retro Ranker, I needed a testing solution that could:

  • Test across multiple browsers and devices
  • Handle authentication flows
  • Test responsive design
  • Integrate with my Fresh/Deno stack
  • Provide reliable CI/CD feedback

Let's dive into how I achieved this.

Project Structure

My Playwright setup is organized in a dedicated playwright/ directory within the project:

plaintext
playwright/
├── tests/
│   ├── home.spec.ts
│   ├── navigation.spec.ts
│   ├── auth.spec.ts
│   ├── login.spec.ts
│   ├── leaderboard.spec.ts
│   └── utils/
│       ├── test-helpers.ts
│       ├── auth-helpers.ts
│       ├── constants.ts
│       └── index.ts
├── playwright.config.ts
├── package.json
├── install.sh
└── test-workflow.sh

This structure separates concerns and makes the test suite maintainable and scalable.

Configuration Setup

The heart of my Playwright setup is the playwright.config.ts file. Here's how I configured it for optimal performance and reliability:

typescript
import { defineConfig, devices } from '@playwright/test';
import path from 'node:path';
import process from 'node:process';
import { config } from 'dotenv';

// Load environment variables from .env file
config({ path: path.join(__dirname, '.env'), quiet: true });

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: 2,
  workers: 1, // Single worker for CI stability
  timeout: 10000,

  reporter: [
    ['html'],
    ['json', { outputFile: 'test-results.json' }],
    ['junit', { outputFile: 'test-results.xml' }],
  ],

  use: {
    baseURL: process.env.CI
      ? 'https://retroranker.site'
      : 'http://localhost:8000',

    trace: 'on-first-retry',
    navigationTimeout: 10000,
    actionTimeout: 10000,

    // Disable analytics tracking during tests
    storageState: {
      cookies: [],
      origins: [
        {
          origin: process.env.CI
            ? 'https://retroranker.site'
            : 'http://localhost:8000',
          localStorage: [{ name: 'umami.disabled', value: '1' }],
        },
      ],
    },
  },

  projects: process.env.CI
    ? [
        // CI: Test Chrome desktop and mobile for speed
        { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
        { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
      ]
    : [
        // Local: Test all browsers for comprehensive coverage
        { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
        { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
        { name: 'webkit', use: { ...devices['Desktop Safari'] } },
        { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
        { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
      ],

  // Auto-start dev server for local development
  ...(process.env.CI
    ? {}
    : {
        webServer: {
          command: 'deno task start',
          cwd: path.join(__dirname, '..'),
          url: 'http://localhost:8000',
          reuseExistingServer: true,
        },
      }),
});

Key configuration highlights:

  • Environment-aware base URL: Uses localhost for development, production URL for CI
  • Analytics disabling: Prevents test runs from polluting analytics data
  • Conditional browser testing: Full browser suite locally, optimized set for CI
  • Auto-server startup: Automatically starts the Deno dev server for local testing

Test Helper Architecture

One of the most powerful aspects of my setup is the comprehensive helper utilities that make tests more readable and maintainable. The architecture is built around three core components:

TestHelpers Class

The TestHelpers class provides robust navigation and interaction methods:

typescript
export class TestHelpers {
  constructor(private page: Page) {}

  async navigateTo(
    path: string,
    options?: {
      waitForSelector?: string;
      timeout?: number;
      waitForLoadState?: 'load' | 'domcontentloaded' | 'networkidle';
      waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit';
    }
  ) {
    const {
      waitForSelector = 'main, body',
      timeout = 10000,
      waitForLoadState = 'domcontentloaded',
      waitUntil = 'domcontentloaded',
    } = options || {};

    // Check if page is still open before navigation
    this.checkPageOpen();

    await this.page.goto(path, { waitUntil });

    // Wait for the specified load state
    try {
      await this.page.waitForLoadState(waitForLoadState, { timeout });
    } catch (error) {
      console.log(
        `Warning: Load state '${waitForLoadState}' timed out for ${path}`
      );
    }

    // Wait for key element to be visible
    try {
      await this.page.waitForSelector(waitForSelector, {
        state: 'visible',
        timeout,
      });
    } catch (error) {
      // Fallback to body if specific selector fails
      await this.page.waitForSelector('body', {
        state: 'visible',
        timeout: 5000,
      });
    }
  }

  async testResponsiveDesign() {
    // Desktop
    await this.page.setViewportSize({ width: 1280, height: 720 });
    await expect(this.page.locator('body')).toBeVisible();

    // Tablet
    await this.page.setViewportSize({ width: 768, height: 1024 });
    await expect(this.page.locator('body')).toBeVisible();

    // Mobile
    await this.page.setViewportSize({ width: 375, height: 667 });
    await expect(this.page.locator('body')).toBeVisible();
  }

  async clickElement(selector: string) {
    await this.page.waitForSelector(selector, { state: 'visible' });
    await this.page.click(selector);
  }

  async fillField(selector: string, value: string) {
    await this.page.waitForSelector(selector, { state: 'visible' });
    await this.page.fill(selector, value);
  }

  async elementShouldBeVisible(selector: string) {
    await expect(this.page.locator(selector)).toBeVisible();
  }
}

The helper utilities provide:

  • Robust navigation: Handles timeouts and fallbacks gracefully
  • Responsive testing: Easy viewport switching for mobile/desktop testing
  • Element interactions: Safe clicking and form filling with proper waits
  • Error handling: Comprehensive error checking and recovery

AuthHelpers Class

For authentication flows, we have a specialized AuthHelpers class:

typescript
export class AuthHelpers {
  constructor(private page: Page) {}

  async loginWithEnvCredentials() {
    const nickname = process.env.TEST_USER_NICKNAME;
    const password = process.env.TEST_USER_PASSWORD;

    if (!nickname || !password) {
      throw new Error(
        'TEST_USER_NICKNAME and TEST_USER_PASSWORD must be set in .env file'
      );
    }

    await this.page.goto('/auth/sign-in');
    await this.page.waitForLoadState('networkidle');

    // Verify CSRF token is present
    await expect(this.page.locator('input[name="csrf_token"]')).toHaveValue(
      /^.+$/
    );

    // Fill in the login form
    await this.page.fill('input[name="nickname"]', nickname);
    await this.page.fill('input[name="password"]', password);

    // Submit the form and wait for navigation
    await Promise.all([
      this.page.waitForURL(/\/profile/, { timeout: 10000 }),
      this.page.click('button[type="submit"]'),
    ]);

    await this.page.waitForLoadState('networkidle');
  }

  async isLoggedIn(): Promise<boolean> {
    try {
      // Check if we're on the profile page
      if (this.page.url().includes('/profile')) {
        return true;
      }

      // Check if we're on mobile or desktop
      const isMobile = await this.page.locator('.mobile-nav').isVisible();

      if (isMobile) {
        // On mobile, check if profile link exists in the mobile menu
        const mobileProfileElements = await this.page
          .locator('.mobile-nav-content [href="/profile"]')
          .count();
        return mobileProfileElements > 0;
      } else {
        // On desktop, check for user-specific elements in navigation
        const userElements = await this.page
          .locator('[href="/profile"]')
          .count();
        return userElements > 0;
      }
    } catch {
      return false;
    }
  }

  async logout() {
    await this.page.goto('/api/auth/sign-out', {
      waitUntil: 'domcontentloaded',
    });
  }
}

The AuthHelpers class provides:

  • Environment-based login: Uses credentials from environment variables
  • Session state detection: Intelligently detects login state across mobile/desktop
  • CSRF token handling: Manages CSRF tokens for secure form submissions
  • Logout functionality: Handles logout flows consistently

Constants Management

I use a centralized constants file to maintain consistent selectors across all tests:

typescript
/**
 * Common selectors for the application
 */
export const SELECTORS = {
  // General navigation
  NAVIGATION: 'nav',
  NAV_LINKS: 'nav a',
  MAIN_CONTENT: 'main',

  // Desktop navigation
  DESKTOP_NAV: '.desktop-nav',
  DESKTOP_NAV_LINKS: '.desktop-nav a[href]',
  DESKTOP_SEARCH: '.nav-search-item input',
  DESKTOP_SEARCH_BUTTON: '.search-button',

  // Mobile navigation
  MOBILE_NAV: '.mobile-nav',
  MOBILE_NAV_CONTENT: '.mobile-nav-content',
  MOBILE_NAV_LINKS: '.mobile-nav-content a[href]',
  BURGER_MENU: '.burger-menu',
  MOBILE_SEARCH_CONTAINER: '.mobile-nav-search-container',
  MOBILE_SEARCH_INPUT: ".mobile-nav-search-container input[type='search']",
  MOBILE_SEARCH_BUTTON: '.search-button-mobile',
  MOBILE_CONTROLS: '.mobile-nav-controls',

  // Form elements
  FORM: 'form',
  EMAIL_INPUT: 'input[type="email"], input[name="email"]',
  NICKNAME_INPUT: 'input[name="nickname"], input[type="text"]',
  PASSWORD_INPUT: 'input[name="password"]',
  CONFIRM_PASSWORD_INPUT: 'input[name="confirmPassword"]',
  SUBMIT_BUTTON: 'button[type="submit"], input[type="submit"]',
  ERROR_MESSAGE: '.error, [data-testid="error"]',
  SUCCESS_MESSAGE: '.success, [data-testid="success"]',
  AUTH_FORM: '.auth-form',
  OAUTH_BUTTONS: '.auth-signin-btn',
} as const;

/**
 * Common test data
 */
export const TEST_DATA = {
  VALID_EMAIL: 'test@example.com',
  VALID_PASSWORD: 'password123',
  INVALID_EMAIL: 'invalid-email',
  INVALID_PASSWORD: '123',
} as const;

This allows me to write tests using consistent selectors and makes maintenance much easier.

Writing Effective Tests

With our helper utilities in place, writing tests becomes much cleaner and more maintainable. Let's look at examples from different types of tests.

Home Page Tests

Here's an example from our home page tests:

typescript
import { expect, test } from '@playwright/test';
import { createTestHelper } from './utils/index.ts';

test.describe('Home Page', () => {
  test('should load the home page with correct title and meta', async ({
    page,
  }) => {
    const helper = createTestHelper(page);
    await helper.navigateTo('/', {
      waitForLoadState: 'networkidle',
      waitForSelector: 'main.main-content',
    });

    // Check that the page has the correct title
    await helper.pageShouldHaveTitle(/Retro Ranker - Home/);

    // Check for meta description
    await expect(page.locator('meta[name="description"]')).toHaveAttribute(
      'content',
      /Retro Ranker - Home to browse and compare retro gaming handhelds/
    );
  });

  test('should display hero section with main content', async ({ page }) => {
    const helper = createTestHelper(page);
    await helper.navigateTo('/');

    // Check for hero container
    await helper.elementShouldBeVisible('.hero-container');

    // Check for main heading with "Retro Ranker"
    await expect(page.locator('h1')).toContainText('Retro Ranker');

    // Check for hero description
    await helper.elementShouldBeVisible('.hero-section p');

    // Check for join community button
    await helper.elementShouldBeVisible(
      '.hero-section a[href="/auth/sign-in"]'
    );
  });

  test('should be responsive across different viewport sizes', async ({
    page,
  }) => {
    const helper = createTestHelper(page);
    await helper.navigateTo('/');

    // Test responsive design with helper
    await helper.testResponsiveDesign();
  });
});

Authentication Tests

For authentication flows, I use specialized AuthHelpers:

typescript
import { expect, test } from '@playwright/test';
import process from 'node:process';
import { createAuthHelper, createTestHelper } from './utils/index.ts';

test.describe('Login Functionality', () => {
  test('should login successfully with valid credentials from .env', async ({
    page,
  }) => {
    const authHelper = createAuthHelper(page);

    // Check if credentials are available
    const nickname = process.env.TEST_USER_NICKNAME;
    const password = process.env.TEST_USER_PASSWORD;

    if (!nickname || !password) {
      test.skip(); // Skip test if credentials not available
      return;
    }

    // Perform login using helper
    await authHelper.loginWithEnvCredentials();

    // Verify we're logged in
    expect(await authHelper.isLoggedIn()).toBe(true);

    // Check we're on the profile page
    await expect(page).toHaveURL(/\/profile/);
  });
});

Using Helper Functions

The helper functions make tests much more readable and maintainable:

typescript
import { SELECTORS, TEST_DATA } from './utils/constants.ts';

test('should handle form validation', async ({ page }) => {
  const helper = createTestHelper(page);
  await helper.navigateTo('/auth/sign-in');

  // Use constants for selectors
  await helper.elementShouldBeVisible(SELECTORS.AUTH_FORM);
  await helper.elementShouldBeVisible(SELECTORS.NICKNAME_INPUT);
  await helper.elementShouldBeVisible(SELECTORS.PASSWORD_INPUT);

  // Fill form using constants
  await helper.fillField(SELECTORS.NICKNAME_INPUT, 'testuser');
  await helper.fillField(SELECTORS.PASSWORD_INPUT, TEST_DATA.VALID_PASSWORD);

  // Submit form
  await helper.clickElement(SELECTORS.SUBMIT_BUTTON);
});

Key testing patterns I follow:

  • Use helper functions: Encapsulate common operations
  • Test user workflows: Focus on real user interactions
  • Responsive testing: Ensure mobile and desktop compatibility
  • Accessibility checks: Verify page structure and content
  • Error scenarios: Test edge cases and error handling
  • Environment-aware testing: Skip tests when dependencies aren't available
  • Session management: Handle login/logout flows consistently

CI/CD Integration

My Playwright setup integrates seamlessly with GitHub Actions for automated testing. There are 2 versions:

  1. runs on every main/develop PR/push
  2. runs every night

The playwright configuration automatically adapts to the CI environment:

yaml
# .github/workflows/playwright-tests.yml
name: 🧪 Playwright Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]
  workflow_dispatch:

concurrency:
  group: playwright-tests-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    name: Run Playwright Tests
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
          cache-dependency-path: playwright/package-lock.json

      - name: Cache Playwright browsers
        uses: actions/cache@v4
        id: cache-playwright
        with:
          path: |
            ~/.cache/ms-playwright
            playwright/.cache
          key: ${{ runner.os }}-playwright-${{ hashFiles('playwright/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-playwright-

      - name: Install Playwright dependencies
        working-directory: playwright
        run: npm ci

      - name: Install Playwright browsers
        working-directory: playwright
        run: npx playwright install --with-deps
        if: steps.cache-playwright.outputs.cache-hit != 'true'

      - name: Create screenshots directory
        run: mkdir -p playwright/screenshots

      - name: Run Playwright tests
        working-directory: playwright
        run: npm test
        env:
          CI: true
          TEST_USER_NICKNAME: ${{ secrets.TEST_USER_NICKNAME }}
          TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
          TEST_USER_2_NICKNAME: ${{ secrets.TEST_USER_2_NICKNAME }}
          TEST_USER_2_PASSWORD: ${{ secrets.TEST_USER_2_PASSWORD }}

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-test-results-${{ github.run_number }}
          path: |
            playwright/test-results/
            playwright/playwright-report/
          retention-days: 30

      - name: Upload screenshots
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-screenshots-${{ github.run_number }}
          path: playwright/screenshots/
          retention-days: 30

    ...

The CI setup provides:

  • Automated testing: Runs on every PR and push
  • Artifact storage: Preserves test reports and screenshots
  • Caching: Speeds up builds with dependency caching
  • Multi-browser testing: Ensures cross-browser compatibility

Best Practices and Patterns

Throughout my Playwright implementation, I've established several best practices:

1. Environment Configuration

typescript
// Use environment variables for different contexts
baseURL: process.env.CI
  ? "https://retroranker.site"
  : "http://localhost:8000",

2. Robust Waiting Strategies

The test- and auth- helpers are filled with these!

typescript
// Wait for content with fallbacks
async navigateTo(path: string, options?: {
  waitForSelector?: string;
  timeout?: number;
}) {
  // Primary wait strategy
  try {
    await this.page.waitForSelector(waitForSelector, { timeout });
  } catch (error) {
    // Fallback to body
    await this.page.waitForSelector("body", { timeout: 5000 });
  }
}

3. Test Data Management

  • Using Constants for selectors and test data
  • Use .env files locally for credentials
  • Use pipeline secrets in CI for credentials

typescript
// Use environment variables for test credentials
const nickname = process.env.TEST_USER_NICKNAME;
const password = process.env.TEST_USER_PASSWORD;

if (!nickname || !password) {
  test.skip(); // Skip test if credentials not available
  return;
}

4. Error Handling and Recovery

typescript
// Safe element interactions
async safeClick(selector: string, options?: { timeout?: number }) {
  try {
    if (this.page.isClosed()) {
      throw new Error("Page has been closed");
    }
    await this.page.click(selector, { timeout });
  } catch (error) {
    // Handle page closure errors gracefully
    if (error.message.includes("Target page, context or browser has been closed")) {
      throw new Error(`Page was closed during click on ${selector}`);
    }
    throw error;
  }
}

5. Responsive Testing

typescript
// Test across different viewport sizes
async testResponsiveDesign() {
  // Desktop
  await this.page.setViewportSize({ width: 1280, height: 720 });
  await expect(this.page.locator("body")).toBeVisible();

  // Tablet
  await this.page.setViewportSize({ width: 768, height: 1024 });
  await expect(this.page.locator("body")).toBeVisible();

  // Mobile
  await this.page.setViewportSize({ width: 375, height: 667 });
  await expect(this.page.locator("body")).toBeVisible();
}

Conclusion

Setting up Playwright for Retro Ranker has provided me with a robust, maintainable testing solution that scales with my application. The key benefits I've achieved include:

  • Comprehensive coverage: Multi-browser testing across desktop and mobile
  • Developer experience: Clean, readable tests with helpful utilities
  • CI/CD integration: Automated testing with detailed reporting
  • Maintainability: Well-structured code with clear separation of concerns
  • Reliability: Robust error handling and recovery mechanisms

The combination of thoughtful configuration, comprehensive helper utilities, and established testing patterns has created a testing suite that not only catches bugs but also serves as living documentation of my application's expected behavior.

Whether you're building a Fresh/Deno application like Retro Ranker or working with any modern web framework, Playwright provides the tools you need to build confidence in your application's quality and reliability.

For more details about my specific implementation, check out the Retro Ranker repository and explore the playwright/ directory to see how these patterns work in practice.