Writing tests
Taqwright tests perform actions on a live mobile device and assert the resulting UI state. Each test gets a fresh WebDriver session against your configured emulator, simulator, or physical device. Locators auto-wait for elements to be visible and actionable before driving them, so tests stay reliable even when the app animates.
Introduction
Taqwright sits on top of Playwright's test runner and Appium 3. From Playwright you get parallelism, retries, projects, sharding, fixtures, the HTML reporter, and the test / expect primitives. From Appium you get UiAutomator2 (Android) and XCUITest (iOS) underneath, with a flat mobile API and a chainable Locator on top.
You write tests the same way you'd write a Playwright web test, same file shape, same hooks, same runner flags, but you drive a phone instead of a browser. New here? Do the Installation first.
In this guide you'll learn how to write your first test, perform actions, use auto-retrying assertions, run tests in isolation, and use test hooks.
First test
taqwright init already wrote one for you at tests/example.spec.ts (against the bundled demo app):
import { test, expect } from 'taqwright';
test('user can log in to the demo app', async ({ mobile }) => {
await mobile.getByLabel('Username').fill('emma@demoapp.com');
await mobile.getByLabel('Password').fill('10203040');
await mobile.getByLabel('Login').click();
await expect(mobile.getByLabel('View All')).toBeVisible();
});
Then run it (Android):
npx taqwright test --project=android
Line-by-line:
import { test, expect } from 'taqwright': Playwright'stest+expect, extended with amobilefixture. Use them exactly like the Playwright ones you know.async ({ mobile }) =>: destructure the fixture. It opens a fresh WebDriver/Appium session when the test starts and tears it down when it ends.mobile.getByLabel(…): returns a lazyLocatorby accessibility label; nothing touches the device until an action or assertion runs. Prefer stable locators like this over XPath / UiSelector..fill('emma@demoapp.com'): taps the field, clears it, sends the text as real key events (so React Native / FlutteronChangeTexthandlers fire)..click(): auto-waits for the element to be visible and enabled before tapping.await expect(locator).toBeVisible(): auto-retrying assertion: polls every 200 ms until the element is visible or the timeout (default 30 s) elapses.
:::tip Two assertion styles, same engine
Taqwright ships a Playwright-style expect(locator) wrapper, expect(loc).toBeVisible() / .toHaveText() / .toBeChecked() / …, that delegates to the auto-retrying assertion methods on the Locator itself (loc.assertVisible(), loc.assertText(), …). Both forms behave identically; use whichever reads better.
:::
Actions
Actions fall into three buckets: app lifecycle (install / launch / background / deep-link), locating (the getBy* family), and per-element actions on the returned Locator.
App lifecycle
Most tests don't need these directly, the fixture handles install + launch via resetBetweenTests (see Test isolation). But they're there when you need them:
// Install and launch
await mobile.installApp('/path/to/app.apk');
await mobile.launchApp('com.example.app');
// Send to background for 5 seconds, then come back
await mobile.backgroundApp(5);
await mobile.activateApp('com.example.app');
// Deep link
await mobile.openDeepLink('myapp://product/123');
// Android-only system back
await mobile.goBack();
// Tear down
await mobile.terminateApp('com.example.app');
Locating elements
Pick the locator strategy that matches what your app exposes. Most stable to least:
| Method | What it matches |
|---|---|
getById('login_btn') | Accessibility id (iOS name) / Android resource-id. Same as getByTestId. |
getByLabel('Submit') | Accessibility label. |
getByText('Submit') | Visible text. Pass { exact: false } for substring, or a RegExp for regex. |
getByPlaceholder('Email') | Input placeholder / hint text. |
getByRole('button', { name }) | Best-effort role → widget-type mapping. |
getByType('android.widget.Button') | Class name / XCUI element type. Use for grouping (e.g. .all()). |
getByXpath('//*[@hint="..."]') | Raw XPath. Slowest; reach for last. |
getByUiSelector('new UiSelector()...') | Android UiAutomator2 selector. Fast, expressive. |
getByPredicate("type == '...'") | iOS NSPredicate. Fastest iOS strategy. |
getByClassChain('**/X[\label == "OK"`]')` | iOS class-chain. Hierarchical, much faster than XPath. |
When several elements match the same locator, chain to disambiguate:
// Pick by position
await mobile.getByText('Item').nth(2).click();
await mobile.getByText('Item').first().click();
await mobile.getByText('Item').last().click();
// Filter
await mobile.getByType('android.widget.LinearLayout')
.filter({ hasText: 'Wi-Fi' })
.click();
// Scope a child find under a parent
await mobile.getById('login_form')
.locator(mobile.getById('submit'))
.click();
// Iterate
for (const row of await mobile.getByType('XCUIElementTypeCell').all()) {
console.log(await row.getText());
}
Common actions
Called on the Locator a getBy* returns. Each auto-waits for actionability, visible, enabled, stable, so you never write a manual wait:
| Action | Description |
|---|---|
click() / tap() | Tap the element. Auto-waits for visible + enabled. |
fill(text) | Focus, clear, send the text as real key events. |
clear() | Empty the field. |
check() / uncheck() | Idempotent toggle for switches and checkboxes. |
focus() / blur() | Bring focus / dismiss the keyboard. |
press('Enter') | Single key. Supports Enter / Tab / Backspace / Space / arrows / nav keys. |
pressSequentially(text, { delay }) | One char at a time. Use when autocomplete needs to react. |
selectOption(value) | Drive a native picker / spinner / date / time picker. |
scrollIntoView() | Native scroll-to-visible if available, gesture-swipe fallback. |
swipeLeft() / Right / Up / Down | Swipe inside the element's bounding box. |
longPress({ duration }) | Press-and-hold. Default 1000 ms. |
dragTo(target) | Drag onto another locator. |
pinchIn() / pinchOut() | Two-finger zoom. |
screenshot() | Element screenshot as a Buffer. |
Mobile-specific actions
These have no direct web Playwright analogue:
// Hardware buttons, HOME | BACK | POWER | VOLUME_UP | VOLUME_DOWN | ENTER
await mobile.pressButton('BACK');
await mobile.pressButton('HOME');
await mobile.press('Enter'); // a single named key, device-level
await mobile.goBack(); // Android back / iOS nav-bar back
// Screen-level gestures (vs the locator-scoped ones above)
await mobile.swipe('left');
await mobile.scroll('down'); // content-reveal direction
await mobile.scroll('down', { from: { y: 0.7 }, to: { y: 0.3 } });
await mobile.scrollIntoView(mobile.getByText('Submit'));
await mobile.dragAndDrop({ x: 100, y: 400 }, { x: 100, y: 120 });
await mobile.clickByPercent({ x: 0, y: 0, width: 1080, height: 2400 }, 0.5, 0.9);
// Pickers, auto-detects element type
await mobile.getByType('XCUIElementTypePickerWheel').selectOption('March');
await mobile.getByType('XCUIElementTypeDatePicker').selectOption({ date: '2026-05-14' });
await mobile.getByType('android.widget.Spinner').selectOption('Large');
// Orientation / keyboard
await mobile.setOrientation('landscape');
await mobile.hideKeyboard();
const up = await mobile.isKeyboardShown();
// OS dialogs
await mobile.acceptAlert();
await mobile.dismissAlert();
const msg = await mobile.getAlertText();
// App state & lifecycle queries
await mobile.isAppInstalled(); // defaults to the configured bundle id
await mobile.queryAppState(); // not_installed | not_running | background | foreground
await mobile.backgroundApp(3); // send to background for 3s (-1 = indefinitely)
// Clipboard / geolocation
await mobile.setClipboard('hello');
await mobile.setLocation({ latitude: 37.422, longitude: -122.084 });
// Permissions / network (Android-only, throw a clear error on iOS)
await mobile.setPermission('android.permission.CAMERA', 'grant');
await mobile.setNetworkConnection({ wifi: false, data: true, airplane: false });
// Files / logs / locale / time
await mobile.pushFile('/sdcard/Download/in.txt', 'payload');
const logs = await mobile.getDeviceLogs(); // 'logcat' | 'syslog' | 'crashlog'
await mobile.setLocale('en-US'); // Android-only at runtime
// Screen recording (separate from the auto-captured trace video)
await mobile.startScreenRecording();
const mp4 = await mobile.stopScreenRecording();
// Device info / misc
const size = await mobile.getScreenSize(); // { width, height }
const png = await mobile.screenshot(); // full-screen Buffer
const xml = await mobile.viewTree(); // current page source
await mobile.pause(); // attach the inspector mid-test (no-op if PWDEBUG=0)
mobile.raw is the escape hatch back to the underlying WebDriver Client if you need a command not surfaced here.
Assertions
Locator assertions (auto-retrying)
Every assertion that operates on a Locator is a method on the locator itself. They auto-retry, polling every 200 ms until the condition holds or the timeout elapses.
const submit = mobile.getById('Submit');
await submit.assertVisible();
await submit.assertEnabled();
await submit.assertText('Submit order');
await submit.assertContainsText('Submit');
// State for toggles
await mobile.getById('darkMode').assertChecked();
// Counts (pairs with .all() / chain)
await mobile.getByType('CartItem').assertCount(3);
// Arbitrary attribute
await mobile.getById('promo').assertAttribute('content-desc', /Save \d+%/);
| Method | Asserts |
|---|---|
assertVisible() / assertHidden() | Element is (or isn't) displayed. |
assertEnabled() / assertDisabled() | Element is interactive. |
assertChecked() / assertUnchecked() | Toggle / switch state. |
assertText(s | RegExp) | Exact text or regex match. |
assertContainsText(s) | Substring match. |
assertValue(s | RegExp) | Input value match. |
assertCount(n) | The chain currently matches exactly n elements. |
assertAttribute(name, s | RegExp) | Arbitrary attribute matches. |
assertEditable() / assertReadonly() | Input is (or isn't) editable. |
assertFocused() | Element has keyboard focus. |
assertAttached() | Element exists in the UI tree (whether or not visible). |
assertEmpty() | No children and no text. |
assertInViewport() | Bounding box intersects the device viewport. |
Generic matchers (expect)
Playwright's expect is re-exported from taqwright. For plain values, strings, numbers, arrays, objects, it's Playwright's expect unchanged. For a Locator it returns taqwright's auto-retrying mobile matchers.
import { test, expect } from 'taqwright';
test('totals match', async ({ mobile }) => {
const total = await mobile.getById('total').getText();
expect(total).toBe('$69.99');
const items = await mobile.getByType('CartItem').all();
expect(items).toHaveLength(3);
});
Using expect(locator)
No setup, no expect.extend. expect imported from taqwright returns taqwright's mobile matchers when you pass a Locator, and falls through to Playwright's real expect for plain values.
import { test, expect } from 'taqwright';
test('cart', async ({ mobile }) => {
await expect(mobile.getById('VIEW CART')).toBeVisible();
await expect(mobile.getByText('Total')).toContainText('$69.99');
await expect(mobile.getByType('CartItem')).toHaveCount(3);
await expect(mobile.getById('darkMode')).toBeChecked();
});
| Assertion | Description |
|---|---|
toBeVisible() / toBeHidden() | Element is visible / hidden |
toBeEnabled() / toBeDisabled() | Control is enabled / disabled |
toBeChecked() | Checkbox / switch is checked |
toBeEditable() | Input is editable |
toBeFocused() | Element has keyboard focus |
toBeAttached() | Element exists in the UI tree |
toBeInViewport() | Element intersects the device viewport |
toBeEmpty() | Element has no children and no text |
toHaveText(s | RegExp) | Element matches text |
toContainText(s) | Element contains text |
toHaveValue(s | RegExp) | Input element has value |
toHaveCount(n) | The chain matches exactly n elements |
toHaveAttribute(name, s | RegExp) | Element has attribute |
The paired matchers also take a .not form. Web-only Playwright matchers (toHaveScreenshot, toHaveCSS, toHaveTitle, …) throw a clear error rather than misfire. It's a thin standalone wrapper (not expect.extend), so Playwright's value matchers, expect.soft, expect.poll and expect.configure are all unchanged.
await expect
.poll(() => mobile.getById('VIEW CART').isVisible(), { timeout: 10_000 })
.toBe(true);
Test isolation
Each worker holds one WebDriver / Appium session. By default that session stays open across tests in the same worker, fast, but tests can leak state.
Set resetBetweenTests: true in your config to get a clean app between tests:
import { defineConfig, Platform } from 'taqwright';
export default defineConfig({
projects: [{
name: 'android-emulator',
use: {
platform: Platform.ANDROID,
device: { provider: 'emulator', name: 'Pixel_7_API_34' },
buildPath: './app/build/outputs/apk/debug/app-debug.apk',
appBundleId: 'com.example.app',
resetBetweenTests: true,
},
}],
});
When resetBetweenTests is true, the mobile fixture terminates → uninstalls → re-installs → activates the app between every test. Both buildPath and appBundleId are required, TypeScript will enforce that.
Hooks
Inherited from Playwright unchanged: test.describe, test.beforeEach, test.afterEach, test.beforeAll, test.afterAll.
import { test } from 'taqwright';
test.describe('Cart', () => {
test.beforeEach(async ({ mobile }) => {
await mobile.getById('Username').fill('emma@demoapp.com');
await mobile.getById('Password').fill('10203040');
await mobile.getById('Login').click();
});
test('add to cart', async ({ mobile }) => {
await mobile.getById('View All').click();
await mobile.getById('Search dresses...').fill('boho');
await mobile.getById('Add to Cart').click();
await mobile.getById('VIEW CART').assertVisible();
});
});
:::tip Keep beforeEach light
Every action there runs before every test. If your login flow takes 8 s, three tests cost 24 s of repeated work. Combine with resetBetweenTests: false (default) and a single test.beforeAll if the tests don't need fresh app state.
:::