Testing is crucial for delivering quality mobile apps. This guide covers testing strategies, tools, and best practices for iOS, Android, React Native, and Flutter applications.
Testing Pyramid
/\
/ \ E2E Tests (10%)
/────\ UI Tests, User Flows
/ \
/────────\ Integration Tests (20%)
/ \ API, Services, Components
/────────────\
/ \ Unit Tests (70%)
/────────────────\ Functions, Classes, Utils
Types of Mobile Testing
| Type | Purpose | Speed | Confidence |
|---|---|---|---|
| Unit | Test individual functions | Fast | Low-Medium |
| Integration | Test component interactions | Medium | Medium |
| UI/E2E | Test user flows | Slow | High |
| Performance | Test speed & memory | Slow | Medium |
| Security | Test vulnerabilities | Medium | High |
Unit Testing
React Native with Jest
// utils/calculator.js
export const add = (a, b) => a + b;
export const multiply = (a, b) => a * b;
export const formatCurrency = (amount, currency = 'USD') => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
};
// utils/__tests__/calculator.test.js
import { add, multiply, formatCurrency } from '../calculator';
describe('Calculator', () => {
describe('add', () => {
it('adds two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('adds negative numbers', () => {
expect(add(-1, -1)).toBe(-2);
});
it('adds zero', () => {
expect(add(5, 0)).toBe(5);
});
});
describe('formatCurrency', () => {
it('formats USD by default', () => {
expect(formatCurrency(1000)).toBe('$1,000.00');
});
it('formats EUR', () => {
expect(formatCurrency(1000, 'EUR')).toContain('€');
});
});
});
Testing Async Functions
// services/api.js
export const fetchUser = async (id) => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error('User not found');
}
return response.json();
};
// services/__tests__/api.test.js
import { fetchUser } from '../api';
// Mock fetch globally
global.fetch = jest.fn();
describe('API Service', () => {
beforeEach(() => {
fetch.mockClear();
});
it('fetches user successfully', async () => {
const mockUser = { id: 1, name: 'John' };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const user = await fetchUser(1);
expect(user).toEqual(mockUser);
expect(fetch).toHaveBeenCalledWith('/api/users/1');
});
it('throws error for non-existent user', async () => {
fetch.mockResolvedValueOnce({ ok: false });
await expect(fetchUser(999)).rejects.toThrow('User not found');
});
});
Flutter Unit Testing
// lib/utils/validator.dart
class Validator {
static bool isValidEmail(String email) {
final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
return regex.hasMatch(email);
}
static bool isValidPassword(String password) {
return password.length >= 8 &&
password.contains(RegExp(r'[A-Z]')) &&
password.contains(RegExp(r'[0-9]'));
}
}
// test/utils/validator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/utils/validator.dart';
void main() {
group('Validator', () {
group('isValidEmail', () {
test('returns true for valid email', () {
expect(Validator.isValidEmail('test@example.com'), isTrue);
expect(Validator.isValidEmail('user.name@domain.org'), isTrue);
});
test('returns false for invalid email', () {
expect(Validator.isValidEmail('invalid'), isFalse);
expect(Validator.isValidEmail('no@domain'), isFalse);
expect(Validator.isValidEmail('@domain.com'), isFalse);
});
});
group('isValidPassword', () {
test('returns true for valid password', () {
expect(Validator.isValidPassword('Password1'), isTrue);
expect(Validator.isValidPassword('SecurePass123'), isTrue);
});
test('returns false for weak password', () {
expect(Validator.isValidPassword('short'), isFalse);
expect(Validator.isValidPassword('nouppercase1'), isFalse);
expect(Validator.isValidPassword('NoNumbers'), isFalse);
});
});
});
}
iOS Unit Testing (Swift)
// Calculator.swift
struct Calculator {
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
func divide(_ a: Double, _ b: Double) throws -> Double {
guard b != 0 else {
throw CalculatorError.divisionByZero
}
return a / b
}
}
enum CalculatorError: Error {
case divisionByZero
}
// CalculatorTests.swift
import XCTest
@testable import MyApp
class CalculatorTests: XCTestCase {
var calculator: Calculator!
override func setUp() {
super.setUp()
calculator = Calculator()
}
override func tearDown() {
calculator = nil
super.tearDown()
}
func testAdd() {
XCTAssertEqual(calculator.add(2, 3), 5)
XCTAssertEqual(calculator.add(-1, 1), 0)
}
func testDivide() throws {
let result = try calculator.divide(10, 2)
XCTAssertEqual(result, 5.0, accuracy: 0.001)
}
func testDivideByZero() {
XCTAssertThrowsError(try calculator.divide(10, 0)) { error in
XCTAssertEqual(error as? CalculatorError, .divisionByZero)
}
}
}
Component Testing
React Native Component Testing
// components/Button.js
import React from 'react';
import { TouchableOpacity, Text, StyleSheet } from 'react-native';
export const Button = ({ title, onPress, disabled, testID }) => (
<TouchableOpacity
style={[styles.button, disabled && styles.disabled]}
onPress={onPress}
disabled={disabled}
testID={testID}
>
<Text style={styles.text}>{title}</Text>
</TouchableOpacity>
);
// components/__tests__/Button.test.js
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { Button } from '../Button';
describe('Button', () => {
it('renders correctly', () => {
const { getByText } = render(<Button title="Click Me" />);
expect(getByText('Click Me')).toBeTruthy();
});
it('calls onPress when pressed', () => {
const onPress = jest.fn();
const { getByText } = render(
<Button title="Click Me" onPress={onPress} />
);
fireEvent.press(getByText('Click Me'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('does not call onPress when disabled', () => {
const onPress = jest.fn();
const { getByText } = render(
<Button title="Click Me" onPress={onPress} disabled />
);
fireEvent.press(getByText('Click Me'));
expect(onPress).not.toHaveBeenCalled();
});
it('applies disabled styles', () => {
const { getByTestId } = render(
<Button title="Click Me" disabled testID="button" />
);
const button = getByTestId('button');
// Check style is applied
expect(button.props.style).toContainEqual(
expect.objectContaining({ opacity: 0.5 })
);
});
});
Flutter Widget Testing
// lib/widgets/counter_widget.dart
import 'package:flutter/material.dart';
class CounterWidget extends StatefulWidget {
final int initialValue;
const CounterWidget({super.key, this.initialValue = 0});
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
late int _count;
@override
void initState() {
super.initState();
_count = widget.initialValue;
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Count: $_count', key: const Key('countText')),
ElevatedButton(
key: const Key('incrementButton'),
onPressed: () => setState(() => _count++),
child: const Text('Increment'),
),
ElevatedButton(
key: const Key('decrementButton'),
onPressed: () => setState(() => _count--),
child: const Text('Decrement'),
),
],
);
}
}
// test/widgets/counter_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/widgets/counter_widget.dart';
void main() {
testWidgets('CounterWidget displays initial value', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: CounterWidget(initialValue: 5)),
);
expect(find.text('Count: 5'), findsOneWidget);
});
testWidgets('increment button increases count', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: CounterWidget()),
);
expect(find.text('Count: 0'), findsOneWidget);
await tester.tap(find.byKey(const Key('incrementButton')));
await tester.pump();
expect(find.text('Count: 1'), findsOneWidget);
});
testWidgets('decrement button decreases count', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: CounterWidget(initialValue: 5)),
);
await tester.tap(find.byKey(const Key('decrementButton')));
await tester.pump();
expect(find.text('Count: 4'), findsOneWidget);
});
}
Integration Testing
React Native Integration Test
// screens/__tests__/LoginScreen.test.js
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { LoginScreen } from '../LoginScreen';
import { AuthProvider } from '../../contexts/AuthContext';
import * as api from '../../services/api';
jest.mock('../../services/api');
const renderWithProviders = (component) => {
return render(
<AuthProvider>
{component}
</AuthProvider>
);
};
describe('LoginScreen', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('shows validation errors for empty fields', async () => {
const { getByText, getByTestId } = renderWithProviders(
<LoginScreen />
);
fireEvent.press(getByTestId('loginButton'));
await waitFor(() => {
expect(getByText('Email is required')).toBeTruthy();
expect(getByText('Password is required')).toBeTruthy();
});
});
it('calls login API with correct credentials', async () => {
api.login.mockResolvedValueOnce({ token: 'abc123', user: { id: 1 } });
const { getByTestId } = renderWithProviders(
<LoginScreen />
);
fireEvent.changeText(getByTestId('emailInput'), 'test@example.com');
fireEvent.changeText(getByTestId('passwordInput'), 'password123');
fireEvent.press(getByTestId('loginButton'));
await waitFor(() => {
expect(api.login).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
it('shows error message on login failure', async () => {
api.login.mockRejectedValueOnce(new Error('Invalid credentials'));
const { getByTestId, getByText } = renderWithProviders(
<LoginScreen />
);
fireEvent.changeText(getByTestId('emailInput'), 'test@example.com');
fireEvent.changeText(getByTestId('passwordInput'), 'wrong');
fireEvent.press(getByTestId('loginButton'));
await waitFor(() => {
expect(getByText('Invalid credentials')).toBeTruthy();
});
});
});
E2E Testing
Detox (React Native)
// e2e/login.e2e.js
describe('Login Flow', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('should show login screen', async () => {
await expect(element(by.id('loginScreen'))).toBeVisible();
await expect(element(by.id('emailInput'))).toBeVisible();
await expect(element(by.id('passwordInput'))).toBeVisible();
});
it('should show error for invalid credentials', async () => {
await element(by.id('emailInput')).typeText('invalid@test.com');
await element(by.id('passwordInput')).typeText('wrongpassword');
await element(by.id('loginButton')).tap();
await expect(element(by.text('Invalid credentials'))).toBeVisible();
});
it('should login successfully and navigate to home', async () => {
await element(by.id('emailInput')).typeText('test@example.com');
await element(by.id('passwordInput')).typeText('validpassword');
await element(by.id('loginButton')).tap();
await waitFor(element(by.id('homeScreen')))
.toBeVisible()
.withTimeout(5000);
await expect(element(by.text('Welcome'))).toBeVisible();
});
it('should logout and return to login screen', async () => {
// First login
await element(by.id('emailInput')).typeText('test@example.com');
await element(by.id('passwordInput')).typeText('validpassword');
await element(by.id('loginButton')).tap();
await waitFor(element(by.id('homeScreen'))).toBeVisible().withTimeout(5000);
// Then logout
await element(by.id('profileTab')).tap();
await element(by.id('logoutButton')).tap();
await expect(element(by.id('loginScreen'))).toBeVisible();
});
});
Flutter Integration Testing
// integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:myapp/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Login Flow', () {
testWidgets('complete login flow', (tester) async {
app.main();
await tester.pumpAndSettle();
// Verify login screen is displayed
expect(find.byKey(const Key('loginScreen')), findsOneWidget);
// Enter credentials
await tester.enterText(
find.byKey(const Key('emailInput')),
'test@example.com',
);
await tester.enterText(
find.byKey(const Key('passwordInput')),
'password123',
);
// Tap login button
await tester.tap(find.byKey(const Key('loginButton')));
await tester.pumpAndSettle();
// Verify navigation to home screen
expect(find.byKey(const Key('homeScreen')), findsOneWidget);
expect(find.text('Welcome'), findsOneWidget);
});
testWidgets('shows error for invalid credentials', (tester) async {
app.main();
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('emailInput')),
'wrong@example.com',
);
await tester.enterText(
find.byKey(const Key('passwordInput')),
'wrongpassword',
);
await tester.tap(find.byKey(const Key('loginButton')));
await tester.pumpAndSettle();
expect(find.text('Invalid credentials'), findsOneWidget);
});
});
}
Mocking and Test Doubles
Mocking APIs
// __mocks__/api.js
export const fetchUser = jest.fn();
export const createUser = jest.fn();
export const updateUser = jest.fn();
// In test file
import * as api from '../api';
jest.mock('../api');
beforeEach(() => {
api.fetchUser.mockClear();
});
it('handles API response', async () => {
api.fetchUser.mockResolvedValue({ id: 1, name: 'John' });
const result = await api.fetchUser(1);
expect(result.name).toBe('John');
});
Mocking Modules
// Mock AsyncStorage
jest.mock('@react-native-async-storage/async-storage', () => ({
setItem: jest.fn(),
getItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
}));
// Mock Navigation
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({
navigate: jest.fn(),
goBack: jest.fn(),
}),
useRoute: () => ({
params: {},
}),
}));
Test Coverage
Jest Coverage Configuration
// jest.config.js
module.exports = {
preset: 'react-native',
collectCoverage: true,
coverageDirectory: 'coverage',
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.test.{js,jsx,ts,tsx}',
'!src/**/index.{js,ts}',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
coverageReporters: ['text', 'lcov', 'html'],
};
Running Coverage
# React Native / Jest
npm test -- --coverage
# Flutter
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
Testing Best Practices
1. Test Behavior, Not Implementation
// Bad - tests implementation
it('sets state to loading', () => {
component.setState({ loading: true });
expect(component.state.loading).toBe(true);
});
// Good - tests behavior
it('shows loading indicator while fetching', async () => {
const { getByTestId, queryByTestId } = render(<UserProfile userId={1} />);
expect(getByTestId('loadingIndicator')).toBeTruthy();
await waitFor(() => {
expect(queryByTestId('loadingIndicator')).toBeNull();
expect(getByTestId('userInfo')).toBeTruthy();
});
});
2. Use Descriptive Test Names
// Bad
it('test 1', () => {});
it('works', () => {});
// Good
it('displays error message when email format is invalid', () => {});
it('navigates to home screen after successful login', () => {});
3. Keep Tests Independent
// Bad - depends on previous test
let user;
it('creates user', () => {
user = createUser({ name: 'John' });
});
it('updates user', () => {
updateUser(user.id, { name: 'Jane' }); // Fails if first test fails
});
// Good - independent tests
describe('User operations', () => {
let user;
beforeEach(() => {
user = createUser({ name: 'John' });
});
it('creates user', () => {
expect(user.name).toBe('John');
});
it('updates user', () => {
const updated = updateUser(user.id, { name: 'Jane' });
expect(updated.name).toBe('Jane');
});
});
CI/CD Integration
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
e2e:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Build app
run: npm run build:e2e
- name: Run Detox tests
run: npm run test:e2e
Conclusion
Comprehensive testing ensures app quality and enables confident releases. Start with unit tests for critical business logic, add component tests for UI, and use E2E tests for critical user flows. Aim for 80%+ coverage on critical paths.
Need help setting up testing? Contact Hevcode for professional mobile app development with comprehensive testing strategies.