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:

  1. User taps “Sign Up”
  2. Enters email and password
  3. Submits form
  4. API creates database record
  5. User sees welcome screen
  6. 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:

Playwright for Mobile Apps: Complete Guide Infographic 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.