안녕하세요, 비브로스에서 백엔드 개발자로 일하고 있는 김예림입니다.
개발 과정에서 이제는 테스트 코드가 필수처럼 여겨지고 있지만, 여전히 어렵고 멀게 느껴지는 경우가 많습니다. 저 역시 테스트 코드를 작성하면서 내가 쓰고 있는 기법의 정확한 의미나 스타일을 잘 알지 못하거나, 어느 정도로 작성해야 할지 감이 오지 않아 혼란스러웠던 경험이 많았습니다. 이런 경험들이 쌓이다 보니 점점 테스트 코드의 필요성도 희미해지고 귀찮고 불필요하게 느껴질 때도 있었습니다.
그래서 이번 글에서는 저와 비슷한 경험을 하신 분들을 위해 테스트 코드 작성 가이드를 준비해보았습니다. 이 글은 방대한 이론서처럼 어렵게 쓰인 문서가 아니라, 가볍게 읽으며 기초와 목적성을 다질 수 있는 내용을 담았습니다. 글을 읽고 테스트 코드 작성의 감을 잡아보고, 더 궁금한 점이 생기면 스스로 찾아보게 되는 출발점이 되었으면 합니다.
여기에 Jest를 살짝 곁들였으니, 맛있게 읽어주세요! 😊
개발하면서 얼마나 테스트를 하고 있나요? 결과물에 급급하여 테스트 코드를 제대로 작성하지 못한 채 배포한 적이 있나요? 이 글은 그런 상황을 겪는 개발자들을 위해 테스트 코드의 기본 개념과 방법론에 대해 설명합니다.
테스트의 종류
단위 테스트 (Unit Test)
단위 테스트는 소프트웨어의 개별 구성 요소(모듈, 함수, 메서드 등)가 의도된 대로 정확히 작동하는지 검증하는 절차입니다. 다시 말해, 특정 코드 조각의 올바른 동작을 보장하기 위해 테스트 케이스를 작성하고 실행하는 과정입니다.
테스트 대상 정의
단위 테스트의 테스트 대상은 테스트 중인 주제 (Subject Under Test, SUT) 라고 불리며, 테스트하려는 주요 객체나 함수를 의미합니다. SUT는 일반적으로 테스트의 중심에 놓이는 핵심 코드입니다.
단위 테스트의 격리
단위 테스트의 핵심 목표는 테스트 대상을 외부 요인으로부터 최대한 격리하는 것입니다. 이를 위해 Mock (모의 객체), Stub (스텁), 또는 Fake 객체와 같은 테스트 대역(Test Double)을 사용하여 외부 의존성을 시뮬레이션합니다.
예를 들어, 클래스 A를 단위 테스트할 때, A가 클래스 B와 C가 상호작용한다고 가정해 봅시다. 이 경우, 클래스 B, C를 실제로 호출하지 않고도 A를 테스트할 수 있도록 Mock 객체를 활용해 B, C의 동작을 시뮬레이션합니다. 이를 통해 테스트의 단순성과 독립성을 유지할 수 있습니 다.
단위 테스트의 장단점
장점
- 빠르다: 단위 테스트는 코드의 작은 단위를 테스트하기 때문에 실행 속도가 빠릅니다.
- 간단하다: 테스트 범위가 명확하고 복잡도가 낮아 작성하기 쉽습니다.
- 신뢰성 확보: 개별 모듈의 동작이 보장되므로 코드 품질이 향상됩니다.
- 코드 변경시 회귀 버그 예방: 새로운 기능 추가나 코드 변경시 기존 기능이 깨지지 않도록 보장합니다.
- 유지보수 시 변경 범위를 신속히 파악가능: 테스트 실패를 통해 어떤 코드가 영향을 받는지 바로 확인 할 수 있고 그걸 토대로 변경범위가 파악이 가능합니다.
- 스펙문서 역할: 테스트 코드는 모듈의 입출력, 동작을 명확히 설명하기때문에 신규 인원이 테스트 코드를 읽으면서 모듈의 스펙과 동작을 빠르게 이해할 수 있습니다.
- 리팩터링의 안전망 제공: 테스트 코드가 리팩터링의 안전망 역할을 합니다. 기존 테스트가 성공하면 리팩터링이 성공적으로 이루어진 것을 보장합니다.
단점
- 현실성이 떨어질 수 있다: 단위 테스트는 개별 모듈의 동작만 검증하므로, 시스템 전체의 복잡한 상호작용을 포괄하지 못할 수 있습니다
- 제약 조건이 많다: 외부 의존성을 제거해야 하기 때문에 설정이 번거로울 수 있습니다.
좋은 단위 테스트란?
좋은 단위 테스트는 다음과 같은 특징이 있습니다.
- 빠르게 실행되어야 한다.
- 테스트 환경을 일관되게 유지하고, 테스트 결과가 항상 예측 가능해야한다.
- 다른 테스트와 완전히 독립적으로 실행되어야한다.
- 시스템 파일, 네트워크, 데이터베이스가 없어도 메모리 내에서 실행되어야한다.
- 가능한 한 동기적인 흐름으로 실행되어야 하며, 불필요한 병렬 스레드를 사용하지 않아야 합니다.
모든 테스트가 좋은 단위 테스트의 특성을 전부 만족하는 것은 사실 불가능에 가깝습니다. 그래서 항상 모든 조건을 만족할 필요는 없습니다. 단위테스트 조건을 만족하기 까다로운 테스트는 적당한 리팩터링을 거쳐 보다 많은 조건을 충족하도록 만들 수도 있지만, 통합 테스트로 만드는 것도 하나의 방법일 수도 있습니다.
통합 테스트 (Integration Test)
통합 테스트는 2 개 이상의 여러 컴포넌트나 모듈이 통합되어 상호작용할 때의 동작을 검증하는 절차입니다. 이를 통해 모듈 간의 인터페이스와 데이터 흐름이 올바르게 작동하는지 확인하는 것이 주 목적입니다.
통합 테스트의 주요 특징
- 모듈 간 상호작용 검증
- 개별적으로 검증된 모듈들이 함께 작동할 때, 데이터가 정확히 전달되고 동작이 예상한 대로 이루어지는지 확인합니다.
- 예: 사용자 인증 서비스와 데이터베이스가 올바르게 연동되어 로그인 요청이 처리되는지 확인.
- 외부 의존성 포함 가능
- 통합 테스트는 내부 시스템뿐 아니라 데이터베이스, 외부 API 등 외부 의존성과의 상호작용도 다룹니다. 이를 통해 외부 서비스와의 연결 상태와 데이터 교환의 정확성을 확인할 수 있습니다.
- 단위 테스트보다 넓은 범위
- 단위 테스트가 개별 모듈의 동작에 집중한다면, 통합 테스트는 그 모듈들이 실제로 서로 조화를 이루며 작동하는지를 보장합니다.
통합 테스트 작성 시 유의점
- 명확한 테스트 범위 설정
- 통합 테스트는 단위 테스트보다 범위가 넓지만, 너무 광범위하면 디버깅이 어려워집니다. 특정 모듈 간의 상호작용에 초점을 맞추는 것이 중요합니다.
- 테스트 환경 구성
- 실제 환경과 유사한 테스트 환경을 구성해야 합니다.
- 데이터베이스나 API와의 통합을 검증하는 경우, 테스트 데이터베이스를 사용하는 것이 일반적입니다.
- Mock 객체나 Stub을 사용해 일부 외부 의존성을 시뮬레이션할 수도 있습니다.
- 속도와 성능 고려
- 통합 테스트는 단위 테스트보다 느리기 때문에 필요한 경우에만 실행하고, CI/CD 파이프라인에 통합하여 자동화합니다.
- 실패 시 디버깅 전략
- 통합 테스트는 범위가 넓기 때문에 실패 원인을 찾기 어려울 수 있습니다. 로그와 디버깅 도구를 활용해 문제를 빠르게 진단할 수 있도록 설계합니다.
통합 테스트의 장단점
장점:
- 모듈 간 상호작용과 데이터 흐름의 실제 문제를 발견할 수 있음.
- 외부 서비스와의 통합을 검증함으로써 시스템 안정성을 높임.
- 사용자 관점에서 발생할 수 있는 다양한 문제를 미리 확인 가능.
단점:
- 단위 테스트보다 느리고 설정이 복잡함.
- 외부 의존성에 따라 테스트 결과가 변할 가능성이 있음(네트워크 장애, 서비스 중단 등).
- 실패 시 원인 분석이 어려울 수 있음.
E2E 테스트 (End-to-End Test)
E2E 테스트는 시스템의 전체 흐름을 사용자 관점에서 검증하는 절차입니다. 애플리케이션의 주요 기능과 사용자의 시나리오가 제대로 동작하는지 확인하는 것이 주 목적입니다.
E2E 테스트의 주요 특징
- 사용자 시나리오 기반 테스트
- E2E 테스트는 실제 사용자가 애플리케이션을 사용하는 방식으로 테스트를 진행합니다.
- 예: 사용자가 로그인 버튼을 클릭하고, 대시보드가 성공적으로 로드되는지 확인.
- 전체 시스템 검증
- E2E 테스트는 프론트엔드, 백엔드, 데이터베이스, 외부 서비스 등 애플리케이션의 모든 구성 요소가 통합된 상태에서 동작을 검증합니다.
- 실제 환경과 유사한 테스트 환경
- 실제 프로덕션 환경과 유사하게 설정하여 시스템의 복잡한 시나리오를 재현합니다.
E2E 테스트 작성 시 유의점
- 테스트 커버리지 설정
- 모든 사용자 시나리오를 테스트하려는 것은 비효율적일 수 있습니다. 핵심 사용자 흐름에 초점을 맞추는 것이 중요합니다.
- 테스트 환경 관리
- 테스트 실행 시, 프로덕션 데이터나 환경이 변경되지 않도록 주의해야 합니다. 별도의 테스트 전용 환경을 마련합니다.
- 자동화 도구 활용
- Cypress, Selenium, Playwright 같은 E2E 테스트 도구를 사용하여 테스트를 자동화하고, 안정성과 반복성을 확보합니다.
- 실패 분석과 유지보수
- E2E 테스트는 실행 시간이 길고, 실패 원인을 분석하는 데 시간이 걸릴 수 있습니다. 잘 설계된 테스트 로그와 명확한 오류 메시지가 필요 합니다.
E2E 테스트의 장단점
장점:
- 사용자 관점에서 실제 동작을 확인할 수 있어, 사용자 경험에 대한 신뢰성을 확보 가능.
- 전체 시스템을 검증하므로, 통합 테스트에서 놓칠 수 있는 문제를 발견 가능.
단점:
- 실행 시간이 길고 자원 소모가 많음.
- 디버깅이 복잡하며, 작은 변화에도 테스트가 실패할 가능성이 있음.
- 설정 및 유지보수 비용이 높음.
여기서는 단위테스트와 통합테스트 위주로만 설명하도록 하겠습니다
통합 테스트 vs. E2E 테스트
통합 테스트는 우리의 구성요소와 외부 구성요소 간의 상호작용에 중점은 둡니다. 반면, E2E 테스트(End-to-End Test) 는 사용자 관점에서 전체 시스템의 워크플로를 검증합니다. 예를 들어:
- 통합 테스트: 사용자 입력이 서비스 계층과 데이터베이스를 거쳐 올바른 데이터를 반환하는지 확인.
- E2E 테스트: 사용자가 로그인 버튼을 클릭하고 대시보드가 로드되는 전체 과정을 검증.
통합 테스트는 E2E 테스트보다 간단하고 유지보수가 용이하며, 필요에 따라 외부 의존성을 Mock으로 대체할 수 있습니다. 반면, E2E 테스트는 실제 사용자 관점에서 전체 시스템을 검증하므로 두 테스트를 균형 있게 활용하는 것이 중요합니다.
테스트 방법론
TDD (Test Driven Development)
TDD는 코드를 작성하기 이전에 테스트를 먼저 작성하고, 그 테스트를 통과하는 코드를 구현함으로써 테스트된 동작 가능한 코드를 만들어내는 개발 방법입니다. TDD는 코드 품질을 높이고, 리팩터링을 용이하게 하며, 안정적인 소프트웨어 개발을 돕습니다.
테스트 주도 개발에 대한 여러가지 견해
TDD를 바라보는 관점은 다양합니다:
- 테스트 우선 개발 (Test-First Development) : 테스트를 먼저 작성하는 것으로 TDD를 정의합니다.
- 테스트 중심 개발: 테스트를 많이 작성하는 것으로 간주합니다.
- 설계 방법론: 코드를 설계하고 동작을 점진적으로 발전시키는 도구로 TDD를 이해합니다.
DD는 단순한 "테스트 작성"을 넘어, 설계와 구현의 사이클을 유기적으로 결합하여 점진적으로 코드를 발전시키는 방법론으로 볼 수 있습니다.
일반적인 단위 테스트와 TDD 의 비교
TDD는 테스트 코드 작성과 기능 구현을 결합하여 개발 주기를 구성하는 접근 방식입니다. 이에 비해, 일반적인 단위 테스트는 이미 작성된 코드를 검증하기 위한 테스트 작성에 초점이 맞춰져 있습니다. 두 방법의 주요 차이점을 아래와 같이 정리할 수 있습니다.
특징 | 일반적인 단위 테스트 | TDD |
---|---|---|
코드 작성 순서 | 코드 -> 테스트 | 테스트 ->코드 |
목적 | 코드 검증 | 설계 주도 및 구현 |
테스트 범위 | 개별 함수/메서드 중심 | 기능 요구사항 중심 |
리팩터링 | 필수가 아님(선택사항) | 필수 단계로 포함 |
개발 접근법 | 사후 검증 중심 | 설계와 구현을 동시에 진행 |
TDD 의 핵심 : Red-Green-Refactor 사이클
TDD는 다음과 같은 Red-Green-Refactor 주기를 반복하는 방식으로 진행됩니다:
- Red: 실패하는 테스트 작성
- 구현하려는 기능에 대한 (단위) 테스트를 작성합니다.
- 초기에는 테스트가 실패하도록 설계합니다. (즉, 아직 기능이 구현되지 않았음을 확인)
- Green: 테스트 통과를 위한 코드 작성
- 작성한 테스트를 통과하기 위해 필요한 최소한의 코드를 구현합니다.
- 테스트가 성공하는 것을 확인합니다.
- Refactor: 리팩터링
- 작성한 제품 코드와 테스트 코드를 개선합니다.
- 리팩터링 중에도 테스트가 통과하는지를 지속적으로 확인합니다.
이 주기를 반복하면서 코드를 점진적으로 발전시키고, 설계 품질을 개선할 수 있습니다.
입코딩의 끝판왕 TDD?
흔히 입코딩이라고 하는 대표주자를 TDD로 말하는 경우가 많은데 TDD는 만능이 아닙니다. 많은 사람들이 TDD를 “ 테스트를 위한 개발” 이라고 착각하지만, TDD의 이상적인 모습은 “ 테스트로 설계를 주도하고, 안정적이고 깔끔한 코드를 만들어내는 것 ” 입니다.
TDD에 대한 몇가지 주의사항은 아래와 같습니다.
- 테스트를 위한 코드가 아니다
- TDD는 테스트를 위한 코드를 작성하는 것이 아니라, 테스트를 통해 요구사항을 충족하는 코드를 점진적으로 만들어가는 과정입니다.
- 테스트 과잉에 주의
- 모든 코드를 테스트하려다 보면, 비즈니스적으로 중요하지 않은 부분에 지나치게 많은 리소스를 소비할 수 있습니다. 핵심 로직과 주요 기능에 초점을 맞추어야 합니다.
- 예: 단순한 Getter/Setter 메서드 테스트, 외부 라이브러리나 기본 메서드(Node.js의 path.join 등)의 동작을 재검증, 의미 없는 데이터 시나리오를 지나치게 세분화한 테스트 등
- 지나치게 세분화 된 테스트를 지양
- 지나치게 작은 단위로 테스트를 작성하면, 코드 변경 시 과도한 수정이 필요해질 수 있습니다. 적절한 수준의 테스트 범위를 설정하는 것이 중요합니다.
- 리팩토링에 대한 두려움을 줄여야 한다
- TDD의 핵심은 리팩터링을 지속적으로 수행하는 것입니다. 테스트가 리팩터링을 지원하도록 설계하고, 리팩터링을 두려워하지 않아야 합니다.
- 팀의 합의와 표준화 필요
- 개인마다 테스트 작성 스타일이 다르거나, 팀 내 TDD 적용 기준이 명확하지 않으면 오히려 비효율이 발생할 수 있습니다. TDD를 도입하려면, 팀 내 테스트 작성 규칙과 목표를 공유하는 것이 중요합니다.
테스트 과잉과 과잉 명세(Overspecification)
테스트 과잉과 과잉 명세는 비슷한 맥락에서 쓰이지만, 정확히 같은 의미는 아닙니다.
테스트 과잉은 비즈니스적으로 중요하지 않은 부분이나, 테스트하지 않아도 무방한 코드에 너무 많은 테스트를 작성하는 것을 의미합니다. 이는 테스트 유지보수를 어렵게 하고, 개발 리소스를 낭비하는 원인이 됩니다.
과잉명세는 테스트의 조건을 지나치게 엄격하게 정의하여, 테스트가 코드의 사소한 변경(구조 변경, 리팩터링 등)에도 실패하는 상황을 뜻합니다. 단순히 코드의 외부 동작을 확인하는 것이 아니라, 코드 내부가 어떻게 구현되어야 하는지 까지 검증하는 테스트를 의미합니다.
다음은 단위 테스트가 과잉 명세 되었다고 볼 수 있는 조건입니다
- 테스트가 객체의 내부 상태만 검증한다
- 테스트가 Mock을 여러개 만들어 사용한다
- 테스트가 Stub을 Mock 처럼 사용한다
- 테스트가 필요하지 않은데도 특정 실행 순서나 정확한 문자열 비교를 포함한다
구분 | 테스트 과잉 | 과잉 명세(Overspecification) |
---|---|---|
초점 | 불필요하거나 중요하지 않은 코드를 과도하게 테스트 | 테스트 조건을 지나치게 엄격히 정의 |
문제 발생 시점 | 테스트의 가치와 우선순위 판단 단계 | 테스트 조건과 코드 간 결합도가 높은 경우 |
문제의 결과 | 유지보수 비용 증가, 테스트의 비효율성 | 잦은 테스트 실패로 리팩터링과 유지보수를 어렵게 만듦 |
상황에 맞는 TDD
TDD 가 적합한 경우
- 복잡한 비즈니스 로직
- 반복적으로 검증해야 하는 복잡한 계산이나 처리 로직이 많을 때 유용합니다.
- 핵심 모듈 개발
- 시스템의 핵심 기능이나 변경 가능성이 높은 부분에 대해 TDD를 적용하면 안정성을 높일 수 있습니다.
- 지속적 통합/배포 환경
- CI/CD 환경에서 코드 품질과 안정성을 유지하는 데 효과적입니다.
TDD 가 어려운 경우
- 초기 프로토타입 개발
- 빠르게 아이디어를 검증해야 할 경우, TDD가 오히려 개발 속도를 늦출 수 있습니다.
- 비즈니스 요구사항이 불명확한 경우
- 요구사항이 자주 바뀌거나 명확하지 않으면 테스트도 자주 변경되어야 하므로 비효율적일 수 있습니다.
- UI 중심 개발
- 사용자 인터페이스(UI) 관련 코드는 테스트 자동화가 어렵고, 유지보수가 복잡할 수 있습니다.
BDD (Behavior Driven Development)
BDD(행위 주도 개발)은 TDD를 확장한 소프트웨어 개발 방법론으로, 소프트웨어의 행동에 초점을 맞춥니다. BDD는 기술적인 세부사항보다는 사용자 관점에서 시스템이 어떻게 동작해야 하는지를 정의하고 검증하는 것을 목표로 합니다.
BDD 가 중점을 두는 사항
BDD는 다음과 같은 질문에 답하며 개발 프로세스를 이끌어갑니다:
- 프로세스 시작 지점: 어디서부터 시작해야 하는가?
- 테스트 대상: 무엇을 테스트하고, 무엇을 제외할 것인가?
- 테스트 범위: 한 번에 얼마나 테스트할 것인가?
- 테스트 명명: 테스트를 무엇이라 부를 것인가?
- 테스트 실패 이해: 테스트 실패 원인을 어떻게 이해하고 해결할 것인가?
BDD 의 핵심 구성요소
BDD는 Given-When-Then
구조를 통해 소프트웨어 행동을 기술합니다.
- Given (조건)
- 테스트를 실행하기 전에 시스템의 초기 상태를 설정.
- 예: “사용자가 로그인 페이지에 접속했고, 로그인 폼이 표시되어 있을 때”
- When (행동)
- 사용자가 수행하는 특정 행동을 정의.
- 예: “사용자가 유효한 이메일과 비밀번호를 입력하고 로그인 버튼을 클릭했을 때”
- Then (결과)
- 행동의 결과로 기대되는 시스템 동작을 기술.
- 예: “사용자가 대시보드 페이지로 리디렉션된다”
BDD 의 장점
- 사용자 관점의 테스트
- 사용자 시나리오 기반으로 테스트를 설계하여 요구사항과 기대치를 명확히 정의.
- 효율적인 커뮤니케이션
- 개발자, QA, 비즈니스 이해 관계자 간의 협업을 증진.
- 요구사항 변경에 강함
- 요구사항이 자연어로 표현되므로 변경사항을 반영하기 용이.
- 자동화 가능성
- Gherkin(예: Cucumber, SpecFlow) 같은 도구를 사용하여 테스트를 자동화.
TDD 와 BDD 의 차이점
항목 | TDD | BDD |
---|---|---|
초점 | 코드의 기능적 단위 | 시스템의 동작(행동) |
언어 | 프로그래밍 언어의 테스트 코드 | 비즈니스 친화적인 자연어 사용 |
관점 | 개발자 관점 | 사용자 및 비즈니스 관점 |
산출물 | 테스트 코드 | 행동 설명서 + 테스트 자동화 스크립트 |
커뮤니케이션 | 개발자 내부 협업에 초점 | 비즈니스, QA, 개발자 간 협업 강조 |
테스트 작성 가이드
데이터베이스나 다른 의존성을 스텁으로 대체하기
B, C와 같이 의존성이 전이된 요소에 의존하지 않고 A를 테스트할 수 있는 방법이 있을까?
다른 클래스에 의존하는 클래스를 테스트 할 때 의존성을 사용하지 않는 방법을 알아내야하는데 이때, 테스트 대역(Test Double)이 도움이 될 수 있습니다.
테스트 대역(Test Double)은 소프트웨어 테스트에서 코드의 특정 부분을 대체하기 위해 사용되는 객체 또는 구성 요소를 말합니다. 이는 실제 코드의 일부를 대체하여 테스트를 더 효율적이고 효과적으로 수행할 수 있게 한다. 영화나 연극에서 “ 스턴트 더블 “이 배우를 대신해 특정 장 면을 수행하는 것과 유사한 개념입니다.
단위 테스트의 고전파와 런던파
고전파 (Classical School)
- 디트로이트 스타일(Detroit), 고전주의적(Classicist) 접근법
- 대표 책: 켄트 백(Kent Beck)의 “테스트 주도 개발(Test-Driven Development)”.
고전파는 단위 테스트에서 실제 객체(real objects) 를 사용하는 것을 선호하며, Mock 객체 사용을 최소화합니다. 테스트는 결과값(상태 기반) 검증에 초점을 맞추며, 테스트 대상 코드와 의존성을 함께 사용하여 실제 환경과 가까운 테스트를 수행합니다.
런던파 (London School)
- 목 추종자(Mockist). (Note: 런던파 지지자들은 "목 추종자"라는 용어를 선호하지 않습니다.)
- 대표 책: 스티브 프리먼(Steve Freeman)과 냇 프라이스(Nat Pryce)의 “Growing Object-Oriented Software, Guided by Tests”.
런던파는 단위 테스트에서 Mock 객체를 적극적으로 사용해 테스트 대상을 외부 의존성으로부터 완전히 격리합니다. 행동 기반 테스트(Behavioral Testing)에 중점을 두며, 의존성의 호출 여부와 상호작용을 검증합니다.
1. Dummy( 더미 )
더미는 테스트 대상 클래스에 전달되지만, 실제로는 사용되지 않는 객체입니다. 테스트 대상의 인터페이스를 만족시키기 위해 단순히 자리를 채우는 용도로 사용됩니다.
function process(data, logger) {
// logger는 실제로 사용되지 않음
return data.processed;
}
const dummyLogger = null; // 아무 동작하지 않는 더미 객체
process(someData, dummyLogger);
2. Stub( 스텁 )
스텁은 테스트 중 호출된 메서드에 대해 미리 정의된 응답을 제공합니다.
- 구현체가 없으며, 하드코딩된 값이나 행동을 반환합니다.
- 테스트 중 필요한 데이터나 결과를 강제로 제공하는 데 사용됩니다.
const stub = {
getUser: () => ({ id: 1, name: "Test User" }),
};
console.log(stub.getUser()); // { id: 1, name: "Test User" }
3. Spy( 스파이 )
스파이는 의존성을 감시하여 객체와의 상호작용을 기록합니다.
- 실제 객체를 감싸서 행동을 관찰하며, 호출 여부와 전달된 인자를 확인할 수 있습니다.
const realObject = {
calculate: (a, b) => a + b,
};
const spy = jest.spyOn(realObject, "calculate");
realObject.calculate(2, 3);
expect(spy).toHaveBeenCalledWith(2, 3); // 호출 여부와 인자 확인
4. Mock( 모의 객체 / 목 )
모의 객체(Mock)는 테스트 중 코드의 상호작용 (호출 여부, 횟수, 인자) 을 검증하기 위해 사용됩니다.
- 호출 기록을 저장하고, 특정 동작을 흉내낼 수 있습니다.
const mockLogger = {
log: jest.fn(),
};
function executeAction(logger) {
logger.log("Action executed");
}
executeAction(mockLogger);
expect(mockLogger.log).toHaveBeenCalledWith("Action executed"); // 호출 검증
5. Fake( 페이크 )
페이크는 실제 객체처럼 동작하지만, 간단하게 구현된 버전입니다.
- 실제 시스템을 모방하면서도 메모리 내 데이터 저장처럼 가볍게 동작합니다.
class FakeDatabase {
constructor() {
this.data = {};
}
save(key, value) {
this.data[key] = value;
}
get(key) {
return this.data[key];
}
}
const fakeDB = new FakeDatabase();
fakeDB.save("user1", { id: 1, name: "Test User" });
console.log(fakeDB.get("user1")); // { id: 1, name: "Test User" }
테스트코드의 유지보수
테스트 코드는 코드베이스와 함께 빠르게 증가하며, 잘 관리하지 않으면 품질 저하와 유지보수성 문제가 발생할 수 있습니다. 이는 리먼 소프트웨어 변화 법칙, "코드는 관리하지 않으면 부패한다"가 테스트 코드에도 적용된다는 점에서 중요합니다. 품질 좋은 테스트 코드를 유지하려면 적극적인 관리와 지속적인 개선이 필요합니다.
너의 테스트코드에서 냄새나
코드 냄새 (Code Smell) 라는 용어는 시스템의 소스코드에서 큰 문제가 있을 것 같은 낌새를 뜻합니다. 잘 알려진 예로는 길이가 긴 메서드, 길 이가 긴 클래스, 전지전능 클래스(God Class)가 있습니다. 이러한 코드 냄새는 소프트웨어의 이해도와 유지 보수성을 저해하며, 이는 테스트 코드에서도 동일하게 적용됩니다.
테스트 코드는 시스템의 신뢰성과 품질을 보장하는 핵심적인 역할을 하지만, 테스트 코드 자체가 유지보수와 확장성을 해치는 경우가 있습니 다. 아래 항목들을 통해 본인의 테스트 코드에서 냄새가 나는지 확인해보세요.
- 과다한 중복
- 중복된 코드는 테스트 코드의 가독성과 유지 보수성을 떨어뜨립니다.
- 문제점:
- 중복된 코드는 변경 시 여러 위치에서 수정이 필요하며, 일부를 놓치면 테스트의 신뢰성이 떨어질 수 있습니다.
- 해결 방법:
- 중복된 코드를 헬퍼 메서드, 유틸리티 클래스, 또는 공통 테스트 함수로 추출합니다.
- 중복 제거가 과도하면 오히려 테스트 코드를 이해하기 어려워질 수 있으니, 실용적 관점에서 적절히 관리하세요.
👎
test("User 생성", () => {
const user = new User("John", 30);
expect(user.name).toBe("John");
expect(user.age).toBe(30);
});
test("User 업데이트", () => {
const user = new User("John", 30);
user.updateAge(31);
expect(user.age).toBe(31);
});
👍
const createTestUser = () => new User("John", 30);
test("User 생성", () => {
const user = createTestUser();
expect(user.name).toBe("John");
expect(user.age).toBe(30);
});
test("User 업데이트", () => {
const user = createTestUser();
user.updateAge(31);
expect(user.age).toBe(31);
});
- 불명확한 단언문 (Assertion)
- 단언문은 테스트의 실패 원인을 빠르게 파악하는 데 중요한 역할을 합니다.
- 문제점:
- 단언문이 모호하거나 지나치게 추상적이면, 테스트 실패 시 원인을 파악하기 어렵습니다.
- 해결 방법:
- 단언문은 구체적인 값을 기반으로 작성하며, 필요한 경우 적절한 설명 메시지를 추가합니다.
- 하나의 테스트 케이스에서 너무 많은 단언문을 사용하지 마세요. 단일 동작을 검증하도록 작성합니다.
👎
expect(user).toBeTruthy(); // 실패 원인 파악 불가
👍
expect(user.age).toBe(30); // 명확하고 구체적
복잡하거나 외부에 있는 자원에 대한 잘못된 처리
- 테스트가 외부 의존성(네트워크, 데이터베이스, 파일 시스템 등)에 과도하게 의존하면 테스트가 느려지고, 불안정해질 수 있습니다.
- 문제점:
- 테스트가 실제 자원을 사용하거나 복잡한 외부 시스템에 의존하면 재현성이 떨어지고, 속도가 느려집니다.
- 해결 방법:
- Mock, Stub, Fake 객체를 활용해 외부 의존성을 제거합니다.
- 통합 테스트가 아닌 단위 테스트에서는 가능한 한 외부 의존성을 피하세요.
- 데이터베이스와 같은 리소스를 테스트 중 여러 번 초기화하지 않도록 인메모리 데이터베이스를 활용하거나 데이터 스냅샷을 사용합니다.
너무 범용적인 픽스처 (Fixture)
- 픽스처 (Fixture) 란 테스트 실행 전에 준비된 상태나 데이터입니다.
- 문제점:
- 지나치게 범용적이거나 방대한 픽스처는 테스트를 복잡하게 만들고, 테스트 간에 의존성을 생성할 위험이 있습니다.
- 해결 방법:
- 픽스처는 테스트가 실제로 필요로 하는 데이터만 포함하도록 간소화합니다.
- 개별 테스트에 필요한 데이터만 생성하거나, 공통 데이터는 Factory 패턴으로 관리합니다.
👎
const globalFixture = {
users: [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }],
roles: ["admin", "user"],
settings: { theme: "dark" },
};
👍
const createTestUser = (name = "Test User") => ({ id: 1, name });
- 민감한 단언문 (Assertion)
- 민감한 단언문은 코드의 작은 변경에도 테스트가 실패하게 만드는 단언문입니다.
- 문제점:
- 필요 이상으로 엄격한 단언문은 코드 리팩터링이나 환경 변화에 취약합니다.
- 해결 방법:
- 단언문은 검증이 필요한 핵심 로직에만 초점을 맞추고, 부수적인 동작에는 관대하게 작성합니다.
- 정규 표현식이나 일부 속성만 검증하여 변경에 유연하게 대처합니다.
단언문(Assertion) 은 테스트 코드에서 실제 결과가 기대 결과와 일치하는지 확인하는 명령문입니다. 단언문은 테스트의 핵심 부분으로, 코드가 예상대로 동작하는지 검증하며, 테스트가 성공했는지 실패했는지를 결정합니다.
👎
expect(response).toEqual({ status: 200, data: { id: 1, name: "John" } });
👍
expect(response.status).toBe(200);
expect(response.data).toHaveProperty("id");
expect(response.data.name).toBe("John");
유지보수성을 높이는 몇가지 방법
유지보수성이 좋지 않은 테스트는 프로젝트 일정을 지연시키며, 일정이 더 빠듯할 때는 테스트를 챙길 여력도 없게 합니다. 그래서 개발자들은 코드 수정에 많은 시간이 걸리거나 프로덕션 코드의 작은 변경에도 테스트를 매번 고쳐야한다면 유지 보수를 포기해버리기도 합니다.
유지보수성이 테스트를 얼마나 자주 변경해야 하는지 가늠하는 척도라면 그 횟수를 최소화 하는 것이 중요합니다. 이를 위한 근본적인 원인을 파악하려면 다음 항목을 점검해보세요.
- 언제 테스트가 실패하여 변경이 필요하다는 사실을 깨닫는가?
- 왜 테스트가 실패하는가?
- 어떤 테스트가 실패했을 때 테스트 코드를 반드시 수정해야하는가?
- 테스트 코드를 반드시 수정할 필요가 없더라도 언제 코드를 변경하는가?
테스트 코드 유지보수를 위한 원칙
- DRY 원칙 준수
- 테스트 코드에서 반복되는 설정과 단언을 헬퍼 함수로 추상화하여 가독성과 유지보수성을 높임.
- beforeEach() 최소화
- 테스트 간 공유 상태를 피하고 독립적인 초기화 메서드 사용.
- 과잉 명세(Overspecification) 지양
- private 상태 검증, 불필요한 순서 검증 등 필요 없는 조건은 제외.
- 거짓 실패 (False Failure) 줄이기
- 테스트는 비즈니스적으로 중요한 실패만 발생해야 하며, 거짓 실패는 프로덕션 코드와 테스트 간 결합도를 낮춰 해결.
장기적으로 유지보수하기 위한 핵심 전략
- 변경에 유연한 테스트 작성: 리팩터링이나 요구사항 변경에도 적응할 수 있는 테스트 작성.
- 실제 실패만 허용: 테스트는 코드의 진짜 문제를 발견하는 데 집중.
- 테스트 설계의 가이드라인 준수: 팀 차원의 명확한 테스트 설계 원칙을 마련.
부록 ) Jest 의 테스트코드 작성법
Jest 란?
Jest는 Facebook 에서 개발한 오픈 소스 테스트 프레임워크로, 사용자 친화적이고 강력한 기능을 제공합니다. Jest는 단순한 테스트 작성뿐만 아니라 검증 (assertion) , 테스트 실행 및 결과 리포팅 등 전체 테스트 흐름을 지원합니다.
Jest 의 주요 기능
- 테스트 라이브러리
- 테스트 작성 시
test
및describe
같은 함수를 제공.
- 테스트 작성 시
- 검증 (assertion) 라이브러리
expect
를 사용하여 코드 동작을 검증.
- 테스트 러너 (Runner)
- 작성된 테스트를 실행.
- 테스트 리포터 (Reporter)
- 테스트 성공/실패 결과를 시각적으로 표시.
- Mock, Stub, Spy 지원
- 외부 의존성을 격리하여 독립적인 테스트 가능.
Jest 같은 프레임워크 사용시 달라지는 점
- 테스트 코드의 일관된 형식 : 새로운 기능을 테스트 할때마다 매번 새로운 검증 방식을 고민하는 대신 테스트 프레임워크를 사용하면 항상 동일한 방식으로 코드를 작성할 수 있습니다. 이미 구조화된 방식으로 테스트를 작성하므로 누구나 쉽게 읽고 이해할 수 있습니다.
- 반복성 : 테스트 프레임워크를 사용하면 새로운 테스트를 작성하는 작업을 쉽게 반복할 수 있습니다. 테스트 러너를 사용하여 테스트를 반복해서 실행하기도 쉽고, 테스트 실패와 그 원인을 이해하기도 쉽습니다.
- 신뢰성 및 버그 감소 : 간단한 기능부터 복잡한 시나리오까지 폭넓게 테스트가 가능합니다. 작성이 쉬워 테스트 커버리지를 자연스럽게 확대할 수 있습니다.
- 협업 및 가시성 : 프레임워크의 리포팅 기능은 팀 차원에서 일감을 관리하는데 유용합니다. 테스트가 통과하면 해당 작업이 완료되었다고 간주할 수 있어 테스트의 완료 여부로 다른 팀원들도 작업 진행 상황을 쉽게 파악할 수 있습니다.
테스트 파일 구조
Jest에서 테스트 파일의 구조는 프로젝트의 규모, 팀의 선호도, 그리고 유지보수 편의성 등에 따라 다양하게 구성될 수 있습니다. 일반적으로 널리 사용되는 구조는 다음과 같습니다:
- 테스트 파일의 명명 규칙 및 위치
- 파일 명명 규칙: 테스트 파일은 보통
*.test.ts
또는*.spec.ts
와 같은 형식을 사용합니다. 이러한 명명 규칙을 따르면 Jest는 해당 파일들을 자동으로 테스트 파일로 인식합니다. - 파일 위치: 테스트 파일은 다음과 같은 위치에 배치될 수 있습니다:
- 소스 코드와 동일한 디렉터리: 각 모듈이나 컴포넌트와 같은 위치에 테스트 파일을 배치하여 관련성을 명확히 합니다.
- 별도의
__tests__
디렉터리: 프로젝트 내에__tests__
디렉터리를 생성하고, 모든 테스트 파일을 해당 디렉터리에 모아 관리합니다.
- 파일 명명 규칙: 테스트 파일은 보통
- Mock 파일의 위치
- Mock 파일은 일반적으로 mocks 디렉터리에 저장됩니다. 이 디렉터리는 소스 코드와 동일한 위치에 두거나, 테스트 파일과 동일한 위치에 배치할 수 있습니다. 이를 통해 모듈의 의존성을 쉽게 Mocking할 수 있습니다.
- Mock 파일이름은 대상 파일과 동일하게 합니다.
- 프로젝트의 특성에 따른 구조 선택
- 소규모 프로젝트: 테스트 파일을 소스 코드와 동일한 디렉터리에 배치하여 관련성을 높이고, 관리의 편의성을 도모할 수 있습니다.
- 대규모 프로젝트: 테스트 파일을 별도의 디렉터리(tests 등)에 모아 관리함으로써 테스트 코드의 체계적인 관리와 유지보수를 용이하게 할 수 있습니다.
- 설정 파일을 통한 커스터마이징
- 프로젝트의 구조나 명명 규칙이 다를 경우, Jest의 설정 파일(jest.config.ts 또는 package.json)을 통해 테스트 파일의 위치나 패턴을 지 정할 수 있습니다. 예를 들어, testMatch 옵션을 사용하여 특정 디렉터리나 파일 패턴을 설정할 수 있습니다.
- 팀의 현재 구조
- 현재 저희 팀에서 사용 중인 구조는 다음과 같습니다:
src/
├── services/
│ ├── A.service.ts
│ ├── spec/
│ │ └── A.service.spec.ts
│ └── __mocks__/
│ └── A.service.ts
├── utils/
│ ├── helper.ts
│ ├── spec/
│ │ └── helper.spec.ts
│ └── __mocks__/
│ └── helper.ts
test/
├── fake/
│ ├── db.fake.ts // 공통으로 사용할 Fake DB 파일
│ └── redis.fake.ts // Redis를 대체할 Fake 파일
├── dummy/
│ └── data.dummy.ts // 테스트에서 사용할 더미 데이터
USE 전략
테스트 이름을 잘 지어야 코드를 읽는 이가 쉽게 이해할 수 있습니다
- 테스트하려는 대상 (Unit under test)
- 입력 값이나 상황에 대한 설명 (Scenario)
- 기댓값이나 결과에 대한 설명 (Expectation)
한마디로 “ 테스트 대상을 명시하고, 어떤 입력이나 상황이 주어지면 어떤 결과로 이어져야하는지 간결하고 명확하게 적는다 “ 입니다.
describe()
와 it()
/test()
로 테스트 구조를 체계적으로 나누면 USE 전략에서 말하는 세가지 요소를 서로 분리할 수 있습니다.
describe("User 생성 및 업데이트", () => {
test("User 생성 시 이름과 나이가 설정된다", () => {
const user = new User("John", 30);
expect(user.name).toBe("John");
expect(user.age).toBe(30);
});
test("User 나이 업데이트 시 새로운 값이 반영된다", () => {
const user = new User("John", 30);
user.updateAge(31);
expect(user.age).toBe(31);
});
});
Jest 팁과 고급 기능
Mocking 으로 외부 의존성 대체
- Jest의
jest.fn()
과jest.mock()
을 사용해 외부 의존성을 대체. - HTTP 요청, 데이터베이스 호출 등 실제 리소스 사용 없이 동작 검증 가능.
jest.mock("./api", () => ({
fetchData: jest.fn(() => Promise.resolve({ data: "mocked data" })),
}));
import { fetchData } from "./api";
test("API 호출 Mocking", async () => {
const data = await fetchData();
expect(data).toEqual({ data: "mocked data" });
});
beforeEach 와 팩토리 함수 활용
beforeEach()
로 반복적인 초기화 작업을 제거할 수 있지만, 파일이 커질 경우 팩토리 함수를 활용해 스크롤 피로감을 줄이는 것이 좋습니다.
팩토리 함수는 객체나 특정 상태를 쉽게 생성하고, 여러 곳에서 동일한 로직을 재사용할 수 있도록 도와주는 간단한 헬퍼 함수입니다.
const createTestUser = (id = 1, name = "John Doe") => ({ id, name });
test("User 객체 생성", () => {
const user = createTestUser();
expect(user).toEqual({ id: 1, name: "John Doe" });
});
Snapshot 테스트
- Jest는 UI 렌더링 결과나 JSON 객체의 상태를 검증하기 위해 스냅샷 테스트를 지원.
- UI 변경 시 의도하지 않은 변화를 빠르게 파악 가능.
import renderer from "react-test-renderer";
import MyComponent from "./MyComponent";
test("컴포넌트 스냅샷 테스트", () => {
const tree = renderer.create(<MyComponent />).toJSON();
expect(tree).toMatchSnapshot();
});
타이머 및 비동기 함수 테스트
Jest는 타이머(jest.useFakeTimers
)와 비동기 함수(async/await
) 테스트를 지원합니다
jest.useFakeTimers();
test("타이머 동작 확인", () => {
const callback = jest.fn();
setTimeout(callback, 1000);
jest.runAllTimers();
expect(callback).toHaveBeenCalledTimes(1);
});
test("getData()는 데이터를 반환한다", async () => {
const data = await getData();
expect(data).toBeDefined();
});
테스트 실행 최적화
--watch
: 파일 변경 시 자동으로 테스트 실행.--onlyChanged
: 최근 변경된 파일의 테스트만 실행.--coverage
: 테스트 커버리지 리포트 생성.
참조 문헌
- 로이 오셔로브&블라디미르 코리코프(2024). 단위 테스트의 기술 : 견고하고 신뢰할 수 있는 코드를 만드는 단위 테스트 작성법. (양문규 역). 길벗.
- 켄트 벡(2024). 테스트 주도 개발. (김창준&강규영 역). 인사이트.
- 마우리시오 아니시(2023). 이펙티브 소프트웨어 테스팅. (한용재 역). 제이펍.