소프트웨어 테스트
어떤 테스트를 얼마나 둘지(피라미드와 트로피), 단위·통합·E2E가 어디까지를 진짜로 쓰는지, 목과 스텁의 차이, 커버리지 100%가 버그 없음을 뜻하지 않는 이유, 속성 기반 테스트와 뮤테이션 테스트로 테스트 자체를 시험하는 법, 흔들리는 테스트, TDD와 계약 테스트까지를 작은 예제로 직접 계산해 가며 정리한다.
목차 (11)
테스트는 ‘코드가 맞다’를 증명하지 않는다. 테스트가 하는 일은 바꿔도 된다는 자신감을 주는 것이다. 좋은 테스트 묶음은 무언가 깨졌을 때 빨리, 정확한 자리를 가리키며 실패하고, 아무것도 깨지지 않았을 때는 조용하다. 이 테스트들이 매 커밋마다 도는 곳이 CI/CD 노트의 파이프라인이고, 성능을 재는 테스트는 부하 테스트 노트에서 따로 다룬다.
테스트 피라미드와 트로피
마이크 콘(Mike Cohn)의 테스트 피라미드는 빠르고 싼 단위 테스트를 많이, 느리고 비싼 E2E 테스트를 적게 두라는 그림이다. 예제 묶음에서 E2E 30개는 600초가 걸리지만 단위 3,000개는 15초면 끝난다. 개수는 100분의 1인데 시간은 40배다. 통합 테스트 300개는 그 사이다.
켄트 C. 도즈(Kent C. Dodds)의 테스팅 트로피는 프런트엔드 경험에서 나왔다. 컴포넌트 내부를 잘게 목으로 바꾼 단위 테스트는 리팩터링할 때마다 깨지면서도 실제 버그는 놓치기 쉬우니, 여러 부품을 함께 쓰는 통합 테스트를 가장 두껍게 두고 오타·타입 오류는 정적 분석(타입 검사, 린트)에 맡기자는 것이다. 두 모델은 대립이라기보다 ‘구현이 아닌 동작을 시험하라, 느린 테스트는 아껴 쓰라’는 같은 원칙의 다른 그림이다.
단위, 통합, E2E
세 이름의 경계는 팀마다 조금씩 다르지만, 핵심 질문은 같다. 이 테스트는 시스템의 어디까지를 진짜로 쓰는가? 진짜로 쓰는 범위가 넓을수록 실제 버그를 더 잡지만 느려지고, 실패했을 때 원인을 찾기 어렵고, 환경 문제로 흔들리기 쉽다.
- 단위: 함수·클래스 하나를 다른 부품과 떼어 시험한다. 수 밀리초에 끝나 수천 개를 매 저장마다 돌릴 수 있다. 계산·분기가 많은 순수한 로직에 가장 잘 맞는다.
- 통합: 내 코드와 DB·메시지 큐 같은 진짜 의존성을 함께 시험한다. SQL이 실제 DB에서 도는지, 직렬화가 맞는지는 여기서만 확인된다. Testcontainers처럼 테스트마다 컨테이너로 DB를 띄우면 메모리 DB 같은 흉내보다 믿을 만하다.
- E2E: 사용자처럼 화면에서 시작해 전체를 지난다. Playwright 같은 도구로 브라우저를 조종한다. 가입·결제처럼 돈이 걸린 핵심 경로 몇 개에만 두고, 경우의 수는 아래층에서 다룬다.
테스트 한 개의 모양: 준비, 실행, 확인
테스트 하나는 세 부분으로 읽힌다. 시험 전의 상태를 만드는 준비, 시험할 동작 하나를 부르는 실행, 결과를 보는 확인이다. 같은 구조를 Arrange-Act-Assert(빌 웨이크), Given-When-Then(행동 주도 개발), 정리 단계를 더한 4단계 테스트(메스자로스)라고 부른다(GivenWhenThen). 이름이 달라도 요점은 같다. 실행이 한 번뿐이어야 실패했을 때 무엇이 깨졌는지 바로 안다.
- 이름에 기대 동작을 적는다:
test1대신 “재고가 0이면 주문을 거절한다”처럼 쓰면, 실패 보고만 읽어도 어떤 약속이 깨졌는지 보인다. - 테스트끼리 기대지 않는다: 앞 테스트가 남긴 DB 행이나 전역 변수에 기대면 순서를 바꾸거나 병렬로 돌릴 때 깨진다. 준비는 테스트마다 따로 한다.
- 버그를 고칠 때는 그 버그부터 테스트로: 고치기 전에 실패하는 테스트를 하나 남기면 같은 버그가 다시 들어오는 것(회귀)을 막는다.
- 스냅숏 테스트: 출력 전체를 파일로 저장해 두고 다음 실행과 비교하는 방식이다. 만들기는 쉽지만, 실패할 때마다 원인을 보지 않고
jest --updateSnapshot으로 다시 찍는 습관이 들면 아무것도 검사하지 않게 된다. Jest 문서도 스냅숏을 코드처럼 리뷰하고, 짧고 초점 있게 유지하라고 권한다(Snapshot Testing).
테스트 더블: 목, 스텁, 페이크
시험 대상이 기대는 부품을 테스트용으로 바꿔 끼운 것을 통틀어 테스트 더블(test double, 대역 배우에서 따온 말)이라 한다. 제라드 메스자로스(Gerard Meszaros)가 정리한 다섯 가지가 표준 용어다. 흔히 모두 ‘목’이라 부르지만, 무엇을 검증하느냐가 다르다.
- 상태 검증: 스텁이 ‘환율은 1,300원’이라고 답하게 해 두고, 결과 금액이 맞는지 본다. 구현이 바뀌어도 결과가 같으면 테스트가 깨지지 않는다.
- 행위 검증: 목이 ‘이메일 발송이 정확히 한 번, 이 주소로 불렸는가’를 확인한다. 밖으로 나가는 부수 효과(메일, 결제 요청)는 결과 값이 없으니 행위로 볼 수밖에 없다.
- 목을 과하게 쓰면: 내부 호출 순서까지 목으로 고정하면 테스트가 구현의 사본이 된다. 리팩터링마다 깨지고, 진짜 부품과의 약속이 바뀌어도 목은 옛 약속대로 답하니 통과한다. 내가 소유하지 않은 경계(외부 API)는 목으로, 내가 소유한 협력 객체는 가능하면 진짜나 페이크로 쓰는 것이 무난하다(Mocks Aren't Stubs).
커버리지와 그 한계
커버리지는 테스트가 코드의 몇 %를 실행했는지다. 줄 커버리지는 실행된 줄, 분기 커버리지는 각 if의 참·거짓이 모두 나왔는지를 센다. 커버리지가 낮으면 ‘시험받지 않은 코드가 있다’는 확실한 신호다. 그런데 높다고 ‘버그가 없다’는 뜻은 아니다. 커버리지는 코드가 실행됐는지만 볼 뿐, 그 결과를 확인했는지, 어떤 입력으로 실행했는지는 보지 않는다.
- 단언 없는 테스트: 함수를 부르기만 하고 결과를 확인하지 않아도 커버리지는 올라간다. 숫자를 목표로 삼으면 이런 테스트가 늘어난다.
- 쓸모 있게 보기: 전체 숫자보다 바뀐 코드의 커버리지를 PR마다 보는 것이 낫다. 구글의 지침은 60%를 ‘괜찮음’, 75%를 ‘칭찬할 만함’, 90%를 ‘모범’으로 두면서도, 90%에서 95%로 올리는 데 매달리지 말라고 한다. 커버리지 보고서의 진짜 쓸모는 ‘아무도 시험하지 않는 갈래’를 찾아 주는 것이다.
속성 기반 테스트
예제 기반 테스트는 사람이 고른 입력 몇 개로 시험한다. 위의 평균 함수처럼 사람이 떠올리지 못한 입력에서 버그가 나온다. 속성 기반 테스트(property-based testing)는 입력 대신 모든 입력에서 성립해야 할 성질을 적고, 도구가 무작위 입력 수백 개를 만들어 시험한다. 2000년 Haskell의 QuickCheck에서 시작해 Python의 Hypothesis, JavaScript의 fast-check 등으로 퍼졌다.
무작위 입력 [37, 5, 102, 8]에서 속성이 깨졌다. 이제 줄인다.
무작위로 찾은 반례는 대개 크고 지저분하다. 그래서 도구는 반례를 찾으면 줄이기(shrinking)를 한다. 원소를 빼거나 수를 작게 바꾼 후보 중 여전히 실패하는 것을 골라 더 줄일 수 없을 때까지 되풀이한다. 위 예에서 10단계 만에 [10, 2]가 남았고, 이것을 정렬하면 [10, 2]이다. JavaScript의 기본 sort()가 숫자를 글자로 바꿔 ‘10’ < ‘2’로 비교한다는 원인이 곧바로 보인다.
- 자주 쓰는 성질: 되돌리기(디코드(인코드(x)) = x), 불변량(정렬해도 원소 수와 구성은 같다), 두 구현 비교(빠른 새 구현 = 느리지만 확실한 옛 구현), 멱등(두 번 해도 한 번 한 것과 같다).
- 재현: 무작위라도 시드를 기록해 같은 입력을 다시 만들 수 있고, 한 번 찾은 반례는 예제 테스트로 고정해 둔다.
뮤테이션 테스트: 테스트를 시험하기
커버리지가 ‘실행했는가’라면 뮤테이션 테스트는 ‘틀렸을 때 알아채는가’를 잰다. 코드에 작은 고장을 일부러 넣은 판(뮤턴트, 예: >= → >)을 많이 만들고 테스트를 돌린다. 테스트가 실패하면 뮤턴트를 죽인 것이고, 통과하면 뮤턴트가 살아남은 것이다. 죽인 비율이 뮤테이션 점수다.
입력 30·10만 있는 테스트는 줄 커버리지 100%이지만 뮤테이션 점수는 62.5%다. 살아남은 age > 18, age >= 17, age >= 19는 모두 18 언저리를 바꾼 것이다. 18을 더하면 87.5%, 17까지 더하면 100%가 된다. 뮤테이션 테스트가 가리키는 것은 정확히 사람이 놓치기 쉬운 경계값이다.
- 도구: Java의 PIT, JavaScript·C#·Scala의 Stryker가 대표적이다.
- 비용: 뮤턴트마다 테스트를 다시 돌리니 무겁다. 바뀐 코드에만 돌리거나, 구글처럼 의미 없는 줄(로그 등)을 빼고 고른 뮤턴트를 코드 리뷰에 보여 주는 방식으로 줄인다(State of Mutation Testing at Google).
- 같은 뜻의 뮤턴트: 바꿔도 동작이 같은 뮤턴트(equivalent mutant)는 어떤 테스트로도 죽일 수 없다. 그래서 100%를 목표로 삼기보다 살아남은 뮤턴트를 하나씩 읽어 보는 데 쓴다.
불안정한 테스트
코드를 바꾸지 않았는데 통과와 실패를 오가는 테스트가 불안정한 테스트(flaky test)다. 구글은 전체 테스트 실행의 약 1.5%가 이런 결과를 내고, 통과에서 실패로 바뀐 사례의 84%에 불안정한 테스트가 끼어 있었다고 보고했다. 하나하나는 드물어도 개수가 쌓이면 확률이 곱해진다.
0.1%로 흔들리는 테스트가 500개면 묶음이 빨간불일 확률이 39%, 1,000개면 63%다. 이 정도면 개발자는 빨간불을 ‘또 그거겠지’ 하고 무시하기 시작하고, 진짜 실패도 함께 묻힌다. 자동 재시도 한 번이면 1,000개에서도 0.10%로 떨어지지만, 재시도는 증상을 덮을 뿐이다.
- 흔한 원인: 고정 시간 대기(
sleep(1000)), 테스트끼리 공유하는 상태와 실행 순서 의존, 현재 시각·시간대·무작위 값, 네트워크와 외부 서비스, 동시성 경쟁. - 다루는 법: 재시도해서 통과한 테스트를 기록해 불안정 목록을 만들고, 격리(quarantine)해 배포를 막지 않게 한 뒤 담당자를 정해 고친다. 고칠 때는 조건을 기다리는 대기, 테스트마다 새 데이터, 시계와 난수의 주입으로 비결정성을 걷어 낸다(Eradicating Non-Determinism in Tests).
TDD: 테스트를 먼저
테스트 주도 개발(TDD)은 켄트 벡(Kent Beck)이 정리한 짧은 순환이다. 실패하는 테스트를 하나 쓰고(빨강), 그것을 통과할 만큼만 코드를 쓰고(초록), 테스트가 초록인 채로 구조를 다듬는다(리팩터링). 이 순환을 몇 분 단위로 돈다.
얻는 것은 테스트 자체보다 설계 압력이다. 테스트부터 쓰려면 함수를 밖에서 부르기 쉬운 모양으로 생각해야 하니, 의존성이 드러나고 작게 나뉜 코드가 나오기 쉽다. 반대로 무엇을 만들지 아직 모르는 탐색 단계나 UI 배치처럼 결과를 눈으로 봐야 하는 일에는 맞지 않는다. 그런 때는 먼저 만들어 보고(스파이크) 방향이 정해지면 테스트와 함께 다시 쓴다.
- 오해: 테스트를 전부 먼저 쓴다: TDD는 시험 목록을 먼저 적어 두되, 그중 하나만 골라 빨강-초록-리팩터링을 돌고 다음으로 넘어간다. 테스트 수십 개를 한꺼번에 써 두는 방식이 아니다.
- 오해: 초록이면 끝: 마틴 파울러는 TDD를 망치는 가장 흔한 길로 세 번째 단계인 리팩터링을 빼먹는 것을 꼽는다. 통과만 시키고 다듬지 않으면 코드 조각을 덕지덕지 붙인 결과가 남는다(TestDrivenDevelopment).
계약 테스트
서비스가 여럿으로 나뉘면 ‘주문 서비스가 기대하는 응답’과 ‘상품 서비스가 실제로 주는 응답’이 어긋나는 사고가 잦다. 둘을 함께 띄우는 E2E로 잡으려면 느리고 불안정하다. 소비자 주도 계약 테스트는 이 사이의 약속만 따로 시험한다. 서비스 사이 계약(엄격·느슨한 계약)의 설계는 시스템 설계 › 분해와 분산 데이터에서 다뤘다.
- 소비자 쪽: 가짜 제공자(목 서버)를 상대로 테스트를 돌리며 ‘이 요청에 이런 모양의 응답을 기대한다’를 계약 파일로 남긴다. 실제로 쓰는 필드만 적힌다.
- 제공자 쪽: 진짜 제공자 서비스에 계약의 요청을 재생해 응답이 기대와 맞는지 본다. 필드를 지우거나 이름을 바꾸면 그 필드를 쓰는 소비자가 있을 때만 실패한다.
- 배포 전 질문: Pact 브로커는 ‘운영에 있는 소비자 판들과 이 제공자 판이 모두 검증됐나’에 답한다(can-i-deploy). 아니면 배포를 멈춘다.
핵심 자료
| 자료 | 한 줄 요지 |
|---|---|
| The Practical Test Pyramid (Ham Vocke) | 피라미드의 층마다 무엇을 어떻게 시험하는지 예제 코드와 함께 정리한 긴 글. |
| The Testing Trophy (Kent C. Dodds) | 정적 분석을 바닥에 깔고 통합 테스트를 가장 두껍게 두자는 트로피 모델을 제안한 글. |
| Mocks Aren't Stubs (Martin Fowler) | 상태 검증과 행위 검증, 고전파와 목 중심파의 차이를 테스트 더블 용어로 설명한다. |
| Code Coverage Best Practices (Google Testing Blog) | 커버리지 숫자의 쓸모와 한계, 변경분 커버리지를 보라는 실무 지침. |
| Hypothesis | Python의 대표 속성 기반 테스트 라이브러리. 입력 생성과 줄이기를 자동으로 한다. |
| State of Mutation Testing at Google (Petrović, Ivanković) | 모든 변경에 뮤턴트를 다 돌리는 대신 코드 리뷰에 흥미로운 뮤턴트만 보여 주는 방식을 6,000명 규모로 운영한 경험. |
| Flaky Tests at Google and How We Mitigate Them | 테스트 실행의 약 1.5%가 흔들리고, 통과→실패 전환의 84%가 흔들리는 테스트였다는 측정. |
| Pact 문서 | 소비자 주도 계약 테스트 도구. 계약 파일, 브로커, can-i-deploy의 흐름을 설명한다. |
- The Practical Test Pyramid (Ham Vocke)
- The Testing Trophy (Kent C. Dodds)
- Mocks Aren't Stubs (Martin Fowler)
- Code Coverage Best Practices (Google Testing Blog)
- Hypothesis
- State of Mutation Testing at Google (Petrović, Ivanković)
- Flaky Tests at Google and How We Mitigate Them
- Pact 문서
- GivenWhenThen (Martin Fowler)
- Jest 문서: Snapshot Testing
- TestDouble (Martin Fowler)
- Eradicating Non-Determinism in Tests (Martin Fowler)
- TestDrivenDevelopment (Martin Fowler)
- Consumer-Driven Contracts (Ian Robinson)
- fast-check — JavaScript 속성 기반 테스트
- PIT — Java 뮤테이션 테스트
- Stryker Mutator
- Playwright
- Testcontainers