Most Ionic teams we audit have unit tests, no end-to-end tests, and a manual regression checklist that someone works through the night before a release. That checklist is where release delays come from, and it is the first thing we automate on a maintenance engagement.
The reason E2E testing stalls on hybrid apps is that "end to end" means two different things. Most of your app is web code running in a WebView, which a browser test runner can drive quickly and cheaply. But the parts that break in production — camera permissions, biometrics, push handling, deep links, the native back button, splash-to-first-paint — only exist on a device. So you need two layers:
- Playwright against
ionic serve, covering routing, forms, state, and API contracts. Runs in seconds on every pull request. - Maestro against real debug builds on an emulator/simulator, covering the handful of native flows. Runs on merge and nightly.
This tutorial sets both up for a Capacitor 8 / Ionic 8+ app and wires them into GitHub Actions.
1. Playwright against the dev server
npm install -D @playwright/test
npx playwright install --with-deps chromium
playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e/web',
timeout: 30_000,
retries: process.env.CI ? 1 : 0,
use: {
baseURL: 'http://localhost:8100',
trace: 'on-first-retry',
video: 'retain-on-failure',
...devices['Pixel 7'], // mobile viewport + touch, not a desktop window
},
webServer: {
command: 'npm run start -- --port 8100',
url: 'http://localhost:8100',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
Testing at a mobile viewport with hasTouch matters: Ionic renders different
components on md/ios modes and at different breakpoints, and a desktop
viewport happily passes tests for a layout your users never see.
Selectors that survive a refactor
Ionic components are custom elements with shadow DOM. Playwright pierces
shadow roots automatically for CSS selectors, so page.locator('ion-button')
works — but do not select on internal class names. Add data-testid to your
own markup and use roles for the rest:
await page.getByTestId('email').fill('ada@example.com');
await page.getByTestId('password').fill('correct horse');
await page.getByRole('button', { name: 'Sign in' }).click();
For ion-input, note that fill() targets the inner <input>; if a locator
resolves to the host element, drill in explicitly:
await page.locator('ion-input[data-testid=email] input').fill('ada@example.com');
Waiting for Ionic transitions
The single most common flake is asserting during a page transition. Do not
waitForTimeout. Wait for the page that is actually active:
// e2e/web/helpers.ts
import { Page, expect } from '@playwright/test';
export async function expectOnPage(page: Page, testId: string) {
const view = page.locator(`ion-router-outlet .ion-page:not(.ion-page-hidden)`);
await expect(view.getByTestId(testId)).toBeVisible();
}
.ion-page-hidden is removed only once the incoming view has finished
animating, so this assertion is both correct and fast. Same idea for
overlays — wait for ion-modal to have class show-modal, or simply assert
on content inside the modal, which is not attached until it opens.
A first spec
// e2e/web/login.spec.ts
import { test, expect } from '@playwright/test';
import { expectOnPage } from './helpers';
test.beforeEach(async ({ page }) => {
await page.route('**/api/session', (route) =>
route.fulfill({ json: { token: 'test-token', user: { name: 'Ada' } } }));
});
test('signs in and lands on the dashboard', async ({ page }) => {
await page.goto('/login');
await page.locator('ion-input[data-testid=email] input').fill('ada@example.com');
await page.locator('ion-input[data-testid=password] input').fill('correct horse');
await page.getByRole('button', { name: 'Sign in' }).click();
await expectOnPage(page, 'dashboard-greeting');
await expect(page.getByTestId('dashboard-greeting')).toHaveText('Hi, Ada');
});
test('shows a toast when the API is down', async ({ page }) => {
await page.route('**/api/session', (route) => route.fulfill({ status: 500 }));
await page.goto('/login');
await page.locator('ion-input[data-testid=email] input').fill('ada@example.com');
await page.locator('ion-input[data-testid=password] input').fill('nope');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.locator('ion-toast')).toContainText('try again');
});
Route interception is what keeps this layer fast and deterministic: no real backend, no seeded database, no shared staging environment that another team is redeploying while your suite runs.
Stubbing Capacitor plugins in the browser
Plugin calls that have no web implementation throw under ionic serve. Inject
a stub before the app boots:
// e2e/web/fixtures.ts
import { test as base } from '@playwright/test';
export const test = base.extend({
page: async ({ page }, use) => {
await page.addInitScript(() => {
(window as any).Capacitor = {
...(window as any).Capacitor,
isNativePlatform: () => false,
Plugins: {
Camera: { getPhoto: async () => ({ webPath: '/assets/test/receipt.png' }) },
Geolocation: { getCurrentPosition: async () => ({ coords: { latitude: 51.5, longitude: -0.12 } }) },
},
};
});
await use(page);
},
});
Keep the stubs honest — they should return the same shape as the real plugin, including error cases. Anything you cannot stub honestly belongs in the Maestro layer.
2. Maestro on a real build
Maestro drives the installed app with YAML flows and handles native dialogs, which is exactly what the WebView layer cannot do.
curl -Ls "https://get.maestro.mobile.dev" | bash
Build a debug app first:
npm run build
npx cap sync
cd android && ./gradlew assembleDebug && cd ..
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
.maestro/camera-upload.yaml:
appId: com.example.app
---
- launchApp:
clearState: true
permissions:
camera: allow
notifications: allow
- tapOn:
id: "email"
- inputText: "ada@example.com"
- tapOn:
id: "password"
- inputText: "correct horse"
- tapOn: "Sign in"
- assertVisible: "Hi, Ada"
- tapOn:
id: "add-receipt"
- tapOn: "Take photo"
- assertVisible:
id: "receipt-preview"
- tapOn: "Save"
- assertVisible: "Receipt saved"
Two Capacitor-specific notes. First, id: maps to the accessibility id, which
Maestro reads from the WebView's id attribute — so put stable ids on the
elements you drive, not just data-testid. Second, permissions: on
launchApp pre-grants the OS dialogs so the flow tests your code rather than
your ability to tap "Allow".
Flows worth writing at this layer, and roughly no others:
- cold start → login → first meaningful screen (catches splash and bundle regressions)
- a permission-gated feature: camera, location, or contacts
- biometric unlock, using
- runFlow: when: platform: iOSbranches where the platforms differ - a deep link:
- openLink: https://example.com/orders/123and assert the right screen - a push notification tap, if you use
@capacitor/push-notifications - Android hardware back from a nested route (
- back) — a perennial Ionic routing bug
Run locally with maestro test .maestro/.
3. Both layers in GitHub Actions
name: e2e
on: [pull_request]
jobs:
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with: { name: playwright-report, path: playwright-report }
android:
runs-on: ubuntu-latest
needs: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 21 }
- run: npm ci && npm run build && npx cap sync android
- run: cd android && ./gradlew assembleDebug
- run: curl -Ls "https://get.maestro.mobile.dev" | bash
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
arch: x86_64
script: |
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
$HOME/.maestro/bin/maestro test .maestro/ --format junit
- uses: actions/upload-artifact@v4
if: always()
with: { name: maestro-results, path: report.xml }
The web job gates every PR; the emulator job takes ten to fifteen minutes, so
many teams run it on merge to main and nightly instead of on every push. iOS
simulator flows need a macos-latest runner and xcodebuild -sdk iphonesimulator before the Maestro step — worth adding once the Android job
is stable.
If you already have the signed-build pipeline from CI/CD for Capacitor apps with GitHub Actions + fastlane, these jobs slot in ahead of it: tests gate the build, the build gates the store upload.
4. Keeping the suite trustworthy
A flaky suite gets ignored within a month, and an ignored suite is worse than none. What keeps ours green:
- Budget the pyramid. Dozens of Playwright specs, under ten Maestro flows. Native flows are slow and brittle; spend them on things only a device can prove.
- No sleeps. Every wait is a wait for a state —
.ion-page-hiddenremoval, a network response, an element assertion. - Deterministic data. Intercept in Playwright; for Maestro, point the debug build at a seeded environment reset before each run.
- Quarantine, don't disable. A flaky test moves to a nightly-only tag with
an owner and a date, not to
test.skipforever. - Own the reports. Traces, videos, and Maestro's
--format junitoutput as CI artifacts. If a failure cannot be diagnosed from artifacts, nobody will fix it. - Test the upgrade path. Run the suite against a build that upgrades over the previous store release, not just a clean install — migration bugs in SQLite and secure storage only show up that way.
What good looks like
For a mid-sized Ionic app, we aim for a PR suite under five minutes, a nightly device suite under twenty, and a manual regression checklist short enough to fit on one screen. Getting there is usually one to two weeks of work, most of it spent adding stable test ids and untangling flows that depend on shared state.
Our Ionic consultants do this as a fixed-scope engagement: audit, first suite, CI wiring, and a handover session with your team. Get in touch if release night is the most stressful part of your month.