본문 바로가기
JavaScript

프론트엔드 테스트는 어디서부터 어디까지, 어떻게, 꼭 해야할까? (+Jest, Puppeteer, Cypress)

by lumayi 2023. 11. 2.

https://mailtrap.io/blog/qa-testing-memes/

들어가며

이 글에선 프론트엔드의 테스트 코드에는 어떤 것들이 있으며, 테스트 코드 작성법, 좋은 테스트란 어떤 것인지, 나아가 리액트에서는 어떻게 적용할 수 있는지를 설명할 것입니다. 저와 같은 문제를 겪은 분들께 도움이 되는 글이 될 것이라 생각합니다. 간결한 문장을 위해 평어체로 진행하겠습니다.

 

내가 겪은 문제

나는 테스트 코드를 작성하려고 할 때면, 어디까지 Mocking을 해야하는지, 파라미터 테스트 범위는 어느 정도가 적합한지, 테스트 단위를 얼마나 나눠야하는지 막막했다. 그리고 이렇게 짜는게 맞는건지, 생각할 수 있는 케이스는 다 해본 것 같은데도 불구하고 테스트 커버리지가 높지 않아 고민하기도 했다. 그러다 최근 우테코에 지원해 4주간의 프리코스를 진행 중인데, 여기서 또 테스트와 만나게 되었다. 더이상 다음으로 미룰 수 없다고 생각했고, 테스트에 대해 제대로 공부해보기로 결심했다. 

 

내가 막막했던 이유는 무엇일까?

나는 테스트를 어디까지 수행해야하는지에 대한 기준이 불명확했다. 그런데 그건 함수가 하나의 역할을 못하고 있다는 뜻이기도 했다. 테스팅 공부를 한 후에 이걸 많이 느끼게 되었다. 애초에 내가 작성한 하나의 함수 자체가, 하나의 기능이 아니라, 여러 기능을 하니 테스트도 어려워진 것이다. 예를 들어, 로그인을 처리하는 로직이 있다고 하자. 나는 해당 함수에서 네트워크 요청도 하고, 데이터를 받아와 가공까지 처리했다. 이럴 경우 Mocking으로 분리하기도 번거롭고, 로그인 함수가 호출된 것을 검증할 것인지, 호출됨으로서 받아온 값을 검증할 것인가 불명확하다. 그래서 테스트 코드를 공부할수록 "하나의 함수는 하나의 기능만하도록 만들어야한다."를 몸소 느낄 수 있었다.

그리고 테스팅에서 Mocking을 처리하는 이유를 제대로 알지 못했기 때문이다. 함수들간의 의존성을 해체하는 것이 바로 Mocking이다. 함수의 파라미터에 있는 콜백 함수는 내가 검증하고자하는 함수와는 다른 기능을 하는 함수이다. 그 콜백 함수는 콜백 함수대로 테스트 코드를 작성해야하는 것이고, 지금 검증하고자하는 테스트 코드는 그 콜백함수가 불러졌는지만 확인을 하면 되는 것이다. 그럴 때 바로 Mocking이 필요하다. 뒤에서 좀 더 자세히 설명할 것이다. 안타깝게도 난 이러한 사실들을 제대로 알지 못하고 무작정 테스트 방법만을 보고 작성했으니 매번 막막할 수밖에 없다. 이 기회에 테스트에 대해 정확히 알아보자!

 

테스팅이란?

Google Test Automation Conference에서 제안된 테스트 피라미드

기본적으로 테스팅이란 프로그램이 예상하는대로 동작하는지 확인하는 과정이다. 프론트에서는 기본적인 기능, 성능 외에도 UI가 추가적으로 테스팅 범위에 추가가 된다. 그래서 테스트 피라미드가 등장하게 되는데, 아래에서부터 단위, 통합, E2E(End to End, 끝단 UI)테스트로 구성되어 있다.

 

Unit Test, 단위 테스트

Unit테스트는 말그대로 함수, 모듈, 클래스 등 하나의 단위를 검사하는 테스트를 말한다. 나는 전 프로젝트에서는 Unit테스트는 작성하는데 걸리는 시간과 노력에 비해 크게 효용이 없다고 생각해 E2E만 작성을 했던 적이 있다. 하지만 전체 비례 테스트 비중은 단위테스트가 70%로 권장될 정도로 가장 높다. 이 단계에서 Jest가 많이 사용된다. Jest는 자바스크립트, 타입스크립트 환경에서 가장 많이 쓰이는 테스팅 프레임워크이다. 설정해야할 것도 없고, 스냅샷 테스트도 지원하며, 무엇보다 누구나 쉽게 따라할 수 있도록 문서화가 아주 잘되어있다. 단위테스트라고 처음 들었을 때, 코드를 보지 않고는 감이 안잡혔다. 우테코 1주차 문제였던 숫자야구게임을 예시로 코드를 먼저 보자! 

 

// 게임 코드, 스트라이크와 볼의 갯수를 받아오는 함수
function getScore(computer, user) {
  const result = { strike: 0, ball: 0 };
  computer.forEach((v, i) => {
    if (v === user[i]) return (result.strike += 1);
    if (v !== user[i] && user.includes(v)) return (result.ball += 1);
  });
  return result;
}

// 테스트 코드
describe('숫자 야구 게임', () => {
  test('3 스트라이크', () => {
    // 컴퓨터와 유저가 같은 숫자를 제시한 상황 GIVEN
    const sameNumber = [3, 6, 9];
    // 예상 결과
    const result = { strike: 3, ball: 0 };
    // 실행 WHEN
    const score = getScore(sameNumber, sameNumber);

    // 원시 값의 동등비교는 toBe()를 사용
    // 원시 값이 아닌 객체의 동등비교는 toEqual() THEN
    expect(score).toEqual(result);
  });

});

 

이처럼 하나, 하나의 함수들을 테스트 해주는 것이 바로 단위테스트이다. 자 그럼 아까 말했던 Mocking이 필요한 테스트는 어떤 것일까? Moking은 말그대로 흉내내는 것이다. 그 함수인 것처럼 흉내낼 수 있도록 도와주는 것인데, 어떻게 쓰일 수 있을지 코드로 설명하겠다.

 

// 게임 코드, 정답일 경우 endGame을 호출, 아닐 경우 playBaseball을 호출하는 함수
function restartGame(result, endGame, playBaseball) {
  if (result) endGame();
  else playBaseball();
}

// 테스트 코드
describe('숫자 야구 게임', () => {
  // 중복되는 코드는 beforeEach로 작성해주면 독립적으로 먼저 시행해준다.
  beforeEach(() => {
    // jest에서 제공하는 mocking함수는 jest.fn()
    onEndGame = jest.fn();
    onPlayBaseball = jest.fn();
  });

  // it = test는 같다.
  it('3 스트라이크일 경우, 게임을 종료하는 함수가 호출된다.', () => {
    restartGame(true, onEndGame, onPlayBaseball);

    expect(onEndGame).toHaveBeenCalledTimes(1);
  });

  it('0 스트라이크일 경우, 재시작하는 함수가 호출된다.', () => {
    restartGame(false, onEndGame, onPlayBaseball);

    expect(onPlayBaseball).toHaveBeenCalledTimes(1);
  });

});

 

이처럼 지금 테스트하고자하는 함수는 restartGame이라는 함수이기 때문에, onEndGame이나 onPlayBaseball이 어떤 함수인지는 중요하지 않다. restartGame은 result가 true일 때, onEndGame을 호출하는지만 검증을 해주면 된다. 이럴 때, 함수간의 의존성을 해제시켜줄 수 있는 것이 바로 Mocking이다. 이처럼 행위를 검증할 때는 Mocking을 써주는 것이 좋고, 값에 대한 상태 검증을 할 때는 Stub을 사용하는 것이 좋다. Stub은 짧게 말하면 직접 해당 함수가 리턴하는 값을 반환하는 클래스, 함수를 직접 만들어 가짜처럼 쓰는 것이다. 이것까지 말하면 글이 너무 길어질 것 같아서 생략...!

 

하지만 보통 프론트엔드 프로젝트를 진행하는 React나 Next.js에서는 이렇게 jest만으로 테스트 코드를 짤 일이 거의 없는 것 같다! Util 함수외엔 이렇게 사용하지 않고, 보통 testing-library를 사용하여 단위테스트를 진행하게 된다. 

 

describe('Baseball Item', () => {
  const item = {
    name: '튼튼한 야구공',
    price: '50000',
    thumbnail: { url: 'https://image.co.kr' },
  };
  it('야구 상품을 렌더링한다.', () => {
    // 렌더링하는 것처럼 만들어줌
    render(
      <MemoryRouter>
        <Item item={item} />
      </MemoryRouter>
    );

    const img = screen.getByRole('img');
    expect(img.src).toBe(item.thumbnail.url);
    expect(screen.getByText(item.name)).toBeIntheDocument();
  });
});

 

쇼핑몰을 예로 들면, 상품들 중 하나인 상품 아이템 카드가 화면에 잘 보이는지를 단위 테스트로 진행한다거나, 클릭 시에 어떠한 경로로 제대로 이동을 한다거나 하는 것을 테스트한다. 위의 예시는 페이지 렌더링 시에 해당 상품 아이템이 제대로 노출되는지를 테스트한 것이다. 이처럼 프론트엔드라면 testing-library를 함께 사용하게 될 것이다. 그리고 여기에서 제공하는 Mock과 비슷한 MemoryRouter 등과 같은 것들은 다 문서에 나와있다. 예전에 이런 것들을 하나하나 찾아보고 작성하는게 공수가 많이 들어서 중간에 포기했던 것 같다🥲

 

Integration Test, 통합테스트

자 그럼 통합테스트는 무엇일까? 통합테스트는 단위테스트에서 검증했던 함수, 클래스, 모듈 등을 통합해서 테스트를 돌리는 것이다. 즉, 자동차의 바퀴만 테스트 하는 것이 단위 테스트이고, 자동차 자체를 검증하는 것이 바로 통합테스트인 것이다. 바퀴가 둥글다고 해도, 4개의 바퀴가 크기가 다를 수도 있고, 빠져버릴 수도 있듯이, 바퀴가 굴러간다고 자동차가 잘 굴러가는 것은 보장되지 않은 상황이니, 이를 테스트해보는 것이다. 

 

위의 리액트 코드를 예로 든다면, 저 상품이 하나가 아닌 여러 개가 제대로 불러지는지 테스트를 해볼 수 있다. 지금은 튼튼한 야구공을 하나 불러오지만, Item 컴포넌트를 불러오는 상위의 컴포넌트(Cart)에서는 장바구니에 담은 아이템의 갯수만큼 데이터만 다르게 Item 컴포넌트를 호출할 것이다.이 때, [1] 정확한 장바구니의 아이템 갯수만큼 불러졌는지를 테스트 할 수 있고, [2] 아이템이 불러와지는 중일때, 로딩바가 제대로 불러지고 있는지를 테스트 할 수 있고, [3] 에러 처리 등을 테스트 해볼 수 있는 것이다.

 

여기서 스냅샷 테스트를 보통 진행하게 되는데, 스냅샷 테스트는 해당 화면에 보일 html 태그들을 사진으로 찍듯이 저장하여 비교할 수 있는 테스트이다. 그래서 해당 단계에 list 태그가 있는지 등을 검사할 수 있다. 이제 어떤 것들을 단위로 테스트하고, 통합으로 테스트하는지 이해할 수 있게 됐다!

 

E2E Test, End To End 테스트, 끝단 UI 테스트

마지막으로 E2E 테스트는 무엇일까? 보통 QA가 하는 역할이라고 보는 영역이다. 실제 환경처럼 사이트를 방문하면 제대로 노출하고자하는 것들이 보이는지, 버튼을 누르면 UI 효과가 제대로 나타나는지, 실데이터가 제대로 나오는지 등을 테스팅 할 수 있다. 예전에는 QA가 직접 모든 버튼을 하나, 하나 다 눌러보고 테스트를 했기 때문에, 이 단계에서 정말 많은 시간이 소요되었다. 사실 지금도 그렇게하는 기업들이 많을 것이라 생각한다!

 

하지만 E2E Test를 진행한다면 기본적인 테스트는 자동화시킬 수 있으며, QA는 정말 유저의 가치가 있는 동작 방식에 좀 더 고민하고, 집중할 수 있게 된다. 실제로 개인프로젝트를 진행했을 때, 하나씩 버튼을 눌러보는 일은 정말이지 너무나 큰 고역이었다🥲 그래서 Unit 테스트는 작성하지 않아도, E2E테스트는 작성을 했었다. 그리고 코드로 테스트가 어려운 몇 가지만 직접 테스트해보면 되었기에 정말 많은 시간이 단축되었었다!

 

E2E 테스트 라이브러리에는 CypressPuppeteer가 가장 유명하다. 두 개 다 깊게는 아니지만 맛만 본 경험으로는 Puppeteer를 더 선호한다. AWS EC2 Amazon Linux환경에서 Cypress는 제대로 작동하지 않았지만, Puppeteer는 동작했기 때문이다. 그 이유뿐이고, 사실 사용해보면 방법도 거의 유사하기에 어느 것이든 상관없을 것 같다.

 

cypress에 접속하면 보이는 예시 동영상인데, 마치 크롤링을 하는 것처럼 테스트가 동작하게 된다. Headless로 창을 띄우지 않을 수도 있고, 스냅샷을 찍어 사진 파일로 남겨서 테스트 진행 과정을 확인해볼 수도 있다.

 

좋은 테스트란?

그럼 어떤 테스트가 좋은 테스트일까? 클린코드의 저자 Bob Martin이 제시한 아주 기억하기 쉬운 원칙이 있다. 바로 FIRST이다.

Fast: 유닛테스트는 빠르게 실행되어야 한다. 즉, Mocking을 이용해 네트워크 등에 대한 의존성을 낮춘다고 생각할 수 있다.
Isolated: 최소한의 단위로 독립적으로 만든다. 하나에 하나만!
Repeatable: 테스트를 반복해도 같은 결과를 리턴한다. 이것도 외부 환경에 의존해서 매번 바뀌는 상황을 유의하라는 것이다.
Self-Validating: 항상 테스트의 성공, 실패 결과가 자동으로 나와야 한다. 이는 jest와 같은 프레임워크를 사용하여 실행하고 결과 값을 검증함으로서 지킬 수 있다.
Timely: 적절한 때에 작성되어야 한다. 즉, 배포하기 전에 미리 테스트코드를 작성한다!

 

그런데 이런 것 보다 나처럼 테스트의 범위나 조건을 뭘 해야할지 고민이거나 헷갈릴 땐, 아래의 원칙이 더 큰 도움이 된다! 기본적인 요구사항을 준수하는 것을 테스트로 확인했다면 아래의 7가지를 확인해보고 추가적으로 테스트 코드를 작성해볼 수 있다!

 

[1] 특정한 포맷을 준수하는가?
-> 특수문자, 이메일, 전화번호 등의 형식을 테스트한다.
[2] 데이터의 순서를 준수하는가?
-> 가격순, 인기도순과 같은 순서가 있는 데이터일 경우, 이를 준수하는지를 테스트해 볼 수 있다.
[3] 숫자의 범위가 있는가?
-> 너무 작거나 큰 숫자, 정수, 음수, 실수 등의 숫자 범위를 준수하는지 테스트한다.
[4] 특정한 상황에 따른 ~한 동작을 하는가?
-> 다른 상황이 주어졌을 때, 예상되는 동작을 하는지 테스트한다.
[5] 값이 존재하지 않을 때, 에러처리가 되는가?
[6] 값이 하나도 없을 때, 하나만 있을 때, 여려개가 있을 때 처리 방식은?
[7] 시간을 준수하는가?
-> 정해진 시간이내의 작업인지, 지역시간이 제대로 맞춰져 있는지를 테스트한다.

 

테스트 코드를 작성하고 나서는 FIRST 원칙이 지켜졌는지 확인해보면 좋을 것 같고, 추가적으로 어떤 테스트를 작성해볼 수 있을지 고민된다면 아래의 7가지 조건들을 확인해보면 좋을 것 같다. 다음 3주차 우테코 과제부터는 꼭 적용해봐야겠다.

 

좋긴한데... 테스트 코드 꼭 짜야할까?

테스트코드를 작성해야하는 장점은 무엇이 있을까? [1] 요구사항이 있을 때, 테스트코드 문서만으로도 해당 요구사항을 충족했는지 안했는지 알 수 있다. 좀 전에 설명한 것처럼, [2] QA가 좀 더 가치있는 일을 할 수 있도록 할 수 있고, [3] 버그를 빠르게 발견해서 사용자의 편의성을 올릴 수 있다. 그리고 [4] 레거시를 리팩토링할 때, 기능이 될지 안될지 걱정하며 직접 프로그램을 돌려보지않아도 테스트코드만으로도 확인할 수 있기에 유지보수가 쉽다. 

 

이렇듯 장점이 많지만, 백엔드보다 프론트엔드의 테스트 코드는 확실히 더 많은 공수가 든다. 컴포넌트 단위테스트도 매번 모킹 API를 찾아서 작성해야하고 번거로움이 많고 그만큼의 효용을 잘 느끼지 못할 수도 있다. 하나의 컴포넌트에 10개가 넘는 테스트 단위가 있기도 하기 때문이다. 그리고 조금만 코드가 바뀌어도 관련된 UI 테스트는 모두 함께 수정해야될 확률이 매우 높다. 물론 테스트 코드는 한 번 작성하면 끝인 코드는 절대 아니지만, 사소한 변경에 일이 많아지면 번거로운 것은 사실이다🥲 그래서 무엇을 테스트해야할지 정확이 바운더리를 설정하고 테스트를 진행해야 한다.

 

일단 우테코를 진행하는 동안에는 유닛 테스트를 작성하는데 집중해볼 수 있을 것 같다. 하나의 함수는 하나의 기능만 한다. 를 명심해서 코드를 짜고, 테스트 코드도 짜봐야겠다. 즐겁다! 감사하다! 

 

반응형