Resource library

QA How-To

Test Mobile Dynamic Type Layouts

Learn to test mobile dynamic type layouts with Appium, semantic geometry checks, screenshots, and reliable Android and iOS accessibility text automation.

20 min read | 2,163 words

TL;DR

To test mobile dynamic type layouts, set the OS text size, restart the app, and assert that content remains visible, complete, ordered, non-overlapping, and actionable. Save screenshots for diagnosis and always restore device state.

Key Takeaways

  • Run the same semantic layout checks at default, large, and accessibility-large text sizes.
  • Restart the app after changing the operating system text preference.
  • Assert visibility, containment, overlap, order, and complete labels instead of fixed coordinates.
  • Keep platform setup in helpers while sharing layout expectations.
  • Save screenshots as evidence, but do not use pixels as the only oracle.
  • Reset every device after the suite.

To test mobile dynamic type layouts, change the operating system text size, relaunch the app, and verify that important content remains visible, readable, ordered, and actionable. A passing default-size test says little about the same layout at an accessibility size.

This tutorial builds an Appium and TypeScript test for Android and iOS. It is part of the Mobile Accessibility Automation Complete Guide, which connects text scaling with screen-reader, touch-target, contrast, and release-gate coverage.

You will use semantic assertions instead of fragile pixel snapshots. Element rectangles expose clipping and collisions, accessibility attributes reveal lost meaning, and screenshots preserve evidence for review.

What You Will Build

You will build a WebdriverIO project that:

  • Sets Android font scale with ADB and iOS simulator content size with simctl.
  • Runs one account-card test at default, large, and accessibility-large sizes.
  • Checks the heading, description, status, and action.
  • Detects overlap, clipping, reversed order, and suspicious truncation.
  • Saves a screenshot for every platform and scale.
  • Restores the device preference after the suite.

The sample expects accessibility IDs named account-card, account-title, account-description, account-status, and account-action. Replace them with IDs from your app. Stable IDs survive localization and text wrapping better than visible strings or coordinates.

Prerequisites

Use Node.js 22 LTS or a supported newer LTS, Appium 3, WebdriverIO 9, and TypeScript 5. Use an Android emulator with ADB access and a booted iOS simulator. Physical iOS devices do not offer the same practical command-line control of the user's text-size preference.

mkdir dynamic-type-tests && cd dynamic-type-tests
npm init -y
npm install --save-dev @wdio/cli@9 @wdio/local-runner@9 @wdio/mocha-framework@9 @wdio/spec-reporter@9 webdriverio@9 typescript@5 tsx@4 @types/node@22
npm install --global appium@3
appium driver install uiautomator2
appium driver install xcuitest
mkdir -p test/specs test/support artifacts

You also need Android platform tools, Xcode, an Android app build, and an iOS simulator app build. Confirm the environment:

node --version
appium --version
adb devices
xcrun simctl list devices booted
appium driver list --installed

Use a dedicated emulator or simulator. These tests mutate user preferences, so parallel workers must not share a device. Store app paths in environment variables and never commit signing credentials.

Verification: Confirm Appium reports major version 3, both drivers appear in the installed list, the intended Android device is available, and the target iOS simulator is booted.

Step 1: Configure Appium for Both Platforms

Create wdio.conf.ts:

import type { Options } from '@wdio/types'

const platform = process.env.MOBILE_PLATFORM ?? 'android'
const android = platform === 'android'
const app = android ? process.env.ANDROID_APP : process.env.IOS_APP
if (!app) throw new Error(android ? 'ANDROID_APP is required' : 'IOS_APP is required')

export const config: Options.Testrunner = {
  runner: 'local',
  framework: 'mocha',
  specs: ['./test/specs/**/*.ts'],
  maxInstances: 1,
  logLevel: 'info',
  hostname: '127.0.0.1',
  port: 4723,
  path: '/',
  capabilities: [{
    platformName: android ? 'Android' : 'iOS',
    'appium:automationName': android ? 'UiAutomator2' : 'XCUITest',
    'appium:app': app,
    'appium:deviceName': android ? 'Android Emulator' : 'iPhone Simulator',
    'appium:noReset': true,
    ...(android ? { 'appium:autoGrantPermissions': true } :
      { 'appium:udid': process.env.IOS_UDID })
  }],
  reporters: ['spec'],
  mochaOpts: { timeout: 120000 },
  beforeTest: async () => browser.setTimeout({ implicit: 0 })
}

maxInstances: 1 prevents preference changes from racing. noReset avoids reinstalling the app for every case, but the test will terminate and activate it after each change. The capabilities use W3C Appium prefixes and Appium 3's root path.

Keep grid host, port, and path configurable if your environment differs. Do not clone the entire file for each platform. One conditional configuration makes capability drift obvious during review.

Verification: Start appium in another terminal. Run MOBILE_PLATFORM=android ANDROID_APP=/absolute/app.apk npx wdio run wdio.conf.ts. Session creation should succeed even though no test exists. Repeat with the iOS variables.

Step 2: Define Representative Text-Size Cases

Create test/support/textScale.ts:

import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
const run = promisify(execFile)

export type Platform = 'android' | 'ios'
export type ScaleCase = { name: string; android: string; ios: string }

export const scaleCases: ScaleCase[] = [
  { name: 'default', android: '1.0', ios: 'UICTContentSizeCategoryL' },
  { name: 'large', android: '1.3', ios: 'UICTContentSizeCategoryXXXL' },
  { name: 'accessibility-large', android: '2.0',
    ios: 'UICTContentSizeCategoryAccessibilityXXXL' }
]

export async function setTextScale(platform: Platform, scale: ScaleCase) {
  if (platform === 'android') {
    await run('adb', ['shell', 'settings', 'put', 'system',
      'font_scale', scale.android])
    return
  }
  const udid = process.env.IOS_UDID
  if (!udid) throw new Error('IOS_UDID is required')
  await run('xcrun', ['simctl', 'spawn', udid, 'defaults', 'write',
    '-g', 'UIContentSizeCategory', scale.ios])
}

export async function resetTextScale(platform: Platform) {
  if (platform === 'android') {
    await run('adb', ['shell', 'settings', 'delete', 'system', 'font_scale'])
    return
  }
  const udid = process.env.IOS_UDID
  if (!udid) throw new Error('IOS_UDID is required')
  await run('xcrun', ['simctl', 'spawn', udid, 'defaults', 'delete',
    '-g', 'UIContentSizeCategory']).catch(() => undefined)
}

Use execFile, not a shell string. Its argument array avoids shell interpretation. Android scale values are deliberate test cases, not a claim that every vendor exposes identical named presets.

Case Android iOS category Purpose
Default 1.0 Large Baseline
Large 1.3 XXX Large Common enlargement
Accessibility large 2.0 Accessibility XXX Large Severe reflow pressure

A three-case matrix is suitable for pull requests. Expand it nightly across supported devices, locales, and OS versions. The product's documented maximum supported size should determine the final gate.

Verification: Run npx tsx -e "import {scaleCases} from './test/support/textScale.ts'; console.log(scaleCases)". Confirm three cases print. On a disposable emulator, invoke the Android helper and verify adb shell settings get system font_scale.

Step 3: Restart the App After Every Change

Create test/support/app.ts:

export async function restartApp(platform: 'android' | 'ios') {
  const appId = platform === 'android'
    ? process.env.ANDROID_APP_ID
    : process.env.IOS_BUNDLE_ID
  if (!appId) throw new Error('Platform app identifier is required')

  await browser.terminateApp(appId)
  await browser.activateApp(appId)
  await $('~account-card').waitForDisplayed({ timeout: 20000 })
}

A preference write does not guarantee that an existing activity or scene immediately rebuilds. Restarting creates a consistent lifecycle and makes the app read the new configuration. A stable element wait is better than a fixed sleep, which is slow locally and unreliable on CI.

Some apps are required to reflow while remaining foregrounded. Test that behavior separately. This tutorial deliberately tests the stable post-restart layout, which is the minimum acceptable outcome.

Pass package and bundle identifiers explicitly because an app file path is not a runtime identifier. Do not use a generic back command or coordinate tap to relaunch.

Verification: Set the relevant app ID, call restartApp from a temporary spec, and watch the app terminate and reopen. The helper should finish only when the account card appears.

Step 4: Create Reusable Geometry Assertions

Create test/support/layout.ts:

import type { ChainablePromiseElement } from 'webdriverio'
type El = ChainablePromiseElement
type Rect = { x: number; y: number; width: number; height: number }
const right = (r: Rect) => r.x + r.width
const bottom = (r: Rect) => r.y + r.height

export const intersects = (a: Rect, b: Rect) =>
  a.x < right(b) && right(a) > b.x &&
  a.y < bottom(b) && bottom(a) > b.y

export async function expectInside(child: El, parent: El, label: string) {
  const [c, p] = await Promise.all([child.getRect(), parent.getRect()])
  if (c.x < p.x || c.y < p.y || right(c) > right(p) || bottom(c) > bottom(p))
    throw new Error(`${label} outside container: ${JSON.stringify({ c, p })}`)
}

export async function expectNoOverlap(aEl: El, bEl: El, labels: string) {
  const [a, b] = await Promise.all([aEl.getRect(), bEl.getRect()])
  if (intersects(a, b))
    throw new Error(`${labels} overlap: ${JSON.stringify({ a, b })}`)
}

export async function expectBelow(upper: El, lower: El, labels: string) {
  const [a, b] = await Promise.all([upper.getRect(), lower.getRect()])
  if (b.y < a.y) throw new Error(`${labels} have reversed order`)
}

These checks describe relationships that should survive wrapping and device-width changes. Exact coordinates are brittle. A heading may grow from one line to three and still be correct, so do not assert its baseline height.

Overlap is only wrong when elements should be separate. A badge intentionally over an avatar needs a product-specific rule. Likewise, containment assumes the accessibility rectangle represents visible content. Confirm that once with the platform hierarchy inspector.

Touching edges are not overlap in this helper. If the design requires spacing, add a minimum-gap assertion with a documented product token rather than quietly changing collision logic.

Verification: Import intersects with tsx. Two rectangles that share area should return true; rectangles that only touch at their edges should return false.

Step 5: Check Meaning and Truncation

Add to test/support/layout.ts:

export async function expectReadableText(
  element: El, expected: string, platform: 'android' | 'ios'
) {
  await element.waitForDisplayed({ timeout: 10000 })
  const text = (await element.getText()).trim()
  const name = platform === 'ios'
    ? String(await element.getAttribute('label') ?? '')
    : String(await element.getAttribute('content-desc') ?? '')
  const observed = `${text} ${name}`

  if (!observed.includes(expected))
    throw new Error(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(observed)}`)
  if (text.endsWith('...') || text.endsWith('…'))
    throw new Error(`Visible text appears truncated: ${text}`)
}

Pass expected copy from localized fixtures in a multilingual suite. Android and iOS expose text through different attributes, and widget implementations vary. Inspect the native hierarchy and adapt one mapping helper rather than scattering platform conditions across tests.

An ellipsis check is a signal, not a universal oracle. Some valid copy ends in dots, and some truncation is not exposed through getText(). Combine semantic content, geometry, screenshots, and an explicit requirement about whether descriptions may collapse.

For screen-reader-specific behavior, continue with Android TalkBack focus order testing in Appium and iOS VoiceOver label validation with XCUITest.

Verification: Pass a deliberately incorrect expected title. Confirm the helper fails with expected and observed values, then restore the correct fixture.

Step 6: Test Mobile Dynamic Type Layouts Across the Matrix

Create test/specs/dynamicType.e2e.ts:

import { mkdir } from 'node:fs/promises'
import { restartApp } from '../support/app.js'
import { expectBelow, expectInside, expectNoOverlap,
  expectReadableText } from '../support/layout.js'
import { resetTextScale, scaleCases, setTextScale,
  type Platform } from '../support/textScale.js'

const platform = (process.env.MOBILE_PLATFORM ?? 'android') as Platform

describe('account card dynamic type layout', () => {
  before(async () => mkdir('artifacts', { recursive: true }))

  after(async () => {
    await resetTextScale(platform)
    await restartApp(platform)
  })

  for (const scale of scaleCases) {
    it(`keeps content usable at ${scale.name}`, async () => {
      await setTextScale(platform, scale)
      await restartApp(platform)

      const card = await $('~account-card')
      const title = await $('~account-title')
      const description = await $('~account-description')
      const status = await $('~account-status')
      const action = await $('~account-action')

      for (const el of [card, title, description, status, action])
        await el.waitForDisplayed({ timeout: 10000 })

      await expectReadableText(title, 'Premium account', platform)
      await expectReadableText(action, 'Manage account', platform)
      await expectInside(title, card, 'title')
      await expectInside(description, card, 'description')
      await expectInside(status, card, 'status')
      await expectInside(action, card, 'action')
      await expectBelow(title, description, 'title and description')
      await expectNoOverlap(description, action, 'description and action')
      await expectNoOverlap(status, action, 'status and action')
      await expect(action).toBeEnabled()
      await browser.saveScreenshot(`./artifacts/${platform}-${scale.name}.png`)
    })
  }
})

Every case changes the setting, restarts the app, and locates elements again. Reusing element references across a restart creates stale references. The after hook restores the preference even when an assertion fails.

Run Android:

MOBILE_PLATFORM=android ANDROID_APP=/apps/app.apk ANDROID_APP_ID=com.example.app npx wdio run wdio.conf.ts

Run iOS:

MOBILE_PLATFORM=ios IOS_APP=/apps/App.app IOS_BUNDLE_ID=com.example.app IOS_UDID=<udid> npx wdio run wdio.conf.ts

Use real fixture phrases in place of the sample account copy. Keep the largest case even if it initially fails, because removing it hides product risk.

Verification: Expect three passing cases and three PNG files per platform. Open the accessibility-large image and confirm text wraps, the action remains reachable, and nothing is hidden.

Step 7: Gate the Checks in CI

Run each platform in an isolated job with one emulator or simulator. Preserve screenshots, the Appium log, platform version, device size, locale, and selected scale when a test fails.

name: dynamic-type
on: [pull_request]
jobs:
  android-dynamic-type:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm install --global appium@3
      - run: appium driver install uiautomator2
      - run: appium > appium.log 2>&1 &
      - run: npm run test:dynamic-type:android
      - if: always()
        uses: actions/upload-artifact@v4
        with:
          name: android-dynamic-type-artifacts
          path: |
            artifacts/
            appium.log

This fragment assumes your established provisioning boots the emulator and installs the app. Keep project-specific setup instead of inventing a runner action. Use an equivalent macOS job for XCUITest.

Do not make raw pixel equality the only gate. Font rasterization and system chrome create harmless differences. If you add visual comparison, pin device images, mask volatile regions, use a reviewed tolerance, and pair every visual failure with semantic evidence.

Large text can also crowd controls. Add automated Android touch target size checks so reflow does not create small or unreachable targets.

Verification: Temporarily constrain the card height. The test should fail with containment or overlap details, and the artifact screenshot should show the defect. Revert the constraint after proving the gate.

Step 8: Extend Coverage Beyond the Happy Path

The first matrix proves one stable card, but production defects often appear only when text scaling combines with another state. Add focused cases instead of multiplying every variable into an unmanageable Cartesian product. Select combinations from real layout risk: narrow screens, long localized text, validation messages, loading indicators, offline banners, keyboard-visible forms, and user-generated names.

Start with localization. Run the accessibility-large case in the longest supported locale for the target screen. Use production translation resources, not artificially repeated characters, because real words have representative break opportunities and glyph widths. Assert that the complete accessible meaning survives even when visible copy is allowed to wrap. If the product truncates optional secondary content by design, encode that exception explicitly and keep primary instructions complete.

Next, rotate only screens that officially support landscape:

it('reflows accessibility text in landscape', async () => {
  await setTextScale(platform, scaleCases[2])
  await restartApp(platform)
  await browser.setOrientation('LANDSCAPE')

  const description = await $('~account-description')
  const action = await $('~account-action')
  await description.waitForDisplayed({ timeout: 10000 })
  await expectNoOverlap(description, action, 'description and action')
  await expect(action).toBeEnabled()

  await browser.saveScreenshot(
    `./artifacts/${platform}-accessibility-large-landscape.png`
  )
  await browser.setOrientation('PORTRAIT')
})

Restore orientation inside a finally block in a shared production helper so a failed assertion cannot contaminate later cases. On screens with forms, open the keyboard and verify that the focused field and its error remain scrollable into view. On scroll containers, assert that the action can be scrolled to and activated rather than requiring it to be initially visible.

Keep the coverage model intentional. A useful risk table records screen, state, scale, width, locale, expected reflow, and owner. It prevents duplicate snapshots while exposing untested combinations. Run the small deterministic matrix on every pull request, then schedule broader combinations on stable device images.

Verification: Run the landscape case on a supported screen and confirm the screenshot uses the enlarged font, the action remains enabled, and the description does not overlap it. Force a long validation message and confirm the screen can scroll to both the full error and its related control.

Troubleshooting

Problem: Android retains the old scale -> Verify font_scale with ADB, then terminate and activate the app. Recreate the emulator if configuration state remains stale.

Problem: iOS ignores the category -> Confirm the UDID is booted, the command targets that UDID, and the app uses Dynamic Type compatible styles. Pin and record the Xcode runtime.

Problem: Elements become stale -> Locate every element after restartApp. Never store mobile element references across application termination.

Problem: getText() is empty -> Inspect the native hierarchy and read the attribute that carries the platform's accessible name. Centralize that mapping.

Problem: An intentional overlay fails -> Replace the generic overlap assertion with a product-specific invariant. Geometry must express the intended design.

Problem: Later suites inherit huge text -> Reset in teardown and CI cleanup, use dedicated devices, and recreate disposable devices after interrupted jobs.

Test Mobile Dynamic Type Layouts Best Practices

  • Test default, enlarged, and maximum-supported accessibility sizes.
  • Assert relationships and outcomes, not fixed coordinates or line counts.
  • Give important content stable accessibility identifiers.
  • Re-find elements after every relaunch.
  • Record platform, runtime, dimensions, locale, and scale.
  • Include at least one long-copy locale because localization compounds reflow pressure.
  • Test landscape and split screen when supported.
  • Do not shrink text inside a fixed container merely to pass.
  • Do not treat an accessible name as proof that visible text is readable.
  • Do not share a preference-mutating device between workers.

Interview Questions and Answers

Q: Why are screenshots alone insufficient?

Screenshots are useful evidence, but pixel differences are noisy and reviewers can miss clipping. Semantic checks directly assert visibility, containment, order, overlap, enabled state, and complete names.

Q: Why restart after changing size?

The preference may not rebuild an existing screen. A controlled restart gives every case the same lifecycle and forces the UI to read the configuration.

Q: Which sizes belong in pull requests?

Use default, one common enlarged size, and the largest supported accessibility size. Expand devices, orientations, locales, and platform versions nightly.

Q: How do you detect truncation?

Compare expected content with visible text and the accessible name, flag prohibited ellipses, inspect bounds, and retain a screenshot. No single attribute detects every native truncation mode.

Q: Why avoid exact coordinates?

Valid responsive layouts move when text wraps. Relative rules such as inside, below, and non-overlapping express the requirement without freezing one rendering.

Q: How do you prevent state leakage?

Use one device per worker, reset in teardown, relaunch the app, and recreate disposable devices after interrupted jobs.

Where To Go Next

Return to the complete mobile accessibility automation guide to place this check in a balanced strategy.

Continue with:

Add long localized copy, landscape, narrow devices, loading states, errors, and keyboard-visible states next. Prioritize fixed-height rows, horizontal button groups, and user-generated content.

Conclusion

A reliable way to test mobile dynamic type layouts is to control the OS setting, rebuild the screen, and assert stable semantic relationships. Visibility, complete meaning, containment, order, non-overlap, and operable actions matter more than matching one baseline image.

Start with one high-risk screen and the three-case matrix. Isolate the device, preserve evidence, and expand only after the helper is dependable.

Apply it consistently.

Interview Questions and Answers

How would you automate Dynamic Type layout testing?

Set representative OS text sizes on an isolated device and restart the app. Assert visibility, containment, order, non-overlap, full labels, and enabled actions. Attach screenshots and reset state.

Why not rely on pixel baselines?

Pixel comparisons are sensitive to rendering, while valid responsive layouts wrap differently. Use semantic geometry as the primary gate and visuals as supporting evidence.

Which failures are common at accessibility sizes?

Clipped labels, ellipsized instructions, overlapping buttons, content outside fixed cards, hidden actions, reversed order, and screens that cannot scroll are common.

How do you choose a scale matrix?

Choose sizes from supported requirements and risk. Pull requests get default, enlarged, and maximum-supported cases, while nightly runs expand devices and locales.

How do you keep these tests reliable in CI?

Give each worker an isolated device, serialize preference changes, verify the setting, restart the app, and reset in teardown. Retain logs and screenshots.

What is a strong responsive layout assertion?

Express an invariant, such as an action remaining inside its card without overlapping its description. Allow expected wrapping and movement.

Why use accessibility IDs?

Stable IDs locate semantic elements after reflow without depending on coordinates or localized copy. They also support accessible-name checks.

Frequently Asked Questions

How do I test mobile Dynamic Type layouts?

Change the OS text-size preference, restart the app, and repeat semantic assertions at several sizes. Check visibility, full labels, containment, order, overlap, and action state, then save screenshots.

Can Appium change Android font scale?

The test runner can call ADB to write the emulator's `font_scale` setting. Use a dedicated device, restart the app, verify the value, and delete it in teardown.

Can tests change iOS Dynamic Type size?

A simulator test can use `xcrun simctl spawn` to set the UIKit content-size category, then restart the app. Pin and validate the Xcode runtime used by CI.

Which text sizes should tests cover?

Cover default, a common enlarged size, and the largest product-supported size in pull requests. Expand intermediate sizes, devices, locales, and orientations nightly.

How can I detect overlapping elements?

Read both element rectangles and test whether their horizontal and vertical intervals intersect. Apply the rule only to elements that should remain separate.

Should Dynamic Type tests use snapshots?

Use screenshots as diagnostic evidence, not the only oracle. Semantic assertions are more stable for clipping, overlap, order, state, and complete labels.

Why does a layout fail only at large text?

Fixed heights, horizontal groups, absolute positioning, and long copy often fit only at baseline. Enlarged text exposes clipping, collision, and unreachable actions.

Related Guides