End-to-end tests for com.haris.testapp using MobileWright, a Playwright-style framework for Android and iOS automation.
- Project Overview
- Setup
- Running Tests
- Test Coverage
- Key Patterns
- Locator Reference
- Assertions
- Inspecting the Screen
- Which Locator Should I Use?
This project tests the full user lifecycle of a personal-information Android app:
- Home screen — verify all UI elements before any interaction
- Registration — fill and submit the sign-up form
- Login — authenticate with the registered credentials
- Profile screen — verify the initial empty profile state
- Update profile — fill gender, date of birth, marital status, address, and mobile
- Verify updated profile — assert every row shows the saved values
- Change password — update to a new password
- Logout — sign out and return to the home screen
- Re-login — authenticate with the new password
- Final verification — confirm the profile header is correct after re-login
App ID: com.haris.testapp
Platform: Android
Test timeout: 600 s (the full lifecycle takes ~5 minutes on a real device)
npm installConfigure mobilewright.config.ts (already committed):
import { defineConfig } from 'mobilewright';
export default defineConfig({
testDir: './tests',
reporter: 'html',
platform: 'android',
timeout: 30_000,
});Connect an Android device or start an emulator before running.
# Run all tests
npm test
# Run only the lifecycle test
npm run test:fileOpen the HTML report after a run:
npx mobilewright show-report| File | Description |
|---|---|
tests/testApp.spec.ts |
Full user lifecycle: register → login → update profile → change password → re-login |
tests/example.spec.ts |
Minimal smoke test |
tests/full_testApp.spec.ts |
Extended variant |
Each element is located with the most stable available locator — in priority order:
// 1. Accessibility label (most stable)
await screen.getByLabel('Full Name Edit Text').fill('Haris Roy');
// 2. Android resource ID
await screen.getByTestId('com.haris.testapp:id/btn_login').tap();
// 3. Unique visible text (use when no label or ID exists)
await screen.getByText('OR').getText();The id() helper keeps resource IDs DRY:
const id = (value: string) => `${APP_ID}:id/${value}`;
await screen.getByTestId(id('et_login_email')).fill('haris@mail.com');
// expands to: screen.getByTestId('com.haris.testapp:id/et_login_email')getText() requires the element to be visible. After scrolling down, fields near the top of a form are off-screen and will timeout. Use viewTree() instead — it returns all nodes regardless of scroll position:
const formNodes = collectAll(await screen.viewTree());
const getFormValue = (resourceId: string): string =>
formNodes.find((n: any) =>
n.identifier === id(resourceId) || n.resourceId === id(resourceId)
)?.text || '';
const fullName = getFormValue('et_full_name'); // works even if scrolled off screen
const address = getFormValue('et_address');The same technique is used to read profile row values, where the row container (LinearLayout) returns "" from getText() because the text lives in a child TextView. The getRowValue helper finds the tv_row_value child by matching Y coordinates within the row's bounds:
const profileNodes = collectAll(await screen.viewTree());
const getRowValue = (rowResourceId: string): string => {
const row = profileNodes.find((n: any) =>
n.identifier === rowResourceId || n.resourceId === rowResourceId
);
if (!row) return '';
const { y: ry, height: rh } = row.bounds;
return profileNodes.find((n: any) =>
n.isVisible &&
(n.identifier === id('tv_row_value') || n.resourceId === id('tv_row_value')) &&
n.bounds.y >= ry && n.bounds.y < ry + rh
)?.text || '';
};
expect(getRowValue(id('row_full_name'))).toBe('Haris Roy');
expect(getRowValue(id('row_gender'))).toBe('N/A');Android AutoCompleteView popups are not in the accessibility tree — getByText('Male') will not find the dropdown option. The fix is to tap at a calculated coordinate below the field's bottom edge:
await screen.getByTestId(id('acv_gender')).tap();
await sleep(800); // wait for popup to render
const nodes = collectAll(await screen.viewTree());
const genderNode = nodes.find((n: any) =>
n.identifier === id('acv_gender') || n.resourceId === id('acv_gender')
);
if (!genderNode) throw new Error('Could not find gender dropdown field');
const { x: gx, y: gy, width: gw, height: gh } = genderNode.bounds;
await device.driver.tap(Math.round(gx + gw / 2), Math.round(gy + gh + 50));Scrolling to Hidden Fields
Some form fields (mobile number, terms checkbox, save button) are below the fold. findVisibleElement scrolls up automatically until the element appears:
const mobileField = await findVisibleElement([
{ name: 'et_mobile full id', locator: screen.getByTestId(id('et_mobile')) },
{ name: 'Mobile Number Edit Text', locator: screen.getByLabel('Mobile Number Edit Text') },
], 5); // up to 5 swipes
await mobileField.fill('01712345678');The helper:
const findVisibleElement = async (locators: LocatorItem[], maxSwipes = 0) => {
for (let i = 0; i <= maxSwipes; i++) {
for (const item of locators) {
if (await item.locator.isVisible().catch(() => false)) {
return item.locator;
}
}
if (i < maxSwipes) {
await device.driver.swipe('up', { distance: 650 });
await sleep(500);
}
}
throw new Error(`Could not find visible element: ${locators.map(i => i.name).join(', ')}`);
};MobileWright communicates with the device over a WebSocket. On a real device under load (especially after a ~5-minute test run), rapid interactions can drop the connection with code=1005. Rules that keep the session alive:
- Wait for the screen section before filling fields — never fill immediately after a screen transition.
toBeVisible()before eachfill()— ensures the field is ready.dismissKeyboard()between fields — closes the IME so its animation doesn't interfere with the next interaction.sleep()after taps that trigger network calls — password update and login tap need ~3 s to complete before the next assertion.
// Good pattern (Change Password section):
await changePasswordButton.tap();
await expect(screen.getByLabel('Change Password Section')).toBeVisible({ timeout: 20_000 });
await sleep(1000);
await expect(screen.getByTestId(id('et_current_password'))).toBeVisible({ timeout: 10_000 });
await screen.getByTestId(id('et_current_password')).fill('Password123');
await expect(screen.getByTestId(id('et_new_password'))).toBeVisible({ timeout: 10_000 });
await screen.getByTestId(id('et_new_password')).fill('Password456');
await dismissKeyboard();
await screen.getByTestId(id('btn_update_password')).tap();
await sleep(3000); // wait for API call before polling the screen
await waitForProfileScreen(25_000);| Helper | Purpose |
|---|---|
collectAll(nodes) |
Recursively flattens viewTree() into a single array for searching |
dismissKeyboard() |
Presses BACK to close the soft keyboard, then waits 500 ms |
findVisibleElement(locators, maxSwipes) |
Swipes up until one of the candidate locators becomes visible |
dumpVisibleUi(title) |
Prints all visible nodes to the console — useful for debugging unexpected screens |
waitForProfileScreen(timeout) |
Waits for My Profile text; dumps the UI and throws a clear error if it never appears |
assertProfileHeader(name, email) |
Asserts the profile page title, name, and email from the view tree |
Finds an element by its accessibility label — the most stable locator because labels are set by developers explicitly.
const text = await screen.getByLabel('App Name').getText();
await expect(screen.getByLabel('Login Button')).toBeVisible();✅ Best for: any element with an explicit accessibility label.
Finds an element by its visible text content.
const text = await screen.getByText('Sign In').getText();
const text = await screen.getByText('Sign in', { exact: false }).getText();
const text = await screen.getByText(/manage your/i).getText();
⚠️ Breaks if copy changes (translations, A/B tests). PrefergetByLabel.
Finds an element by its Android resource ID or iOS accessibility identifier.
const email = await screen.getByTestId('com.haris.testapp:id/et_login_email').getText();
await expect(screen.getByTestId('com.haris.testapp:id/btn_login')).toBeVisible();✅ Best for: form fields and buttons with resource IDs — very reliable on Android.
Finds elements by their native component type. Returns a collection.
const first = await screen.getByType('android.widget.TextView').first().getText();
const second = await screen.getByType('android.widget.EditText').nth(1).getText();
const count = await screen.getByType('android.widget.Button').count();Common Android types:
| Type | Description |
|---|---|
android.widget.TextView |
Text label |
android.widget.EditText |
Input field |
android.widget.Button |
Button |
android.widget.CheckBox |
Checkbox |
android.widget.RadioButton |
Radio button |
android.widget.AutoCompleteTextView |
Dropdown text field |
android.widget.LinearLayout |
Layout container (returns "" from getText) |
android.widget.ScrollView |
Scrollable container |
androidx.cardview.widget.CardView |
Card container |
⚠️ .nth()is order-dependent — fragile if layout changes. Use as last resort.
Cross-platform semantic role locator — same code works on iOS and Android.
const loginText = await screen.getByRole('button', { name: 'Login' }).getText();
const email = await screen.getByRole('textfield').first().getText();| Role | Android | iOS |
|---|---|---|
button |
Button, ImageButton |
XCUIElementTypeButton |
textfield |
EditText |
XCUIElementTypeTextField |
text |
TextView |
XCUIElementTypeStaticText |
image |
ImageView |
XCUIElementTypeImage |
switch |
Switch |
XCUIElementTypeSwitch |
checkbox |
CheckBox |
— |
Finds an input by its placeholder text.
const field = await screen.getByPlaceholder('Enter your email').getText();
await expect(screen.getByPlaceholder('Password')).toBeVisible();All assertions auto-retry until the condition is met or timeout is reached.
await expect(locator).toBeVisible();
await expect(locator).not.toBeVisible();
await expect(locator).toBeEnabled();
await expect(locator).toHaveText('Test Application'); // exact
await expect(locator).toContainText('Application'); // substring
await expect(locator).toHaveText(/test/i); // regex
// Custom timeout
await expect(locator).toBeVisible({ timeout: 15_000 });When locators are unknown, dump the full accessibility tree:
test('inspect screen', async ({ screen, device }) => {
await device.launchApp('com.your.app');
const tree = await screen.viewTree();
function printNodes(nodes: typeof tree, depth = 0) {
const indent = ' '.repeat(depth);
for (const node of nodes) {
console.log(
`${indent}type=${node.type}` +
(node.label ? ` label="${node.label}"` : '') +
(node.text ? ` text="${node.text}"` : '') +
(node.resourceId ? ` resourceId="${node.resourceId}"` : '') +
` visible=${node.isVisible}`
);
if (node.children?.length) printNodes(node.children, depth + 1);
}
}
printNodes(tree);
});Pick your locator from the output:
| Output field | Locator |
|---|---|
label="..." |
screen.getByLabel('...') |
text="..." |
screen.getByText('...') |
resourceId="..." |
screen.getByTestId('...') |
type="..." |
screen.getByType('...') |
Does the element have an accessibility label?
└─ YES → getByLabel('...') ✅ most stable
Does it have a resource ID / test ID?
└─ YES → getByTestId('...') ✅ reliable on Android
Is it a button or input and you want cross-platform code?
└─ YES → getByRole('button' / 'textfield', { name: '...' })
Is the visible text unique on screen?
└─ YES → getByText('...')
None of the above?
└─ getByType('...').nth(n) ⚠️ last resort only
Priority:
getByLabel>getByTestId>getByRole>getByText>getByType