Complete Playwright Guide
This guide provides comprehensive documentation for using Playwright with cypress-playwright-on-rails.
Table of Contents
- Installation
- Basic Setup
- Commands and Helpers
- Factory Bot Integration
- Fixtures and Scenarios
- Database Management
- Advanced Configuration
- Migration from Cypress
Installation
1. Add the gem to your Gemfile
group :test, :development do
gem 'cypress-on-rails', '~> 1.0'
end
2. Install with Playwright framework
bundle install
bin/rails g cypress_on_rails:install --framework playwright
# Or with custom folder
bin/rails g cypress_on_rails:install --framework playwright --install_folder=spec/e2e
3. Install Playwright
# Using yarn
yarn add -D @playwright/test
# Using npm
npm install --save-dev @playwright/test
# Install browsers
npx playwright install
Basic Setup
Directory Structure
e2e/
├── playwright/
│ ├── e2e/ # Test files
│ │ └── example.spec.js
│ ├── support/
│ │ └── on-rails.js # Helper functions
│ ├── e2e_helper.rb # Ruby helper
│ └── app_commands/ # Ruby commands
│ ├── clean.rb
│ ├── factory_bot.rb
│ └── scenarios/
│ └── basic.rb
└── playwright.config.js # Playwright configuration
Playwright Configuration
// playwright.config.js
module.exports = {
testDir: './e2e/playwright/e2e',
timeout: 30000,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:5017',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } }
]
};
Commands and Helpers
Complete on-rails.js Helper File
// e2e/playwright/support/on-rails.js
const { request } = require('@playwright/test');
const API_PREFIX = ''; // or '/api' if configured
async function appCommands(body) {
const context = await request.newContext();
const response = await context.post(`${API_PREFIX}/__e2e__/command`, {
data: body,
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok()) {
const text = await response.text();
throw new Error(`Command failed: ${response.status()} - ${text}`);
}
return response.json();
}
async function app(name, commandOptions = {}) {
const result = await appCommands({
name,
options: commandOptions
});
return result[0];
}
async function appScenario(name, options = {}) {
return app(`scenarios/${name}`, options);
}
async function appFactories(factories) {
return app('factory_bot', factories);
}
async function appFixtures() {
return app('activerecord_fixtures');
}
async function appClean() {
return app('clean');
}
async function appEval(code) {
return app('eval', { code });
}
module.exports = {
app,
appCommands,
appScenario,
appFactories,
appFixtures,
appClean,
appEval
};
Using Helpers in Tests
// e2e/playwright/e2e/user.spec.js
const { test, expect } = require('@playwright/test');
const { app, appFactories, appScenario, appClean } = require('../support/on-rails');
test.describe('User Management', () => {
test.beforeEach(async () => {
await appClean();
});
test('create and view user', async ({ page }) => {
// Create user with factory bot
const users = await appFactories([
]);
await page.goto(`/users/${users[0].id}`);
await expect(page.locator('h1')).toContainText('John Doe');
});
test('load scenario', async ({ page }) => {
await appScenario('user_with_posts');
await page.goto('/users');
await expect(page.locator('.user-count')).toContainText('5 users');
});
});
Factory Bot Integration
Creating Records
test('factory bot examples', async ({ page }) => {
// Single record
const user = await appFactories([
['create', 'user', { name: 'Alice' }]
]);
// Multiple records
const posts = await appFactories([
['create_list', 'post', 3, { published: true }]
]);
// With traits
const adminUser = await appFactories([
['create', 'user', 'admin', { name: 'Admin User' }]
]);
// With associations
const postWithComments = await appFactories([
['create', 'post', 'with_comments', { comment_count: 5 }]
]);
// Building without saving
const userData = await appFactories([
['build', 'user', { name: 'Not Saved' }]
]);
});
Using Attributes For
test('get factory attributes', async ({ page }) => {
const attributes = await appFactories([
['attributes_for', 'user']
]);
// Use attributes to fill form
await page.fill('[name="user[name]"]', attributes[0].name);
await page.fill('[name="user[email]"]', attributes[0].email);
});
Fixtures and Scenarios
Loading Rails Fixtures
test('load fixtures', async ({ page }) => {
await appFixtures();
await page.goto('/products');
// Fixtures are loaded
});
Creating Scenarios
# e2e/playwright/app_commands/scenarios/complex_setup.rb
# Create a complex test scenario
5.times do |i|
user = User.create!(
name: "User #{i}",
email: "user#{i}@example.com"
)
3.times do |j|
user.posts.create!(
title: "Post #{j} by User #{i}",
content: "Content for post #{j}",
published: j.even?
)
end
end
# Add some comments
Post.published.each do |post|
2.times do
post.comments.create!(
author: "Commenter",
content: "Great post!"
)
end
end
Using scenarios in tests:
test('complex scenario', async ({ page }) => {
await appScenario('complex_setup');
await page.goto('/posts');
await expect(page.locator('.post')).toHaveCount(15);
await expect(page.locator('.published')).toHaveCount(7);
});
Database Management
Cleaning Between Tests
// Global setup
test.beforeEach(async () => {
await appClean();
});
// Or selectively
test('with fresh database', async ({ page }) => {
await appClean();
await app('load_seed'); // Optionally load seeds
// Your test here
});
Custom Clean Commands
# e2e/playwright/app_commands/clean.rb
if defined?(DatabaseCleaner)
DatabaseCleaner.strategy = :truncation
DatabaseCleaner.clean
else
# Manual cleaning
tables = ActiveRecord::Base.connection.tables
tables.delete('schema_migrations')
tables.delete('ar_internal_metadata')
tables.each do |table|
ActiveRecord::Base.connection.execute("DELETE FROM #{table}")
end
end
# Reset sequences for PostgreSQL
if ActiveRecord::Base.connection.adapter_name == 'PostgreSQL'
ActiveRecord::Base.connection.tables.each do |table|
ActiveRecord::Base.connection.reset_pk_sequence!(table)
end
end
Rails.cache.clear if Rails.cache
Advanced Configuration
Running Custom Ruby Code
test('execute ruby code', async ({ page }) => {
// Run arbitrary Ruby code
const result = await appEval(`
User.count
`);
console.log('User count:', result);
// More complex evaluation
const stats = await appEval(`
{
users: User.count,
posts: Post.count,
latest_user: User.last&.name
}
`);
});
Authentication for Commands
// e2e/playwright/support/authenticated-on-rails.js
const TOKEN = process.env.CYPRESS_SECRET_TOKEN;
async function authenticatedCommand(name, options = {}) {
const context = await request.newContext();
const response = await context.post('/__e2e__/command', {
data: {
name,
options,
cypress_token: TOKEN
},
headers: {
'Content-Type': 'application/json'
}
});
if (response.status() === 401) {
throw new Error('Authentication failed');
}
return response.json();
}
Parallel Testing
// playwright.config.js
module.exports = {
workers: 4, // Run 4 tests in parallel
fullyParallel: true,
use: {
// Each worker gets unique database
baseURL: process.env.BASE_URL || 'http://localhost:5017',
},
globalSetup: './global-setup.js',
globalTeardown: './global-teardown.js'
};
// global-setup.js
module.exports = async config => {
// Setup databases for parallel workers
for (let i = 0; i < config.workers; i++) {
process.env[`TEST_ENV_NUMBER_${i}`] = i.toString();
}
};
Migration from Cypress
Command Comparison
| Cypress | Playwright |
|---|---|
cy.app('clean') | await app('clean') |
cy.appFactories([...]) | await appFactories([...]) |
cy.appScenario('name') | await appScenario('name') |
cy.visit('/path') | await page.goto('/path') |
cy.contains('text') | await expect(page.locator('text')).toBeVisible() |
cy.get('.class') | page.locator('.class') |
cy.click() | await locator.click() |
Converting Test Files
// Cypress version
describe('Test', () => {
beforeEach(() => {
cy.app('clean');
cy.appFactories([
['create', 'user', { name: 'Test' }]
]);
});
it('works', () => {
cy.visit('/users');
cy.contains('Test');
});
});
// Playwright version
const { test, expect } = require('@playwright/test');
const { app, appFactories } = require('../support/on-rails');
test.describe('Test', () => {
test.beforeEach(async () => {
await app('clean');
await appFactories([
['create', 'user', { name: 'Test' }]
]);
});
test('works', async ({ page }) => {
await page.goto('/users');
await expect(page.locator('text=Test')).toBeVisible();
});
});
Running Tests
Using Rake Tasks
# Open Playwright UI
bin/rails playwright:open
# Run tests headless
bin/rails playwright:run
Manual Execution
# Start Rails server
CYPRESS=1 bin/rails server -p 5017
# In another terminal
npx playwright test
# With specific browser
npx playwright test --project=chromium
# With UI mode
npx playwright test --ui
# Debug mode
npx playwright test --debug
CI Configuration
# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: yarn install
- run: npx playwright install --with-deps
- run: bundle exec rails db:create db:schema:load
env:
RAILS_ENV: test
- run: bundle exec rails playwright:run
env:
RAILS_ENV: test
- uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-traces
path: test-results/
Best Practices
- Always clean between tests to ensure isolation
- Use Page Object Model for complex applications
- Leverage Playwright's auto-waiting instead of explicit waits
- Run tests in parallel for faster CI
- Use fixtures for static data, factories for dynamic data
- Commit test recordings for debugging failures
Debugging
Enable Debug Mode
# Run with debug
PWDEBUG=1 npx playwright test
# This will:
# - Open browser in headed mode
# - Open Playwright Inspector
# - Pause at the start of each test
Using Traces
// Enable traces for debugging
const { chromium } = require('playwright');
test('debug this', async ({ page }, testInfo) => {
// Start tracing
await page.context().tracing.start({
screenshots: true,
snapshots: true
});
// Your test
await page.goto('/');
// Save trace
await page.context().tracing.stop({
path: `trace-${testInfo.title}.zip`
});
});
View traces:
npx playwright show-trace trace-debug-this.zip