Testing Expo apps is an important part of ensuring code quality and application stability. Expo supports multiple testing frameworks and tools, with comprehensive solutions from unit testing to end-to-end testing.
Testing Framework Selection:
- Jest (Unit and Integration Testing)
Jest is Expo's default testing framework, suitable for unit tests and component tests.
Installation and Configuration:
bashnpm install --save-dev jest @testing-library/react-native @testing-library/jest-native
jest.config.js Configuration:
javascriptmodule.exports = { preset: 'jest-expo', setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect'], transformIgnorePatterns: [ 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)' ], testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'], };
Unit Test Example:
typescript// __tests__/utils.test.ts import { formatDate, calculateTotal } from '../utils'; describe('Utils', () => { describe('formatDate', () => { it('should format date correctly', () => { const date = new Date('2024-01-15'); expect(formatDate(date)).toBe('2024-01-15'); }); it('should handle null date', () => { expect(formatDate(null)).toBe(''); }); }); describe('calculateTotal', () => { it('should calculate total correctly', () => { const items = [{ price: 10 }, { price: 20 }]; expect(calculateTotal(items)).toBe(30); }); }); });
Component Test Example:
typescript// __tests__/components/Button.test.tsx import { render, fireEvent } from '@testing-library/react-native'; import Button from '../components/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('disables button when disabled prop is true', () => { const { getByText } = render( <Button title="Click me" disabled /> ); const button = getByText('Click me'); expect(button.props.disabled).toBe(true); }); });
- Detox (End-to-End Testing)
Detox is a gray-box end-to-end testing framework suitable for testing complete user flows.
Installation and Configuration:
bashnpm install --save-dev detox detox-cli detox init
detox.config.js Configuration:
javascriptmodule.exports = { testRunner: { args: { '$0': 'jest', config: 'e2e/config.json', }, jest: { setupTimeout: 120000, }, }, apps: { 'ios.debug': { type: 'ios.app', binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/ExpoApp.app', build: 'xcodebuild -workspace ios/ExpoApp.xcworkspace -scheme ExpoApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build', }, 'android.debug': { type: 'android.apk', binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..', }, }, devices: { simulator: { type: 'ios.simulator', device: { type: 'iPhone 14' }, }, emulator: { type: 'android.emulator', device: { avdName: 'Pixel_5_API_33' }, }, }, configurations: { 'ios.sim.debug': { device: 'simulator', app: 'ios.debug', }, 'android.emu.debug': { device: 'emulator', app: 'android.debug', }, }, };
End-to-End Test Example:
typescript// e2e/login.e2e.ts describe('Login Flow', () => { beforeAll(async () => { await device.launchApp(); }); beforeEach(async () => { await device.reloadReactNative(); }); it('should login successfully with valid credentials', async () => { await element(by.id('email-input')).typeText('user@example.com'); await element(by.id('password-input')).typeText('password123'); await element(by.id('login-button')).tap(); await expect(element(by.id('welcome-screen'))).toBeVisible(); }); it('should show error with invalid credentials', async () => { await element(by.id('email-input')).typeText('invalid@example.com'); await element(by.id('password-input')).typeText('wrongpassword'); await element(by.id('login-button')).tap(); await expect(element(by.text('Invalid credentials'))).toBeVisible(); }); });
- React Native Testing Library
Focuses on testing user behavior, not implementation details.
Installation:
bashnpm install --save-dev @testing-library/react-native @testing-library/jest-native
Usage Example:
typescriptimport { render, fireEvent, waitFor } from '@testing-library/react-native'; import LoginForm from '../components/LoginForm'; describe('LoginForm', () => { it('should submit form with valid data', async () => { const onSubmit = jest.fn(); const { getByPlaceholderText, getByText } = render( <LoginForm onSubmit={onSubmit} /> ); fireEvent.changeText( getByPlaceholderText('Email'), 'user@example.com' ); fireEvent.changeText( getByPlaceholderText('Password'), 'password123' ); fireEvent.press(getByText('Login')); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ email: 'user@example.com', password: 'password123', }); }); }); });
Testing Best Practices:
-
Testing Pyramid
- Unit tests: 70%
- Integration tests: 20%
- End-to-end tests: 10%
-
Test Naming
typescript// Clear test descriptions describe('User Component', () => { it('should display user name when user data is provided', () => { // Test code }); it('should show loading state when fetching user data', () => { // Test code }); it('should display error message when fetch fails', () => { // Test code }); });
- Mock and Stub
typescript// Mock API calls jest.mock('../api/user', () => ({ fetchUser: jest.fn(), })); import { fetchUser } from '../api/user'; describe('UserScreen', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should fetch and display user', async () => { const mockUser = { id: 1, name: 'John' }; (fetchUser as jest.Mock).mockResolvedValue(mockUser); // Test code }); });
- Testing Async Code
typescriptit('should handle async operations', async () => { const { getByText, findByText } = render(<AsyncComponent />); // Wait for async operation to complete await findByText('Loaded Data'); expect(getByText('Loaded Data')).toBeTruthy(); });
- Snapshot Testing
typescriptit('should match snapshot', () => { const tree = renderer.create(<MyComponent />).toJSON(); expect(tree).toMatchSnapshot(); });
CI/CD Integration:
- GitHub Actions Configuration
yamlname: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: node-version: '18' - run: npm ci - run: npm test -- --coverage - uses: codecov/codecov-action@v2
- Test Coverage
json{ "collectCoverage": true, "coverageReporters": ["text", "lcov", "html"], "coverageThreshold": { "global": { "branches": 80, "functions": 80, "lines": 80, "statements": 80 } } }
Common Testing Scenarios:
- Navigation Testing
typescriptimport { NavigationContainer } from '@react-navigation/native'; const renderWithNavigation = (component) => { return render( <NavigationContainer> {component} </NavigationContainer> ); };
- State Management Testing
typescriptimport { renderHook, act } from '@testing-library/react-hooks'; import { useUserStore } from '../store/user'; it('should update user state', () => { const { result } = renderHook(() => useUserStore()); act(() => { result.current.setUser({ name: 'John' }); }); expect(result.current.user.name).toBe('John'); });
- Network Request Testing
typescriptimport { rest } from 'msw'; import { setupServer } from 'msw/node'; const server = setupServer( rest.get('/api/user', (req, res, ctx) => { return res(ctx.json({ id: 1, name: 'John' })); }) ); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());
By establishing a comprehensive testing system, you can significantly improve the quality and maintainability of Expo apps.