많은 IT 회사의 채용 공고를 보면 테스트 코드 작성 유닛 테스트 코드, UI 테스트 코드 등 테스트 코드를 작성할 수 있는 개발자를 많이 원하고 있는 것 같습니다. 저는 모회사 과제 전형을 하면서 처음 접했는데요 빠르게 과제에 테스트를 적용하느라 왜 테스트 코드를 작성해야 하는가 어떤 테스트 코드가 좋은 테스트 코드인가에 대해서는 생각하지 않고 테스트 코드를 짰던 것 같습니다. 

 

 이후 리팩토링한 프로젝트에도 Jest와 React Testing Library를 이용해서 테스트 코드를 작성하고 있는데 테스트 코드를 작성하는 이유와 좋은 테스트 코드는 무엇인지 제대로 공부를 하고 작성해야 겠다는 생각에 이런 글을 작성하게 됐습니다.

회사의 테크 블로그와 구글링을 통해 공부한 내용을 정리해 보겠습니다.

 



테스트 코드는 무엇인가?

 먼저 테스트 코드는 소프트웨어의 기능과 동작을 테스트 하는 데 사용되는 코드입니다. 테스트 코드를 통해 어플리케이션의 결함을 빠르게 찾아내고 수정할 수 있습니다. 테스트 코드는 대표적으로 단위(유닛) 테스트, 통합 테스트, E2E(End to End) 테스트가 있고, 각각의 범위에 맞춰서 테스트 코드를 작성하고 개발 과정 중에 예상치 못한 문제를 미리 발견할 수 있고, 위에서 말씀드렸다시피 코드 수정이 필요한 상황에서 유연하고 안정적인 대응을 할 수 있습니다. 테스트 코드를 작성하는 이유에 대해 자세히 알아보며, 더 깊이 알아보겠습니다.

 

테스트 코드를 작성하는 이유는 무엇인가?

1. 디버깅 비용 절감

 실제로 자체 서비스 회사에서 서비스를 운영하고 유지 보수를 하는 과정 속에 개발자의 총 개발 소요 시간 중 온전히 요구사항에 대한 기능 개발을 하는 시간이 생각보다 많지 않다고 합니다. 

 

 즉 기능 개발 시간보다 어플리케이션에서 발생하는 버그에 대한 디버깅을 하는 시간이 더 많은 비중을 차지한다는 것인데요. 이 과정에서 테스트 코드가 없다면 문제 자체를 해결하는 것보다 어떤 코드 블록에서 문제가 발생했는지 찾는 과정에 더 많은 시간을 투자하게 됩니다. 이러한 테스트 코드를 매우 견고하게 작성하더라도 완벽하게 결함을 없앨 수 없는 것은 사실이지만, 테스트 코드를 작성함으로써 오류를 줄일 수 있고, 빠르게 대처할 수 있습니다.

 

 테스트는 위에서 언급했듯이 테스트하는 범위와 비중에 따라 세 분류로 나누어지고, 각각의 역할을 가지고 있습니다.

출처 : 인프랩 기술 블로그

  • 단위(Unit) 테스트 : 도메인 모델과 비즈니스 로직을 테스트합니다. 하나의 기능을 하는 코드를 테스트하며, 해당 코드가 의도한 대로 작동하는지 확인합니다. 일반적으로 Class나 Method를 테스트하는 범위로 정해지며, 가장 핵심적인 테스트라고 할 수 있습니다. 예를 들어 장바구니에 담는 기능을 테스트한다고 하면 특정 작업을 했을 때 의도한대로 상품이 담기는 지 테스트 합니다.
  • 통합(Intergration) 테스트 : 코드의 주요 흐름들을 통합적으로 테스트하며, 주요 외부 의존성(ex. DB)에 대해서 테스트합니다. 단위 테스트에서 검증된 개별 모듈들을 결합하여 그들이 예상대로 상호작용하고 있는지 확인합니다. 구체적으로 말씀드리자면 DB에 새로운 데이터가 추가됐을 경우 DB가 이를 올바르게 저장하고 프론트 단에서 데이터를 올바르게 표시하는지 확인합니다. Unit을 넘어 각기 다른 시스템이 잘 상호작용하는지 확인하는 작업이고, 더 많은 코드를 테스트 하기 때문에 에러 검출이 명확하지 않을 수 있습니다. 그렇기에 Unit 테스트에 초점을 두는 것이 좋다고 하네요.
  •  E2E 테스트 : 최종 사용자의 흐름에 대한 테스트이며, 외부로부터의 요청부터 응답까지 기능이 잘 동작하는지에 대해 테스트합니다. 즉 실제 사용자의 시나리오를 테스트하여 어플리케이션 자체가 의도한대로 동작하는지 확인합니다. 예를 들어 쇼핑몰 웹 어플리케이션에 대한 E2E 테스트를 작성한다고 했을 때, 고객이 상품을 선택하고, 결제한 후 배송받는 과정을 처음부터 끝까지 확인하는 것입니다.

이러한 테스트를 통해 결함을 최소화시켜 디버깅에 소모되는 시간을 줄여줌으로써 서비스 개발자가 비즈니스 개발에 집중할 수 있도록 하여 생산성을 향상시켜줍니다.

 

 

2. 코드 변경에 대한 불안감 해소

 버그를 고치는 과정에서 다른 버스가 발생하는 경우가 있습니다. 이를 회귀 버그라고 하는데요. 이는 굉장히 자주 발생하는 문제입니다. 어플리케이션에서 기능은 단일 하나의 요소로 이루어지지 않고, 여러 요소들이 서로 상호작용하고 협력하여 만들어지기 때문에 하나의 기능을 수정하는 과정에서 협력하고 있는 다른 기능에도 영향을 주면서 회귀 버그가 발생하게 됩니다. 

 

 이러한 회귀 버그완벽하게 차단하고 예방하는 것은 불가능하지만, 우리는 이를 관리하고 대처해야합니다. 즉, 회귀 테스트를 해야하는 것이죠. 회귀 테스트는 기능 추가나 오류 수정으로 인해 새롭게 유입되는 오류가 없는지 검증합니다. 여기서 우리가 알 수 있는 것은 테스트 코드는 그때 당시의 기능을 만들기 위해서만 필요한 코드가 아니라 코드를 작성한 이후에 변경된 요구사항을 적용하거나 코드를 리팩토링하는 과정에서도 필요합니다. 서비스가 지속 가능하게 발전하기 위해 필요한 코드인 것이죠. 우리가 코드를 작성할 때 이 부분을 고려하고 짜야한다고 생각합니다.

 

 

3. 더 나은 문서자료

 우리 개발자들은 좋은 코드를 작성하기 위해 가독성 좋은 코드를 작성하기 위해 노력합니다. 하지만 서비스 복잡도가 늘어나며 코드의 양이 많이지게 되고 자연스럽게 코드의 복잡도도 높아지게 됩니다. 이는 신규 입사자들이 코드를 이해하는데 많은 불편함을 겪게 합니다.(저도 빨리 신규 입사자가 되고 싶네요..ㅠㅠ TMI 죄송합니다..) 이를 대비하여 코드에 대한 문서화를 해두지만 코드처럼 문서를 유지 보수하지 않는 경우가 대다수이기 때문에 문서의 신뢰도는 시간이 지날수록 떨어지게 되어 바라던 효과를 내지 못합니다.

 

테스트 코드가 문서의 역할을 해준다면 위의 상황에서 발생하는 문제들을 모두 해결할 수 있습니다. 즉 테스트를 작성할 때 코드의 실제 동작을 기술함으로써 코드가 어떤 코드인지 이해할 수 있는 것이죠. 문서화 테스트를 통해 코드를 처음 접하는 사람들에게 기능과 코드를 이해할 수 있도록 도움을 줄 수 있습니다.

 

 

4. 좋은 코드는 테스트하기 쉽다.

 변경하기 쉬운 코드가 좋은 코드 중 하나라고 할 수 있습니다. 즉, 약한 결합도를 가지고 있는 코드를 좋은 코드라고 할 수 있으며, 강결합이 되어있는 코드는 유지비용이 증감되기에 좋지 못한 코드라고 할 수 있습니다.

 

 이러한 강결합으로 이루어진 코드는 테스트하기가 매우 어렵습니다. 외부의 영향을 받거나 내부적으로 의존성을 가지고 있기에 변경에 유연하게 대응하지 못하고 재사용하기도 어려운 코드입니다. 결론적으로 테스트 코드를 작성하면서 기존 강결합으로 이루어진 코드를 약한 결합도로 가진 코드로 변경할 수 있게 되기에 테스트 코드를 작성하며 자연스럽게 좋은 코드를 작성할 가능성이 높아지는 것입니다.

 

 테스트 코드를 작성하기 쉽다고 모두 좋은 코드는 아니지만 테스트하기 어려운 코드라면 좋지 못한 코드일 가능성이 매우 높습니다.

 

5. 테스트 코드를 작성함으로써 안정감 있는 프로젝트를 진행할 수 있다.

 코드를 실제 운영 환경에 배포했을 때 에러가 발생하지는 않을지 과연 자신이 작성한 코드가 정상적으로 운영이 되는지 생각하게 되면서 불안감에 빠지게 됩니다. 하지만 테스트 코드가 있다면 배포 전에 오류를 확인할 수 있기에 안정감 있는 프로젝트를 진행할 수 있고, 안정감 있는 우리는 볼 수 있습니다.

 

그리고 이는 자연스럽게 서비스 품질의 향상을 이끌어냅니다.

 



테스트 코드를 잘 작성하는 방법

 이제 이러한 테스트 코드를 어떻게하면 잘 작성할 수 있는지 인프랩 블로그의 내용들을 정리하며 알아봅시다.

1. 테스트 코드는 DRY 보다는 DAMP 하게 작성하라.

 개발 원칙 중 DRY(Don't Repeat Yourself) 원칙에 대해 아시나요? 개발자인 우리는 중복 코드를 싫어하기에 중복 코드를 최대한 없애려고 노력합니다. 하지만 중복을 줄이기 전에 DAMP(Descriptive and Meaningful Phrases) 하게 테스트 코드를 작성하는 것을 고려해야 합니다. DAMP 원칙은 의미있고 설명적인 구문을 사용하라는 원칙입니다. 

 

 즉, 테스트 코드의 중복을 줄이기 위해 테스트 간의 결합도를 높이는 것은 좋지 않은 방식이라는 것입니다. 여기서 중요한 점은 테스트는 서로 독립적이고 격리되어야 한다는 것입니다. 테스트가 서로 독립적이지 않다면, 테스트 코드를 수정하는 과정에서 다른 테스트의 구조까지 확인해야하는 불편함이 생길 것이고, 테스트에 대한 신뢰도도 떨어지게 됩니다.

DAMP 원칙을 지키면서 중복을 줄이는 방법은 테스트 픽스쳐 함수나 클래스 등을 사용하는 방식이 있습니다.

 

 

2. 테스트는 구현이 아닌 결과를 검증하도록 한다.

 테스트 코드를 작성하는 과정에서 빠른 테스트를 작성하기 위해 모의 객체를 사용하는 경우가 있습니다. 모의 객체로 mock, spy, stub 등을 사용하는 경우 테스트 대상의 구현을 알아야만 테스트를 작성할 수 있는데요. 그런데 이 부분을 잘못 사용하여 테스트를 작성하는 경우가 종종 있습니다.

인프래랩 블로그의 예시를 톻해 이 경우를 알아봅시다.

export class JobApplicant {
  updatePass() {
    this.validateIsNotCancel();
    this.validateIsNotFail();
    // 유효성 검증 통과후 상태 변경 
  }

  validateIsNotFail(): void {
    // 검증
  }

  validateIsNotCancel(): void {
    // 검증 
  }
}

위와 같이 지원자를 합격 상태로 변경하기 전에 지원자가 현재 불합격이거나 취소 상태가 아닌지에 대한 조건이 있다고 가정해봅시다. 해당 코드를 jest의 spyOn 메소드를 통해 작성해보면 아래와 같습니다.

it('지원자를 합격시킨다', () => {
	jobApplicant = new JobApplicant
    
    jest.spyOn(jobApplicant, 'validateIsNotCancel').mockReturnValue(undefined);
    jest.spyOn(jobApplicant, 'validateIsNotFail').mockReturnValue(undefined);
    
    jobApplicant.updatePass();
    
    expect(joApplicant.validateIsNotCancel).toBeCalledTimes(1);
    expect(joApplicant.validateIsNotFail).toBeCalledTimes(1);
    expect(joApplicant.status).toBe(JobApplicantStatus.PASS);
    
});

위 테스트 코드처럼 상태 변경에 대한 검증 뿐만 아니라 Cancel과 Fail에 대한 메소드가 호출되었는지 검증을 해서 더 완벽한 테스트라고 생각할 수 있지만, 이 테스트는 깨지기 쉬우며 좋은 테스트가 아닙니다.

만약 Cancel에 대한 메소드의 네이밍이 변경되거나 Fail에 대한 메소드가 삭제되면 이 테스트는 깨집니다. 즉 구현에 의존적이며 테스트의 목적이 구현에 맞춰져 있는 테스트인 것이죠. 내부 구현이나 비공개 메소드들은 언제든지 바뀔 여지가 있는 코드이기 때문에 정보의 은닉을 위해 숨깁니다. 이를 굳이 꺼내서 테스트 하는 것은 좋지 않습니다.

 

테스트 코드는 내부 구현보다는 실행 결과에 집중하는 것이 리팩토링 내성을 높일 수 있습니다. 그리고 호출되었는지 검증하는 부분 또한 의미 없는 검증입니다. 이 테스트의 검증 목적은 합격 상태 변경에 대한 검증으로도 충분합니다. 호출에 대한 검증을 진행하며 오히려 테스트의 목적을 흐리게 하고 가독성을 떨어뜨립니다.

 

우리는 어떻게 라는 내부 구현 검증보다는 무엇을 이라는 결과 검증에 집중하여 테스트 코드를 작성해야합니다.

 

옳은 테스트 코드 ▼

it ('지원서를 합격시킨다', () => {
	const jobApplicant = new JobApplicant();
    
    jobApplicant.updatePass();
    
    expect(jobApplicant.status).toEqual(JobApplicantStatus.PASS);

});

it.each([
	['취소', JobApplicantStatus.CANCLE],
    ['불합격', JobApplicantStatus.FAIL],
])(
	'%s 상태의 지원서는 합격시킬 수 없습니다.'
    (_, status) => {
    	const jobApplicant = new JobApplicant();
        jobApplicant.status = staus;
        
        expect(() => jobApplicant.updatePass()).toThrowError();
    },
);

 

 

3. 읽기 좋은 테스트를 작성하라.

테스트 코드는 메인 , 제품 코드를 위한 코드지만 가독성도 좋아야합니다. 불명확한 테스트 코드는 읽는 행위도 유지보수 하는 행위도 어렵게 만듭니다.

 

좋은 테스트 코드는 읽는 사람 입장에서 이 테스트를 이해하는 데 필요한 모든 정보를 테스트 케이스 본문에 담고 있어야합니다. 동시에 관련 없는 정보는 담지 말아야 합니다.

 

이렇게 가독성이 높은 코드를 작성하기 위한 방법 중 하나는 테스트의 구조를 잘 잡는 것입니다.

 

테스트 구조는 준비, 실행, 검증 3개의 구절로 나위어질 수 있습니다. 각각의 구절을 AAA 패턴 (Arrange-Act-Assert 주석으로 구분)이나 GWT 패턴(given-when-then 행위 주석으로 구분)으로 구간을 나누어 작성하는 것이 좋습니다. 

 

그리고 테스트 안에 너무 많은 양의 코드가 있는 경우 재사용의 목적으로 모듈화(테스트 팩토리, 빌더, 헬처 메소드)해두면 쉽게 읽힐 수 있습니다. 모듈화에 대한 내용은 이후 학습하고 정리하여 올리겠습니다.

 

 

4. 테스트 명세에 비즈니스 행위를 담자.

 테스트명을 작성할 때 명확한 의도가 들어나도록 작성해야 합니다. 테스트 코드는 코드를 처음보는 개발자 입장에서 코드를 설명하는 문서가 될 수 있기에 명확한 의도가 담긴 명세를 작성함으로써 코드에 대해 이해하기 쉬도록 유도합니다.

 

 그리고 명확하게 의도가 들어나는 이름을 적기 위해선 개발자 용어가 아닌 비즈니스 행위가 담긴 이름을 작성하여 비개발자도 읽을 수 있도록 설명되어야 합니다.

 

 

[요약]

  • 테스트 코드의 중복을 줄이는 것이 아니라 서술적이고 의미있게 작성해야한다.
  • 테스트는 서로 독립적이고 격리되게 구현하여 테스트 수정 과정에 다른 테스트가 영향을 받지 않도록 해야한다.
  • DAMP 원칙을 지키면서 중복을 줄이는 방법 중 하나는 테스트 픽스펴 함수나 클래스 등을 사용하는 것이다.
  • 어떻게 하는 내부 구현 검증보다는 무엇을 이라는 결과 검증에 집중하여 테스트 코드를 작성해야 한다.
  • 테스트 코드도 관리의 대상이기에 읽기 좋은 코드로 작성해야 한다. 이를 위해 테스트 구조를 잘 잡아서 작성한다.
  • 테스트 안에 너무 많은 양의 코드가 있다면 코드 재사용의 목적을 모듈화함으로써 쉽게 읽힐 수 있도록 하자.
  • 테스트 명세에 비개발자도 이해하기 쉽도록 비즈니스 행위를 담자.


 

이상입니다. 저 또한 테스트 코드를 작성함으로써 지속 가능하 서비스 및 프로젝트를 만들 수 있다고 생각합니다. 만약 시간이 촉박한 경우 테스트 코드 작성으로 인해 오버 엔지니어링으로 이어질 수도 있는데 이 경우 핵심 로직 위주로 테스트 코드를 작성하여 일정을 잘 맞출 수 있도록 해야합니다. 이 글을 읽는 여러분도 테스트 코드 작성을 고려하고 적용해 보면 좋을 것 같습니다 :) 

 

 

 

정리하기 위해 참고한 사이트 및 블로그 :

 

테스트 코드를 왜 그리고 어떻게 작성해야 할까?

테스트 코드가 필요한 이유와 잘 작성하는 방법에 대해 공유합니다.

tech.inflab.com

 

테스트 코드의 필요성

안녕하세요.

www.startupcode.kr

 

프로젝트를 완료한 후 Jest로 주요 기능에 대한 테스트 코드를 작성하여 테스트 커버리지를 높이고자 노력하고 있습니다. 이 과정에서 OpenWeatherMap API와 Kakao 지도 API를 이용해 날씨 데이터와 위치 데이터를 가져와 전역을 데이터를 관리해주는 Provider에 대한 테스트 코드를 작성하며 어떻게 하면 효과적인 테스트 코드를 작성할 수 있을지 고민했습니다.

 

처음에는 각 API의 쿼리스트링으로 들어가는 경도와 위도를 고정시키고 현시각에 기반하여 먼저 각 API를 이용해 데이터를 불러온 후에 날씨 Provider를 실행시켜서 처음에 불러온 데이터와 일치하는지 테스트 하는 코드를 작성해 볼려고 했습니다. 하지만 테스트 코드에 대해 알아보면서 이런 방식은 외부 의존성을 갖는 코드를 이용해 테스트를 진행하는 것이기 때문에 테스트의 독립성과 신뢰성을 떨어뜨리는 옳지 못한 방식이라는 것을 이해했습니다. 그래서 의존성을 갖는 부분을 모킹( Mocking )하여 날씨 Provider가 모킹한 데이터를 잘 받고 하위 컴포넌트로 잘 전달하는지 테스트 하는 방식으로 코드를 작성하기로 결정했습니다.

 

이러한 효과적인 테스트 코드 작성을 위한 규칙에 대한 내용은 이 글 이후에 따로 정리하여 올리겠습니다. 우선은 이 과정에서 참고한 우하한 기술블로그 링크와 Chat GPT 4o가 알려준 내용을 간단하게 적고 넘어가겠습니다.

 

코드와 함께 살펴보는 프론트엔드 단위 테스트 – Part 1. 이론 편 | 우아한형제들 기술블로그

{{item.name}} “테스트 코드 작성은 파일을 만들어서 한 글자 쓰기 전까지 엄청 귀찮다.” from 언젠가 내가 데일리 스크럼에서 한 말 개발자들에게 테스트 코드 작성은 해야 하지만 손이 잘 가지 않

techblog.woowahan.com

ChatGPT 4o의 답변

1. 단일 책임 원칙
각 테스트는 단일한 동작을 검증해야 합니다. 
즉, 하나의 테스트 케이스는 하나의 기능만 테스트해야 합니다.


2. AAA 패턴 (Arrange, Act, Assert)
테스트 코드는 일반적으로 세 단계로 구성됩니다.
- Arrange : 테스트에 필요한 데이터를 준비하고 설정합니다.
- Act: 테스트하려는 동작을 수행합니다.
- Assert: 동작의 결과를 검증합니다.


3. 독립적인 테스트
테스트는 서로 독립적이어야 하며, 하나의 테스트가 다른 테스트에 영향을 주어서는 안 됩니다. 
테스트 간에 상태가 공유되지 않도록 해야 합니다.


4. 명확하고 설명적인 테스트 이름
테스트 이름은 테스트의 목적을 명확하게 설명해야 합니다. 
이는 나중에 테스트 실패 시 문제를 쉽게 파악하는 데 도움이 됩니다.


5. 테스트 커버리지
가능한 많은 코드를 테스트하려고 노력하세요. 
중요한 로직, 경계 조건, 예외 상황 등을 포함해 다양한 시나리오를 테스트합니다.


6. 적절한 Mocking
외부 의존성을 갖는 코드(예: API 호출, 데이터베이스 접근 등)는 Mocking을 사용하여 테스트합니다. 
이를 통해 테스트의 독립성과 신뢰성을 높일 수 있습니다.


7. 빠른 피드백 루프 (Fast Feedback Loop)
테스트는 빠르게 실행되어야 합니다. 
느린 테스트는 개발자의 피드백 루프를 방해하므로, 가능한 한 테스트 속도를 최적화합니다.


8. 지속적인 통합 (Continuous Integration)
테스트는 지속적인 통합(Continuous Integration, CI) 파이프라인에서 자동으로 실행되어야 합니다. 
이를 통해 코드 변경 시마다 자동으로 테스트가 실행되어 문제를 조기에 발견할 수 있습니다.


9. 경계 값 분석 (Boundary Value Analysis)
경계 값과 같은 중요한 값들을 테스트합니다. 
이는 예기치 않은 버그를 발견하는 데 도움이 됩니다.


10. 테스트 유지보수
테스트 코드는 애플리케이션 코드와 동일한 수준의 유지보수가 필요합니다. 
코드 변경 시 테스트 코드도 함께 업데이트되어야 합니다.

 

 

모킹(Mocking) 이란?

단위 테스트를 작성할 때, 해당 코드가 의존하는 부분을 가짜(mock)으로 대체하는 기법

이러한 모킹 기법은 임의로 객체를 만들고 데이터를 넣어주는 방식으로 할 수 있습니다.

jest.spyOn(window, "fetch").mockImplementation(() => {
	return Promise.resolve({
			json: () => Promise.resolve([
					{ id: 1, title: "Blog 1", author: "Author 1" },
					{ id: 2, title: "Blog 2", author: "Author 2" },
					{ id: 3, title: "Blog 3", author: "Author 3" },
			])
	})
});

하지만 위와 같은 방식은 추후에 호출 방식이 바뀔 때마다 테스트를 하나하나 바꿔줘야 하기 때문에 좋은 방식이라고 하기는 어렵습니다. 이러한 문제를 해결할 수 있는 방법이 MSW를 이용해 API 요청을 모킹하는 방법입니다.

 

 MSW(Mock Service Worker) 란?

프론트엔드 개발에서 API 요청을 모킹할 수 있는 라이브러리입니다. Fetch API와 XHR을 사용한 네트워크 요청을 가로채 요청된 URL 및 HTTP 메소드에 맞추어 모킹된 응답을 제공할 수 있습니다. 이를 이용해 외부에 의존하는 실제 API가 아닌 모킹한 API를 통해 날씨 Provider를 테스트 해보는 코드를 작성할 예정입니다.

 

기초적인 사용법은 따로 정리하여 올리겠습니다. 지금은 프로젝트에 직접 적용해보고 어떤 방식으로 적용했는지에 대해 정리하겠습니다. 이를 통해서 외부 API를 이용하는 코드에 대한 테스트 코드를 작성하는 방법을 함께 알아봅시다.


Next.js + TypeScript 프로젝트에 Jest 테스트 환경 설정

먼저 Next.js + TypeScript에서 테스트 코드를 실행할 수 있는 환경을 설정해 줍시다. Jest와 React Testing Library를 사용하여 테스트 코드를 작성할 것입니다.

 

1. 필요한 패키지를 설치해 줍니다. (-D는 개발 환경에서만 사용하겠다는 의미입니다. --save-dev를 적어도 됩니다.)

npm i -D 
jest
jest-environment-jsdom
@testing-library/jest-dom 
@testing-library/react 
@testing-library/user-event 
@types/jest 
ts-jest
ts-node
msw

 

2. package.json scripts에 test 추가

// package.json

"scripts": {
    "test": "jest",
    "test:watch": "jest --watchAll",
    "coverage": "jest --coverage"
},

 

2. jest.config.ts와 jest.setup.ts 파일 루트 디렉토리에 추가

// jest.config.ts
import type { Config } from 'jest'
import nextJest from 'next/jest.js'

const createJestConfig = nextJest({
  dir: './',
})

const config: Config = {
  coverageProvider: 'v8',
  testEnvironment: 'jest-environment-jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  transformIgnorePatterns: [
    '/node_modules/',
    '^.+\\.module\\.(css|sass|scss)$',
  ],
  // 절대 경로 사용시
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
}

export default createJestConfig(config)
// jest.setup.ts

import '@testing-library/react'
import '@testing-library/jest-dom'

 

3. TypeScript가 jest 코드들을 인식할 수 있도록 타입 추가

// tsconfig.json
"compilerOptions": {
    "typeRoots": ["./types", "./node_modules/@types"],
    "types": ["jest", "node"],
}

 

이렇게 설정을 끝내고 msw를 이용해 테스트 코드를 작성해 보겠습니다.

 


날씨 Provider에 대한 테스트 코드 작성

테스트 코드를 작성할 날씨 Provider 코드는 다음과 같습니다. (완전 초짜 개발자라 미흡한 부분이 많을 수 있습니다. 코드리뷰는 언제나 환영입니다.)

'use client'
import {
  Dispatch,
  ReactNode,
  SetStateAction,
  createContext,
  useEffect,
  useState,
} from 'react'

type WeatherType =
  | 'Clear'
  | 'Rain'
  | 'Thunderstorm'
  | 'Snow'
  | 'Mist'
  | 'Drizzle'
  | 'Clouds'
  | 'Fog'
  | 'Haze'
  | 'Sand'

interface WeatherProvider {
  icon: string | null
  tempNow: number | null
  tempMax: number | null
  tempMin: number | null
  address: string | null
  weather: WeatherType | null
  setTempMax: Dispatch<SetStateAction<number | undefined>>
  setTempMin: Dispatch<SetStateAction<number | undefined>>
}

export const WeatherContext = createContext<WeatherProvider>({
  icon: null,
  tempMax: null,
  tempMin: null,
  tempNow: null,
  address: null,
  weather: null,
  setTempMax: () => {},
  setTempMin: () => {},
})

export const WeatherProviderByContext = ({
  children,
}: {
  children: ReactNode
}) => {
  const [temperature, setTemp] = useState<number>()
  const [temperatureMin, setTempMin] = useState<number>()
  const [temperatureMax, setTempMax] = useState<number>()
  const [weatherIcon, setIcon] = useState<string>()
  const [latitude_state, setLatitude] = useState<number>()
  const [longitude_state, setLongitude] = useState<number>()
  const [address, setAddress] = useState<string>()
  const [weather, setWeather] = useState<WeatherType>()

  // API Keys
  // openweathermap
  const API_KEY: string = ''
  // KaKao
  const KAKAO_API_KEY: string = ''

  useEffect(() => {
    const getLocation = async () => {
      try {
        // 위치 노출이 허용되어 있는지 확인하고 현재 위치 가져오기
        const position = await new Promise<GeolocationPosition>(
          (resolve, reject) => {
            navigator.geolocation.getCurrentPosition(resolve, reject)
          },
        )

        // 위도와 경도 가져오기
        setLatitude(position.coords.latitude)
        setLongitude(position.coords.longitude)
      } catch (error) {
        console.error('Error getting location:', error)
      }
    }

    getLocation()
  }, [])

  useEffect(() => {
    const getWeather = async () => {
      try {
        // OpenWeatherMap에서 위치 기반 날씨 정보 불러오기
        const weatherResponse = await fetch(
          `https://api.openweathermap.org/data/2.5/weather?lat=${latitude_state}&lon=${longitude_state}&appid=${API_KEY}&units=metric`,
        )
        const weatherData = await weatherResponse.json()

        // 날씨 정보 저장하기
        setIcon(weatherData.weather[0].icon)
        setWeather(weatherData.weather[0].main)
        setTemp(weatherData.main.temp.toFixed(1))
        setTempMax(weatherData.main.temp_max.toFixed(1))
        setTempMin(weatherData.main.temp_min.toFixed(1))
      } catch (error) {
        console.error('Error getting location:', error)
      }
    }

    if (latitude_state && longitude_state) getWeather()
  }, [latitude_state, longitude_state])

  useEffect(() => {
    const getAddress = async () => {
      try {
        // Kakao API에서 위치에 대한 주소 가져오기
        const addressResponse = await fetch(
          `https://dapi.kakao.com/v2/local/geo/coord2address.json?x=${longitude_state}&y=${latitude_state}`,
          {
            method: 'GET',
            headers: { Authorization: `KakaoAK ${KAKAO_API_KEY}` },
          },
        )
        const addressData: LocationResponse = await addressResponse.json()
        
        // 값을 제대로 불러왔다면 주소 state에 넣기
        if (addressData.documents && addressData.documents.length > 0) {
          setAddress(
            addressData.documents[0].address.region_1depth_name +
              ' ' +
              addressData.documents[0].address.region_2depth_name,
          )
        }
      } catch (error) {
        console.error('Error getting location:', error)
      }
    }

    getAddress()
  }, [temperatureMin])

  return (
    <>
      {weatherIcon &&
        temperature &&
        temperatureMax &&
        weather &&
        temperatureMin &&
        address && (
          <WeatherContext.Provider
            value={{
              icon: weatherIcon,
              weather: weather,
              address: address,
              tempMax: temperatureMax,
              tempMin: temperatureMin,
              tempNow: temperature,
              setTempMax,
              setTempMin,
            }}>
            {children}
          </WeatherContext.Provider>
        )}
    </>
  )
}

 

위의 Provider를 가지고 와서 모킹한 API 요청에 대한 데이터를 보내줬을 때 잘 받아서 뿌려주는 지에 대한 테스트 코드를 작성해 보겠습니다.


MSW API 모킹하기

1. msw handlers 설정하기

msw handlers에 원하는 요청 경로와 메서드, 응답값 등을 작성하면 됩니다.

저는 위에서 말씀 드렸다시피 OpenWeatherMap API와 Kakao 지도 API 응답을 모킹하겠습니다.

이전에는 rest를 가지고 와서 사용하는 방식이었는데 msw 공식문서를 보면 지금은 http와 HttpResponse를 가져와 API를 모킹해야 합니다. 더 자세한 내용은 아래 공식 문서를 참고해 주세요.

 

Getting started

Three steps to get started with Mock Service Worker.

mswjs.io

 

1.x → 2.x

Migration guidelines for version 2.0.

mswjs.io

 

 

Provider에서 받고 보내주는 데이터는 다음과 같습니다.

날씨 데이터 :

    setIcon(weatherData.weather[0].icon)
    setWeather(weatherData.weather[0].main)
    setTemp(weatherData.main.temp.toFixed(1))
    setTempMax(weatherData.main.temp_max.toFixed(1))
    setTempMin(weatherData.main.temp_min.toFixed(1))

 

위치 데이터 :

setAddress(
            addressData.documents[0].address.region_1depth_name +
              ' ' +
              addressData.documents[0].address.region_2depth_name,
          )

 

API를 모킹하여 해당하는 응답을 보내주면 됩니다.

// src/mocks/handlers.ts

import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('https://api.openweathermap.org/data/2.5/weather', () => {
    return HttpResponse.json({
      // 실제 데이터는 배열을 보내주기 때문에 [{}] 형대로 목데이터 설정
      weather: [{ icon: '01d', main: 'Clear' }],
      // 실제 데이터는 객체를 보내주기 때문에 {} 형태로 목데이터 설정
      main: { temp: 23.5, temp_min: 20.7, temp_max: 26.2 },
    });
  }),

  http.get('https://dapi.kakao.com/v2/local/geo/coord2address', () => {
    return HttpResponse.json({
      documents: [
        {
          address: {
            region_1depth_name: '서울',
            region_2depth_name: '관악구',
          },
        },
      ],
    });
  }),
];

 

 

2. msw 서버 설정하기

// scr/mocks/server.ts

import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

 

jest.setup.ts에서 서버 설정 (모든 테스트가 msw API를 이용하는 경우)

// jest.setup.ts

import '@testing-library/react';
import '@testing-library/jest-dom';
import { server } from './src/mocks/server';

beforeAll(() => server.listen());

afterEach(() => server.resetHandlers());

afterAll(() => server.close());

beforeAll을 통해 모든 테스트가 시작되기 전에 msw를 시작해주고, afterEach를 통해 테스트가 끝난 후 서버를 리셋해줘 서로에게 영향을 주지 못하게 하여 테스트 독립성을 지키고 테스트가 끝난 후 afterAll로 msw 서버를 종료시켜 줍니다.

 

테스트 전체적으로 msw 서버를 이용해 모킹한 API를 사용해야 한다면 위와 같이 jest.setup.ts에 설정을 해주는 게 좋습니다. 그게 아니라면 테스트 개별적으로 서버를 열어주는 것이 더 나은 것 같습니다. 다른 테스트는 msw 서버를 이용하지 않는데 굳이 setup 파일에 설정하여 모든 테스트에 적용할 필요는 없다고 생각합니다.


테스트 코드 작성하기

이제 Provider에 대한 테스트 코드를 작성해 봅시다.

import { useContext } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { WeatherProviderByContext, WeatherContext } from './WeatherContext'
import { server } from '../src/mocks/server'


// 테스트할 때 msw 이용하기
beforeAll(() => server.listen())

afterEach(() => server.resetHandlers())

afterAll(() => server.close())

// 테스트용 컴포넌트 만들기
const TestComponent = () => {
  const { icon, tempNow, tempMax, tempMin, address, weather } =
    useContext(WeatherContext)

  return (
    <>
      <div data-testid="icon">{icon}</div>
      <div data-testid="tempNow">{tempNow}</div>
      <div data-testid="tempMax">{tempMax}</div>
      <div data-testid="tempMin">{tempMin}</div>
      <div data-testid="address">{address}</div>
      <div data-testid="weather">{weather}</div>
    </>
  )
}

test('WeatherContext를 통해 불러온 데이터를 하위 컴포넌트로 잘 보내주는 지 테스트', async () => {
  render(
    <WeatherProviderByContext>
      <TestComponent />
    </WeatherProviderByContext>,
  )

  await waitFor(() => {
    expect(screen.getByTestId('icon')).toHaveTextContent('01d')
    expect(screen.getByTestId('tempNow')).toHaveTextContent('23.5')
    expect(screen.getByTestId('tempMax')).toHaveTextContent('26.2')
    expect(screen.getByTestId('tempMin')).toHaveTextContent('20.7')
    expect(screen.getByTestId('address')).toHaveTextContent('서울 관악구')
    expect(screen.getByTestId('weather')).toHaveTextContent('Clear')
  })
})

 

테스트를 시작할 떄 msw 서버를 켜줘서 API를 모킹해주고 일전에 설정해 줬던 데이터를 잘 전달해주는지 테스트 하는 코드입니다.


테스트 진행 중 발생한 에러 해결 과정

1번째 에러 : cannot find module 'msw/node'

해당 테스트 코드를 테스트 하는 과정에서 msw/node를 찾지 못한다는 오류가 발생할 수도 있는데 이럴 경우 jest.config.ts 팔일에 아래의 코드를 추가해주면 됩니다.

// jest.config.ts
module.exports = {
  testEnvironmentOptions: {
    customExportConditions: [''],
  },
}

 

이 설정은 JSDOM이 msw/node 모듈을 가져올 때 기본 내보내기 조건을 사용하도록 강제합니다. 이를 통해 올바른 모듈을 가져와서 테스트 환경에서 사용할 수 있게 됩니다. 즉, 이 설정을 적용하면 msw/node 모듈을 제대로 가져올 수 있도록 도와줍니다.

 

 

2번째 에러 : ReferenceError: TextEncoder is not defined

msw/node를 찾지 못하는 문제는 해결했지만 계속해서 TestEncoder를 찾지 못하는 문제가 발생합니다. 찾아본 결과 공식 문서에서 해결 방법을 알려주고 있는 것을 알아냈습니다.

 

1.x → 2.x

Migration guidelines for version 2.0.

mswjs.io

루트 디렉토리에 jest.polyfills.ts 파일을 만들어 TestEncoder와 TestDecoder를 정의해주면 됩니다.

// jest.polyfills.ts

import { TextDecoder, TextEncoder } from 'node:util';

Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
});

이후 jest.config.ts에서 파일을 사용한다고 명시해 줍시다.

module.exports = {
  setupFiles: ['./jest.polyfills.js'],
}

 

 

3번째 에러 : ReferenceError: Response is not defined

이렇게 정의 해주면 이제 Response를 찾을 수 없다고 나오네요.. 공식 문서에  있는 모든 설정을 적용해 주는게 좋을 것 같습니다.

 

const { Blob, File } = require('node:buffer')
const { fetch, Headers, FormData, Request, Response } = require('undici')
 
Object.defineProperties(globalThis, {
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

위와 같이 정의해줘야 하기 때문에 undici를 설치해 줍니다.

https://github.com/nodejs/undici

 

아래와 같이 import 상태로 변환하였더니

// jest.ployfills.ts

import { TextDecoder, TextEncoder } from 'node:util'
import { Blob, File } from 'node:buffer'
import { fetch, Headers, FormData, Request, Response } from 'undici'

Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

아예 TestEncoder도 찾지 못하더라고요.. 그래서 공식 문서 그대로 하기 위해서 js 파일로 바꾸고 다음과 같이 코드를 작성했습니다.

// jest.ployfills.js

const { TextDecoder, TextEncoder } = require('node:util')

Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
})

const { Blob, File } = require('node:buffer')
const { fetch, Headers, FormData, Request, Response } = require('undici')

Object.defineProperties(globalThis, {
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

 

 

4번째 에러 : ReadableStream is not defined

이 에러는 node:util에서 ReadableStream을 가져와 정의해주면 해결됩니다.

// jest.polyfills.js

const { TextDecoder, TextEncoder } = require('node:util')
const { ReadableStream, TransformStream } = require('node:stream/web')

Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
  ReadableStream: { value: ReadableStream },
  TransformStream: { value: TransformStream },
})

const { Blob, File } = require('node:buffer')
const { fetch, Headers, FormData, Request, Response } = require('undici')

Object.defineProperties(globalThis, {
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

 

 

5번째 에러 :  Error getting location: TypeError: Cannot read properties of undefined

생각해 보니까 OpenWeatherMap API랑 Kakao 지도 API에 대한 요청만 모킹해주고, geolocation에 대한 모킹은 안 해줬더라고요..하하 이에 대한 모킹도 해보겠습니다.

 

beforeAll에 브라우저 API에 대한 모킹을 해놓으면 됩니다.

// WeatherContext.test.ts

beforeAll(() => {
  server.listen()
  // getCurrentPosition 모킹
  Object.defineProperty(global.navigator, 'geolocation', {
    value: {
      getCurrentPosition: jest.fn().mockImplementation(callback =>
        callback({
          coords: {
            latitude: 37.514575,
            longitude: 127.0495556,
          },
        }),
      ),
    },
  })
})

 

이렇게 하면 테스트를 진행 시 getCurrentPosition 함수를 사용할 때 설정한 coords 데이터를 보내줍니다.

 

긴 글 읽어주셔서 감사합니다.

+ Recent posts