이 글은 Kent C. Dodds의 How to know what to test 포스트를 번역한 글입니다.
Thank you Kent C. Dodds. Because of your posts, I can continue to grow as an engineer.
테스트하는 방법을 아는 것은 훌륭하고 중요한 일이다. 나는 사람들에게 테스트의 기본 사항, 도구 구성 방법, 특정 시나리오에 대한 테스트 작성 방법 등을 알려주는 많은 콘텐츠를 만들었다.
하지만 테스트를 작성하는 방법을 알아도, 애플리케이션에 대한 신뢰성을 얻기 위해서는 그저 절반일 뿐이다. 무엇을 테스트할지 아는 것은 중요한 또 다른 절반이다.
워크숍 자료와 TestingJavaScript.com에서 무엇을 테스트해야 하는지 아는 방법에 대해 이야기하고 있지만, 이에 대한 질문을 많이 받아서 블로그 포스팅을 작성하는 것이 좋겠다고 생각했다. 가보자고!
테스트하는 이유 기억하기
우리는 사용자가 애플리케이션을 사용할 때 제대로 작동할 것이라는 신뢰성을 위해 테스트를 작성한다. 워크플로우를 개선하기 위해 테스트를 작성하는 사람들도 있지만, 나는 궁극적으로 신뢰성에 관심이 있다.
그렇기 때문에 우리가 테스트하는 것은 신뢰성 향상과 직결되어야 한다. 테스트를 작성할 때 고려해야 할 핵심 사항은 다음과 같다.
테스트 중인 코드에 대한 생각보다는 코드가 지원하는 Use Case에 대해 더 많이 생각하라.
코드 자체에 대해 생각하면 구현 세부 사항을 테스트하기 시작하게 되는데, 이것은 재앙으로 가는 길이다.
반대로 Use Case를 생각함으로써, 우리는 사용자가 애플리케이션을 사용하는 방식과 유사한 방식으로 테스트를 작성할 수 있다.
(※ 역자 주: 코드 내부의 동작 방식에 초점을 맞추는 게 아닌, 코드가 제공하는 기능을 테스트하는 것에 초점을 맞추라는 뜻이다.)
Code Coverage < Use Case Coverage
Code Coverage는 실행된 코드 라인의 비율을 나타내는 지표로, 테스트 중에 실행된 코드 라인을 보여준다. 예를 들어:
function arrayify(maybeArray) {
if (Array.isArray(maybeArray)) {
return maybeArray
} else if (!maybeArray) {
return []
} else {
return [maybeArray]
}
}
현재, 이 함수에 대한 테스트가 없으므로 Code Coverage 보고서에는 이 함수에 대한 Coverage가 '0%'라고 표시된다.
이 경우 Code Coverage 보고서는 테스트가 필요함을 알려주지만, 이 함수의 중요성과 이 함수가 지원하는 Use Case가 무엇인지는 알려주지 않는다. 이것은 우리가 테스트를 작성할 때 가장 중요하게 고려해야 하는 사항이다.
사실 전체 애플리케이션을 고려하고 무엇을 테스트해야 할지 고민할 때, Code Coverage 보고서는 대부분의 시간을 어디에 사용해야 할지에 대한 인사이트를 제공하는 데 아주 부족하다. 대신 우리의 코드베이스에서 어떤 코드가 테스트되지 않았는지를 식별하는 데 도움이 된다.
따라서 Code Coverage 보고서를 보고 테스트가 누락된 부분을 확인할 때 if/else 문, loop 문, lifecyles에 대해 생각하지 말고, 자신에게 물어봐라:
"What use cases are these lines of code supporting, and what tests can I add to support those use cases?"
이 코드 라인들은 어떤 Use Case를 지원하고 있으며, 이 Use Case를 지원하는 어떤 테스트를 추가할 수 있을까?
"Use Case Coverage"는 우리가 테스트하는 Use Case의 수를 나타내는 지표이다. 불행하게도 자동화된 "Use Case Coverage"는 없다. 우리가 직접 만들어야 한다.
하지만 Code Coverage 보고서는 때때로 우리가 커버하지 않은 Use Case를 식별하는 데 도움이 될 수 있다. 한번 시도해 보자.
따라서 코드를 읽고 잠시 생각해 보면, "배열이 주어지면 배열을 리턴한다."는 Use Case를 파악할 수 있다.
이 Use Case 문장은 실제로 우리의 테스트에 대한 좋은 제목이 될 수 있다.
test('배열이 주어지면 배열을 리턴한다.', () => {
expect(arrayify(['Elephant', 'Giraffe'])).toEqual(['Elephant', 'Giraffe'])
})
이 테스트가 완료되면 Coverage 보고서는 다음과 같이 표시된다 (강조 표시된 선이 Coverage):
이제 나머지를 살펴보면 아직 테스트하지 않은 Use Case가 두 개 더 있다는 것을 확인할 수 있다:
- falsy 값이 주어지면 빈 배열을 리턴한다.
- 배열이 아닌 인자가 주어지면 해당 인자를 원소로 가지는 배열을 리턴한다.
테스트에 이 Use Case들을 추가하고, Code Coverage에 어떤 영향을 미치는지 확인해 보자.
test('falsy 값이 주어지면 빈 배열을 리턴한다', () => {
expect(arrayify()).toEqual([])
})
좋아! 거의 다 왔어!
test(`배열이 아니고 falsy 값이 아니라면, 해당 인자를 원소로 가지는 배열을 리턴한다.`, () => {
expect(arrayify('Leopard')).toEqual(['Leopard'])
})
멋지다! 브라보! 이제 이 함수의 Use Case를 변경하지 않는 한 테스트가 계속 정상적으로 통과할 것이라고 신뢰할 수 있다.
Code Coverage가 완벽한 지표는 아니지만, 우리 코드베이스에서 "Use Case Coverage"가 빠진 부분을 식별하는 유용한 도구가 될 수 있다.
Code Coverage로 Use Case를 숨길 수 있다.
가끔 Code Coverage 보고서에는 Code Coverage가 100%로 표시되지만, Use Case Coverage가 100%가 아닐 수 있다.
이것이 바로 내가 테스트를 작성하기 전에 모든 Use Case를 생각하려고 노력하는 이유이다.
예를 들어 `arrayify` 함수가 다음과 같이 구현되었다고 가정해 보자:
function arrayify(maybeArray) {
if (Array.isArray(maybeArray)) {
return maybeArray
} else {
return [maybeArray].filter(Boolean)
}
}
아래 두 Use Case를 통해, 우리는 100%의 Coverage를 얻을 수 있다.
- 배열이 주어지면 배열을 리턴한다.
- 배열이 아닌 인자가 주어지면 해당 인자를 원소로 가지는 배열을 리턴한다.
하지만 Use Case Coverage 보고서를 보면 아래 Use Case는 빠져있는 것으로 나올 것이다:
- falsy 값이 주어진다면, 빈 배열을 리턴한다.
이는 문제가 될 수 있다. 사용자가 직접 `arrayify()` 코드를 사용할 때 이러한 테스트 케이스는 코드에 대한 확신을 주지 못할 것이기 때문이다.
지금은 잘 작동하는 것 같고, 이 케이스에 대한 테스트를 작성하지 않아도 코드가 해당 Use Case를 지원할 수 있지만, 테스트를 작성하는 이유는 코드의 변경이 발생하더라도 우리가 의도한 Use Case를 계속해서 지원할 수 있도록 보장하기 위함이다.
따라서, 우리는 이러한 Use Case를 커버할 수 있도록 테스트를 추가해야 한다.
이 테스트가 누락되면 어떻게 잘못될 수 있는지에 대한 예로, 누군가 이 `.filter(Boolean)` 코드를 보고 "음, 이상하네... 저게 정말 필요한 건가?"라고 생각할 수 있다.
그래서 제거하면 테스트는 계속 통과하지만 잘못된 동작에 의존하는 모든 코드는 손상된다.
핵심 요점:
Test use cases, not code.
코드를 테스트하지 말고, Use Case들을 테스트하라.
React에 적용하는 방법
코드를 작성할 때는 지원해야 하는 사용자가 이미 두 명이라는 것을 기억하라: End 사용자와 developer 사용자. 다시 말하지만, Use Case보다 코드에 대해 초점을 맞추면, 코드의 세부사항을 구현하기 시작하게 되는 함정에 빠지기 쉽다.
그렇게 되면 의도치 않은 세 번째 사용자, 즉 테스트 사용자가 추가된다.
다음은 React로 개발하는 개발자들이 테스트에 대해 생각하는 몇 가지 측면이 있는데, 결국 세부 사항을 구현하는 테스트를 하게 된다.
이 모든 것에 대해 코드에 대해 생각하기보다는 코드가 End 사용자와, developer 사용자에게 영향을 미치는 요소에 대해 생각해야 하며, 다음과 같은 Use Case에 대해 생각하고 이를 테스트하라.
- 라이프사이클 메서드(Lifecycle methods)
- 요소 이벤트 핸들러(Element event handlers)
- 내부 컴포넌트 상태(Internal Component State)
반면, 두 사용자와 관련이 있으므로 테스트해야 하는 항목은 다음과 같다. 각각 DOM을 변경하거나, HTTP 요청을 하거나 콜백 프로퍼티를 호출하거나, 관찰 가능한 side effect 생성등 테스트를 하기 위한 여러 가지 유용한 작업들이 있다:
- 사용자(User) 상호작용(`@testing-library/user-event`라이브러리에서는 `userEvent`를 사용.): End 사용자가 컴포넌트가 렌더링 하는 요소(element)와 상호작용할 수 있는가?
- 프로퍼티(Props) 변경(React Testing Library에서 `rerender` 사용): developer 사용자가 컴포넌트를 새로운 프로퍼티로 re-render 되면 어떻게 되는가?
- 컨텍스트(Context) 변경(React Testing Library에서 `rerender` 사용): developer 사용자가 컨텍스트를 변경하여 컴포넌트가 re-reder 되면 어떻게 되는가
- 구독(Subscription) 변경: 컴포넌트가 구독하는 이벤트 Emitter가 변경되면 어떻게 되는가? (fierbase, redux store, router, media query, 온라인 상태와 같은 브라우저 기반 구독 같은)
애플리케이션에서 어디서부터 시작해야 하는지 어떻게 알 수 있나?
애플리케이션의 개별 컴포넌트와 페이지에 대해 '무엇을 테스트할지' 생각하는 방법은 알지만, 어디서부터 시작해야 하나?
다소 부담스러울 수 있다. 특히 대규모 애플리케이션에서 테스트를 막 시작한 경우라면 더욱 그렇다.
따라서 사용자의 관점에서 애플리케이션을 고려하고 다음과 같이 질문하라:
이 애플리케이션이 고장 났을 때 가장 화가 나는 부분은 어디인가?
좀 더 일반적으로:
이 애플리케이션에서 에러가 발생하면 큰일이 나는 곳은 어디인가?
애플리케이션이 지원하는 기능의 목록을 작성하고, 이 기준에 따라 우선순위를 정하는 것이 좋다. 팀 및 관리자와 함께 해보는 것도 좋다.
이 회의를 통해 회의에 참석한 모든 사람들이 테스트의 중요성을 이해하고, 테스트가 다른 기능 개발보다 우선순위를 갖는 것이 필요하다는 것을 확신하게 되어, 많은 이점을 얻을 수 있을 것이다.
우선순위가 정해진 목록이 있으면 특정 Use Case에 대해 대부분의 사용자가 거치는 '행복한 경로'를 포괄하는 단일 End To End(E2E) 테스트를 작성하는 것이 좋다.
이 방법으로 목록에 있는 몇 가지 주요 기능들을 테스트할 수 있다. 설정하는데 시간이 조금 걸릴 수 있지만, 투자 대비 큰 효과를 얻을 수 있다.
E2E테스트는 100% Use Case Coverage를 제공하지 않지만(100%를 얻으려는 시도조차 하지 말아야 한다.) 100% Code Coverage를 제공하지 않지만(E2E 테스트에서는 신경 쓰지 말아야 한다.), 좋은 출발점이 될 것이며, 현재 코드에 대한 자신감을 크게 높여줄 것이다.
몇 가지 E2E 테스트 케이스가 있으면 E2E 테스트에서 놓치고 있는 일부 특정 케이스에 대한 통합 테스트와, 해당 기능이 사용하는 더 복잡한 비즈니스 로직에 대한 Unit 테스트 작성을 시작할 수 있다.
100% Code Coverage를 목표로 삼지 말아야 한다. 그럴 가치가 없다.
테스트 문화와 합리적인 코드 커버리지 목표를 설정하는 방법에 대해 자세히 알아보려면 AssertJS 2018에서 발표한 Aaron Abramov의 강연을 시청하면 좋다: 소프트웨어 설계 원칙으로 테스트 패턴 수립하기
다양한 테스트 유형의 차이점에 대해 자세히 알아보려면 이 게시물을 보는 것이 좋다: 프론트엔드에서의 Static, Unit, Integration, E2E 테스트
결론
충분한 시간과 경험이 주어지면 무엇을 테스트해야 하는지 직관적으로 알 수 있다. 실수도 하고 약간의 어려움을 겪을 수도 있다. 포기하지 말고 계속 진행하자!
Good luck.