본문 바로가기

Client/Front-end

<FE> 프론트 엔드의 테스트 (with. Jest, testing-library)

1. 소개

 - 소프트웨어 분야에서 테스트란 프로그램이 요구사항에 맞게 동작하는지 확인하는 것을 의미한다. 이러한 테스트를 통해 추후 발생할 문제들을 사전에 발견하고, 요구사항을 충족시키는지 확인할 수 있다. 게다가 코드를 수정하거나 개선하면서 생길 수 있는 부가적인 문제들을 확인하며 개발자는 어플리케이션의 품질을 보장할 수 있다.

 

 - 모던 소프트웨어에서 테스트는 상당히 발전하여 수많은 툴로 자동화를 할 수 있게되었다. 개발자가 직접 테스트를 하지 않고 라이브러리와 같은 툴의 도움으로 더 빠르고 정확한 테스트를 수행할 수 있다. 하지만 프론트엔드는 점점 복잡해지고 있을뿐만 아니라 프론트엔드 특성상 사용자와 격리된 환경에서 테스트를 작성하는 것이 쉽지 않다. 

 

 - 이번 포스팅에서는 위와 같은 환경에서 프론트 엔드는 테스트를 어떻게 작성할 수 있을지 무엇이 좋은 테스트일지 생각하고 공유하는 차원에서 작성하였다. 다행히 복잡한 환경에서도 테스트 코드를 작성할 수 있는 다양한 도구가 등장했다. 필자는 리액트 공식 문서에서 권장하는 jest와 testing-libray를 예시로 사용하겠다.(각 라이브러리의 사용법 및 셋팅은 다루지 않음)

 

2. 테스트 종류

* Static Test

 - 정적 테스트를 테스트로 보지 않는 경향도 있으나 엄연히 테스트라고 생각한다. 정적 테스트란 코드를 실행시키지 않고 테스트를 하는 것을 의미한다. 이를 통해 개발자의 실수를 예방하고 좋은 코드를 작성하게 한다.

 

 

 - 프론트엔드의 경우 ESLint를 통해 사용되지 않는 변수를 찾거나 컨벤션을 강제할 수 있다. 위 이미지는 필자의 프로젝트에서 console에 warn옵션을 주어 경고를 표시한 것이다. 이 외에도 타입스크립트를 통해 타입 검사를 시행하고 리액트에서 props를 강제하는 등이 좋은 예시이다.

 

* Unit Test

 - 단위 테스트는 가장 흔히 작성되는 테스트이다. 최소 단위의 유틸 함수, 커스텀 훅, 하나의 컴포넌트 등을 테스트 한다. 먼저 간단한 함수를 테스트하는 예시를 살펴보자.

test('sum fn', () => {
  const result = mySum(10, 20);
  expect(result).toBe(30);
}

 - mySum이라는 유틸 함수가 제대로 동작하는지 테스트 코드를 작성하였다. 이를 돌려보면 아래와 같이 함수 테스트 결과를 받아볼 수 있다.

 

 

 - 함수 테스트는 사실 예상 범주 안이다. 그렇다면 모던 프론트엔드에서 빠질 수 없는 컴포넌트는 대체 어떻게 테스트 할 수 있을까. 브라우저를 켜지 않고도 가능한 것일까. 다행히 가능하다. testing-library와 같은 라이브러리들은 노드 환경에서 jsdom 이라는 가상의 DOM에서 테스트를 진행시킬 수 있도록 돕는다. 

 - 만약 클릭하면 글자가 'disabled'로 변경되고 버튼 상태 또한 disabled로 변경되어야하는 컴포넌트가 있다고 가정하자. 이를 testing-library를 통해 테스트하면 아래와 같이 작성할 수 있다.

 

test('button disalbed', () => {
  render(<MyButton />)

  fireEvent.click(screen.getByText('click'))

  expect(screen.getByRole('button')).toBeDisabled()
})

 

 - MyButton이라는 컴포넌트를 jsdom에 렌더링 시킨후, 스크린에서 click이라는 글자를 클릭하도록 하라고 작성하였다. testing-library는 DOM 환경을 테스트하기 위한 다양한 메서드를 제공한다. 텍스트를 통해 요소를 찾거나 Role(접근성과 관련), 요소 그자체 등이 있는데 여기서는 텍스트로 찾아 클릭하였고, 버튼 역할을 가진 요소가 disabled 되었는지를 확인하였다.

 

 - 단위 테스트의 경우 코드를 작성하면서 결합도가 높고 의존성이 낮은 코드를 작성할 수 있게 돕는다는 장점이 있다. 이를 통해 리팩토링을 수월하게 진행하도록 돕는다. 하지만 큰 리팩토링이 발생할 경우 테스트가 깨져버릴 수 있다. 필자의 경우 특정 라이브러리를 완전히 변경하면서 기존의 테스트 코드들이 엉망이 된 경험이 있다.

 

* Integration Test

 - 통합 테스트는 두 개 이상의 모듈을 연결하여 테스트하는 것을 의미한다. 단위 테스트 보다 넓은 범위에서 테스트하여 단위 테스트와 달리 리팩토링에도 쉽게 깨지지 않는다. 통합 테스트의 경우 테스트 더블(모의 코드나 데이터)의 사용 여부에 따라 brad test와 narrow test로 구분된다.

 

 - 프론트 엔드의 경우 API와 상호작용이 올바르게 일어나는지, 또는 상태에 따라 UI가 잘 변경되는지를 테스트할 수 있을 것이다. API의 경우 백엔드와의 상호 작용이 될 수 있고, 상태의 경우 Redux, Mob X등의 Store와의 상호작용이 될 수도 있다.

 

 - 실제 백엔드의 API를 호출하여 broad test를 진행할 경우 실제 어플리케이션에 가까운 테스트를 진행할 수 있고, 폭넓은 검증을 할 수 있다는 장점이 있다. 그러나 경우에 따라 테스트가 실패할 경우 책임의 분리가 모호해질 수도 있다. 이럴 경우 목 데이터를 활용거나 목 서버를 활용하는 narrow test를 통해 역할을 분리할 수 있다.

 

 - 목 데이터를 활용하여 컴포넌트를 테스트하는 코드를 살펴보자. 아래 코드는 msw 라이브러리를 활용하여 서버측 목 데이터를 구축하였고, Greeting 버튼을 클릭하면 인삿말 데이터를 받아오도록 하였다. 데이터는 h태그에 표시된다.

 

const server = setupServer(
  rest.get('/greeting', (req, res, ctx) => {
    return res(ctx.json({greeting: '안녕하세요'}))
  }),
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

test('loads and displays greeting', async () => {
  render(<MyComponent url="/greeting" />)

  fireEvent.click(screen.getByText('Greeting'))

  await waitFor(() => screen.getByRole('heading'))

  expect(screen.getByRole('heading')).toHaveTextContent('안녕')
})

 

 - heading의 역할을 한 요소가 뜰 때까지 기다리도록 작성한 것을 볼 수 있다. tesing-library는 waifFor와 같은 함수를 통해 비동기 테스트를 지원한다. h태그는 모두 heading 이라는 Role을 갖고 있고, 안녕이라는 글자가 포함된 '안녕하세요'를 띄우면서 위 테스트는 성공하게 된다.

 

* E2E Test

 - 위에서 처럼 가상으로 테스트할 경우 여전히 부족한 부분들이 있다. 페이지의 레이아웃이나 크로스 브라우징 이슈 등 다양한 브라우저 환경이 필요한 경우를 놓치게 된다.

 

 - E2E(End to End)테스트는 실제 사용자 관점에서 시뮬레이션을 하여 위와 같은 단점을 제거한 테스트 방법이다. E2E테스트를 통해 웹앱부터 서버, 인프라까지 테스트가 가능하며, 높은 커버리지를 보여주고 실제 상황에서 나타날 수 있는 에러를 찾아낼 수 있다.

 

 - 그러나 실제 브라우저를 띄워서 동작시키는 것이므로 실행 환경에 따른 변수가 발생할 수 있고(네트워크 이슈) 단위테스트나 통합테스트에 비해 느리다는 단점이 있다.

 

cypress.io

 

 - 프론트엔드에서 최근 가장 흔히 쓰이는 E2E 테스트 툴은 Cypress 이다. cypress를 실제로 써본적이 없기 때문에 간단하게만 언급해보겠다. cypress는 다른 E2E 테스트 툴과 마찬가지로 크로미움환경에서 실제 시뮬레이션을 동작시킨다.

 

 - Cypress의 큰 장점은 Record, Debugging, Time Traveling 이다. 비개발자와의 협업을 위해 녹화기능과 스크린샷 기능을 제공하고 있으므로 원활하게 의사소통을 할 수 있다. 그리고 크롬과 동일하게 개발자 도구를 사용하여 디버깅이 가능한데 이는 Time Traveling과 함께할 때 빛을 발한다. 왼쪽에 보이는 것처럼 히스토리가 남으며 그 시점으로 넘어가서 클라이언트의 해당 시점을 확인할 수 있다. 게다가 그 시점의 개발자 도구까지 열어볼 수 있으므로 효과적인 디버깅이 가능해진다.

 

 

3. 좋은 테스트

* 테스트의 필요성

 - 여기까지 글을 읽었다면 테스트가 얼마나 좋은지를 충분히 느꼈을 것이다. 앞서 소개에서도 언급했듯이 테스트 코드는 품질 좋은 코드를 만드는 데 기여하며, 요구사항에 집중할 수 있고 추후 발생할 문제들을 예방할 수 있다.

 

 - 뿐만 아니라 유닛테스트를 작성함으로써 코드의 결합도를 낮추면서 프로그램을 구성할 수 있고, 통합 테스트를 통해 올바른 상호작용을 확인할 수 있다. 게다가 E2E테스트를 통해 클라이언트 관점에서 어플리케이션을 확인할 뿐만 아니라 비개발자와의 소통까지 원활하게 할 수 있다.

 

 - 그렇다면 이 모든 테스트코드를 모든 구성요소에 대해 작성해야할까. 그것은 아닐 것이다. 무엇보다 그랬다가는 엄청난 시간적인 비용이 들 것이며 어플리케이션을 만드는 비용보다 테스트 코드를 만드는 비용이 훨씬 크게 될 것이다. 그렇다면 어떤 테스트 코드를 작성해야할까. 어떤 관점에서 테스트코드를 바라봐야할까.

 

* 도움이 되는가

 - 먼저 우리가 작성하는 테스트 코드가 정말 도움이 되는지 생각해봐야한다. 지금까지 좋다고 찬양하더니 이게 무슨소리인가 싶겠지만 좀더 관점을 세분화해서 우선순위가 높은 것을 찾아보자는 의미이다. 이를 통해 높은 우선순위를 가진 테스트부터 작성해나갈 수 있다.

 

 - 클라이언트 입장에서 테스트 코드가 어떤 도움이 될까. 여러 테스트코드들의 목적이 발생할 수 있는 문제를 잡는 것이니 만큼 테스트가 통과된 어플리케이션은 클라이언트에게 충분히 안정적인 코드라 할 수 있게된다. 특히 E2E테스트를 통해 클라이언트의 환경에서 다양한 케이스를 검증한다면 클라이언트가 만족할 만한 어플리케이션이 될 것이다. 게다가 코드를 유지보수하더라도 E2E테스트로 해당 케이스들에서 동일한 동작을 보장하는 것을 확인할 수 있다.

 

 - 팀의 입장에서는 테스트 코드가 어떤 도움이 될까. 팀으로 프로젝트를 하면 느끼는 어려움 중 하나는 다른 사람의 코드를 읽어야 한다는 점이다. 특정 코드만 이해하면 되는 상황에서 설명이 없어 그 코드와 관련된 다른 코드, 또는 히스토리를 파악해야한다. 하지만 테스트 코드가 작성되어 있다면 명세의 역할을 하게 된다. 테스트 코드에 해당 코드를 확인하여 어떤 역할을 하는지 파악할 수 있고, 이는 미래에 이 코드를 읽을 팀원 뿐만 아니라 나 자신에게도 도움이 된다. 게다가 해당 코드와 관련된 부분을 개선할 경우 테스트의 성공 여부를 통해 안심하고 코드를 푸시할 수 있게 된다. 따라서 복잡하고 변경이 잦은 코드, 어플리케이션내 핵심 로직의 경우 특히 우선순위가 높다.

 

* 무엇에 집중할 것인가

 - 지금까지 우선순위가 높은 테스트 코드를 생각했다. 그렇다면 이 테스트 코드들은 어떤 부분에 집중하여 테스트해야할까. 무슨 말인지 내가 적고도 이해가 안가는데 위에서 봤던 버튼 컴포넌트를 예로 들어보겠다. 버튼 컴포넌트를 보면서 테스트가 필요한 항목을 나열하면 다음과 같다.

 

  1. 버튼이 제대로 돔에 마운트 되는가.

  2. 버튼이 렌더링 되는가.

  3. 클릭시 클릭 이벤트가 발생하는가.

  4. 상태가 제대로 변경되는가.

  5. 상태에 따라 버튼이 disabled가 되는가.

 

 - 위와 같은 사항들을 확인하고 싶을 것이다. 위는 모두 단위테스트일 것이다. 겨우 하나의 컴포넌트와 관련된 부분이 5개나된다. 각각 테스트 케이스까지 여러개가 있으면 작업량은 어마어마할 것이다. 그 와중에 기획자가 기획을 바꾼다면? 테스트 코드를 쓰는 것보다 기획자와 싸우는 것이 더 효율적일 수 있다.

 

 - 이 때 우리는 무엇에 집중하여 테스트 코드를 작성할 것인지 선택해야 한다. 결론만 말하면 위의 5개중 실제 우리가 작성해야할 코드는 4번 정도면 충분하다. 

 

 - 왜 우리가 테스트 코드를 쓰는지를 잊으면 안된다. 테스트 코드는 "우리가" 작성한 코드에 대해 확인하고 개선하기 위해 작성하였다. 4번 외의 동작은 우리의 코드가 아니라 우리가 쓰고 있는 환경과 관련된 부분이다. 1번의 경우 jsx에 버튼을 작성했다면 돔에 마운트 시키는 것은 리액트의 역할이다. 렌더링 또한 돔에 마운트했다면 당연히 브라우저가 렌더링 시키며, 클릭시의 로직을 onClick에 작성했다면 리액트가 알아서 이벤트를 등록시켜 브라우저가 발생시킨다. 우리는 이런 환경을 믿어야 한다. 우리가 테스트해야하는 것은 이벤트가 발생하여 어떠한 side Effect를 발생시켰는가이다. 상태가 변경이 됐는지, 모달이 뜨는지, 다른 컴포넌트의 상태를 잘 변경시켰는지 등이 해당한다.

 

* 결론

- 위의 내용들을 정리하여 좋은 테스트가 무엇인지, 어떤 테스트를 해야하는지 요약하자면 '불필요한 테스트를 덜어내자"이다. 애플리케이션 내의 모든 로직이나 코드를 테스트한다면 좋겠지만 여기에 드는 시간과 비용을 생각한다면 절대 좋은 테스트는 아니다. 강하게 말하자면 어플리케이션을 망치는 일이다.

 

- 정말 우선순위가 높은 테스트 코드가 무엇인지 판단하여 해당 코드들을 작성하여 팀과 클라이언트 모두에게 만족할만한 아웃풋을 내야한다. 하나의 테스트를 작성할 때에도 정말 집중해야할 부분을 파악하여 필요없는 부분은 과감하게 테스트하지 말아야 한다. 이렇게 작성함으로써 점차 테스트코드를 늘려나간다면 더 탄탄하고 안정적인 어플리케이션을 개발할 수 있을 것이다.

 

 


참고

 

 

프론트엔드에서 의미있는 테스트 코드 작성하기

모두싸인 프론트엔드 팀에서 테스트 코드 작성 성장기

team.modusign.co.kr

 

 

[Testing] 1. 프론트엔드, 무엇을 테스트 할 것인가

이 앱, 지금 제대로 동작하니? 아마 이 질문에 대한 피드백을 받기 위해 테스트 코드를 작성할 것이다. React Application을 예로 들어보면 다음과 같은 테스트 대상들을 쉽게 생각할 수 있다. 액션이

jbee.io

 

 

[Testing] 2. 프론트엔드, 어떻게 테스트 할 것인가

앞서 프론트엔드 테스트 코드를 작성하면서 마주할 수 있는 몇 가지에 대해 이야기했다. 이번 편에서는 다시 테스트에 대한 내용으로 돌아가 앞서 다룬 이야기들을 기반으로 프론트엔드 입장에

jbee.io

 

 

프론트엔드 테스트의 모든 것

저와 같은 사람이라면 개발 워크플로우에서 테스트를 자동화하여 원치 않는 사이드 이펙트를 줄이고 애플리케이션의 품질을 향상하는 것이 중요하다고 생각하겠죠

medium.com

 

 

모던 프론트엔드 테스트 전략 — 1편

프론트엔드를 효율적으로 테스트하기 위한 방법을 알아봅시다.

blog.mathpresso.com

 

 

Example | Testing Library

Full Example

testing-library.com

 

 

[웹 프론트엔드] e2e test 프레임워크인 cypress를 소개합니다

이 포스팅 작성 시점 기준 cypress 버전은 v9.1.0 입니다. Cypress가 기존 test 프레임워크와 다른 점 e2e test(end to end test)는 작은 단위의 유닛 테스트가 아닌 사용자 시나리오를 통째로 실행하여 정상적

cocoder16.tistory.com