2024-12-21 · 5 min read
On this page Introduction Project Structure Configuration Setup Test Helper Architecture TestHelpers Class AuthHelpers Class Constants Management Writing Effective Tests Home Page Tests Authentication Tests Using Helper Functions CI/CD Integration Best Practices and Patterns 1. Environment Configuration 2. Robust Waiting Strategies 3. Test Data Management 4. Error Handling and Recovery 5. Responsive Testing Conclusion 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.
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.
My Playwright setup is organized in a dedicated playwright/ directory within the project:
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.
The heart of my Playwright setup is the playwright.config.ts file. Here's how I configured it for optimal performance and reliability:
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
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:
The TestHelpers class provides robust navigation and interaction methods:
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
For authentication flows, we have a specialized AuthHelpers class:
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
I use a centralized constants file to maintain consistent selectors across all tests:
/**
* 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.
With our helper utilities in place, writing tests becomes much cleaner and more maintainable. Let's look at examples from different types of tests.
Here's an example from our home page tests:
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();
});
});
For authentication flows, I use specialized AuthHelpers:
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/);
});
});
The helper functions make tests much more readable and maintainable:
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
My Playwright setup integrates seamlessly with GitHub Actions for automated testing. There are 2 versions:
runs on every main/develop PR/push
runs every night
The playwright configuration automatically adapts to the CI environment:
# .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
Throughout my Playwright implementation, I've established several best practices:
// Use environment variables for different contexts
baseURL: process.env.CI
? "https://retroranker.site"
: "http://localhost:8000",
The test- and auth- helpers are filled with these!
// 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 });
}
}
Using Constants for selectors and test data
Use .env files locally for credentials
Use pipeline secrets in CI for credentials
// 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;
}
// 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;
}
}
// 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();
}
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.