번역] 모던 리액트 테스팅, 파트 1 - 모범사례

번역] 모던 리액트 테스팅, 파트 1 - 모범사례

Artem Sapegin의 Modern React testing, part 1: best practices를 저작자의 허가를 받고 번역한 글입니다.

이 시리즈는 React 컴포넌트, 좀 더 보편적으로는 프론트엔드 분야에서 이루어지는 테스팅의 오늘날 모습을 심층적으로 요약한 것으로, 단순히 방법만이 아니라 이유에 관해서도 설명합니다. 왜 자동화된 테스트를 작성해야 하고, 어떤 테스트를 해야 하는지, 어떻게 테스트해야 하는지에 대해 알아볼 것이고, 더 나아가서는 Jest, Enzyme 그리고 React Testing Library를 사용해서 어떻게 리액트 컴포넌트를 하는지 알아볼 것입니다.

3년 전에도 이와 비슷한 글을 썼는데, 지금 돌아보면 아주 엉망입니다. 그 당시에 추천했던 것들의 대부분은 지금 사용하지 않습니다.

이 글은 시리즈의 첫 번째 글이고, 왜 테스트 자동화가 유용하고, 어떤 종류의 테스트를 작성해야 하며, 테스팅 베스트 프랙티스가 무엇인지 배울 것입니다.

  • 모던 리액트 테스팅: 모범사례 (이 글)
  • 모던 리액트 테스팅: Jest와 Enzyme (*역자 주: 곧 번역할 예정입니다.)
  • 모던 리액트 테스팅: Jest와 React Testing Library (*역자 주: 곧 번역할 예정입니다.)

왜 테스트를 자동화하는가?

자동화 테스트가 유용한 이유는 여러 가지가 있지만, 제가 가장 좋아하는 이유는 ‘우리가 이미 테스트를 하고 있다’라는 점입니다.

새로운 버튼을 하나 추가한다고 생각해봅시다. 코드를 다 작성하고 나면, 브라우저를 연 다음, 버튼이 잘 작동하는지 클릭해 볼 것입니다. 이것이 수동 테스트입니다. 이 절차를 자동화함으로써 우리는 한번 제대로 동작했던 기능이 언제나 제대로 동작할 것이라는 확신을 할 수 있습니다.

자동화된 테스트는 특히 잘 사용되지 않는 기능들이 있을 때 유용합니다. 폼과 제출 버튼이 있다고 생각해봅시다. 우리는 폼이 잘 채워지고 제대로 값을 제출하는지는 매번 확인할 것이지만, 모달 창 어느 한 귀퉁이에 숨어있는 체크박스가 잘 동작하는지는 깜빡하고 테스트하지 않을 수도 있습니다. 자동화된 테스트는 이런 일이 일어나지 않도록 해줍니다.

테스트를 자동화해야 할 이유는 또 있습니다.

코드수정에 자신감이 생깁니다. 잘 쓴 테스트코드는 코드를 리팩토링 할 때, 내가 생각하지 못한 다른 무언가를 고장 내지 않는다는 자신감을 주고, 테스트코드를 수정하느라 시간을 낭비하지 않게 해줍니다.

테스트는 그 자체로 문서화의 기능을 합니다. 테스트코드는 그 자체로 코드가 어떻게 동작하고 또 어떻게 동작하도록 짜인 것인지 잘 설명해줍니다. 게다가 테스트코드는 따로 시간을 내서 작업해야 하는 다른 문서들과 달리 개발과정에 속해 있어서 자연스레 항상 최신상태를 유지합니다.

버그와 후퇴를 방지합니다. 발생한 모든 버그에 대해 테스트 케이스를 추가해두면, 그 버그가 다시 발생하지 않으리라는 것을 확신할 수 있습니다. 테스트코드를 작성하는 과정에서 코드를 더 잘 이해할 수 있게 되며, 좀 더 꼼꼼히 코드를 이해하게 되어서, 보통이라면 놓쳤을 만한 이슈들도 찾아낼 수 있습니다.

자동화된 테스트는 버그를 저장소에 커밋하기 전에 잡아서 수정할 수 있게 해주는데, 수동 테스트는 그럴 수 없어서 대부분의 버그를 테스트하는 과정이나 심한 경우 프로덕션 코드에서 발견하게 됩니다.

무엇을 테스트 할 것인가?

Mike Cohn이 소개한 테스팅 피라미드는 소프트웨어 테스팅에 있어서 아마 가장 유명한 접근법일 겁니다.

test-pyramid

이에 따르면 UI 테스트는 가장 느리며 코드 작성에 큰 비용이 드는 반면, 유닛 테스트는 가장 빠르고 비용이 저렴하므로, UI 테스트보다는 유닛 테스트를 더 많이 작성해야 한다고 합니다.

유닛 테스트는 하나의 함수나 리액트 컴포넌트와 같이, 단일 유닛의 코드를 테스트합니다. 유닛 테스트는 실제 브라우저나 데이터베이스를 사용하지 않기 때문에 아주 빠릅니다. UI 테스트는 실제 브라우저에 로드된 앱 전체를 테스트하고 보통 데이터베이스도 진짜를 사용합니다. 앱의 모든 부분이 제대로 동작하는지 확실히 하기 위해서는 이게 유일한 방법이지만, 너무 오래 걸리며 작성하기도 까다롭습니다. 서비스 테스트는 이 둘의 중간인데, UI 없이 여러 유닛의 통합(Integration)을 테스트합니다.

이러한 전략은 백엔드에서는 잘 통할지 모르나, 프론트엔드 환경에서 UI는 사용자의 흐름을 크게 바꾸지 않으면서도 종종 변화하기 때문에 종종 많은 유닛 테스트에서 에러를 내버립니다. 그래서 우리는 유닛 테스트를 업데이트 하는 데에 많은 시간을 쓰면서도 더 큰 단위인 기능이 제대로 동작할지에 대한 확신을 얻지 못합니다.

어쩌면 프론트엔드는 테스팅에 대해 다른 접근법이 필요한게 아닐까요?

Kent C. Dodds테스팅 트로피는 프론트엔트 테스트 분야에서 인기를 모으고 있습니다.

test-trophy

그의 주장에 따르면 통합 테스트가 가장 가성비가 좋아서, 다른 테스트보다는 통합 테스트를 많이 작성해야 한다고 합니다.

테스팅 트로피에서의 End-to-End 테스트는 테스팅 피라미드의 UI 테스트와 거의 같습니다. 통합 테스트는 큰 기능들 혹은 전체 페이지를 테스트하는데 이때 실제 백엔드나 데이터베이스 혹은 브라우저를 사용하지 않습니다. 예를 들면 로그인 페이지를 렌더링하고, 사용자 이름과 암호를 입력한 후에 로그인 버튼을 클릭했을 때, 올바른 네트워크 요청이 전송되는지 테스트하지만 실제로 네트워크 요청을 보내지는 않습니다. 이렇게 하는 방법은 나중에 알아볼 예정입니다.

통합 테스트는 작성하는데 꽤 큰 비용이 들지만, 유닛 테스트와 비교하면 몇 가지 장점들이 있습니다.

유닛 테스트 통합 테스트
테스트 하나가 단일 모듈 하나만 커버한다. 테스트 하나가 전체 기능 혹은 페이지 하나를 다 커버한다.
리팩토링 후에 종종 다시 작성해야한다. 대부분 리팩토링 후에도 살아남는다.
테스트에서 세부사항 구현을 피하기 힘들다. 실제 사용자가 앱을 사용하는 방식과 가깝다.

마지막 부분이 중요한데, 통합 테스트는 우리의 앱이 예상한 대로 동작한다는 것을 가장 확실히 보장해줄 수 있습니다. 하지만 그렇다고 통합 테스트만 작성하라는 뜻은 아닙니다. 다른 테스트들도 저마다 필요한 상황이 있습니다. 다만 가장 유용한 테스트에 한정된 자원을 집중할 필요도 있다는 뜻입니다.

테스팅 트로피의 각 단계에 대해서 하나씩 자세히 알아봅시다.

  1. 정적 분석은 문법 오류, 나쁜 코드 스타일, 잘못된 API 사용 등의 오류를 잡아냅니다. Prettier와 같은 코드 포매터, ESLint와 같은 린터 그리고 TypescriptFlow와 같은 타입체커가 사용됩니다.
  2. 유닛 테스트는 복잡한 알고리즘이 제대로 동작하는지 확인합니다. Jest를 사용합니다.
  3. 통합 테스트는 앱의 모든 기능이 제대로 동작한다는 확신을 줍니다. Jest, Enzyme 혹은 react-testing-library를 사용합니다.
  4. E2E 테스트는 앱 전체가 제대로 동작하는지 확인해줍니다. 프론트엔드, 백엔드 그리고 데이터베이스와 그외의 모든 것들이요. Cypress라는 도구를 사용합니다.

저는 Prettier도 테스팅 툴이라고 생각하는데, Prettier는 잘못된 코드는 이상하게 포매팅하고, 그러면 당신은 코드를 다시 한 번 돌아보게 될 것이며, 계속 읽다 보면 버그를 발견할 수도 있기 때문입니다.

이 외에도 유용한 프로젝트마다 유용한 테스트 방법들이 있을 수 있습니다.

모범 사례

내부를 테스팅하지마라.

폼 컴포넌트가 있다고 가정해봅시다. 이메일 입력과 제출 버튼이 있고, 사용자가 폼을 제출하면 성공 메시지를 보여주는 것을 테스트하고 싶습니다. 테스트코드를 작성해봅시다.

1
2
3
4
5
6
test('shows a success message after submission', () => {
const wrapper = mount(<SubscriptionForm />)
wrapper.instance().handleEmailChange('hello@example.com')
wrapper.instance().handleSubmit()
expect(wrapper.state('isSubmitted')).toBe(true)
})

이 테스트에는 몇 가지 문제가 있습니다.

  • 만약 상태관리 방법을 변경하거나( 예를 들어 state에서 redux나 훅으로 ), 아니면 필드나 메소드 이름 정도만 변경해도 테스트는 깨질 것입니다.
  • 이 테스트는 실제로 사용자 입장에서 폼이 제대로 동작하는지 확인해주지 않습니다. 폼이 handleSubmit 메소드와 연결되어있지 않을 수도 있고, isSubmittedtrue여도 성공 메시지가 출력되지 않을 수도 있습니다.

첫 번째 문제는 false negative 라고 하는데, 이는 실제 동작과 상관없이 테스트에서 실패하는 경우를 의미합니다. 이러한 테스트는 리팩토링을 굉장히 어렵게 만듭니다. 코드를 수정하는 과정에서 무언가 망가뜨린 것인지, 테스트가 잘못된 건지 알 수가 없거든요.

두 번째 문제는 false positive 라고 하고, 이는 위와 반대로 실제로는 실패해야 하는 케이스가 테스트를 통과하는 경우를 의미합니다. 이러한 테스트는 코드가 실제로 사용자 단계에서 제대로 동작하는지에 대한 확신을 전혀 주지 못합니다.

테스트코드를 다시 짜서 문제를 해결해봅시다.

1
2
3
4
5
6
test(`shows a success message after submission`, () => {
const {getByLabelText, getByText, getByRole} = render(<SubscriptionForm />);
fireEvent.change(getByLabelText(/email/i, { target: { value: `hello@example.com` } });
fireEvent.click(getByText(/submit/i);
expect(getByRole(`status`).textContent).toMatch(`Thank you for subscribing!`);
});

Kent C. Dodds의 테스팅 구현 세부사항에 대한 글을 읽어 보시면 자세한 내용을 확인하실 수 있습니다.

좋은 테스트는 외적인 동작이 정확하다는 것만 증명하고, 내부가 어떻게 구현되는지 자세한 사항은 몰라도 됩니다.

테스트는 결정적(deterministic)이어야 한다.

비결정적인 테스트라고 하면 어떨 때는 통과하고 또 어떨 때는 실패하는 테스트를 의미합니다.

비결정적인 테스트가 생기는 이유는 다음과 같습니다.

  • 서로 다른 타임 존에서 실행되어서,
  • 서로 다른 파일 시스템(특히 다른 경로 구분자)을 사용해서,
  • 테스트 케이스마다 데이터베이스를 제대로 초기화하지 않아서,
  • 상태 값이 여러 테스트 케이스에서 공유되어서,
  • 테스트의 결과가 여러 테스트 케이스들의 실행 순서에 의존적이어서,
  • 비동기적인 작업을 테스트할 때 타임아웃이 발생해서.

이런 비결정적인 테스트의 문제는 polling, 가짜 타이머 혹은 모킹으로 해결할 수 있는데, 앞으로 다음 글에서 알아볼 예정입니다.

좋은 테스트는 결정적이어서 테스트가 실행되는 환경에 상관없이 같은 결과를 내야 합니다.

불필요한 테스트는 하지 마라.

이런 테스트코드를 볼 때가 있습니다.

1
2
expect(pizza).toBeDefined()
expect(pizza).toHaveAProperty('cheese', 'Mozarella')

pizzadefine 되지 않았다면 어차피 두 번째 테스트에서 실패할 것이기 때문에 첫 번째 줄은 필요하지 않습니다. 이런 경우의 jest의 에러메시지는 충분히 이해할만 한 수준이고요.

가끔은 테스트케이스 전체가 불필요한 때도 있습니다.

1
2
test(`error modal is visible`, () => {})
test(`error modal has an error message`, () => {})

모달의 에러 메시지가 보인다면 모달 자체도 보인다고 생각할 수 있을 겁니다. 따라서 첫 줄은 삭제해도 됩니다.

좋은 테스트는 불필요하게 중복되는 테스트케이스가 없어야 합니다.

100% 커버리지에 너무 집착하지 마라.

모든 코드를 완벽하게 테스트로 커버한다는 건 이론상으로는 훌륭해 보이지만 같지만 실제로는 그렇지 않습니다.

높은 테스트 커버리지를 추구하는 것에는 몇 가지 문제점들이 있습니다.

  • 높은 테스트 커버리지는 안전함에 대한 잘못된 감각을 갖게 합니다. 테스트로 커버된 코드라는 것은 해당 코드가 테스트 중에 실행된다는 뜻이지 해당 코드의 기능을 확실히 테스트한다는 뜻은 아닙니다. 100% 커버리지가 반드시 모든 코드가 제대로 테스팅 되고 있다는 것을 의미하지는 않습니다.
  • 파일 업로드나 드래그앤드랍같은 몇몇 기능은 테스트하기가 정말 어렵습니다. 몇몇 내부작업을 모킹해야하고 결과적으로 실제로 사용자가 당신 앱을 사용하는 환경과는 거리가 생기며 테스트를 유지하기도 어려워집니다. 결국, 점점 덜 유용한 코드를 작성하는데 많은 시간을 들이게 되는 거죠.

경험적으로 봤을 때 100% 커버리지가 유용한 두 가지 경우가 있습니다.

  • 라이브러리를 제작할 때에는 기존의 api를 깨뜨리지 않는 것이 정말 중요하기 때문에 100% 커버리지가 필요합니다.
  • 오픈소스 프로젝트에서는 코드 베이스에 익숙하지 않은 많은 컨트리뷰터가 변화를 주기 때문에 100% 커버리지가 필요합니다.

좋은 테스트는 유지하기 쉬워야 하고, 코드 수정에 자신감을 줘야 합니다.

결론

지금까지 프론트엔드 테스트 작성에 있어서 가장 중요한 이론들과 모범사례를 살펴보았습니다.

  • 다른 테스트들 보다는 통합 테스트를 많이 작성해라
  • 내부 테스트를 지양해라.
  • 테스트는 결정적이어야 한다.
  • 불필요한 테스트는 하지 마라.
  • 100% 커버리지에 너무 집착하지 마라.

이제 실제로 테스트코드를 작성할 준비가 끝났습니다. 시리즈의 다음 글들은 두 갈래로 나뉘는 글이기 때문에, Enzyme 이든 React Testing Libary 이든 마음에 드는 도구에 관한 글을 선택해서 하나만 읽으셔도 괜찮습니다. 아직 어떤 도구를 골라야 할 지 모르겠다면 각 글의 초입에 해당 도구의 장·단점이 설명되어있으니 선택하는 데 도움이 될 수 있을 것입니다.

댓글