테스트
코드베이스가 커질수록 예상치 못한 작은 오류와 특수한 상황이 큰 문제로 이어질 수 있다. 버그는 사용자 경험을 악화시키고 결국 비즈니스 손실로 이어진다. 이러한 취약한 프로그래밍을 방지하는 한 가지 방법은 코드를 실제 환경에 배포하기 전에 테스트를 거치는 것이다.
이 가이드에서는 정적 분석부터 엔드투엔드 테스트까지, 앱이 예상대로 작동하는지 확인할 수 있는 다양한 자동화된 방법을 다룬다.
테스트의 중요성
사람은 실수를 하기 마련이다. 테스트는 이러한 실수를 발견하고 코드가 제대로 작동하는지 확인하는 데 도움을 준다. 더 중요한 점은, 테스트를 통해 새로운 기능을 추가하거나 기존 기능을 리팩토링하거나 프로젝트의 주요 의존성을 업그레이드할 때도 코드가 계속 정상적으로 작동하는지 보장할 수 있다.
테스트의 가치는 생각보다 훨씬 크다. 코드의 버그를 수정하는 가장 좋은 방법 중 하나는 해당 버그를 드러내는 실패하는 테스트를 작성하는 것이다. 버그를 수정한 후 테스트를 다시 실행했을 때 통과한다면, 그 버그는 수정되었으며 다시 코드베이스에 등장하지 않는다는 의미다.
테스트는 팀에 새로 합류한 사람들에게 문서 역할을 할 수도 있다. 코드베이스를 처음 접하는 사람들에게 테스트를 읽는 것은 기존 코드가 어떻게 작동하는지 이해하는 데 큰 도움이 된다.
마지막으로, 더 많은 자동화된 테스트는 수동 QA에 소요되는 시간을 줄여줌으로써 귀중한 시간을 확보할 수 있게 해준다.
정적 분석
코드 품질을 향상시키려면 먼저 정적 분석 도구를 사용해야 한다. 정적 분석은 코드를 실행하지 않고 작성 중인 코드에서 오류를 찾아낸다.
- **린터(Linter)**는 코드를 분석해 사용하지 않는 코드와 같은 일반적인 오류를 잡아내고, 스타일 가이드를 위반한 부분(예: 공백 대신 탭 사용 또는 그 반대, 설정에 따라 다름)을 표시해 문제를 피할 수 있게 돕는다.
- 타입 체크는 함수에 전달하는 값이 해당 함수가 받아들일 수 있는 타입인지 확인한다. 예를 들어, 숫자를 기대하는 카운팅 함수에 문자열을 전달하는 것을 방지한다.
React Native는 기본적으로 두 가지 도구를 제공한다: ESLint는 린팅을, TypeScript는 타입 체크를 담당한다.
테스트 가능한 코드 작성하기
테스트를 시작하려면 먼저 테스트 가능한 코드를 작성해야 한다. 비행기 제조 과정을 예로 들어보자. 복잡한 시스템이 잘 작동하는지 확인하기 위해 모델이 첫 비행을 하기 전에, 각 부품이 안전하고 제대로 작동하는지 테스트한다. 예를 들어, 날개는 극한 하중에서 휘는지 테스트하고, 엔진 부품은 내구성을 검증하며, 앞유리는 새 충돌 시뮬레이션을 거친다.
소프트웨어도 마찬가지다. 한 파일에 수많은 코드를 몰아넣는 대신, 여러 작은 모듈로 나누어 작성하면 전체를 테스트하는 것보다 더 철저하게 검증할 수 있다. 따라서 테스트 가능한 코드를 작성하는 것은 깔끔하고 모듈화된 코드를 작성하는 것과 밀접하게 연결되어 있다.
앱을 더 테스트하기 쉽게 만들려면, 먼저 뷰 부분(React 컴포넌트)과 비즈니스 로직 및 앱 상태를 분리해야 한다(Redux, MobX 등의 솔루션을 사용하더라도 마찬가지다). 이렇게 하면 React 컴포넌트에 의존하지 않는 비즈니스 로직 테스트를 컴포넌트 자체와 독립적으로 유지할 수 있다. 컴포넌트의 주요 역할은 앱의 UI를 렌더링하는 것이다!
이론적으로는 모든 로직과 데이터 페칭을 컴포넌트 밖으로 옮길 수도 있다. 이렇게 하면 컴포넌트는 순수하게 렌더링에만 집중하게 된다. 상태는 컴포넌트와 완전히 독립적이며, 앱의 로직은 React 컴포넌트 없이도 작동할 수 있다!
테스트 가능한 코드에 대해 더 깊이 알고 싶다면 다른 학습 자료를 참고하기를 권한다.
테스트 작성하기
테스트 가능한 코드를 작성한 후에는 실제 테스트를 작성할 차례이다. React Native의 기본 템플릿은 Jest 테스트 프레임워크를 포함한다. 이 환경에 맞춰진 프리셋이 제공되므로, 설정과 모의 객체(mocks)를 바로 조정하지 않고도 생산적으로 작업할 수 있다. Jest를 사용해 이 가이드에서 소개하는 모든 유형의 테스트를 작성할 수 있다.
테스트 주도 개발(Test-Driven Development)을 한다면, 실제로 테스트를 먼저 작성한다. 이렇게 하면 코드의 테스트 가능성이 보장된다.
테스트 구조화
테스트는 짧게 작성하고, 가능하면 한 가지 기능만 테스트하는 것이 좋다. Jest를 사용해 작성한 단위 테스트 예제부터 살펴보자:
it('given a date in the past, colorForDueDate() returns red', () => {
expect(colorForDueDate('2000-10-20')).toBe('red');
});
테스트 설명은 it
함수에 전달된 문자열로 표현된다. 무엇을 테스트하는지 명확하게 설명할 수 있도록 신중하게 작성해야 한다. 다음 세 가지를 충분히 고려해 보자:
- Given - 사전 조건
- When - 테스트 대상 함수가 실행하는 동작
- Then - 기대 결과
이 방식은 AAA(Arrange, Act, Assert) 패턴으로도 알려져 있다.
Jest는 테스트를 구조화하는 데 도움을 주는 describe
함수를 제공한다. describe
를 사용해 하나의 기능과 관련된 모든 테스트를 그룹화할 수 있다. 필요하다면 describe
를 중첩해서 사용할 수도 있다. 테스트할 객체를 설정하는 데 자주 사용하는 beforeEach
나 beforeAll
같은 함수도 있다. 자세한 내용은 Jest API 문서를 참고하자.
테스트에 여러 단계가 있거나 여러 기대값을 확인해야 한다면, 여러 개의 작은 테스트로 나누는 것이 좋다. 또한, 테스트는 서로 완전히 독립적이어야 한다. 테스트 스위트의 각 테스트는 다른 테스트를 먼저 실행하지 않고도 독립적으로 실행 가능해야 한다. 반대로, 모든 테스트를 함께 실행할 때 첫 번째 테스트가 두 번째 테스트의 결과에 영향을 미쳐서는 안 된다.
마지막으로, 개발자는 코드가 잘 작동하고 충돌하지 않기를 바란다. 하지만 테스트에서는 종종 그 반대가 좋은 결과로 이어질 수 있다. 테스트가 실패하는 것은 _좋은 일_이라고 생각하자. 테스트가 실패하면 무언가 문제가 있다는 의미다. 이는 사용자에게 영향을 미치기 전에 문제를 해결할 수 있는 기회를 제공한다.
유닛 테스트
유닛 테스트는 개별 함수나 클래스와 같은 코드의 가장 작은 단위를 다룬다.
테스트 대상 객체가 의존성을 가지고 있다면, 다음 단락에서 설명하는 것처럼 이를 모의 객체(mock)로 대체해야 할 때가 많다.
유닛 테스트의 가장 큰 장점은 작성과 실행이 빠르다는 점이다. 따라서 작업 중에 테스트가 통과하는지 빠르게 피드백을 받을 수 있다. Jest는 편집 중인 코드와 관련된 테스트를 지속적으로 실행하는 Watch 모드 옵션도 제공한다.
모킹(Mocking)
테스트 대상 객체가 외부 의존성을 가질 때, 이 의존성을 '모킹'하여 대체할 필요가 있다. 모킹은 코드의 특정 의존성을 직접 구현한 것으로 대체하는 과정이다.
일반적으로 테스트에서 실제 객체를 사용하는 것이 모킹보다 낫지만, 특정 상황에서는 모킹이 불가피하다. 예를 들어 자바스크립트 단위 테스트가 Java나 Objective-C로 작성된 네이티브 모듈에 의존하는 경우가 그렇다.
현재 도시의 날씨를 보여주는 앱을 개발한다고 가정해 보자. 이때 외부 서비스나 다른 의존성을 통해 날씨 정보를 가져온다. 만약 서비스가 비가 오고 있다고 알려주면, 비구름 이미지를 화면에 표시해야 한다. 하지만 테스트에서 이 서비스를 직접 호출하는 것은 바람직하지 않다. 그 이유는 다음과 같다:
- 네트워크 요청으로 인해 테스트가 느려지거나 불안정해질 수 있다.
- 테스트를 실행할 때마다 서비스가 다른 데이터를 반환할 수 있다.
- 테스트를 실행해야 할 때 서드파티 서비스가 다운될 가능성이 있다.
따라서 서비스의 모킹 구현을 제공해, 수천 줄의 코드와 인터넷에 연결된 온도계를 효과적으로 대체할 수 있다.
Jest는 함수 수준부터 모듈 수준까지 다양한 모킹을 지원한다.
통합 테스트
규모가 큰 소프트웨어 시스템을 개발할 때는 각각의 개별 요소들이 서로 상호작용해야 한다. 유닛 테스트에서는 특정 유닛이 다른 유닛에 의존하는 경우, 해당 의존성을 모킹하여 가짜로 대체하는 경우가 많다.
통합 테스트에서는 실제 개별 유닛들을 결합해(실제 애플리케이션과 동일하게) 함께 테스트하며, 이들이 예상대로 협력하는지 확인한다. 이는 모킹이 전혀 없다는 뜻은 아니다. 예를 들어 날씨 서비스와의 통신을 모킹해야 할 수도 있지만, 유닛 테스트에 비해 모킹을 훨씬 덜 사용한다.
통합 테스트의 정의와 관련된 용어가 항상 일관되지는 않다는 점에 유의해야 한다. 또한 유닛 테스트와 통합 테스트의 경계가 항상 명확하지 않을 수 있다. 이 가이드에서는 다음과 같은 경우를 통합 테스트로 간주한다:
- 위에서 언급한 대로 애플리케이션의 여러 모듈을 결합하는 경우
- 외부 시스템을 사용하는 경우
- 날씨 서비스 API와 같은 다른 애플리케이션에 네트워크 호출을 하는 경우
- 파일이나 데이터베이스 I/O를 수행하는 경우
컴포넌트 테스트
React 컴포넌트는 앱의 UI를 렌더링하고, 사용자는 이 출력물과 직접 상호작용한다. 비즈니스 로직이 높은 테스트 커버리지를 갖추고 정확하더라도, 컴포넌트 테스트가 없다면 사용자에게 깨진 UI를 제공할 수 있다. 컴포넌트 테스트는 단위 테스트와 통합 테스트 모두에 해당할 수 있지만, React Native의 핵심 부분이기 때문에 별도로 다룬다.
React 컴포넌트를 테스트할 때는 주로 두 가지를 확인한다:
- 상호작용: 사용자가 컴포넌트와 상호작용할 때 올바르게 동작하는지 확인한다. 예를 들어, 사용자가 버튼을 누르는 경우를 테스트할 수 있다.
- 렌더링: React가 사용하는 컴포넌트의 렌더링 출력이 정확한지 확인한다. 예를 들어, 버튼의 모양과 UI 내 위치를 테스트할 수 있다.
예를 들어, onPress
리스너가 있는 버튼이 있다면, 버튼이 올바르게 표시되는지와 버튼을 탭할 때 컴포넌트가 올바르게 처리하는지 모두 테스트해야 한다.
이를 도와주는 여러 라이브러리가 있다:
- React의 Test Renderer는 React 코어와 함께 개발되었으며, DOM이나 네이티브 모바일 환경에 의존하지 않고 React 컴포넌트를 순수 JavaScript 객체로 렌더링할 수 있다.
- React Native Testing Library는 React의 테스트 렌더러를 기반으로 구축되었으며, 다음 단락에서 설명할
fireEvent
와query
API를 추가로 제공한다.
컴포넌트 테스트는 Node.js 환경에서 실행되는 JavaScript 테스트일 뿐이다. React Native 컴포넌트를 지원하는 iOS, Android 또는 기타 플랫폼 코드는 고려하지 않는다. 따라서 사용자에게 모든 것이 100% 작동한다고 확신할 수 없다. iOS나 Android 코드에 버그가 있는 경우, 이 테스트로는 발견할 수 없다.
사용자 상호작용 테스트하기
컴포넌트는 UI를 렌더링하는 것 외에도 TextInput
의 onChangeText
나 Button
의 onPress
와 같은 이벤트를 처리한다. 또한 다른 함수나 이벤트 콜백을 포함할 수 있다. 다음 예제를 살펴보자:
function GroceryShoppingList() {
const [groceryItem, setGroceryItem] = useState('');
const [items, setItems] = useState<string[]>([]);
const addNewItemToShoppingList = useCallback(() => {
setItems([groceryItem, ...items]);
setGroceryItem('');
}, [groceryItem, items]);
return (
<>
<TextInput
value={groceryItem}
placeholder="Enter grocery item"
onChangeText={text => setGroceryItem(text)}
/>
<Button
title="Add the item to list"
onPress={addNewItemToShoppingList}
/>
{items.map(item => (
<Text key={item}>{item}</Text>
))}
</>
);
}
사용자 상호작용을 테스트할 때는 사용자 관점에서 컴포넌트를 테스트한다. 페이지에 무엇이 표시되는지, 상호작용 시 어떤 변화가 발생하는지 확인한다.
일반적으로 사용자가 보고 들을 수 있는 요소를 사용하는 것을 권장한다:
- 렌더링된 텍스트나 접근성 헬퍼를 사용해 검증한다.
반대로 다음과 같은 방법은 피해야 한다:
- 컴포넌트의 props나 state를 검증하는 것
- testID 쿼리 사용
props나 state와 같은 구현 세부 사항을 테스트하지 않는다. 이러한 테스트는 동작하지만, 사용자가 컴포넌트와 상호작용하는 방식과는 다르며 리팩토링 시 깨지기 쉽다(예: 일부 이름을 변경하거나 클래스 컴포넌트를 훅으로 재작성하는 경우).
React 클래스 컴포넌트는 내부 state, props, 이벤트 핸들러와 같은 구현 세부 사항을 테스트하기 쉽다. 구현 세부 사항을 테스트하지 않으려면 훅을 사용한 함수 컴포넌트를 선호한다. 이렇게 하면 컴포넌트 내부에 의존하기가 더 어려워진다.
React Native Testing Library와 같은 컴포넌트 테스트 라이브러리는 제공된 API를 신중하게 선택해 사용자 중심 테스트를 작성할 수 있도록 돕는다. 다음 예제는 fireEvent
메서드인 changeText
와 press
를 사용해 사용자가 컴포넌트와 상호작용하는 것을 시뮬레이션하고, getAllByText
쿼리 함수를 사용해 렌더링된 출력에서 일치하는 Text
노드를 찾는다.
test('given empty GroceryShoppingList, user can add an item to it', () => {
const {getByPlaceholderText, getByText, getAllByText} = render(
<GroceryShoppingList />,
);
fireEvent.changeText(
getByPlaceholderText('Enter grocery item'),
'banana',
);
fireEvent.press(getByText('Add the item to list'));
const bananaElements = getAllByText('banana');
expect(bananaElements).toHaveLength(1); // 'banana'가 목록에 있는지 확인
});
이 예제는 함수를 호출할 때 state가 어떻게 변하는지를 테스트하지 않는다. 대신 사용자가 TextInput
의 텍스트를 변경하고 Button
을 누를 때 어떤 일이 발생하는지 테스트한다!
렌더링 결과 테스트
스냅샷 테스트는 Jest에서 제공하는 고급 테스트 기법이다. 매우 강력하고 저수준의 도구이기 때문에 사용 시 주의가 필요하다.
"컴포넌트 스냅샷"은 Jest에 내장된 커스텀 React 시리얼라이저가 생성한 JSX와 유사한 문자열이다. 이 시리얼라이저는 Jest가 React 컴포넌트 트리를 사람이 읽을 수 있는 문자열로 변환할 수 있게 한다. 다시 말해, 컴포넌트 스냅샷은 테스트 실행 중에 생성된 컴포넌트의 렌더링 결과를 텍스트로 표현한 것이다. 예를 들어 다음과 같이 보일 수 있다:
<Text
style={
Object {
"fontSize": 20,
"textAlign": "center",
}
}>
Welcome to React Native!
</Text>
스냅샷 테스트를 할 때는 일반적으로 먼저 컴포넌트를 구현한 후 스냅샷 테스트를 실행한다. 스냅샷 테스트는 스냅샷을 생성하고 이를 레포지토리의 파일에 참조 스냅샷으로 저장한다. 이 파일은 커밋되고 코드 리뷰에서 검토된다. 이후 컴포넌트 렌더링 결과가 변경되면 스냅샷도 변경되어 테스트가 실패한다. 이때 참조 스냅샷을 업데이트해야 테스트가 통과한다. 이 변경 사항도 다시 커밋되고 리뷰를 거쳐야 한다.
스냅샷에는 몇 가지 약점이 있다:
- 개발자나 리뷰어 입장에서 스냅샷의 변경이 의도된 것인지, 아니면 버그의 증거인지 판단하기 어려울 수 있다. 특히 큰 스냅샷은 빠르게 이해하기 어려워지고 그 가치가 떨어질 수 있다.
- 스냅샷이 생성될 때, 렌더링 결과가 실제로 잘못된 경우에도 그 시점에 스냅샷이 올바르다고 간주된다.
- 스냅샷이 실패할 때,
--updateSnapshot
옵션을 사용해 적절한 조사 없이 스냅샷을 업데이트하려는 유혹이 있다. 따라서 개발자의 훈련이 필요하다.
스냅샷 자체는 컴포넌트 렌더링 로직이 올바른지 보장하지 않는다. 스냅샷은 예기치 않은 변경을 방지하고, 테스트 중인 React 트리의 컴포넌트가 예상된 props(스타일 등)를 받는지 확인하는 데 유용하다.
작은 스냅샷만 사용할 것을 권장한다(no-large-snapshots
규칙 참조). 두 React 컴포넌트 상태 간의 _변화_를 테스트하려면 snapshot-diff
를 사용하라. 의심스러울 때는 이전 단락에서 설명한 명시적인 기대값을 우선시하라.
엔드투엔드 테스트
엔드투엔드(E2E) 테스트는 사용자 관점에서 앱이 기기(또는 시뮬레이터/에뮬레이터)에서 예상대로 작동하는지 확인하는 과정이다.
이 테스트는 릴리스 구성으로 앱을 빌드한 후 실행하여 진행한다. E2E 테스트에서는 React 컴포넌트, React Native API, Redux 스토어 또는 비즈니스 로직을 고려하지 않는다. 이는 E2E 테스트의 목적이 아니며, 테스트 중에는 이러한 요소에 접근할 수도 없다.
대신, E2E 테스트 라이브러리를 사용하면 앱 화면에서 엘리먼트를 찾고 제어할 수 있다. 예를 들어, 실제 사용자와 동일한 방식으로 버튼을 탭하거나 TextInput
에 텍스트를 입력할 수 있다. 그런 다음 특정 엘리먼트가 화면에 존재하는지, 보이는지, 어떤 텍스트를 포함하는지 등을 확인할 수 있다.
E2E 테스트는 앱의 특정 부분이 제대로 작동한다는 최고 수준의 신뢰를 제공한다. 하지만 다음과 같은 단점도 있다:
- 다른 테스트 유형에 비해 작성 시간이 더 오래 걸린다.
- 실행 속도가 느리다.
- 불안정성이 높다("불안정한" 테스트는 코드 변경 없이 무작위로 통과하거나 실패하는 테스트를 의미한다).
앱의 중요한 부분(인증 흐름, 핵심 기능, 결제 등)을 E2E 테스트로 커버하도록 노력한다. 덜 중요한 부분에는 더 빠른 JS 테스트를 사용한다. 테스트를 추가할수록 신뢰도는 높아지지만, 유지보수와 실행에 드는 시간도 늘어난다. 이러한 장단점을 고려하여 최적의 선택을 한다.
E2E 테스트 도구는 다양하다. React Native 커뮤니티에서는 Detox가 인기 있는 프레임워크다. iOS와 Android 앱 분야에서는 Appium이나 Maestro도 널리 사용된다.
요약
이 가이드를 통해 유익한 시간을 보내고 새로운 지식을 얻었기를 바란다. 앱을 테스트하는 방법은 다양하다. 처음에는 무엇을 사용할지 결정하기 어려울 수 있다. 하지만 여러분의 멋진 React Native 앱에 테스트를 추가하기 시작하면 모든 것이 명확해질 것이다. 이제 더 이상 망설일 필요 없다. 테스트 커버리지를 높여보자!
유용한 링크
이 가이드는 Vojtech Novak이 원작자로 기여한 내용을 바탕으로 작성되었다.