이 글은 Kent C. Dodds의 Testing Implementation Details 포스트를 번역한 글입니다.
Thank you Kent C. Dodds. Because of your posts, I can continue to grow as an engineer.
이전에 enzyme를 사용할 때 (그 시절 모두가 사용했던 것처럼), 나는 enzyme의 특정 API를 신경 써서 사용했다. shallow rendering를 완전히 피하기 위해, `instance()`, `state()`, `find('컴포넌트이름')`와 같은 API를 사용하지 않았다. 그리고 다른 사람들의 풀 리퀘스트 코드를 검토할 때, 왜 이런 API들을 피해야 하는지에 대해 몇 번이고 설명했다.
그 이유는 이런 API들이 컴포넌트의 `구현 세부 사항`을 테스트하도록 만들기 때문이다.
사람들은 종종 '`구현 세부 사항`'이 무엇을 의미하는지 묻는다. 나는 '테스트를 더 어렵게 만드는 것'이라는 뜻으로 사용한다.
`구현 세부 사항` 테스트는 왜 나쁜가?
`구현 세부 사항`을 테스트해서는 안 되는 두 가지 큰 이유가 있다.
- 리팩터링만 했을 뿐인데 테스트 실패. False Negative
- 코드의 로직이 변경되어 정상적으로 동작하지 않음에도 테스트가 성공. False Positive
명확하게 말하자면, 테스트는 "소프트웨어가 작동하는지 확인하는 것"을 의미한다. 테스트가 성공하면 테스트는 "positive"(소프트웨어가 작동함) 임을 의미한다. 그렇지 않으면 테스트는 "Negative"(소프트웨어가 작동하지 않음)가 되었다는 것을 의미한다. "False"라는 용어는 테스트에서 잘못된 결과가 나왔다는 것을 의미한다.
즉, 소프트웨어가 실제로는 고장이 났지만 테스트는 성공(False Positive)하거나, 소프트웨어가 실제로는 작동하지만 테스트는 실패한 경우(False Negative)를 의미한다.
아래의 간단한 Accordion 컴포넌트를 예로 들어 각 컴포넌트들을 살펴보자
// accordion.js
import * as React from 'react'
import AccordionContents from './accordion-contents'
class Accordion extends React.Component {
state = {openIndex: 0}
setOpenIndex = openIndex => this.setState({openIndex})
render() {
const {openIndex} = this.state
return (
<div>
{this.props.items.map((item, index) => (
<>
<button onClick={() => this.setOpenIndex(index)}>
{item.title}
</button>
{index === openIndex ? (
<AccordionContents>{item.contents}</AccordionContents>
) : null}
</>
))}
</div>
)
}
}
export default Accordion
위의 예제에서 왜 최신 함수 컴포넌트(with hook)가 아닌 구식 클래스 컴포넌트를 사용하는지 궁금하겠지만, 계속 읽다 보면 흥미로운 사실을 깨달을 것이다. (enzyme를 알면 이미 예상하고 있을 수도 있다.)
다음은 `구현 세부 사항`을 테스트하는 코드다:
// __tests__/accordion.enzyme.js
import * as React from 'react'
// shadow render를 사용하지 않는 이유
// https://kcd.im/shallow
import Enzyme, {mount} from 'enzyme'
import EnzymeAdapter from 'enzyme-adapter-react-16'
import Accordion from '../accordion'
// enzyme 리액트 어댑터 설치
Enzyme.configure({adapter: new EnzymeAdapter()})
test('setOpenIndex sets the open index state properly', () => {
const wrapper = mount(<Accordion items={[]} />)
expect(wrapper.state('openIndex')).toBe(0)
wrapper.instance().setOpenIndex(1)
expect(wrapper.state('openIndex')).toBe(1)
})
test('Accordion renders AccordionContents with the item contents', () => {
const hats = {title: 'Favorite Hats', contents: 'Fedoras are classy'}
const footware = {
title: 'Favorite Footware',
contents: 'Flipflops are the best',
}
const wrapper = mount(<Accordion items={[hats, footware]} />)
expect(wrapper.find('AccordionContents').props().children).toBe(hats.contents)
})
자신의 코드베이스에서 위와 같은 테스트를 본(혹은 작성한)적이 있다면 손을 들어보자(🙌).
OK. 이제 이 테스트가 어떤 문제를 일으키는지 살펴보자...
리팩터링시 False Nagatives
의외로 많은 사람들이 테스트, 특히 UI 테스트를 싫어한다. 왜 그럴까? 여러 가지 이유가 있지만, 내가 반복해서 듣는 한 가지 큰 이유는 테스트를 돌보는 데 너무 많은 시간을 소비하기 때문이다. "코드를 변경할 때마다 테스트가 실패한다!" 이는 생산성에 큰 걸림돌이 된다.
우리의 테스트가 왜 이런 짜증나는 문제를 일으키는지 살펴보자.
위의 Accordion을 여러 개의 Accordion이 한 번에 열 수 있도록 리팩터링 해보자. 리팩터링은 구현만 변경하고, 기존 동작은 전혀 변경하지 않는 것을 말한다. 따라서 동작을 변경하지 않도록 구현을 변경해 보자.
여러 Accordion 요소를 한 번에 열 수 있도록 내부 상태를 `openIndex`에서 `openIndexes`로 변경해 보자.
좋아, 앱에서 간단히 확인해 보니 모든 것이 여전히 제대로 작동하는 것으로 나타났다. 이제 나중에 여러 개의 Accordion을 여는 기능을 컴포넌트에 쉽게 추가할 수 있을 것 같다!
이제 테스트를 해보자! 💥펑💥 이런, 테스트가 실패했다. 어느 테스트가 실패한 걸까? `setOpenIndex sets the open index state properly.` 테스트가 실패했다.
에러 메시지 내용은?
expect(received).toBe(expected)
Expected value to be (using ===):
0
Received:
undefined
위의 테스트 실패가 실제 컴포넌트의 문제에 대해 경고하는 건가? 아니다! 컴포넌트는 여전히 정상적으로 작동한다.
이를 False Negative라고 한다. 애플리케이션 코드에 문제가 생긴 것이 아니라, 테스트 코드에 문제가 생겼기 때문에 테스트가 실패한 것을 의미한다. 솔직히 이런 상황이 테스트에서 제일 짜증 난다. 자, 테스트 코드를 수정해 보자.
결론: `구현 세부 사항`을 테스트하는 이러한 테스트 케이스에는 리팩터링 시 False Negative를 줄 수 있다. 그 결과 유지 관리가 어렵고 성가신 테스트 코드가 많이 생성된다.
False Positives
이제 동료가 Accordion 컴포넌트 작업을 하다가 다음과 같은 코드를 발견했다고 가정해 보자.
<button onClick={() => this.setOpenIndex(index)}>{item.title}</button>
그는 금방이라도 최적화 하고 싶은 마음에 "그래, render 함수 내부의 인라인 화살표 함수는 성능이 좋지 않으니, 리팩터링 해야겠다! 제대로 동작할 것 같은데, 빠르게 고치고 테스트해보자."라고 생각한다.
<button onClick={this.setOpenIndex}>{item.title}</button>
Cool. 테스트를 실행해보니... ✅✅ 완벽하다! 테스트에 대해 매우 확신하기 때문에 따로 브라우저에서 확인하지 않고 코드를 커밋했다. 이 커밋은 아무런 관련이 없는 PR(Pull Request)과 섞여 있고, 이 PR에는 수천 줄의 코드가 포함되어 있어 눈에 띄지 않게 된다.
결국 운영 환경에서 Accordion 기능이 고장 나게 되고, Nancy는 Salt Lake에서 다가오는 2월의 위키드 공연을 가지 못해 울고 있고, 당신의 팀은 기분이 좋지 않다 (※ 역자 주: 에러 코드를 찾고 수정하느라 공연을 보러 가지 못하고, 팀원들도 마찬가지...)
그렇다면 무엇이 잘못된 걸까? `setOpenIndex`가 호출될 때 상태가 변경되고 Accordion의 내용이 적절하게 표시되는지 확인하는 테스트가 없었나? 아니, 해당 기능의 테스트가 있었다!
하지만 문제는 `button`이 `setOpenIndex`에 올바르게 연결되었는지 확인하는 테스트가 없었다는 것이다.
이것을 False Positive라고 부른다. 이는 테스트는 성공했지만, 원래는 실패했어야 한다는 뜻이다! 어떻게 하면 재발을 방지할 수 있을까? 버튼 클릭 시 상태가 올바르게 업데이트되는지 확인하기 위한 또 다른 테스트를 추가해야 한다. 그리고 다시는 이런 실수를 반복하지 않도록 Code Coverage를 항상 100%로 유지해야 한다.
아, 그리고 ESLint 플러그인을 많이 만들어서 사람들이 구현 세부 사항을 테스트 하는 API를 사용하지 않도록 막아야 한다.
... 하지만 너무 귀찮다... 우리는 False Positive와 False Negative에 지쳐버린 상태다. 거의 테스트를 작성하지 않는 것이 더 좋을 것 같다. 모든 테스트를 삭제해 버리자!
만약 더 넓은 성공 구덩이(pit of success) (※역자 주: 구덩이의 절벽은 U자 형태로 되어있으니 올라가는 것(실패)은 어렵고 떨어지는 것(성공)은 쉽다. 즉 잘 만든 시스템일수록 성공하기는 쉽고, 실패하기는 오히려 어렵다는 뜻.)를 가진 도구가 있으면 좋지 않을까? 물론이지! 그런 도구가 있다!
`구현 세부 사항`이 없는 테스트
우리는 `구현 세부 사항`을 테스트하지 않도록 API를 제한하면서 Enzyme의 모든 테스트를 다시 작성할 수 있지만, React Testing Library 라이브러리를 사용하면 `구현 세부 사항`을 테스트하는 것이 애초에 어렵기 때문에, 이 라이브러리를 사용하기로 한다. 지금 바로 확인해 보자!
// __tests__/accordion.rtl.js
import '@testing-library/jest-dom/extend-expect'
import * as React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Accordion from '../accordion'
test('can open accordion items to see the contents', () => {
const hats = {title: 'Favorite Hats', contents: 'Fedoras are classy'}
const footware = {
title: 'Favorite Footware',
contents: 'Flipflops are the best',
}
render(<Accordion items={[hats, footware]} />)
expect(screen.getByText(hats.contents)).toBeInTheDocument()
expect(screen.queryByText(footware.contents)).not.toBeInTheDocument()
userEvent.click(screen.getByText(footware.title))
expect(screen.getByText(footware.contents)).toBeInTheDocument()
expect(screen.queryByText(hats.contents)).not.toBeInTheDocument()
})
좋아. 하나의 테스트에서 모든 동작을 성공적으로 검증할 수 있다. 또한 이 테스트는 내부 상태의 이름이 `openIndex`, `openIndexes`, `tacosAreTasty`🌮 중 어느 것이든 상관없이 성공한다. Nice! False Negative를 해결했다!
또한 클릭 핸들러가 제대로 연결되지 않은 경우에 테스트가 실패한다. 이러면 False Positive도 해결된다. 그리고 어떤 API를 사용하면 안 되는지를 기억할 필요도 없다. React Testing Library를 정상적으로 사용하기만 하면, Accordion 컴포넌트가 사용자가 원하는 대로 작동하고 있다는 확신을 가질 수 있는 테스트를 얻을 수 있다.
그럼... `구현 세부 사항`은 정확히 뭐야?
간단하게 정의하자면 다음과 같다.
구현 세부 사항은 사용자가 사용하거나 보거나 이해하지 못하는 사항이다.
우리가 답해야 할 첫 번째 질문은 "우리 코드의 사용자는 누구인가?"다.
브라우저에서 컴포넌트와 상호 작용할 end-user는 분명 사용자다. 그들은 렌더링 된 버튼과 콘텐츠를 보고 상호작용 할 것이다.
또한 주어진 props(위 코드의 경우 아이템의 리스트)로 Accordion을 렌더링 할 개발자도 있다. 그래서 React 컴포넌트에는 일반적으로 end-user와 developer라는 두 명의 사용자가 있다.
End-user와 developer는 애플리케이션 코드를 만들 때 반드시 고려해야하는 두 명의 "사용자"다.
그렇다면 각 사용자는 코드의 어떤 부분을 사용하고, 보고, 이해할 수 있을까?
End-user은 컴포넌트에서 렌더링 된 화면을 보고 상호작용한다. 반면 developer는 컴포넌트에 전달하는 props를 보고 상호 작용한다.
따라서 테스트는 일반적으로 "전달된 props를 올바르게 사용할 수 있는가" 와 "렌더링 된 화면이 올바른가" 이 두 가지를 테스트해야 한다.
이것이 바로 React Testing Library가 하는일이다. 이 라이브러리는 더미 props를 Accordion 컴포넌트에 전달한 다음 React Element를 쿼리 하는 구문(getByRole, getByText)등을 사용하여 렌더링 된 화면을 표시하고(또는 표시하지 않고), 렌더링 된 버튼을 클릭하여 상호작용한다.
Enzyme의 테스트를 생각해 보자. Enzyme는 `state와` `openIndex`를 참조하고 있었다. 위에서 언급한 두 가지 유형의 사용자에게는 어떤 함수가 호출되었는지, 인덱스가 primitive 한 값으로 저장되는지, 배열로 저장되는지는 솔직히 신경 쓰지 않는다. 또한 `setOpenIndex` 메서드에 대해서는 특히 알 필요가 없다.
하지만 Enzyme의 테스트 케이스는 아무도 신경 쓰지 않는 것들을 테스트하고 있다.
이것이 바로 Enzyme 테스트가 False Negatives를 일으키기 쉬운 이유다. end-user와 developer와는 다른 방식으로 테스트를 작성하기 때문에 애플리케이션 코드가 고려해야 하는 세 번째 사용자, 즉 테스트가 등장한다! 이 테스트는 솔직히 아무도 신경 쓰지 않는 사용자다.
나는 애플리케이션 코드가 이 세 번째 사용자인 테스트를 고려하지 않았으면 좋겠다. 완전히 시간 낭비다. 테스트 자체를 위한 테스트는 필요하지 않다. 자동화된 테스트는 애플리케이션 코드가 운영 환경의 사용자에게 잘 작동하는지 확인해야 한다.
"The more your tests resemble the way your software is used, the more confidence they can give you. — me"
"테스트가 소프트웨어 사용 방식과 유사할수록, 그것들이 제공하는 신뢰도는 더욱 높아진다." — kent
이에 대해 좀 더 알고 싶다면 다음을 참고하라. Avoid the Test User
그렇다면 hooks는?
글쎄, enzyme는 hooks에서 많은 문제가 있다. `구현 세부 사항`을 테스트할 때 구현이 변경되면 테스트에 큰 영향을 미친다. 클래스 컴포넌트를 hooks가 있는 함수형 컴포넌트로 마이그레이션 하는 경우 그 과정에서 테스트의 무언가를 망가뜨려도 알 수 없기 때문에 문제가 생긴다.
반면 React Test Library는 어떨까? 클래스 컴포넌트와 hooks가 있는 함수형 컴포넌트 둘다 정상적으로 작동한다. 이 글의 마지막에 있는 codesandbox 링크에서 확인해 볼 수 있다. 나는 React Testing Library로 작성하는 테스트를 다음과 같이 부른다:
구현 세부 사항이 없고 리팩터링에 용이하다.
결과
어떻게 `구현 세부 사항`을 테스트 하는 것을 피할 수 있을까? 올바른 테스팅 도구를 사용하는 것이 시작이다. 여기 무엇을 테스트해야 하는지 알기 위한 프로세스가 있다.
이 프로세스를 따르면 테스트할 때 올바른 마인드로 테스트할 수 있으며, 자연스럽게 `구현 세부 사항`을 피할 수 있다.
- 테스트되지 않은 코드베이스 중에서 에러가 발생했을 때 심각한 문제가 발생할 가능성이 있는 부분은 어디인가?( 예: 결제 시 에러)
- 해당 코드를 한개의 unit 혹은 몇 개의 unit으로 범위를 좁혀라 (예: "결제" 버튼 클릭 시 장바구니의 아이템과 함께 /checkout으로 요청함)
- 해당 코드를 살펴보고 '사용자'가 누구인지 생각하라(결제 form을 렌더링하는 developer, 버튼을 클릭하는 end-user)
- 코드가 손상되지는 않았는지 사용자가 직접 수동으로 테스트하기 위한 지침서를 작성하라.
예:- 더미 데이터가 들어있는 장바구니를 사용하여 결제 form을 렌더링한다.
- 결제 버튼을 클릭한다.
- /checkout API로 보낼 때 더미 데이터가 올바르게 전송되는지 확인한다.
- fake 성공 응답이 잘 내려오는지 확인한다.
- 성공 메시지가 잘 보이는지 확인한다.
- 작업한 지침서를 자동화된 테스트로 작성하라.
도움이 되었으면 좋겠다! 테스트를 한 단계 더 발전시키고 싶다면 다음 사이트에서 Pro license를 얻는 것을 추천한다. TestingJavaScript.com🏆
Good luck!
P.S. 위의 코드들을 확인하고 싶다면, codesandbox에서 확인할 수 있다.
P.S.P.S. 연습 문제로... 만약 `AccordionContents` 컴포넌트의 이름을 바꾸면 두 번째 Enzyme 테스트에서는 어떤 일이 일어날까?