Back to Blog
Development Tips10 min read

Mobile App Testing Complete Guide 2026: Strategies, Tools & Best Practices

Complete guide to mobile app testing. Learn unit testing, integration testing, UI testing, and automated testing for iOS, Android, and cross-platform apps.

Hevcode Team
January 23, 2026

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.

Related Articles

Tags:Mobile TestingQAUnit TestingUI TestingMobile Development

Need help with your project?

We've helped 534+ clients build successful apps. Let's discuss yours.

Ready to Build Your App?

534+ projects delivered • 4.9★ rating • 6+ years experience

Let's discuss your project — no obligations, just a straightforward conversation.