구현 세부 사항 테스트(Testing Implementation Details)

구현 세부 사항을 테스트하는 것은 재앙을 부르는 지름길이다. 왜일까?
참고: 이것은 내가 보내는 뉴스레터의 크로스 포스트(cross-post) 이다. 각 이메일은 발송 후 2 주 후에 게시된다. 이메일로 이전과 같은 더 많은 콘텐츠를 구독하자! 💌
지난해 내가 enzyme을 사용했을 때 (당시에는 다른 모든 사람들처럼) enzyme의 특정 API를 사용하는 데 신중했다. 나는 얕은 렌더링(shallow rendering), instance(), state () 또는 find ( 'ComponentName')와 같은 API를 절대 사용하지 않았다. 그리고 다른 사람들의 풀리퀘스트에 대한 코드 리뷰에서 나는 왜 이러한 API를 피하는 것이 중요한지에 대해 반복해서 설명했다. 그 이유는 그런 테스트를 통해 컴포넌트의 구현 세부 사항을 테스트 할 수 있게 되기 때문이다. 사람들은 종종 "구현 세부 사항"이 의미하는 바를 묻는다. 참 설명하기 어렵다! 이런 규칙들이 왜 필요한 걸까?

구현 세부 사항을 테스트 하는 것은 왜 좋지 않을까?

구현 세부 사항을 테스트하지 않는 중요한 이유는 두 가지다:
  1. 코드 리팩토링시에 깨질 수 있다. 잘못된 부정(False negatives)
  2. 앱이 잘못되어도 실패하지 않을 수 있다. 잘못된 긍정(False positives)
다음의 간단한 아코디언 컴포넌트를 예제로 사용하여 이들 각각을 차례로 살펴보자:
// accordion.js import 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
구현 세부 사항 테스트는 다음과 같다:
// __tests__/accordion.enzyme.js import React from 'react' // if you're wondering why not shallow, // then please read blog.kentcdodds.com/c08851a68bb7 import Enzyme, {mount} from 'enzyme' import EnzymeAdapter from 'enzyme-adapter-react-16' import Accordion from '../accordion'
// Setup enzyme's react adapter 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) })
코드베이스 (🙌)에서 이와 같은 테스트를 보았다면 손을 들어보자.
자, 이제 테스트 코드가 어떻게 어플리케이션을 망가뜨리는지 살펴 보자...

리팩터링시의 잘못된 부정(false negatives)

놀랍게도 많은 사람들이 특히 UI 테스트 하는 것을 힘들어한다. 왜일까? 여러 가지 이유가 있지만 반복해서 듣는 한 가지 큰 이유는 사람들이 테스트를 보는데 너무 많은 시간을 할애한다는 것이다. "코드를 변경할 때마다 테스트가 실패한다!". 이것은 생산성을 크게 떨어 뜨린다! 우리의 테스트가 어떻게 이 좌절스러운 문제에 빠지게되는지 보자.
여러 아코디언 아이템을 한 번에 열어 볼 수 있도록 이 아코디언을 리팩토링한다고 가정 해보자. 이 요구사항은 기존의 동작을 전혀 변경하지 않고 단지 구현만 변경하면된다. 이제 동작을 변경하지 않는 방식으로 구현을 변경해 보자.
class Accordion extends React.Component { - state = {openIndex: 0} - setOpenIndex = openIndex => this.setState({openIndex}) + state = {openIndexes: [0]} + setOpenIndex = openIndex => this.setState({openIndexes: [openIndex]}) render() { - const {openIndex} = this.state + const {openIndexes} = this.state return ( <div> {this.props.items.map((item, index) => ( <> <button onClick={() => this.setOpenIndex(index)}> {item.title} </button> - {index === openIndex ? ( + {openIndexes.includes(index) ? ( <AccordionContents>{item.contents}</AccordionContents> ) : null} </> ))} </div> ) } }
우리는 앱을 빠르게 확인하고 모든 것이 제대로 작동하는 걸 확인했으니 나중에 이 컴포넌트에서 여러 개의 아코디언을 열 수 있는 기능을 제공 하면 좋을 것이다. 하지만, 테스트를 돌려보니 떡하니 에러가 난다. 어디서 에러가 나는 걸까? setOpenIndex는 열린(open) 인덱스 상태를 올바르게 설정한다.
무슨 에러가 발생한 것일까?
expect(received).toBe(expected)
Expected value to be (using ===): 0 Received: undefined
이 테스트 실패는 진짜 문제를 알려주는 것일까? 그렇지않다! 컴포넌트는 여전히 잘 동작하고 있지 않나.
이것이 거짓 부정이라고 불리는 것이다. 이 의미는 테스트 실패의 이유가 깨진 테스트때문이지 코드의 문제는 아니라는 것이다. 솔직히 이보다 더 짜증나는 테스트 실패 상황이 떠오르진 않는다. 테스트 코드를 고쳐보자:
test('setOpenIndex sets the open index state properly', () => { const wrapper = mount(<Accordion items={[]} />) - expect(wrapper.state('openIndex')).toEqual(0) + expect(wrapper.state('openIndexes')).toEqual([0]) wrapper.instance().setOpenIndex(1) - expect(wrapper.state('openIndex')).toEqual(1) + expect(wrapper.state('openIndexes')).toEqual([1]) })
시사점(the takeaway): 코드를 리펙토링 할 때 구현 세부 사항을 테스트하면 거짓 부정을 줄 수 있다. 이렇게 되면 결국 코드가 언제든지 깨지기 쉬운 테스트로 이어진다.

거짓 긍정

자, 팀 동료가 Accordion 컴포넌트를 작업하고 있다고 가정 해보자. 코드는 다음과 같다:
<button onClick={() => this.setOpenIndex(index)}> {item.title} </button>
"이봐! 인라인 화살표 함수(arrow function)는 성능면에서 좋지 않으니 정리해 보겠다. 효과적일 거야. 이것을 빨리 바꾸고 테스트를 돌려보자. "
<button onClick={this.setOpenIndex}> {item.title} </button>
좋다. 테스트를 실행해보니 ...✅✅ 됐다! 테스트가 통과 했으니 브라우저에서 실제로 확인하지 않고 코드를 커밋한다. 이 커밋은 수천 줄의 코드를 변경하고 이해할 수 없게 놓여있는 전혀 관계없는 PR로 진행된다. 아코디언은 프로덕션에서 동작하지 않고 낸시(Nancy)는 내년 2월 솔트 레이크에서 위키트(Wicked) 티켓을 구할 수 없다. 낸시는 울고 팀은 끔찍할 것이다.
그래서 무엇이 잘못 되었을까? setOpenIndex가 호출되면 아코디언 내용이 적절하게 표시되고 상태가 변경되는지 확인하기위한 테스트가 있지 않은가? 그렇다! 그러나 문제는 버튼이 setOpenIndex에 올바르게 연결되었는지 확인하는 테스트가 없다는 것이다.
이를 거짓 긍정(false positive)이라고 한다. 이것은 테스트 실패 케이스를 작성했어야 한다는 것을 말해준다! 그렇다면 이런 일이 다시 일어나지 않도록 하려면 우리는 어떻게 해야할까? 버튼을 클릭하면 상태가 올바르게 업데이트되는지 확인하기 위한 또 다른 테스트를 추가해야 한다. 그리고나서 우리는 이 실수를 다시하지 않기 위해 100% 코드 커버리지를 달성 할 필요가 있다. 아, 그리고 사람들이 테스트 세부 구현을 위한 API를 사용하지 않도록하기 위해 십여 가지 정도의 ESLint 플러그인을 작성해야 한다!
...하지만 난 신경 쓰지 않을거야 ... 음, 나는 이 거짓 긍정과 부정 모두에 ​​너무 지쳤다. 나는 차라리 테스트를 쓰지 않는 편이 낫다고 생각하게 된다. 모든 테스트를 지워버리자! 더 넓은 pit of success을 가진 도구가 있다면 좋지 않을까? 그렇다! 우리는 그런 도구가 있다!

구현 세부 사항이 필요없는 테스트

이러한 모든 테스트를 enzyme으로 다시 작성할 수 있다. 구현 세부 사항이 없는 API로 스스로를 제한 할 수도 있지만 대신에 react-testing-library를 사용하면 테스트에 구현 세부 사항을 포함시키기가 어려울 것이다. 지금 확인해보자!
// __tests__/accordion.rtl.js import React from 'react' import {render, fireEvent} from 'react-testing-library' 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', } const {getByText, queryByText} = render( <Accordion items={[hats, footware]} />, )
expect(getByText(hats.contents)).toBeInTheDocument() expect(queryByText(footware.contents)).toBeNull()
fireEvent.click(getByText(footware.title))
expect(getByText(footware.contents)).toBeInTheDocument() expect(queryByText(hats.contents)).toBeNull() })
좋다! 모든 행동을 아주 잘 검증하는 단일 테스트. 그리고 이 테스트는 내 상태가 openIndex, openIndexes 또는 tacosAreTasty 🌮 인지 여부부를 전달한다. 좋다! 이 거짓 부정을 제거했다! 그리고 클릭 핸들러를 잘못 연결하면 이 테스트는 실패한다. 이 거짓 긍정도 제거했다! 그리고 규칙을 외워둘 필요도 성가신 ESLint 플러그인을 설치할 필요도 없다. 난 그냥 도구를 사용하고, 실제로 내 아코디언 사용자가 원하는대로 일하고 있다는 자신감을 줄 수있는 테스트를 얻을있다.

그래서... 구현 세부 사항이 도대체 무엇일까?

이게 내가 할 수있는 가장 간단한 정의다:
구현 세부 사항은 일반적으로 사용자가 코드를 사용하거나 보거나 알 수없는 것들이다.
따라서 우리가 대답해야 할 첫 번째 질문은 "이 코드의 사용자는 누구인가"이다. 브라우저에서 우리 컴포넌트와 상호 작용할 최종 사용자는 뭐라해도 사용자(user)이다. 이들은 렌더링 된 버튼과 내용을 보고 상호 작용할 것이다. 그러나 우리는 props(이 경우에는 주어진 목록)으로 아코디언을 렌더링하는데 사용하는 개발자도 있다. 따라서 React 컴포넌트에는 일반적으로 최종 사용자와 개발자라는 두 명의 사용자가 있는 것이다. 최종 사용자(end-users)와 개발자는 애플리케이션 코드가 고려해야하는 두 "사용자"이다.
그렇다면 각 사용자가 우리 코드의 어느 부분을 사용하고 보고 알 수 을까? 최종 사용자는 render 메소드에서 렌더링 한 내용을보고/상호 한다. 개발자는 컴포넌트로 전달 할 props를 보고/상호 작용한다. 따라서 우리의 테스트는 일반적으로 전달 된 소품과 렌더링 된 결과 만보고 상호 작용해야한다.
이것이 바로 react-testing-library 테스트의 역할이다. 그것은 가짜(fake) props를 Accordion으로 전달한 다음 사용자에게 표시 될 내용의 출력을 쿼리하거나 표시되지 않도록 보장하고, 렌더링 된 버튼을 클릭하여 출력과 상호 작용한다.
이제 enzyme 테스트를 보자. enzyme으로 우리는 openIndexstate에 접근한다. 이것은 사용자가 직접 신경 쓰는 것이 아니다. 그들은 그것이 어떻게 호출되는지, openIndex가 단일 primitive 값으로 저장되는지 또는 배열로 저장되는지를 알지 못한다. 솔직히 상관이 없다. 그들은 또한 setOpenIndex 메소드를 특별히 모르거나 신경 쓰지 않는다. 그럼에도 불구하고 우리 테스트는 이러한 구현 세부 사항을 모두 알고 있다.
이것이 우리의 enzyme 테스트가 거짓 부정으로 만드는 경향이있다. 최종 사용자 및 개발자와는 다른 방식으로 컴포넌트를 사용하도록 만들게 되면 어플리케이션 코드에서 고려해야 할 세 번째 사용자를 만드는 것이나 다름없다. 그것은 바로 테스트이다! 솔직히 테스트는 아무도 신경 쓰지 않는 한명의 사용자이다. 내 어플리케이션 코드가 테스트를 고려하는 것을 원하지 않는다. 얼마나 시간 낭비인가? 나는 테스트 만을 위한 테스트를 원하지 않는다. 자동화 된 테스트는 프로덕션 사용자를 위해 어플리케이션 코드가 작동하는지 확인해야한다.
테스트가 소프트웨어 사용 방식과 유사할수록 더 많은 확신을 줄 수 있다. - 나
아, 그리고 React Hooks를 기대하고 있다면? 아코디언 컴포넌트를 React Hooks으로 다시 작성하면 enzyme 테스트는 크게 실패하는 반면 react-testing-library 테스트는 계속 작동한다.
presentation

결론

그렇다면 구현 세부 사항 테스트를 어떻게 피할 수 있을까? 올바른 도구를 사용하는 것이 좋다. 몇 주 전에 나는 무엇을 테스트 할지를 알기 위해이 프로세스를 보냈다.이 프로세스를 따르면 테스트 할 때 올바른 사고 방식을 취할 수 있으며 구현 세부 사항을 자연스럽게 피할 수 있다.
  1. 테스트되지 않은 코드베이스 중에 어떤 부분이 파손되면 정말 좋지 않은가? (체크아웃 프로세스)
  2. 하나의 유닛 또는 몇개의 유닛 코드 단위로 범위를 좁힌다 ("체크 아웃"버튼을 클릭하면 카트에 있는 요청이 /checkout으로 전송된다)
  3. 해당 코드를보고 "사용자"가 누구인지 고려하십시오 (체크 아웃 양식을 렌더링하는 개발자, 최종 사용자가 단추를 클릭하는 경우)
  4. 해당 사용자가 직접 코드를 테스트하여 코드가 손상되지 않았는지 확인하기위한 지침 목록을 작성한다. (카트의 가짜 데이터로 양식을 렌더링하고 결제 버튼을 클릭 한 다음 올바른 데이터와 함께 mocked / checkout API가 호출되었는지 확인하고 가짜 성공적인 응답으로 응답하고 성공 메시지가 표시되는지 확인한다).
  5. 이 지침 목록을 자동화 된 테스트로 바꾼.
이게 도움이 됐기를 바란다! 테스트 실력을 올리고 싶다면 TestingJavaScript.com Pro 라이센스를 추천한다.
행운을 빈다!
P.S. 여기 codesandbox에서 직접 가지고 놀 수도 있다.
P.S.P.S. 만약에 AccordionContents 컴포넌트의 이름을 변경하면 그 두 번째 enzyme 테스트는 어떻게될까? {insert biggest eye roll ever}
더 읽을거리 :
  • React Hooks and Suspense Playlist on Egghead.io - 35 분 분량의 무료 영상으로 React Hooks와 Suspense 데모가 포함되어 있다. Hook을 테스트하는 두 개의 영상 포함되어 있다!
  • WPACK.IO - wpack.io는 WordPress의 Theme과 Plugin Development를 위해 특별히 제작 된 webpack/browser-sync 구성이다. 이것은 훌륭한 개발자 경험 (DX)과 모든 자바스크립트와 css/sass/scss를 번들링한다.
  • Lessons from Java for testing in React
  • “A brief analysis and comparison of the CSS for Twitter’s PWA vs Twitter’s legacy desktop website. The difference is dramatic and I’ll touch on some reasons why.”