오놀 (2) 테스트

2024. 5. 31. 16:50카테고리 없음

들어가며

오픈소스에 컨트리뷰트를 하면서 테스트 코드의 중요함을 느낀적이 있다. 내가 진행한 프로젝트와는 비교할 수 없이 많은 코드양과 사용자 수. 내가 작성한 코드의 사이드 이펙트가 미칠 영향을 가늠하기가 어려웠다. 그 때 테스트 코드의 힘을 많이 느꼈다. 내가 작성한 코드가 다른 수백명의 사람이 작성한 코드에 영향을 끼치지 않고 + 내가 수정/구현하고자 하는 부분이 정상적으로 동작하는지에 대한 자신감을 줬다. 

 

오놀에서 접근성 개선 작업을 하며 문득 UI에 어떤 변화가 있진 않을까? 키보드 대응 같은 기능적인 측면을 추가할 때 다른 기능이 잘 동작하지 않으면 어떡하지?라는 불안 요소가 있었다. 오픈 소스에서 내게 자신감을 줬던 테스트. 내가 불안한 포인트들을 테스트를 통해 직접 확인하지 않아도 제거하고 싶었다.

 

E2E 테스트

E2E 테스트에서 가장 고민하던 부분은 다음과 같았다. 처음엔 Happy Path만 검증을 했다.

 

it('mode, interest, distance를 선택 후 추천 장소를 볼 수 있다.', () => {
    cy.login();

    cy.visit('http://localhost:3000', {
      onBeforeLoad(win) {
        cy.stub(win.navigator.geolocation, 'getCurrentPosition').callsFake(
          (cb) => cb(stubbedPosition)
        );
      },
    });
    cy.mockServerSideProps('/result', { placeInformations: mockData });

    cy.findByRole('link', { name: '네 좋아요!' }).click();
    cy.location('pathname').should('equal', '/mode');

    cy.findByRole('checkbox', { name: '혼자 놀거에요' })
      .should('have.attr', 'aria-checked', 'false')
      .click()
      .should('have.attr', 'aria-checked', 'true');

    cy.findByRole('link', { name: '선택 완료' }).click();
    cy.location('pathname').should('equal', '/interest');

    cy.findByRole('checkbox', { name: '전체 선택' })
      .should('have.attr', 'aria-checked', 'false')
      .click();

    cy.findByRole('link', { name: '선택 완료' }).click();
    cy.location('pathname').should('equal', '/distance');

    cy.findByRole('slider')
      .should('have.attr', 'aria-valuenow', 250)
      .focus()
      .type('{rightArrow}')
      .should('have.attr', 'aria-valuenow', 500);

    cy.findByRole('link', { name: '추천 받기' }).click();

    cy.location().should((loc) => {
      expect(loc.search).to.include('mode=alone');
      interests.forEach((interest) =>
        expect(loc.search).to.include(`interests=${interest}`)
      );
      expect(loc.search).to.include('distance=500');
      expect(loc.search).to.include(`lat=${stubbedPosition.coords.latitude}`);
      expect(loc.search).to.include(`lng=${stubbedPosition.coords.longitude}`);

      expect(loc.pathname).to.eq('/result');
    });

    cy.findByRole('heading', {
      name: `오늘은.. ${mockData[0].category} 어때요?`,
    });

    cy.findByRole('heading', {
      name: `${mockData[0].documents[0].place_name}`,
    });
    cy.findByRole('button', { name: '캐루셀 오른쪽 이동' }).click();
    cy.findByRole('heading', {
      name: `${mockData[0].documents[1].place_name}`,
    });

    cy.findByRole('button', { name: '처음으로 돌아갈래요' });
    cy.findByRole('button', { name: '다시 추천 받을래요' }).click();

    cy.findByRole('heading', {
      name: `오늘은.. ${mockData[1].category} 어때요?`,
    });

    cy.findByRole('button', { name: '다시 추천 받을래요' }).click();
    cy.findByRole('button', { name: '처음으로 돌아갈래요' }).click();
    cy.location('pathname').should('equal', '/mode');
  });

 

가장 일반적인 유저 시나리오를 담은 테스트였다. 물론 어느정도 기능에 대한 자신감은 가질 수 있었지만, 좀 더 다양한 케이스를 다루고 싶었다. 

 

 

 

하나의 Happy Path, 유저 시나리오를 검증한 테스트가 있었기에 좀 더 작은 부분들에서 다양한 케이스를 고려하도록 했다. 더 작은 유저 시나리오로 구분을 하기 시작했고 오놀의 규모는 작았기에 각 페이지 별로 유저 시나리오가 나뉜다고 판단했다. 또한 라우팅은 전체 유저 시나리오를 검증한 테스트에서 충분히 검증하고 있다고 생각했다. 따라서 각 페이지의 기능들만 테스트를 진행했다.

 

 

 

이처럼 각 페이지의 다양한 케이스들을 다룬 각 페이지 별 E2E 테스트를 만들었다. 이런 테스트의 역할은 최소한 하나의 페이지(유저 시나리오)는 무결하다는 자신감을 가지고 싶었다. 

 

 

getServerSideProps 모킹하기

 

오놀의 결과 페이지는 SSR로 구성이 돼있었다. 하지만 여기서 페치해오는 API는 카카오 맵의 API였다. 내가 작성한 코드로 컨트롤을 할 수 없는 부분이기에 이 부분은 모킹을 해야한다고 판단을 했다.

 

하지만 큰 문제가 있었다. getServerSideProps는 서버에서 실행된다. cypress의 intercept는 클라이언트(브라우저)에서의 네트워크 요청을 가로채기 때문에 이를 활용하긴 어려웠다.

 

물론 해결법은 존재했다. 테스트 환경을 위한 서버를 따로 띄우고 nock과 같이 서버 자체에서 fetch를 가로채면 된다.

 

하지만 다음과 같은 문제가 있었다.

1) 앞 페이지에서 받은 여러 클라 상태를 쿼리 파라미터로 보내야 한다. 하지만 이건 상수 값으로 설정 해놓으면 큰 문제는 되지 않았다. 

2) getServerSideProps에서 Promise.all로 fetch를 여러번 보내야 한다. 1번으로부터 계산된 카테고리 별로 모두 fetch를 보내고 있었는데, 만약 카테고리가 추가/삭제 되는 경우 그에 따라 1번에서 계산된 카테고리 개수를 일일이 확인하여 fetch를 그 개수만큼 mocking 해줘야 했다. 이건 매우 성가신 일이다.

 

 

그러다 뜻하지 않은 곳에서 가장 만족스러운 해답을 찾았다. 바로 Soft Navigation과 Hard Navigation이다. Next는 Soft Navigation을 하면 CSR처럼 동작한다. path.json 경로로 getServerSideProps를 실행한 결과 값을 브라우저에서 가져오는 것을 확인할 수 있다. 

 

 

 

그래서 다음과 같은 커맨드를 통해 해당 요청을 intercept 해주었다.

 

Cypress.Commands.add(
  'mockServerSideProps',
  (pathname: string, props: Record<string, any>) => {
    cy.intercept('GET', `**${pathname}.json?**`, (req) => {
      req.reply((res) => {
        res.body.pageProps = {
          ...res.body.pageProps,
          ...props,
        };
      });
    });
  }
);

 

 

시각적 회귀 테스트

 

UI가 바뀌지 않았다는 확신도 필요했다. 하지만 단순히 DOM을 직렬화 해서 스냅샷을 저장하면 당연하게도 접근성 개선 작업을 할 때 마크업의 변경이 생기기 때문에 테스트는 계속 실패하게 된다. 그래서 아예 스크린샷을 통한 시각적 회귀 테스트를 작성했다.

 

threshold를 0으로 하려 했으나 이미지의 경우 계속 사소한 diff를 보였다. (원인을 정확히 찾지 못했다.) 찾아보니 일반적으로 threshold를 0으로 하지는 않고 0.002정도를 하는듯했다. 내 경우는 0.002xxx인 경우가 많아서 0.003으로 설정을 해주었다.

 

 

@testling-library/cypress

 

"테스트가 소프트웨어 사용 방식과 유사할수록 더 많은 확신을 줄 수 있다"는 Kent C. Dodds의 말처럼 data-cy같은 코드를 테스트 때문에 내 코드에 추가하고 싶지는 않았다. (물론 컴파일 과정에서 지워줄 수 있다.) @testling-library/cypress를 활용하여 테스팅 라이브러리가 접근하는 방식처럼 findByRole등의 쿼리를 사용할 수 있었다.

 

 

마치며

처음으로 테스트를 작성해보며 상당히 큰 재미를 느꼈다. 어떤 테스트가 어떤 변경 사항에 실패할 것인가, 어떤 변경 사항에는 여전히 성공해야 하는가를 고민하는 과정이 도파민이 나왔다 해야할까. 실제로 테스트를 작성하면서 코드에 부족함을 느껴 실제 코드를 변경하기도 했다. 내가 자신감이 떨어지는 부분, 검증하고 싶은 부분을 테스트로 작성하는 것만으로도 꽤나 많은 수고를 덜 수 있다고 생각한다. 좀 더 안전한, 신뢰할 수 있는 코드를 작성하고 싶다.