Introduction
After shipping 60+ mobile apps, we’ve learned this hard truth: manual testing doesn’t scale, and bugs in production are expensive. End-to-end (E2E) testing automates the user journey through your app, catching integration bugs before your users do.
While Playwright and Puppeteer were initially built for web browsers, they’ve become powerful tools for mobile app testing—especially for React Native and Flutter apps with web components. As of October 2024, Playwright has emerged as the preferred choice for most teams, but Puppeteer still has specific use cases where it excels.
This guide shows Australian development teams how to implement production-ready E2E testing for mobile apps, with real code examples and battle-tested strategies from our work with Melbourne, Sydney, and Brisbane startups.
Understanding E2E Testing for Mobile
What is E2E Testing?
End-to-end testing simulates real user interactions across your entire app stack:
User Action → Frontend → API → Database → Response → UI Update
└── E2E test validates entire flow ──┘
Example E2E test scenario:
- User taps “Sign Up”
- Enters email and password
- Submits form
- API creates database record
- User sees welcome screen
- Verification email is sent
An E2E test automates all these steps and validates the outcome.
Why Playwright and Puppeteer for Mobile?
Traditional mobile testing tools:
- Appium: Complex setup, slow execution
- Detox: React Native only, platform-specific
- Espresso/XCUITest: Native only, platform-specific
Playwright/Puppeteer advantages:
- Test mobile web apps (PWAs)
- Test React Native/Flutter web builds
- Test app admin dashboards
- Test server-side rendering
- Cross-platform (one test suite for iOS/Android/Web)
- Fast execution (headless browsers)
- Better developer experience
When to Use Which Tool
Use Playwright for:
- New projects (active development, modern features)
- Cross-browser testing (Chromium, Firefox, WebKit)
- Mobile web apps and PWAs
- React Native Web
- Flutter Web
- Parallel test execution
- Auto-waiting and retry logic
Use Puppeteer for:
- Legacy projects already using it
- Chrome/Chromium-only testing
- PDF generation and screenshots
- Web scraping alongside testing
- Projects where smaller bundle size matters
Use both (rarely):
- When migrating from Puppeteer to Playwright
- Testing Chrome-specific features while supporting other browsers
Playwright for Mobile Apps:
Complete Guide
Installation and Setup
# Create new project
npm init playwright@latest
# For existing projects
npm install -D @playwright/test
npx playwright install
Project structure:
my-mobile-app/
├── tests/
│ ├── e2e/
│ │ ├── auth.spec.ts
│ │ ├── checkout.spec.ts
│ │ └── profile.spec.ts
│ └── helpers/
│ ├── test-data.ts
│ └── custom-matchers.ts
├── playwright.config.ts
└── package.json
Configuration (playwright.config.ts)
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
// Run tests in parallel
fullyParallel: true,
// Fail build on CI if you accidentally left test.only
forbidOnly: !!process.env.CI,
// Retry on CI only
retries: process.env.CI ? 2 : 0,
// Limit parallel workers on CI
workers: process.env.CI ? 1 : undefined,
// Reporter for CI (GitHub Actions, etc.)
reporter: process.env.CI
? [['html'], ['github']]
: [['html'], ['list']],
use: {
// Base URL for your app
baseURL: process.env.APP_URL || 'http://localhost:3000',
// Collect trace on first retry
trace: 'on-first-retry',
// Screenshot on failure
screenshot: 'only-on-failure',
// Video on failure
video: 'retain-on-failure',
},
// Mobile device configurations
projects: [
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
{
name: 'Tablet',
use: { ...devices['iPad Pro'] },
},
// Desktop browsers for admin/web app
{
name: 'Desktop Chrome',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'Desktop Firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'Desktop Safari',
use: { ...devices['Desktop Safari'] },
},
],
// Local dev server (if testing locally)
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});
Writing Your First Test
Example: Testing user signup flow
// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('User Authentication', () => {
test('should sign up new user successfully', async ({ page }) => {
// Navigate to signup page
await page.goto('/signup');
// Fill signup form
const timestamp = Date.now();
const testEmail = `test-user-${timestamp}@example.com.au`;
await page.fill('input[name="email"]', testEmail);
await page.fill('input[name="password"]', 'SecureP@ssw0rd');
await page.fill('input[name="confirmPassword"]', 'SecureP@ssw0rd');
await page.fill('input[name="fullName"]', 'Test User');
// Submit form
await page.click('button[type="submit"]');
// Wait for success message
await expect(page.locator('.success-message')).toBeVisible();
await expect(page.locator('.success-message')).toContainText(
'Account created successfully'
);
// Verify redirect to welcome page
await expect(page).toHaveURL('/welcome');
// Verify user data appears
await expect(page.locator('.user-name')).toContainText('Test User');
});
test('should show error for invalid email', async ({ page }) => {
await page.goto('/signup');
// Fill with invalid email
await page.fill('input[name="email"]', 'invalid-email');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
// Check for error message
await expect(page.locator('.error-message')).toBeVisible();
await expect(page.locator('.error-message')).toContainText(
'Please enter a valid email'
);
});
test('should log in existing user', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', '[email protected]');
await page.fill('input[name="password"]', 'ExistingP@ss');
await page.click('button[type="submit"]');
// Wait for dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Dashboard');
});
});
Testing React Native Web Apps
React Native apps can render to web using React Native Web, making them testable with Playwright:
// tests/e2e/react-native-app.spec.ts
import { test, expect } from '@playwright/test';
test.describe('React Native Food Delivery App', () => {
test('should add item to cart', async ({ page }) => {
await page.goto('/');
// Navigate to restaurant
await page.click('text="Sydney Sushi Bar"');
// Add item to cart
await page.click('[data-testid="menu-item-salmon-roll"]');
await page.click('[data-testid="add-to-cart"]');
// Verify cart badge updates
const cartBadge = page.locator('[data-testid="cart-badge"]');
await expect(cartBadge).toContainText('1');
// Open cart
await page.click('[data-testid="cart-icon"]');
// Verify item in cart
await expect(page.locator('[data-testid="cart-item-name"]'))
.toContainText('Salmon Roll');
});
test('should complete checkout flow', async ({ page, context }) => {
// Login first
await context.addCookies([{
name: 'auth_token',
value: process.env.TEST_AUTH_TOKEN!,
domain: 'localhost',
path: '/',
}]);
await page.goto('/checkout');
// Fill delivery address (Australian format)
await page.fill('input[name="street"]', '123 George Street');
await page.fill('input[name="suburb"]', 'Sydney');
await page.fill('input[name="state"]', 'NSW');
await page.fill('input[name="postcode"]', '2000');
await page.fill('input[name="phone"]', '0412 345 678');
// Select payment method
await page.click('[data-testid="payment-card"]');
// Note: Don't test real payments in E2E tests
// Use Stripe test mode
await page.fill('[data-testid="card-number"]', '4242424242424242');
await page.fill('[data-testid="card-expiry"]', '12/25');
await page.fill('[data-testid="card-cvc"]', '123');
// Place order
await page.click('button:has-text("Place Order")');
// Wait for confirmation
await expect(page.locator('[data-testid="order-confirmation"]'))
.toBeVisible({ timeout: 10000 });
// Verify order number
const orderNumber = await page.locator('[data-testid="order-number"]').textContent();
expect(orderNumber).toMatch(/^ORD-\d+$/);
});
});
Testing Mobile-Specific Features
Geolocation testing:
test('should use user location for restaurant search', async ({ page, context }) => {
// Mock Sydney location
await context.grantPermissions(['geolocation']);
await context.setGeolocation({
latitude: -33.8688,
longitude: 151.2093 // Sydney CBD
});
await page.goto('/');
// Click "Use my location"
await page.click('[data-testid="use-my-location"]');
// Verify Sydney restaurants appear
await expect(page.locator('[data-testid="restaurant-list"]'))
.toContainText('Sydney');
});
Touch gestures (swipe, pinch):
test('should swipe through restaurant carousel', async ({ page }) => {
await page.goto('/');
const carousel = page.locator('[data-testid="restaurant-carousel"]');
// Swipe left (simulate touch)
await carousel.hover();
await page.mouse.down();
await page.mouse.move(-200, 0);
await page.mouse.up();
// Verify second restaurant is now visible
await expect(page.locator('[data-testid="carousel-item-2"]'))
.toBeInViewport();
});
Offline mode testing:
test('should show offline message when disconnected', async ({ page, context }) => {
await page.goto('/');
// Go offline
await context.setOffline(true);
// Try to load data
await page.click('[data-testid="refresh-button"]');
// Verify offline message
await expect(page.locator('[data-testid="offline-message"]'))
.toBeVisible();
await expect(page.locator('[data-testid="offline-message"]'))
.toContainText('No internet connection');
// Go back online
await context.setOffline(false);
await page.click('[data-testid="retry-button"]');
// Verify data loads
await expect(page.locator('[data-testid="restaurant-list"]'))
.toBeVisible();
});
Page Object Model (POM)
For maintainable tests, use Page Object Model:
// tests/pages/SignupPage.ts
import { Page, expect } from '@playwright/test';
export class SignupPage {
constructor(private page: Page) {}
// Locators
get emailInput() {
return this.page.locator('input[name="email"]');
}
get passwordInput() {
return this.page.locator('input[name="password"]');
}
get submitButton() {
return this.page.locator('button[type="submit"]');
}
get successMessage() {
return this.page.locator('.success-message');
}
// Actions
async goto() {
await this.page.goto('/signup');
}
async signup(email: string, password: string, fullName: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.page.fill('input[name="confirmPassword"]', password);
await this.page.fill('input[name="fullName"]', fullName);
await this.submitButton.click();
}
// Assertions
async expectSuccess() {
await expect(this.successMessage).toBeVisible();
}
}
// Usage in tests
import { SignupPage } from '../pages/SignupPage';
test('should sign up user', async ({ page }) => {
const signupPage = new SignupPage(page);
await signupPage.goto();
await signupPage.signup(
'[email protected]',
'SecureP@ss',
'Test User'
);
await signupPage.expectSuccess();
});
Visual Regression Testing
Playwright includes visual comparison:
test('should match homepage design', async ({ page }) => {
await page.goto('/');
// Take screenshot and compare
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100 // Allow small differences
});
});
test('should match button styles', async ({ page }) => {
await page.goto('/components');
const button = page.locator('[data-testid="primary-button"]');
// Screenshot specific element
await expect(button).toHaveScreenshot('primary-button.png');
});
Puppeteer f
or Mobile Apps
While Playwright is recommended for new projects, Puppeteer is still viable:
Installation
npm install -D puppeteer jest
Basic Test Example
// tests/e2e/auth.test.ts
import puppeteer, { Browser, Page } from 'puppeteer';
describe('User Authentication', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
});
afterAll(async () => {
await browser.close();
});
beforeEach(async () => {
page = await browser.newPage();
// Mobile viewport (iPhone 12)
await page.setViewport({
width: 390,
height: 844,
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
});
});
afterEach(async () => {
await page.close();
});
test('should sign up new user', async () => {
await page.goto('http://localhost:3000/signup');
const timestamp = Date.now();
await page.type('input[name="email"]', `test-${timestamp}@example.com.au`);
await page.type('input[name="password"]', 'SecureP@ssw0rd');
await page.click('button[type="submit"]');
await page.waitForSelector('.success-message');
const message = await page.$eval('.success-message', el => el.textContent);
expect(message).toContain('Account created');
});
});
CI/CD Integration
GitHub Actions
# .github/workflows/e2e-tests.yml
name: E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
env:
APP_URL: ${{ secrets.STAGING_URL }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/
retention-days: 30
BitBucket Pipelines
# bitbucket-pipelines.yml
image: node:18
pipelines:
default:
- step:
name: E2E Tests
caches:
- node
script:
- npm ci
- npx playwright install --with-deps
- npx playwright test
artifacts:
- playwright-report/**
Best Practices for Australian Teams
1. Test Australian-Specific Features
test('should accept Australian phone numbers', async ({ page }) => {
await page.goto('/signup');
await page.fill('input[name="phone"]', '0412 345 678');
await page.click('button[type="submit"]');
// Should not show validation error
await expect(page.locator('.error-phone')).not.toBeVisible();
});
test('should format Australian address correctly', async ({ page }) => {
await page.goto('/checkout');
await page.fill('input[name="postcode"]', '2000');
// Auto-fill should populate Sydney, NSW
await expect(page.locator('input[name="suburb"]')).toHaveValue('Sydney');
await expect(page.locator('select[name="state"]')).toHaveValue('NSW');
});
2. Test Mobile Viewport Sizes
const australianDevices = [
{ name: 'iPhone 13 (popular in AU)', width: 390, height: 844 },
{ name: 'Samsung Galaxy S21', width: 360, height: 800 },
{ name: 'iPad Air', width: 820, height: 1180 },
];
australianDevices.forEach(device => {
test(`should work on ${device.name}`, async ({ page }) => {
await page.setViewportSize({ width: device.width, height: device.height });
// Run tests...
});
});
3. Test Australian Payment Methods
test('should support Afterpay', async ({ page }) => {
await page.goto('/checkout');
await page.click('[data-testid="payment-afterpay"]');
// Verify Afterpay widget loads
await expect(page.locator('[data-testid="afterpay-widget"]')).toBeVisible();
});
4. Performance Testing
test('should load homepage within 3 seconds', async ({ page }) => {
const startTime = Date.now();
await page.goto('/');
await page.waitForLoadState('networkidle');
const loadTime = Date.now() - startTime;
expect(loadTime).toBeLessThan(3000);
});
Common Pitfalls and Solutions
Issue: Flaky Tests
Problem: Tests pass/fail randomly
Solution: Use Playwright’s auto-wait and retry logic
// ❌ Bad: Manual waits
await page.click('button');
await page.waitForTimeout(2000); // Brittle
// ✅ Good: Auto-wait for conditions
await page.click('button');
await expect(page.locator('.result')).toBeVisible();
Issue: Slow Test Execution
Problem: Tests take too long
Solution: Run in parallel, use fixtures
// playwright.config.ts
export default defineConfig({
workers: process.env.CI ? 2 : 4, // Parallel workers
fullyParallel: true,
});
Issue: Authentication in Every Test
Problem: Logging in for each test wastes time
Solution: Use Playwright’s storage state
// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', '[email protected]');
await page.fill('input[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
// Save authenticated state
await page.context().storageState({ path: 'auth.json' });
});
// Use in tests
test.use({ storageState: 'auth.json' });
Conclusion
E2E testing transforms mobile app development from “hope it works” to “know it works.” Playwright has emerged as the superior choice in 2024, with better cross-browser support, auto-waiting, and developer experience.
For Australian development teams, investing in E2E testing means:
- Catch bugs before users do
- Confident deployments
- Faster development (less manual testing)
- Better sleep (fewer production incidents)
Start with one critical flow (signup or checkout), automate it, and expand from there.
Need help setting up E2E testing for your Australian mobile app? We’ve implemented test automation for 30+ apps. Contact us for a free consultation.