How to buildOctober 11, 20269 min read
Flutter + DartStep 5 of 6

Testing mobile apps

In short

A mobile app is tested at three levels: unit tests for logic, component tests (widget tests in Flutter) for screens, and E2E tests on an emulator or a device for key flows. In React Native and Expo the first two levels are covered by Jest and React Native Testing Library, in Flutter by the flutter_test package; for E2E teams use Maestro, Detox or integration_test. Automated tests don't replace manual checks on real devices and test builds distributed through TestFlight and Google Play internal testing.

FundamentalsLevel: IntermediateMobile Testing

After this you will be able to

  • split the checks of a mobile app into levels: unit, component, E2E and manual on devices
  • set up jest-expo and React Native Testing Library in an Expo project and write a component test with userEvent
  • write a unit test and a widget test in Flutter with flutter_test and run them with flutter test
  • build a device matrix and pick a test channel: TestFlight or Google Play internal testing

Verified with

Expo SDK 57React Native 0.86React 19.2jest-expo 57.0Jest 29.7@testing-library/react-native 14.1TypeScript 6.0Flutter 3.44 (Dart 3.12)Node.js 24 LTS (24.18)

Checked against docs: October 11, 2026

Contents
  1. 01How testing a mobile app differs from the web
  2. 02Test levels: what runs where
  3. 03React Native and Expo: Jest and React Native Testing Library
  4. 04Flutter: unit and widget tests with flutter_test
  5. 05E2E: Maestro, Detox and integration_test
  6. 06Manual testing: a device matrix and test builds
  7. 07Pre-release checklist and exercises

How testing a mobile app differs from the web

A website runs in a browser that updates itself, and a fix reaches users right after a deploy. A mobile app lives differently: it is built for two platforms, goes through store review, and a user may not update it for months. A bug that ships in a release costs more, because the fix has to go through a build and review again.

  • Devices: different screen sizes, notches, pixel densities, memory and old and new processors.
  • OS versions: Android and iOS show system dialogs, keyboards and permissions differently.
  • Network: mobile connections drop in elevators and the subway, and a screen has to survive a timeout and a retry.
  • Lifecycle: the app is backgrounded, the OS kills it to free memory, and the user comes back an hour later.
  • Permissions and native modules: camera, location and notifications only work in a real build, not in a Node.js test environment.

Hence the main principle: fast automated tests catch logic and screen bugs on the developer's machine, while anything that depends on hardware and the OS is checked on emulators and real devices. The general idea of spreading tests across levels is covered in the article on the testing pyramid; here we look at how it plays out in mobile development.

Test levels: what runs where

Platforms name the levels slightly differently, but the meaning is the same. In Flutter a component test is called a widget test; the Android docs split tests into local tests (on your machine) and instrumented tests (on a device or emulator).

Static analysis

What it checks
Types and common mistakes without running code
Where it runs
Machine, CI
React Native / Expo
TypeScript, ESLint
Flutter
flutter analyze

Unit

What it checks
A function or class: formatting, validation, calculations
Where it runs
Machine, CI
React Native / Expo
Jest
Flutter
test() from flutter_test

Component

What it checks
A screen or component: what is shown, what happens on press
Where it runs
Machine, CI (no device)
React Native / Expo
Jest + React Native Testing Library
Flutter
testWidgets() (widget test)

E2E

What it checks
A whole flow in the built app
Where it runs
Emulator, simulator, device, cloud device farm
React Native / Expo
Maestro, Detox
Flutter
integration_test, Maestro, Patrol

Manual

What it checks
Layout on real screens, gestures, system dialogs, feel
Where it runs
Real devices
React Native / Expo
TestFlight, Google Play internal testing
Flutter
Same

The Flutter docs sum up the trade-off in a simple table: unit tests give low confidence but are cheap and quick; widget tests give more confidence and are still quick; integration tests give the highest confidence but are slow and costly to maintain. Flutter recommends many unit and widget tests tracked by code coverage, plus enough integration tests to cover the important use cases. React Native advises covering vital flows with E2E: authentication, core features and payments.

React Native and Expo: Jest and React Native Testing Library

In an Expo project tests run on Jest with the jest-expo preset, which mocks the native parts of the Expo SDK so components can render in Node.js. Screens are rendered and queried with React Native Testing Library (RNTL). The old react-test-renderer is deprecated and, according to the Expo docs, doesn't support React 19 and above, so remove it if your project still has it.

bash
npx create-expo-app@latest app --template blank-typescript
cd app
npx expo install jest-expo jest @types/jest @testing-library/react-native --dev
A new Expo project and test dependencies. npx expo install picks versions compatible with the SDK
package.json
{
  "scripts": {
    "test": "jest --watchAll",
    "test:ci": "jest"
  },
  "jest": {
    "preset": "jest-expo"
  }
}
Fragment: the preset and scripts. test runs in watch mode, test:ci runs once for CI
tsconfig.json
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "types": [
      "jest"
    ]
  }
}
Jest types for TypeScript, as the Expo docs recommend

The example is a "call me back" form, typical for service-business apps. Phone normalization lives in a separate function: a fast unit test checks it, and the form is only responsible for the UI.

lib/phone.ts
// Нормализует казахстанский номер к виду +7XXXXXXXXXX или возвращает null
export function normalizePhone(input: string): string | null {
  const digits = input.replace(/\D/g, "");
  if (digits.length === 11 && (digits.startsWith("7") || digits.startsWith("8"))) {
    return `+7${digits.slice(1)}`;
  }
  if (digits.length === 10) {
    return `+7${digits}`;
  }
  return null;
}
A pure function is the ideal candidate for a unit test
components/CallbackForm.tsx
import { useState } from "react";
import { Pressable, Text, TextInput, View } from "react-native";
import { normalizePhone } from "../lib/phone";

type Props = {
  onSubmit: (phone: string) => void;
};

export function CallbackForm({ onSubmit }: Props) {
  const [value, setValue] = useState("");
  const [error, setError] = useState<string | null>(null);

  function handlePress() {
    const phone = normalizePhone(value);
    if (!phone) {
      setError("Проверьте номер телефона");
      return;
    }
    setError(null);
    onSubmit(phone);
  }

  return (
    <View>
      <TextInput
        testID="phone-input"
        accessibilityLabel="Телефон"
        keyboardType="phone-pad"
        value={value}
        onChangeText={setValue}
      />
      {error ? <Text role="alert">{error}</Text> : null}
      <Pressable testID="submit-button" role="button" onPress={handlePress}>
        <Text>Перезвоните мне</Text>
      </Pressable>
    </View>
  );
}
The component: an input, error text with the alert role and a button. testID will be useful for E2E
__tests__/phone-test.ts
import { normalizePhone } from "../lib/phone";

describe("normalizePhone", () => {
  it("приводит разные записи к +7XXXXXXXXXX", () => {
    expect(normalizePhone("8 (701) 123-45-67")).toBe("+77011234567");
    expect(normalizePhone("+7 701 123 45 67")).toBe("+77011234567");
    expect(normalizePhone("7011234567")).toBe("+77011234567");
  });

  it("возвращает null для неполного номера", () => {
    expect(normalizePhone("701 123")).toBeNull();
  });
});
A unit test. The jest-expo preset picks up files with the -test suffix in __tests__
__tests__/callback-form-test.tsx
import { render, screen, userEvent } from "@testing-library/react-native";
import { CallbackForm } from "../components/CallbackForm";

describe("CallbackForm", () => {
  it("отправляет нормализованный номер", async () => {
    const onSubmit = jest.fn();
    const user = userEvent.setup();
    await render(<CallbackForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText("Телефон"), "8 701 123 45 67");
    await user.press(screen.getByRole("button", { name: "Перезвоните мне" }));

    expect(onSubmit).toHaveBeenCalledWith("+77011234567");
    expect(screen.queryByRole("alert")).toBeNull();
  });

  it("показывает ошибку и не отправляет неполный номер", async () => {
    const onSubmit = jest.fn();
    const user = userEvent.setup();
    await render(<CallbackForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText("Телефон"), "701");
    await user.press(screen.getByRole("button", { name: "Перезвоните мне" }));

    expect(screen.getByRole("alert")).toHaveTextContent("Проверьте номер телефона");
    expect(onSubmit).not.toHaveBeenCalled();
  });
});
A component test: find elements by label and role, interact through userEvent

Note the await before render. In RNTL 14 render, fireEvent and act became async, and the library needs React 19, React Native 0.78+ and the test-renderer package (npm 7+ installs it automatically as a peer dependency). Matchers such as toHaveTextContent are added automatically when you import from @testing-library/react-native. userEvent simulates real interaction: press goes through the press events, and type through a press on the field, per-character input and blur.

terminal
$ npx jest --verbose
PASS __tests__/phone-test.ts
  normalizePhone
    √ приводит разные записи к +7XXXXXXXXXX (6 ms)
    √ возвращает null для неполного номера

PASS __tests__/callback-form-test.tsx
  CallbackForm
    √ отправляет нормализованный номер (1017 ms)
    √ показывает ошибку и не отправляет неполный номер (245 ms)

Test Suites: 2 passed, 2 total
Tests:       4 passed, 4 total
A real run in a temporary Expo SDK 57 project

Check that the tests aren't "always green": in normalizePhone, change the result for 11 digits to +${digits}, and both tests with a number starting with 8 fail. If a test doesn't fail on a deliberate bug, it checks nothing.

Flutter: unit and widget tests with flutter_test

In Flutter the testing library ships with the SDK: flutter_test is already listed in dev_dependencies of a new project. A unit test is written with test(), a widget test with testWidgets(): it creates a WidgetTester that builds the widget, enters text and taps buttons without a device. How to create a project and how Dart works is covered in the lesson on getting started with Flutter and Dart; here we use the same form as in the React Native section so you can compare the approaches.

lib/phone.dart
/// Нормализует казахстанский номер к виду +7XXXXXXXXXX или возвращает null.
String? normalizePhone(String input) {
  final digits = input.replaceAll(RegExp(r'\D'), '');
  if (digits.length == 11 &&
      (digits.startsWith('7') || digits.startsWith('8'))) {
    return '+7${digits.substring(1)}';
  }
  if (digits.length == 10) {
    return '+7$digits';
  }
  return null;
}
The same normalization logic in Dart
lib/callback_form.dart
import 'package:flutter/material.dart';

import 'phone.dart';

class CallbackForm extends StatefulWidget {
  const CallbackForm({super.key, required this.onSubmit});

  final ValueChanged<String> onSubmit;

  @override
  State<CallbackForm> createState() => _CallbackFormState();
}

class _CallbackFormState extends State<CallbackForm> {
  final _controller = TextEditingController();
  String? _error;

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _submit() {
    final phone = normalizePhone(_controller.text);
    setState(() => _error = phone == null ? 'Проверьте номер телефона' : null);
    if (phone != null) widget.onSubmit(phone);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          controller: _controller,
          keyboardType: TextInputType.phone,
          decoration: InputDecoration(labelText: 'Телефон', errorText: _error),
        ),
        FilledButton(onPressed: _submit, child: const Text('Перезвоните мне')),
      ],
    );
  }
}
The form widget: the error is shown through the field's errorText
test/phone_test.dart
import 'package:callback_app/phone.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  test('приводит разные записи к +7XXXXXXXXXX', () {
    expect(normalizePhone('8 (701) 123-45-67'), '+77011234567');
    expect(normalizePhone('7011234567'), '+77011234567');
  });

  test('возвращает null для неполного номера', () {
    expect(normalizePhone('701 123'), isNull);
  });
}
A unit test with test()
test/callback_form_test.dart
import 'package:callback_app/callback_form.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  Future<List<String>> pumpForm(WidgetTester tester) async {
    final sent = <String>[];
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(body: CallbackForm(onSubmit: sent.add)),
      ),
    );
    return sent;
  }

  testWidgets('отправляет нормализованный номер', (tester) async {
    final sent = await pumpForm(tester);

    await tester.enterText(find.byType(TextField), '8 701 123 45 67');
    await tester.tap(find.text('Перезвоните мне'));
    await tester.pump();

    expect(sent, ['+77011234567']);
    expect(find.text('Проверьте номер телефона'), findsNothing);
  });

  testWidgets('показывает ошибку для неполного номера', (tester) async {
    final sent = await pumpForm(tester);

    await tester.enterText(find.byType(TextField), '701');
    await tester.tap(find.text('Перезвоните мне'));
    await tester.pump();

    expect(sent, isEmpty);
    expect(find.text('Проверьте номер телефона'), findsOneWidget);
  });
}
A widget test: the widget is wrapped in MaterialApp and Scaffold, as in a real app

After tap you need tester.pump(): it schedules a frame and rebuilds the widget, otherwise the error text isn't in the tree yet. If the screen runs an animation, use pumpAndSettle(), which keeps pumping frames until animations finish. Find widgets with find.text, find.byType or find.byKey, and assert with the findsOneWidget, findsNothing and findsNWidgets matchers.

terminal
$ flutter analyze
No issues found!
$ flutter test
00:01 +4: All tests passed!
A run in the ghcr.io/cirruslabs/flutter:stable container (Flutter 3.44): analysis and 4 tests

E2E: Maestro, Detox and integration_test

An E2E (end-to-end) test launches the built app on an emulator, simulator or device and walks through a flow the way a user would. The React Native docs advise building the app in the release configuration for such tests. This is the slowest and flakiest level, so E2E covers only the main flows.

Maestro

Platforms
Android, iOS, React Native, Flutter, web
How it works
YAML flows; works through the accessibility layer, nothing is installed into the app
When to choose it
You want one set of flows for both platforms and a quick start; supported in EAS Workflows

Detox

Platforms
React Native
How it works
JavaScript tests; a gray-box approach: the framework sees the app's internal state and waits for network requests and animations, which makes tests more predictable
When to choose it
A React Native team wants to write E2E in the same language and wait for the app's async work without manual sleeps

integration_test

Platforms
Flutter
How it works
A package from the Flutter SDK; tests use the same API as widget tests but run on a device
When to choose it
A Flutter project; note that the package can't tap native permission dialogs or notifications, and the Flutter docs suggest Patrol for that

A Maestro flow for our form. In React Native, Maestro sees the testID prop as an id, and that selector doesn't break when you change the text or translate the UI.

.maestro/callback.yaml
appId: kz.example.callback
---
- launchApp:
    clearState: true
- tapOn:
    id: "phone-input"
- inputText: "701"
- tapOn:
    id: "submit-button"
- assertVisible: "Проверьте номер телефона"
A flow that checks the error. Run it with maestro test .maestro/callback.yaml while an emulator with the build installed is running; appId is your app's identifier

In Flutter an E2E test goes into the integration_test/ folder, calls IntegrationTestWidgetsFlutterBinding.ensureInitialized() first and runs with flutter test integration_test/app_test.dart while a device is connected. The same tests can run on the Firebase Test Lab device farm; the Flutter docs describe the steps for Android and iOS.

Manual testing: a device matrix and test builds

Automated tests won't notice that a button slipped under the screen notch, the keyboard covers an input, or the permission dialog on an old Android looks different. So before a release the app is walked through by hand on a set of devices, a matrix. Build it from your audience analytics, or, if you don't have them yet, from the edge cases.

OS version

What to include
The minimum supported and the current one for iOS and Android
What it catches
Different behavior of permissions, notifications, system dialogs

Screen size

What to include
A small phone, a large phone, a tablet (if supported)
What it catches
Truncated text, buttons off the edge, scrolling

Performance

What to include
A budget Android with little memory
What it catches
Jank, the app being killed in the background

Network

What to include
Wi-Fi, slow mobile data, airplane mode
What it catches
Endless spinners, lost form data

Settings

What to include
Large text, dark mode, a different system language
What it catches
Broken layout, unreadable colors
An example matrix: substitute models and versions from your own analytics

Test builds are distributed through the stores' official channels, so testers install the same app that will go to release.

TestFlight (App Store Connect)

  • Up to 100 internal testers from your team with App Store Connect roles
  • Up to 10,000 external testers by email or a public link
  • The first build for external testers goes through App Review for TestFlight
  • Each tester can install the build on up to 30 devices

Testing in Google Play Console

  • Internal testing: up to 100 testers per app, the build is available within minutes
  • Closed testing: email lists or Google Groups
  • Open testing: anyone can join
  • New personal accounts (created after November 13, 2023) must run a closed test before production: at least 12 testers opted in for 14 continuous days

Pre-release checklist and exercises

  • tsc --noEmit (or flutter analyze) and the linter pass without errors
  • Unit and component tests are green in CI, not only locally
  • E2E flows for the main paths (sign-in, the core action, payment) passed on the built app
  • The build was walked through the device matrix by hand: minimum and current OS, a small screen, a weak Android
  • Offline and slow-network scenarios, backgrounding and returning to the app are checked
  • System permissions: denial, asking again, going to settings
  • The test build went out through TestFlight and Google Play internal testing, and feedback is triaged
How this was verified2 · passed2 · not runchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.