테스트
코드베이스가 확장되면서 예상치 못한 작은 오류와 엣지 케이스가 더 큰 실패로 이어질 수 있다. 버그는 사용자 경험을 악화시키고 결국 비즈니스 손실로 이어진다. 이러한 취약한 프로그래밍을 방지하는 한 가지 방법은 코드를 실제 환경에 배포하기 전에 테스트를 거치는 것이다.
이 가이드에서는 정적 분석부터 엔드투엔드 테스트에 이르기까지, 앱이 예상대로 작동하는지 확인하는 다양한 자동화된 방법을 다룬다.
테스트의 중요성
사람은 실수를 하기 마련이다. 테스트는 이러한 실수를 발견하고 코드가 제대로 작동하는지 확인하는 데 도움을 준다. 더 중요한 점은, 테스트를 통해 새로운 기능을 추가하거나 기존 기능을 리팩토링하거나 주요 의존성을 업그레이드할 때도 코드가 계속 정상적으로 작동함을 보장할 수 있다.
테스트의 가치는 생각보다 크다. 코드의 버그를 수정하는 가장 좋은 방법 중 하나는 버그를 드러내는 실패하는 테스트를 작성하는 것이다. 그런 다음 버그를 수정하고 테스트를 다시 실행했을 때 통과하면, 버그가 수정되었고 다시 코드에 도입되지 않았음을 의미한다.
테스트는 팀에 새로 합류한 사람들에게 문서 역할도 할 수 있다. 코드베이스를 처음 접하는 사람들에게는 테스트를 읽는 것이 기존 코드가 어떻게 작동하는지 이해하는 데 도움이 된다.
마지막으로, 자동화된 테스트가 많을수록 수동 QA에 소요되는 시간이 줄어들어 소중한 시간을 절약할 수 있다.
정적 분석
코드 품질을 향상시키는 첫 번째 단계는 정적 분석 도구를 사용하는 것이다. 정적 분석은 코드를 실행하지 않은 상태에서 작성 중인 코드의 오류를 검사한다.
- **린터(Linter)**는 코드를 분석해 사용하지 않는 코드나 잠재적인 문제를 발견한다. 또한 스타일 가이드를 위반한 부분을 알려준다. 예를 들어, 설정에 따라 탭 대신 공백을 사용해야 하는 경우를 체크한다.
- 타입 체크는 함수에 전달하는 값이 함수가 기대하는 타입과 일치하는지 확인한다. 예를 들어, 숫자를 기대하는 카운팅 함수에 문자열을 전달하는 것을 방지한다.
React Native는 기본적으로 두 가지 도구를 제공한다. 코드 검사를 위한 ESLint와 타입 체크를 위한 TypeScript다.
테스트 가능한 코드 작성하기
테스트를 시작하려면 먼저 테스트 가능한 코드를 작성해야 한다. 비행기 제조 과정을 생각해 보자. 복잡한 시스템이 잘 작동하는지 확인하기 위해 비행기를 처음으로 날리기 전에, 각 부품이 안전하고 제대로 작동하는지 테스트한다. 예를 들어, 날개는 극한 하중에서 휘어지는지 테스트하고, 엔진 부품은 내구성을 검증하며, 앞유리는 새 충돌 시뮬레이션을 거친다.
소프트웨어도 마찬가지다. 수많은 코드를 하나의 거대한 파일에 작성하는 대신, 여러 작은 모듈로 나누어 작성하면 전체를 테스트하는 것보다 더 철저하게 검증할 수 있다. 따라서 테스트 가능한 코드를 작성하는 것은 깔끔하고 모듈화된 코드를 작성하는 것과 밀접하게 연결되어 있다.
앱을 더 테스트하기 쉽게 만들려면, 먼저 앱의 뷰 부분(React 컴포넌트)을 비즈니스 로직과 앱 상태(Redux, MobX 또는 다른 솔루션을 사용하든 상관없이)와 분리한다. 이렇게 하면 React 컴포넌트에 의존하지 않는 비즈니스 로직 테스트를 컴포넌트 자체와 독립적으로 유지할 수 있다. 컴포넌트의 주요 역할은 앱의 UI를 렌더링하는 것이다!
이론적으로는 모든 로직과 데이터 가져오기를 컴포넌트 밖으로 옮길 수도 있다. 이렇게 하면 컴포넌트는 오직 렌더링에만 집중하게 된다. 상태는 컴포넌트와 완전히 독립적이 되며, 앱의 로직은 React 컴포넌트 없이도 작동할 수 있다.
테스트 가능한 코드에 대한 더 깊은 이해를 위해 다른 학습 자료를 탐색해 보기를 권장한다.
테스트 작성하기
테스트 가능한 코드를 작성한 후에는 실제 테스트를 작성할 차례이다. React Native의 기본 템플릿은 Jest 테스트 프레임워크를 포함한다. 이 환경에 맞춰진 프리셋이 제공되므로, 설정과 모의 객체(mock)를 바로 조정하지 않고도 생산적으로 작업할 수 있다. 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)
테스트 대상 객체가 외부 의존성을 가질 때, 이 의존성을 '모킹'하는 경우가 있다. 모킹은 코드의 특정 의존성을 자신이 구현한 것으로 대체하는 것을 의미한다.
일반적으로 테스트에서 실제 객체를 사용하는 것이 모킹보다 더 좋지만, 특정 상황에서는 모킹이 불가피하다. 예를 들어, 자바스크립트 단위 테스트가 자바나 Objective-C로 작성된 네이티브 모듈에 의존하는 경우가 그렇다.
현재 도시의 날씨를 보여주는 앱을 개발 중이고, 날씨 정보를 제공하는 외부 서비스나 다른 의존성을 사용한다고 가정해 보자. 서비스가 비가 온다고 알려주면, 비구름 이미지를 화면에 표시해야 한다. 하지만 테스트에서 이 서비스를 직접 호출하고 싶지 않다. 이유는 다음과 같다:
- 네트워크 요청이 포함되면 테스트가 느려지고 불안정해질 수 있다.
- 서비스가 매번 다른 데이터를 반환할 수 있다.
- 테스트를 실행해야 할 때 서드파티 서비스가 다운될 수도 있다.
따라서 서비스의 모킹 구현을 제공하면, 수천 줄의 코드와 인터넷에 연결된 온도계를 효과적으로 대체할 수 있다.
Jest는 모킹 지원을 제공하며, 함수 단위부터 모듈 단위까지 다양한 수준에서 모킹을 할 수 있다.
통합 테스트
규모가 큰 소프트웨어 시스템을 개발할 때는 각각의 개별 요소들이 서로 상호작용해야 한다. 유닛 테스트에서는 특정 유닛이 다른 유닛에 의존할 경우, 해당 의존성을 모의 객체(mock)로 대체하는 경우가 많다.
통합 테스트에서는 실제 개별 유닛들을 결합하여(앱에서와 동일하게) 함께 테스트하고, 이들이 기대한 대로 협력하는지 확인한다. 여기서도 모의 객체를 사용할 수는 있다(예를 들어 날씨 서비스와의 통신을 모의하는 경우). 하지만 유닛 테스트에 비해 모의 객체를 사용하는 빈도는 훨씬 적다.
통합 테스트의 정의와 관련된 용어가 항상 일관되지는 않다는 점에 유의해야 한다. 또한 유닛 테스트와 통합 테스트의 경계가 항상 명확하지 않을 수 있다. 이 가이드에서는 다음과 같은 경우를 통합 테스트로 간주한다:
- 위에서 설명한 대로 앱의 여러 모듈을 결합하는 경우
- 외부 시스템을 사용하는 경우
- 다른 애플리케이션에 네트워크 호출을 하는 경우(예: 날씨 서비스 API)
- 파일이나 데이터베이스와의 입출력(I/O)을 수행하는 경우
컴포넌트 테스트
React 컴포넌트는 앱을 렌더링하는 역할을 하며, 사용자는 이 컴포넌트의 출력과 직접 상호작용한다. 비즈니스 로직에 대한 테스트 커버리지가 높고 정확하더라도, 컴포넌트 테스트를 하지 않으면 사용자에게 깨진 UI를 전달할 가능성이 있다. 컴포넌트 테스트는 단위 테스트와 통합 테스트 모두에 해당할 수 있지만, React Native에서 매우 핵심적인 부분이기 때문에 따로 다룬다.
React 컴포넌트를 테스트할 때는 주로 두 가지를 확인한다:
- 상호작용: 사용자가 컴포넌트와 상호작용할 때 올바르게 동작하는지 확인 (예: 사용자가 버튼을 누를 때)
- 렌더링: React가 사용하는 컴포넌트 렌더링 출력이 올바른지 확인 (예: 버튼의 외관과 UI 내 위치)
예를 들어, onPress
리스너가 있는 버튼이 있다면, 버튼이 올바르게 나타나는지와 버튼을 탭했을 때 컴포넌트가 올바르게 처리하는지 모두 테스트해야 한다.
이를 도와줄 수 있는 라이브러리는 다음과 같다:
- React의 Test Renderer는 React 컴포넌트를 순수 JavaScript 객체로 렌더링할 수 있는 렌더러를 제공한다. DOM이나 네이티브 모바일 환경에 의존하지 않는다.
- 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와 유사한 문자열이다. 이 직렬화 도구는 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 테스트를 사용한다. 테스트를 많이 추가할수록 신뢰도는 높아지지만, 유지보수와 실행에 드는 시간도 증가한다. 이러한 트레이드오프를 고려해 자신에게 가장 적합한 방식을 선택한다.
React Native 커뮤니티에서 널리 사용되는 E2E 테스트 도구로는 Detox가 있다. React Native 앱에 특화되어 있다. iOS와 Android 앱 분야에서 인기 있는 다른 라이브러리로는 Appium과 Maestro가 있다.
요약
이 가이드를 통해 유익한 내용을 얻고 즐겁게 읽었기를 바란다. 앱을 테스트하는 방법은 다양하다. 처음에는 어떤 방법을 사용할지 결정하기 어려울 수 있다. 하지만 훌륭한 React Native 앱에 테스트를 추가하기 시작하면 모든 것이 명확해질 것이다. 이제 더 이상 망설일 이유가 없다. 테스트 커버리지를 높여보자!
관련 링크
이 가이드는 Vojtech Novak이 원본을 작성하고 기여했다.