2026년, React Native 테스트는 더 이상 선택이 아니다
솔직히 말해서, React Native 프로젝트에서 테스트 코드 없이 개발하고 있다면 — 그건 시한폭탄이에요. New Architecture 도입에 Expo SDK 53까지 안정화된 2026년 현재, 앱 복잡도가 예전과는 차원이 다릅니다. 서버 상태와 클라이언트 상태 분리, 파일 기반 라우팅, TurboModules로 네이티브 기능 직접 호출까지. 이걸 전부 수동으로 확인한다고요? 현실적으로 불가능합니다.
근데 아직도 많은 개발자들이 테스트를 "나중에"라고 미루거나, console.log 하나로 디버깅하고 있더라고요. State of React Native 2024 설문에서도 디버깅 수단 1위가 여전히 console.log였는데, 솔직히 이건 체계적인 테스트 문화가 아직 자리 잡지 못했다는 방증이죠.
이 글에서는 2026년 기준 React Native 앱 테스트의 A to Z를 다룹니다. Jest 유닛 테스트, React Native Testing Library(RNTL) 컴포넌트 테스트, 커스텀 훅 테스트, 그리고 요즘 가장 핫한 E2E 도구 Maestro까지 — 실전 코드 예제와 함께 바로 써먹을 수 있게 정리했어요.
테스트 피라미드: 무엇을 얼마나 테스트할 것인가
테스트 전략을 세우기 전에, 먼저 테스트 종류와 각각의 역할을 확실히 이해해야 합니다. "테스트 피라미드"라고 불리는 모델인데, 2026년에도 여전히 유효합니다.
유닛 테스트 (Unit Tests) — 피라미드의 기초
유닛 테스트는 가장 작은 코드 단위(함수, 클래스, 모듈)를 독립적으로 검증합니다. 작성이 빠르고, 실행 속도도 가장 빠르죠.
비즈니스 로직, 유틸리티 함수, 데이터 변환 로직을 검증하는 데 핵심적인 역할을 합니다. 피라미드에서 가장 넓은 기초를 차지하는 이유가 있어요 — 비용 대비 효과가 압도적으로 좋거든요.
컴포넌트/통합 테스트 (Component/Integration Tests) — 피라미드의 중간
컴포넌트 테스트는 React Native 컴포넌트가 올바르게 렌더링되는지, 사용자 인터랙션에 제대로 반응하는지 확인합니다. 여러 컴포넌트가 함께 동작하는 통합 테스트도 여기 포함되고요. React Native Testing Library가 이 영역의 핵심 도구입니다.
E2E 테스트 (End-to-End Tests) — 피라미드의 꼭대기
E2E 테스트는 실제 디바이스나 시뮬레이터에서 앱 전체를 구동하고, 사용자처럼 버튼을 탭하고 텍스트를 입력하고 화면을 전환합니다. 가장 높은 수준의 신뢰도를 주지만, 작성과 유지보수 비용도 가장 높아요. 그래서 핵심 사용자 플로우에만 집중하는 게 포인트입니다.
// 2026년 권장 테스트 비율
// 유닛 테스트: 70% — 빠르고, 저렴하고, 안정적
// 컴포넌트 테스트: 20% — 사용자 관점의 UI 검증
// E2E 테스트: 10% — 핵심 플로우만 집중 검증
환경 설정: Jest + React Native Testing Library
Expo 프로젝트에서 시작하기
2026년 기준으로 create-expo-app으로 만든 프로젝트에는 Jest가 기본 포함되어 있습니다. React Native Testing Library만 추가하면 바로 시작할 수 있어요.
# React Native Testing Library 설치
npx expo install -- --save-dev @testing-library/react-native @testing-library/jest-native
# jest-expo 프리셋 확인 (Expo 프로젝트에 기본 포함)
npx expo install -- --save-dev jest-expo
package.json에 Jest 설정이 이미 들어있습니다. 추가 커스터마이징이 필요하면 jest.config.js를 만들면 됩니다.
// jest.config.js
module.exports = {
preset: 'jest-expo',
setupFilesAfterSetup: [
'@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/.*|@sentry/react-native|native-base|react-native-svg)',
],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/index.ts',
],
};
Bare React Native 프로젝트에서 시작하기
Expo를 쓰지 않는 프로젝트라면 설정이 좀 다릅니다.
# 필수 패키지 설치
npm install --save-dev jest @testing-library/react-native @testing-library/jest-native
npm install --save-dev @types/jest ts-jest
// jest.config.js (Bare React Native)
module.exports = {
preset: 'react-native',
setupFilesAfterSetup: [
'@testing-library/jest-native/extend-expect',
],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
transformIgnorePatterns: [
'node_modules/(?!(react-native|@react-native|@react-navigation)/)',
],
};
테스트 유틸리티 파일 만들기
프로젝트에서 Context Provider나 Navigation을 쓴다면, 매번 테스트마다 래퍼를 반복 작성하는 건 정말 비효율적입니다. 저도 처음에는 그냥 복붙했는데, 테스트가 30개 넘어가니까 유지보수가 지옥이더라고요. 공통 유틸리티 파일 하나 만들어두세요.
// test/test-utils.tsx
import React, { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react-native';
import { NavigationContainer } from '@react-navigation/native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Infinity,
},
},
});
function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = createTestQueryClient();
return (
{children}
);
}
const customRender = (
ui: ReactElement,
options?: Omit
) => render(ui, { wrapper: AllProviders, ...options });
// re-export everything
export * from '@testing-library/react-native';
export { customRender as render };
유닛 테스트 실전: 비즈니스 로직 검증하기
자, 이제 본격적으로 코드를 작성해볼게요. 유닛 테스트의 핵심은 비즈니스 로직을 컴포넌트와 분리해서 테스트하는 겁니다. 가격 계산, 데이터 포맷팅, 유효성 검증 같은 순수 함수들이 주요 대상이죠.
순수 함수 테스트
// utils/price.ts
export function calculateDiscount(
price: number,
discountPercent: number
): number {
if (price < 0 || discountPercent < 0 || discountPercent > 100) {
throw new Error('잘못된 입력값입니다');
}
return Math.round(price * (1 - discountPercent / 100));
}
export function formatPrice(amount: number, currency = 'KRW'): string {
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency,
maximumFractionDigits: 0,
}).format(amount);
}
// __tests__/utils/price.test.ts
import { calculateDiscount, formatPrice } from '../../utils/price';
describe('calculateDiscount', () => {
it('할인율을 올바르게 적용한다', () => {
expect(calculateDiscount(10000, 20)).toBe(8000);
expect(calculateDiscount(55000, 10)).toBe(49500);
});
it('0% 할인은 원래 가격을 반환한다', () => {
expect(calculateDiscount(10000, 0)).toBe(10000);
});
it('100% 할인은 0을 반환한다', () => {
expect(calculateDiscount(10000, 100)).toBe(0);
});
it('잘못된 입력에 대해 에러를 던진다', () => {
expect(() => calculateDiscount(-1000, 20)).toThrow('잘못된 입력값입니다');
expect(() => calculateDiscount(1000, 150)).toThrow('잘못된 입력값입니다');
});
});
describe('formatPrice', () => {
it('한국 원화 형식으로 포맷한다', () => {
expect(formatPrice(50000)).toBe('₩50,000');
expect(formatPrice(1234567)).toBe('₩1,234,567');
});
});
비동기 함수 테스트
API 호출 같은 비동기 함수 테스트도 크게 어렵지 않습니다. async/await와 jest.fn()만 잘 쓰면 돼요.
// services/api.ts
export async function fetchUserProfile(userId: string) {
const response = await fetch(
`https://api.example.com/users/${userId}`
);
if (!response.ok) {
throw new Error(`사용자를 찾을 수 없습니다: ${response.status}`);
}
return response.json();
}
// __tests__/services/api.test.ts
import { fetchUserProfile } from '../../services/api';
// fetch 모킹
global.fetch = jest.fn();
describe('fetchUserProfile', () => {
afterEach(() => {
jest.resetAllMocks();
});
it('사용자 프로필을 성공적으로 가져온다', async () => {
const mockUser = { id: '1', name: '김개발', email: '[email protected]' };
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const result = await fetchUserProfile('1');
expect(result).toEqual(mockUser);
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/users/1'
);
});
it('존재하지 않는 사용자에 대해 에러를 던진다', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 404,
});
await expect(fetchUserProfile('999')).rejects.toThrow(
'사용자를 찾을 수 없습니다: 404'
);
});
});
컴포넌트 테스트: 사용자 관점에서 UI 검증하기
React Native Testing Library(RNTL)의 핵심 철학은 간단합니다. "사용자가 보는 것을 테스트하라."
내부 구현(state, props)이 아니라, 사용자가 실제로 보고 상호작용하는 요소를 기준으로 쿼리하는 거예요. 이렇게 하면 나중에 리팩토링으로 내부 구현이 바뀌어도 테스트가 멀쩡합니다. 개인적으로 이 철학 덕분에 테스트 유지보수 부담이 확 줄었어요.
기본 컴포넌트 렌더링 테스트
// components/ProductCard.tsx
import React from 'react';
import { View, Text, Image, TouchableOpacity, StyleSheet } from 'react-native';
interface ProductCardProps {
name: string;
price: number;
imageUrl: string;
onAddToCart: () => void;
isSoldOut?: boolean;
}
export function ProductCard({
name,
price,
imageUrl,
onAddToCart,
isSoldOut = false,
}: ProductCardProps) {
return (
{name}
{new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
maximumFractionDigits: 0,
}).format(price)}
{isSoldOut ? '품절' : '장바구니에 담기'}
);
}
// __tests__/components/ProductCard.test.tsx
import React from 'react';
import { render, fireEvent, screen } from '../test-utils';
import { ProductCard } from '../../components/ProductCard';
describe('ProductCard', () => {
const defaultProps = {
name: '무선 이어폰',
price: 89000,
imageUrl: 'https://example.com/earbuds.jpg',
onAddToCart: jest.fn(),
};
it('상품 이름과 가격을 올바르게 렌더링한다', () => {
render( );
expect(screen.getByText('무선 이어폰')).toBeTruthy();
expect(screen.getByText('₩89,000')).toBeTruthy();
});
it('장바구니 버튼 클릭 시 onAddToCart가 호출된다', () => {
render( );
fireEvent.press(screen.getByRole('button', { name: '장바구니에 담기' }));
expect(defaultProps.onAddToCart).toHaveBeenCalledTimes(1);
});
it('품절 상품은 버튼이 비활성화된다', () => {
render( );
const button = screen.getByRole('button', { name: '품절' });
expect(button).toBeDisabled();
});
it('상품 이미지에 접근성 라벨이 설정되어 있다', () => {
render( );
expect(screen.getByLabelText('무선 이어폰 상품 이미지')).toBeTruthy();
});
});
사용자 인터랙션 테스트: 폼 입력과 검증
이제 좀 더 실전적인 예제를 볼까요? 로그인 폼처럼 사용자 입력과 유효성 검증이 포함된 컴포넌트 테스트입니다.
// components/LoginForm.tsx
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
interface LoginFormProps {
onSubmit: (email: string, password: string) => Promise;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async () => {
setError('');
if (!email.includes('@')) {
setError('올바른 이메일 형식을 입력해주세요');
return;
}
if (password.length < 8) {
setError('비밀번호는 8자 이상이어야 합니다');
return;
}
setIsLoading(true);
try {
await onSubmit(email, password);
} catch (e) {
setError('로그인에 실패했습니다. 다시 시도해주세요');
} finally {
setIsLoading(false);
}
};
return (
{error ? {error} : null}
{isLoading ? (
) : (
로그인
)}
);
}
// __tests__/components/LoginForm.test.tsx
import React from 'react';
import { render, fireEvent, waitFor, screen } from '../test-utils';
import { LoginForm } from '../../components/LoginForm';
describe('LoginForm', () => {
const mockOnSubmit = jest.fn();
beforeEach(() => {
mockOnSubmit.mockReset();
});
it('이메일 형식이 잘못되면 에러 메시지를 표시한다', async () => {
render( );
fireEvent.changeText(
screen.getByLabelText('이메일 입력'),
'invalid-email'
);
fireEvent.changeText(
screen.getByLabelText('비밀번호 입력'),
'password123'
);
fireEvent.press(screen.getByRole('button', { name: '로그인' }));
expect(
await screen.findByText('올바른 이메일 형식을 입력해주세요')
).toBeTruthy();
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it('비밀번호가 8자 미만이면 에러 메시지를 표시한다', async () => {
render( );
fireEvent.changeText(
screen.getByLabelText('이메일 입력'),
'[email protected]'
);
fireEvent.changeText(
screen.getByLabelText('비밀번호 입력'),
'short'
);
fireEvent.press(screen.getByRole('button', { name: '로그인' }));
expect(
await screen.findByText('비밀번호는 8자 이상이어야 합니다')
).toBeTruthy();
});
it('유효한 입력 시 onSubmit을 호출한다', async () => {
mockOnSubmit.mockResolvedValueOnce(undefined);
render( );
fireEvent.changeText(
screen.getByLabelText('이메일 입력'),
'[email protected]'
);
fireEvent.changeText(
screen.getByLabelText('비밀번호 입력'),
'password123'
);
fireEvent.press(screen.getByRole('button', { name: '로그인' }));
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
'[email protected]',
'password123'
);
});
});
it('로그인 실패 시 에러 메시지를 표시한다', async () => {
mockOnSubmit.mockRejectedValueOnce(new Error('인증 실패'));
render( );
fireEvent.changeText(
screen.getByLabelText('이메일 입력'),
'[email protected]'
);
fireEvent.changeText(
screen.getByLabelText('비밀번호 입력'),
'password123'
);
fireEvent.press(screen.getByRole('button', { name: '로그인' }));
expect(
await screen.findByText('로그인에 실패했습니다. 다시 시도해주세요')
).toBeTruthy();
});
});
커스텀 훅 테스트: renderHook 활용하기
React Native Testing Library v14+부터 renderHook이 내장됐습니다. 예전에는 @testing-library/react-hooks를 따로 깔아야 했는데, 이젠 그럴 필요 없어요. (이 변경 하나만으로도 세팅이 한결 깔끔해졌습니다.)
// hooks/useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// __tests__/hooks/useDebounce.test.ts
import { renderHook, act } from '@testing-library/react-native';
import { useDebounce } from '../../hooks/useDebounce';
describe('useDebounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('지정된 딜레이 후에 값을 업데이트한다', async () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: '초기값', delay: 500 } }
);
// 아직 초기값
expect(result.current).toBe('초기값');
// 새로운 값으로 리렌더
rerender({ value: '변경된 값', delay: 500 });
// 딜레이 전에는 아직 이전 값
expect(result.current).toBe('초기값');
// 타이머 실행
await act(async () => {
jest.advanceTimersByTime(500);
});
// 디바운스된 값이 업데이트됨
expect(result.current).toBe('변경된 값');
});
it('딜레이 내에 값이 변경되면 타이머가 리셋된다', async () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'A', delay: 300 } }
);
rerender({ value: 'B', delay: 300 });
await act(async () => {
jest.advanceTimersByTime(200);
});
// 아직 300ms가 안 지났으므로 초기값 유지
expect(result.current).toBe('A');
rerender({ value: 'C', delay: 300 });
await act(async () => {
jest.advanceTimersByTime(300);
});
// 마지막 값으로 업데이트
expect(result.current).toBe('C');
});
});
모킹 전략: 네이티브 모듈과 외부 의존성 처리
솔직히 React Native 테스트에서 가장 골치 아픈 부분이 바로 네이티브 모듈 모킹입니다. 카메라, 위치 정보, AsyncStorage 같은 건 Jest의 JavaScript 환경에서 당연히 안 돌아가거든요. 보통은 실제 객체를 쓰는 게 맞지만, 네이티브 모듈은 Java나 Objective-C에 의존하니까 모킹이 필수예요.
네이티브 모듈 모킹
// __mocks__/react-native-camera.ts
export const Camera = {
Constants: {
Type: { back: 'back', front: 'front' },
FlashMode: { on: 'on', off: 'off', auto: 'auto' },
},
};
// jest.setup.js에서 전역 모킹 설정
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);
jest.mock('react-native-reanimated', () => {
const Reanimated = require('react-native-reanimated/mock');
Reanimated.default.call = () => {};
return Reanimated;
});
jest.mock('expo-location', () => ({
requestForegroundPermissionsAsync: jest.fn().mockResolvedValue({
status: 'granted',
}),
getCurrentPositionAsync: jest.fn().mockResolvedValue({
coords: { latitude: 37.5665, longitude: 126.978 },
}),
}));
API 호출 모킹과 MSW
API 호출을 모킹할 때 jest.fn()으로 직접 하는 방법도 있지만, 좀 더 현실적인 접근법이 있습니다. MSW(Mock Service Worker)를 쓰는 거예요.
MSW는 네트워크 레벨에서 요청을 가로채기 때문에 실제 fetch 코드를 수정할 필요가 없고, 훨씬 실전에 가까운 테스트가 가능합니다. 한번 세팅해두면 모든 테스트에서 재사용할 수 있어서 장기적으로 효율적이에요.
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('https://api.example.com/products', () => {
return HttpResponse.json([
{ id: '1', name: '무선 이어폰', price: 89000 },
{ id: '2', name: '스마트 워치', price: 299000 },
]);
}),
http.post('https://api.example.com/auth/login', async ({ request }) => {
const body = await request.json();
if (body.email === '[email protected]') {
return HttpResponse.json({
token: 'mock-jwt-token',
user: { id: '1', name: '테스트 유저' },
});
}
return HttpResponse.json(
{ message: '인증 실패' },
{ status: 401 }
);
}),
];
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// jest.setup.js에 추가
// import { server } from './mocks/server';
// beforeAll(() => server.listen());
// afterEach(() => server.resetHandlers());
// afterAll(() => server.close());
스냅샷 테스트: 언제 쓰고, 언제 피해야 하는가
스냅샷 테스트는 컴포넌트 렌더링 결과를 파일로 저장하고, 이후 변경 사항을 감지하는 방식입니다. 강력한 도구이긴 한데, 오용하기가 너무 쉬워요. Kent C. Dodds 말처럼, 스냅샷은 "도우미일 뿐 증명은 아닙니다".
스냅샷 테스트의 올바른 사용법
// 좋은 예: 작고 안정적인 컴포넌트
it('Badge 컴포넌트가 올바르게 렌더링된다', () => {
const tree = render( );
expect(tree.toJSON()).toMatchSnapshot();
});
// 나쁜 예: 거대하고 자주 변하는 컴포넌트
// 이런 테스트는 스냅샷이 너무 크고, 변경될 때마다
// 의미 없는 스냅샷 업데이트만 발생합니다
it('전체 홈 화면 스냅샷', () => {
const tree = render( );
expect(tree.toJSON()).toMatchSnapshot(); // 수백 줄의 스냅샷
});
스냅샷 테스트 베스트 프랙티스
- 작게 유지하세요. 큰 스냅샷은 리뷰하기 어렵고, 의미 있는 변경과 무의미한 변경을 구분하기 힘듭니다.
- 커밋하고 리뷰하세요. 스냅샷 파일도 코드 리뷰 대상입니다. 의도된 변경인지 꼭 확인하세요.
- 변경이 잦은 컴포넌트에는 쓰지 마세요. 데이터에 따라 매번 달라지는 컴포넌트는 명시적 assertion이 더 적합합니다.
- 시각적 회귀 검출에는 E2E가 낫습니다. 스냅샷으로 UI 변경을 잡으려면 한계가 있어요.
E2E 테스트의 새로운 기준: Maestro 완벽 가이드
2026년, E2E 테스트 판도가 바뀌었습니다. React Native E2E의 오랜 표준이었던 Detox는 여전히 강력하지만, Maestro가 급부상하면서 전환하는 팀이 눈에 띄게 늘었어요. Flipper가 React Native에서 지원 종료된 것도 이 흐름에 한몫했고요.
왜 Maestro인가?
Maestro는 기존 도구들(Appium, Espresso, UIAutomator, XCTest)의 교훈을 바탕으로 만들어진 차세대 모바일 E2E 프레임워크입니다. 핵심 차별점은 딱 세 가지예요.
- YAML 기반 선언적 문법: JavaScript 코드 없이 누구나 읽고 쓸 수 있는 테스트
- 앱 수정 불필요: SDK 설치나 빌드 수정 없이 기존 앱 그대로 테스트 가능
- 내장된 안정성: 자동 재시도, 스마트 대기, 동기화 — 수동
sleep()이 필요 없음
Maestro 설치 및 시작하기
# macOS/Linux에서 Maestro 설치
curl -Ls "https://get.maestro.mobile.dev" | bash
# 설치 확인
maestro -v
# iOS 시뮬레이터 또는 Android 에뮬레이터가 실행 중이어야 합니다
# Expo 프로젝트라면:
npx expo run:ios # 또는 npx expo run:android
첫 번째 Maestro 테스트 플로우 작성하기
프로젝트 루트에 .maestro/ 디렉토리를 만들고, YAML 파일로 테스트 플로우를 작성합니다. 처음 보면 "이게 끝이야?" 싶을 정도로 간단해요.
# .maestro/login-flow.yaml
appId: com.myapp.example
---
- launchApp:
clearState: true
# 로그인 화면이 표시되는지 확인
- assertVisible: "로그인"
# 이메일 입력
- tapOn: "이메일"
- inputText: "[email protected]"
# 비밀번호 입력
- tapOn: "비밀번호"
- inputText: "securePassword123"
# 로그인 버튼 탭
- tapOn: "로그인"
# 홈 화면으로 이동했는지 확인
- assertVisible: "홈"
- assertVisible: "안녕하세요"
# 테스트 실행
maestro test .maestro/login-flow.yaml
# 디렉토리의 모든 플로우 실행
maestro test .maestro/
# 연속 모드 (파일 변경 시 자동 재실행)
maestro test --continuous .maestro/login-flow.yaml
재사용 가능한 서브플로우 만들기
로그인, 로그아웃, 특정 화면 이동 같은 반복 작업은 서브플로우로 분리하세요. 테스트 중복이 확 줄어듭니다.
# .maestro/flows/login.yaml (재사용 가능한 서브플로우)
appId: com.myapp.example
---
- tapOn: "이메일"
- inputText: "${EMAIL}"
- tapOn: "비밀번호"
- inputText: "${PASSWORD}"
- tapOn: "로그인"
- assertVisible: "홈"
# .maestro/product-purchase-flow.yaml
appId: com.myapp.example
---
- launchApp:
clearState: true
# 로그인 서브플로우 실행
- runFlow:
file: flows/login.yaml
env:
EMAIL: "[email protected]"
PASSWORD: "password123"
# 상품 검색
- tapOn: "검색"
- inputText: "무선 이어폰"
- pressKey: Enter
# 첫 번째 검색 결과 선택
- tapOn: "무선 이어폰.*"
# 장바구니에 담기
- tapOn: "장바구니에 담기"
- assertVisible: "장바구니에 추가되었습니다"
# 장바구니로 이동
- tapOn: "장바구니"
- assertVisible: "₩89,000"
# 스크린샷 저장
- takeScreenshot: cart-with-product
testID로 안정적인 요소 선택
Maestro는 텍스트, 접근성 라벨, testID 등 여러 방법으로 UI 요소를 찾습니다. 텍스트가 자주 바뀌는 요소에는 testID를 달아두면 테스트가 훨씬 안정적이에요.
// React Native 컴포넌트에 testID 추가
결제하기
# Maestro에서 testID로 요소 선택
- tapOn:
id: "checkout-button"
Maestro vs Detox: 2026년 어떤 걸 선택해야 할까?
오해하지 마세요 — Detox가 나쁜 도구라는 뜻이 아닙니다. Shopify, Wix 같은 대형 React Native 앱에서 지금도 활발히 사용되고 있어요. 다만 2026년 현재 두 도구의 포지셔닝이 꽤 명확하게 갈렸습니다.
| 기준 | Maestro | Detox |
|---|---|---|
| 설정 난이도 | CLI 하나로 즉시 시작 | 네이티브 빌드 설정 필요 |
| 테스트 작성 언어 | YAML (누구나 읽기 쉬움) | JavaScript/TypeScript |
| 앱 수정 필요 여부 | 불필요 | Detox 라이브러리 연동 필요 |
| 테스트 안정성 | 자동 재시도로 높은 안정성 | 동기화 이슈 가능성 있음 |
| 반복 속도 | YAML 수정 후 즉시 재실행 | 빌드 사이클 필요 |
| 크로스 플랫폼 | iOS, Android, Flutter, 웹 | React Native 전용 |
| WebView 지원 | 화면 콘텐츠 기반으로 원활히 처리 | 제한적 |
| 최적 사용 시나리오 | 빠른 이터레이션, 다양한 팀 구성 | React Native에 특화된 정밀 테스트 |
추천 전략: 새 프로젝트이거나, QA 팀에 비개발자가 있다면 Maestro를 먼저 검토하세요. 이미 Detox 인프라가 잘 돌아가고 있고 팀 전원이 TypeScript에 익숙하다면 굳이 바꿀 필요는 없습니다. 어느 쪽이든 핵심은, Jest 유닛 테스트와 E2E를 조합하는 하이브리드 전략이 가장 효과적이라는 점입니다.
CI/CD 파이프라인에 테스트 통합하기
테스트를 작성하고 나서 CI/CD에 안 붙이면, 솔직히 의미가 반감됩니다. 로컬에서만 돌리는 테스트는 결국 안 돌리게 되거든요. (경험담입니다.)
GitHub Actions로 Jest 테스트 자동화
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests with coverage
run: npx jest --coverage --ci --reporters=default
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
Maestro E2E를 CI에 통합하기
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
jobs:
maestro-e2e:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Install Maestro
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> $GITHUB_PATH
- name: Build iOS app
run: npx expo run:ios --configuration Release
- name: Run Maestro tests
run: maestro test .maestro/ --format junit --output report.xml
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: maestro-report
path: report.xml
EAS Build를 쓰는 Expo 프로젝트라면 Maestro Cloud와 연동해서 클라우드에서 E2E를 돌릴 수도 있습니다. 로컬 시뮬레이터 관리 부담 없이 스케일 아웃이 가능해요.
테스트 커버리지: 의미 있는 숫자 만들기
커버리지 100%를 맹목적으로 쫓지 마세요. 높은 커버리지가 곧 높은 품질을 의미하진 않습니다.
중요한 건 무엇을 테스트하느냐예요.
커버리지 우선순위
- 필수: 비즈니스 로직 (가격 계산, 유효성 검증, 데이터 변환)
- 필수: 핵심 사용자 플로우 (로그인, 결제, 회원가입)
- 권장: 상태 관리 로직 (Zustand 스토어, 커스텀 훅)
- 권장: 에러 처리 경로 (네트워크 실패, 타임아웃)
- 선택: UI 컴포넌트의 시각적 렌더링
- 불필요: 서드파티 라이브러리 내부 동작
// jest.config.js — 커버리지 기준 설정
module.exports = {
// ...기존 설정
coverageThreshold: {
global: {
branches: 70,
functions: 75,
lines: 80,
statements: 80,
},
'./src/utils/': {
branches: 90,
functions: 95,
lines: 95,
statements: 95,
},
},
};
자주 묻는 질문 (FAQ)
React Native에서 Jest와 Vitest 중 뭘 써야 하나요?
2026년 기준으로 React Native에서는 Jest를 쓰세요. Vitest가 Vite 기반 웹 프로젝트에서 10~20배 빠른 건 맞지만, React Native는 Metro 번들러를 쓰기 때문에 Vitest의 이점을 살리기 어렵습니다. Expo와 React Native CLI 모두 Jest가 기본이고, 커뮤니티의 모킹 가이드나 설정 예제도 거의 다 Jest 기반이에요.
스냅샷 테스트를 써야 하나요, 피해야 하나요?
작고 안정적인 컴포넌트에만 쓰세요. Badge, Icon, 포맷팅된 텍스트처럼 변경이 드문 것에는 효과적입니다. 하지만 데이터에 따라 달라지는 리스트나 자주 바뀌는 폼에는 명시적 assertion이 더 나아요. 스냅샷이 수백 줄 넘어가면, 뭔가 잘못된 겁니다.
Maestro와 Detox 중 어떤 E2E 도구를 골라야 하나요?
새 프로젝트라면 Maestro부터 시작하세요. 설정이 간단하고, YAML이라 QA 팀도 바로 참여할 수 있습니다. 이미 Detox가 잘 세팅되어 있고 팀이 JS에 익숙하다면 유지해도 됩니다. 핵심은 도구 선택보다 E2E 테스트를 실제로 작성하고 CI에 통합하는 것이에요.
테스트 커버리지 몇 퍼센트를 목표로 해야 하나요?
100%를 맹목적으로 쫓지 마세요. 비즈니스 로직과 유틸리티 함수는 90% 이상, 프로젝트 전체로는 70~80%면 충분합니다. 숫자보다 중요한 건, 핵심 플로우와 비즈니스 로직이 진짜로 검증되고 있느냐입니다.
RNTL에서 요소를 찾을 때 어떤 쿼리를 써야 하나요?
우선순위는 getByRole > getByLabelText > getByText > getByTestId 순입니다. 접근성 역할이나 라벨로 찾는 게 가장 좋고, testID는 정말 다른 방법이 없을 때 쓰는 최후의 수단이에요. 이렇게 하면 접근성도 자연스럽게 좋아집니다.