Testing Strategy
Three tiers:
Unit tests: logic and components, in isolation
Web E2E: user journeys in a browser against a real ZoneMinder server
Device E2E: the same journeys on Android emulator and iOS simulator
Every test verifies what a human would verify: can I do the task, does it look right, does the data make sense.
Cross-Platform Architecture
Tests run on 4 platform profiles using two drivers:
Profile |
Device |
Driver |
Connection |
|---|---|---|---|
|
Desktop browser |
Playwright |
Direct launch |
|
Pixel 7 emulator |
Playwright |
ADB port-forward to CDP |
|
iPhone 15 simulator |
WebDriverIO + Appium XCUITest |
WebView context switch |
|
iPad Air simulator |
WebDriverIO + Appium XCUITest |
WebView context switch |
Playwright connects to Chromium WebViews via CDP. iOS uses WKWebView (WebKit), which requires WebDriverIO + Appium.
TestActions Abstraction
Step definitions don’t call Playwright or WebDriverIO APIs directly.
They use a shared TestActions interface
(tests/actions/types.ts) so the same Gherkin steps run on every
platform:
export interface TestActions {
goto(path: string): Promise<void>;
click(testId: string): Promise<void>;
fill(testId: string, value: string): Promise<void>;
getText(testId: string): Promise<string>;
isVisible(testId: string, timeout?: number): Promise<boolean>;
screenshot(name: string): Promise<Buffer>;
compareScreenshot(name: string, threshold?: number): Promise<void>;
platform(): PlatformProfile;
// ... more methods in types.ts
}
Two implementations exist:
PlaywrightActions(tests/actions/playwright-actions.ts), for web and AndroidWebDriverIOActions: for iOS
Unit Tests
Technology Stack
Vitest: Fast test runner (Vite-based)
React Testing Library: Component testing utilities
Testing Library User Event: Simulate user interactions
vi.mock(): Mocking dependencies
File Organization
Tests live next to the code they test in __tests__/ subdirectories:
src/
├── components/
│ └── monitors/
│ ├── MonitorCard.tsx
│ └── __tests__/
│ └── MonitorCard.test.tsx
├── lib/
│ ├── crypto.ts
│ └── __tests__/
│ └── crypto.test.ts
└── stores/
├── profile.ts
└── __tests__/
└── profile.test.ts
Running Unit Tests
# Run all unit tests
npm test
# Run specific test file
npm test -- MonitorCard.test.tsx
# Run tests matching pattern
npm test -- dashboard
# Watch mode (auto-rerun on changes)
npm test -- --watch
# With coverage report
npm test -- --coverage
Writing Unit Tests
Basic Test Structure
import { describe, it, expect } from 'vitest';
import { formatEventCount } from '../utils';
describe('formatEventCount', () => {
it('returns exact number for counts under 1000', () => {
expect(formatEventCount(42)).toBe('42');
expect(formatEventCount(999)).toBe('999');
});
it('formats thousands with K suffix', () => {
expect(formatEventCount(1000)).toBe('1K');
expect(formatEventCount(2500)).toBe('2.5K');
});
it('handles zero', () => {
expect(formatEventCount(0)).toBe('0');
});
});
Testing React Components
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MonitorCard } from '../MonitorCard';
describe('MonitorCard', () => {
const mockMonitor = {
Id: '1', Name: 'Front Door',
Width: '1920', Height: '1080',
Function: 'Modect', Controllable: '0',
};
it('renders monitor name', () => {
render(<MonitorCard monitor={mockMonitor} />);
expect(screen.getByText('Front Door')).toBeInTheDocument();
});
it('calls onShowSettings when settings button clicked', async () => {
const handleShowSettings = vi.fn();
render(<MonitorCard monitor={mockMonitor} onShowSettings={handleShowSettings} />);
await userEvent.click(screen.getByTestId('monitor-settings-button'));
expect(handleShowSettings).toHaveBeenCalledWith(mockMonitor);
});
});
Mocking Dependencies
Zustand stores:
vi.mock('../../../stores/profile');
it('displays current profile name', () => {
useProfileStore.mockReturnValue({
currentProfileId: '1',
profiles: [{ id: '1', name: 'My Profile' }],
});
render(<ProfileSelector />);
expect(screen.getByText('My Profile')).toBeInTheDocument();
});
React Query:
vi.mock('@tanstack/react-query');
it('renders monitors when loaded', () => {
useQuery.mockReturnValue({
data: { monitors: [{ Monitor: { Id: '1', Name: 'Monitor 1' } }] },
isLoading: false,
});
render(<MonitorList />);
expect(screen.getByText('Monitor 1')).toBeInTheDocument();
});
Unit Testing Rules
Test behaviour, not implementation: “clicking delete removes the monitor”, not “handleDelete calls removeMonitor”.
Query with
data-testid.Mock external dependencies (stores, React Query, custom hooks).
Reset shared state in
beforeEach.Cover edge cases: empty lists, null values, boundaries.
E2E Tests
Technology Stack
Playwright: Browser automation (web + Android)
WebDriverIO + Appium: Device automation (iOS)
playwright-bdd: Gherkin/Cucumber integration for Playwright
Real ZoneMinder server: Tests connect to an actual server
File Organization
tests/
├── features/ # Gherkin feature files
│ ├── dashboard.feature
│ ├── monitors.feature
│ ├── events.feature
│ └── ...
├── steps/ # Step definitions (one file per screen)
│ ├── common.steps.ts # Login, navigation, visual baseline
│ ├── dashboard.steps.ts
│ ├── monitors.steps.ts
│ ├── monitor-detail.steps.ts
│ ├── events.steps.ts
│ ├── timeline.steps.ts
│ ├── montage.steps.ts
│ ├── settings.steps.ts
│ ├── profiles.steps.ts
│ ├── kiosk.steps.ts
│ ├── group-filter.steps.ts
│ └── platform.steps.ts
├── actions/ # Driver abstraction
│ ├── types.ts # TestActions interface
│ └── playwright-actions.ts
├── helpers/
│ ├── config.ts # Server credentials from .env
│ ├── ios-launcher.ts # Build iOS app, boot simulator, Appium caps
│ └── visual-regression.ts
├── screenshots/ # Visual baselines per platform
│ ├── web-chromium/
│ ├── android-phone/
│ ├── ios-phone/
│ └── ios-tablet/
├── device-screenshots/ # Device screenshot capture specs
│ └── specs/
├── platforms.config.defaults.ts # Default simulator names, ports, timeouts
├── platforms.config.local.ts # Local overrides (gitignored)
└── platforms.config.ts # Config loader (merges local over defaults)
Writing Gherkin Feature Files
# tests/features/monitors.feature
Feature: Monitor Management
Background:
Given I am logged into zmNinjaNg
@all @visual
Scenario: View monitor list with live status
When I navigate to the "Monitors" page
Then I should see at least 1 monitor card
And each monitor card should show the monitor name
And the page should match the visual baseline
@ios-phone @android
Scenario: Monitor list adapts to phone layout
When I navigate to the "Monitors" page
Then monitor cards should not overflow the screen width
And the page should match the visual baseline
Scenarios test user goals, not element presence.
Step Definitions
Step definitions go in per-screen files under tests/steps/. Use
TestActions methods so steps work across all drivers:
// tests/steps/monitors.steps.ts
import { createBdd } from 'playwright-bdd';
const { Given, When, Then } = createBdd();
When('I navigate to the {string} page', async ({ page }, pageName) => {
await page.getByTestId(`nav-${pageName.toLowerCase()}`).click();
await page.waitForLoadState('networkidle');
});
Then('I should see at least {int} monitor card(s)', async ({ page }, count) => {
const cards = page.getByTestId('monitor-card');
expect(await cards.count()).toBeGreaterThanOrEqual(count);
});
Use dynamic selectors (.first(), .nth(n), “at least N”), never
hardcode monitor names or IDs.
Running Tests
All commands run from the app/ directory.
Quick Reference
Command |
Description |
|---|---|
|
Unit tests (Vitest) |
|
Web browser E2E (Playwright, fast) |
|
Web E2E with visible browser |
|
Single feature file |
|
Regenerate web visual baselines |
|
Verify device tools and simulators |
Device E2E tests are run via shell scripts in scripts/:
Command |
Description |
|---|---|
|
Android emulator (Playwright via CDP) |
|
iPhone simulator (WebDriverIO + Appium) |
|
iPad simulator (WebDriverIO + Appium) |
|
All platforms sequentially |
Running Device Tests Step by Step
Android emulator:
# 1. Build and sync the app
cd app && npm run android:sync
# 2. The npm script handles building, booting the emulator,
# installing the APK, forwarding the CDP port, and running
# Playwright against the Android WebView.
npm run test:e2e:android
# Run a single feature:
npm run test:e2e:android -- tests/features/dashboard.feature
iOS simulator (iPhone or iPad):
# 1. Build and sync the app
cd app && npm run ios:sync
# 2. The npm script builds the app via xcodebuild, boots the
# simulator, starts Appium, launches the app, switches to
# the WebView context, and runs WebDriverIO tests.
npm run test:e2e:ios-phone # iPhone 15
npm run test:e2e:ios-tablet # iPad Air
All platforms sequentially:
npm run test:e2e:all-platforms
This runs: web, Android, iOS phone, iOS tablet, in order.
Device Screenshot Capture
For capturing device screenshots without running the full E2E suite:
npm run test:screenshots:ios-phone
npm run test:screenshots:ios-tablet
npm run test:screenshots:android
These use a separate WebDriverIO config
(wdio.config.device-screenshots.ts) and Appium to launch the app
on the target device and capture screenshots of each screen.
Device Setup
One-Time Machine Setup
Prerequisites
Tool |
Version |
Notes |
|---|---|---|
Xcode |
15+ |
iOS simulators and |
Android Studio |
Latest |
AVD manager and Android SDK |
Node.js |
20+ |
All npm scripts |
Appium |
2.x |
Global install; manages iOS and Android drivers |
Android Setup
Open Android Studio → Virtual Device Manager → Create Device.
Select Pixel 7 as the hardware profile.
Select system image: API 34, arm64-v8a,
google_apisimage (required for Apple Silicon Macs).Name the AVD
Pixel_7_API_34(default expected by config).Verify
adbis on your PATH:
adb version
# If not found, add $ANDROID_HOME/platform-tools to your shell PATH
iOS Setup
Open Xcode → Settings → Platforms → click + to add a platform.
Install iOS 17 simulator runtime.
Verify the required simulators exist:
xcrun simctl list devices | grep -E "iPhone 15|iPad Air"
You need both iPhone 15 and iPad Air 11-inch (M2) listed. If missing, add them via Xcode → Window → Devices and Simulators.
Appium Setup
npm install -g appium
appium driver install xcuitest
appium driver install uiautomator2
# Verify:
appium --version # should be 2.x
appium driver list # should show xcuitest and uiautomator2
Verify All Setup
cd app
npm run test:platform:setup
This checks Xcode, iOS runtime, simulators, Android SDK, AVD, adb, Appium drivers, and port availability. Failing checks include fix instructions.
Platform Config
Default config ships in
tests/platforms.config.defaults.ts:
Android AVD:
Pixel_7_API_34, CDP port9222iOS phone:
iPhone 15(iOS 17.5)iOS tablet:
iPad Air 11-inch (M2)(iOS 17.5)Appium port:
4723App launch timeout:
30000msWebView switch timeout:
10000ms
Local overrides: Copy defaults to
platforms.config.local.ts (gitignored) and edit only the
fields you need to change:
cp tests/platforms.config.defaults.ts tests/platforms.config.local.ts
The config loader (platforms.config.ts) merges local over
defaults at startup.
Finding your simulator names:
xcrun simctl list devices # iOS
emulator -list-avds # Android
Server Credentials
E2E tests connect to a real ZoneMinder server. Set credentials in
app/.env:
ZM_HOST_1=http://your-server:port
ZM_USER_1=admin
ZM_PASSWORD_1=password
Visual Regression
Scenarios tagged @visual capture screenshots and compare against
per-platform baselines stored in tests/screenshots/<platform>/.
Threshold
The pixel diff threshold is 0.2%. Differences within this threshold pass. Differences above it fail.
Generating Baselines
On first run for a platform, or after intentional UI changes:
# Web baselines
npm run test:e2e:visual-update
# Device baselines (via test script with update flag)
bash scripts/test-android.sh --update-snapshots
bash scripts/test-ios.sh phone --update-snapshots
Reviewing Failures
When a visual test fails, a diff image is saved next to the baseline file showing the changed pixels. Inspect the diff to determine whether the change is intentional (update the baseline) or a regression (fix the code).
Testing Workflow
Test-Driven Development (TDD)
Write failing test (feature file or unit test)
Implement the feature/fix
Run tests: verify they pass
Refactor if needed, tests ensure behavior stays correct
Pre-Commit Checklist
All changes:
Run
npm test: all passRun
npx tsc --noEmit: no errorsRun
npm run build: succeeds
UI changes (additional):
data-testidadded to new interactive elementsE2E scenarios updated in
.featurefile with platform tagsnpm run test:e2epassesVisual baselines updated if layout changed
All language files updated (en, de, es, fr, zh)
Device E2E tests are manual-invoke-only. Run them when you want to verify cross-platform behaviour.
Debugging Tests
Unit Test Debugging
it('renders monitor', () => {
render(<MonitorCard monitor={mockMonitor} />);
screen.debug(); // Pretty-prints DOM
});
npm test -- MonitorCard.test.tsx # Run single test file
E2E Test Debugging
# See the browser
npm run test:e2e -- --headed
# Playwright Inspector (pause + step through)
npm run test:e2e -- --debug
// Add pause in step definition
When('I click on monitor', async ({ page }) => {
await page.pause(); // Opens Playwright Inspector
await page.click('[data-testid="monitor-card"]');
});
Test Coverage
npm test -- --coverage
Aim for: logic/utilities at 100%, UI components at 70%+, overall at 90%+.
Troubleshooting
WebView context not found
The app may not have finished loading when the test tried to switch
context. Increase the webviewSwitch timeout in
platforms.config.local.ts:
timeouts: {
webviewSwitch: 20000, // increase from default 10000
}
Appium can’t find device
The simulator or emulator name in config does not match what is installed. Check exact names:
xcrun simctl list devices # iOS
emulator -list-avds # Android
Update platforms.config.local.ts with the exact name shown.
Port already in use
A previous test run left a process holding the port:
lsof -ti :4723 | xargs kill # Appium port
lsof -ti :9222 | xargs kill # Android CDP port
Or change the port in platforms.config.local.ts.
Emulator won’t boot
Check the AVD name matches exactly:
emulator -list-avds
If corrupted, delete and recreate in Android Studio Virtual Device Manager.
iOS build fails
xcode-select --install
sudo xcodebuild -license accept
xcodebuild -showsdks | grep iphonesimulator