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.
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-expoand React Native Testing Library in an Expo project and write a component test withuserEvent - write a unit test and a widget test in Flutter with
flutter_testand run them withflutter test - build a device matrix and pick a test channel: TestFlight or Google Play internal testing
Know beforehand
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
- How testing a mobile app differs from the web
- Test levels: what runs where
- React Native and Expo: Jest and React Native Testing Library
- Flutter: unit and widget tests with flutter_test
- E2E: Maestro, Detox and integration_test
- Manual testing: a device matrix and test builds
- Pre-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()fromflutter_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.
npx create-expo-app@latest app --template blank-typescript
cd app
npx expo install jest-expo jest @types/jest @testing-library/react-native --devnpx expo install picks versions compatible with the SDK{
"scripts": {
"test": "jest --watchAll",
"test:ci": "jest"
},
"jest": {
"preset": "jest-expo"
}
}test runs in watch mode, test:ci runs once for CI{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"types": [
"jest"
]
}
}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.
// Нормализует казахстанский номер к виду +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;
}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>
);
}alert role and a button. testID will be useful for E2Eimport { 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();
});
});jest-expo preset picks up files with the -test suffix in __tests__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();
});
});userEventNote 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.
$ 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 totalCheck 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.
/// Нормализует казахстанский номер к виду +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;
}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('Перезвоните мне')),
],
);
}
}errorTextimport '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);
});
}test()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);
});
}MaterialApp and Scaffold, as in a real appAfter 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.
$ flutter analyze
No issues found!
$ flutter test
00:01 +4: All tests passed!ghcr.io/cirruslabs/flutter:stable container (Flutter 3.44): analysis and 4 testsE2E: 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.
appId: kz.example.callback
---
- launchApp:
clearState: true
- tapOn:
id: "phone-input"
- inputText: "701"
- tapOn:
id: "submit-button"
- assertVisible: "Проверьте номер телефона"maestro test .maestro/callback.yaml while an emulator with the build installed is running; appId is your app's identifierIn 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
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(orflutter 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.