TypeScript를 이용한 뮤테이션 테스팅: AI 코딩 에이전트 시대에 테스트 검증하는 방법
요약
AI 코딩 에이전트 시대에는 기존의 유닛 테스트만으로는 코드의 신뢰성을 보장하기 어렵습니다. 이 글은 뮤테이션 테스팅을 소개하며, 의도적으로 버그를 삽입하고 이를 통해 테스트 스위트의 약점을 발견하는 방법을 다룹니다. TypeScript와 StrykerJS를 활용하여 실제 코드를 검증하는 실습 과정을 안내합니다.
핵심 포인트
- AI 코드 생성 가속화에 따른 코드 검증의 중요성 증대
- 뮤테이션 테스팅은 테스트가 결함을 얼마나 잘 감지하는지 평가하는 기법이다.
- 단순히 테스트를 통과시키는 것을 넘어, 경계값 등 취약점을 발견할 수 있다.
- StrykerJS 등을 활용하여 실제 프로젝트에 적용해 볼 수 있다.
당신의 테스트는 통과합니다. 하지만 버그를 잡아낼 수 있을까요?
AI 코딩 에이전트에게 기능을 구현해 달라고 요청한다고 상상해 보세요. 몇 분 후, 이 에이전트는 구현 코드와 유닛 테스트를 작성하고 풀 리퀘스트(pull request)를 열었습니다.
CI 파이프라인은 녹색입니다. 모든 테스트가 통과합니다. 코드 커버리지도 좋아 보입니다.
하지만 이 변경 사항을 신뢰할 수 있을까요?
반드시 그렇지는 않습니다.
구현된 코드와 그 테스트는 요구사항에 대한 동일한 오해를 공유할 수 있습니다. 테스트는 중요한 동작을 확인하지 않고 모든 라인을 실행할 뿐일 수도 있습니다. 그리고 오늘 통과하는 테스트 스위트가 내일 회귀(regression)를 감지하지 못할 수도 있습니다.
AI가 코드 생성을 가속화함에 따라, 우리는 우리가 생성하는 코드를 어떻게 검증해야 할지에 대해 더 신중하게 생각해야 합니다.
이 튜토리얼에서는 도움이 될 수 있는 한 가지 기술인 **뮤테이션 테스팅(mutation testing)**을 탐구할 것입니다.
작은 TypeScript 프로젝트를 구축하고, 의도적으로 버그를 도입한 다음, StrykerJS를 설치하여 뮤테이션 테스팅을 사용해 테스트의 약점을 발견하는 방법을 알아볼 것입니다. 그런 다음 같은 기술이 어떻게 AI가 생성한 풀 리퀘스트를 검증하는 데 도움이 될 수 있는지 탐구할 것입니다.
마지막에는 뮤테이션 테스팅이 어떻게 작동하는지뿐만 아니라, 왜 이것이 다층적인 검증 전략의 중요한 부분이 될 수 있는지 이해하게 될 것입니다.
1. 간단한 문제부터 시작해 봅시다
온라인 상점을 구축한다고 가정해 봅시다. 우리의 비즈니스 규칙 중 하나는 £100 이상 주문 시 무료 배송 자격이 된다는 것입니다.
이 규칙을 TypeScript로 구현해 보겠습니다.
src/shipping.ts라는 파일을 생성합니다:
export function calculateShipping(
orderTotal: number,
): number {
...
함수는 간단합니다:
- £100 미만 주문은 £10의 배송비를 지불합니다.
- £100 이상 주문은 무료 배송을 받습니다.
이제 유닛 테스트를 작성해 봅시다.
src/shipping.test.ts를 생성합니다:
import { describe, expect, it } from 'vitest';
import { calculateShipping } from './shipping';
...
테스트를 실행합니다:
npx vitest run
두 테스트 모두 통과했습니다.
훌륭합니다! 구현 코드와 유닛 테스트, 그리고 녹색의 테스트 스위트를 갖게 되었습니다.
하지만 약간 다른 질문을 던져보겠습니다.
만약 누군가 실수로 >=를 >로 변경한다면 어떻게 될까요?
저희의 구현은 다음과 같이 됩니다:
export function calculateShipping(
orderTotal: number,
): number {
...
요구사항에는 무료 배송 대상이라고 되어 있음에도 불구하고, 정확히 £100 상당의 주문에 대해 배송비가 청구됩니다.
저희는 버그를 도입했습니다.
기존 테스트를 다시 실행해 보세요.
여전히 통과합니다!
왜일까요? 저희는 £50와 £150 상당의 주문만 테스트했고, £100이라는 경계값은 한 번도 테스트하지 않았기 때문입니다.
저희 테스트는 함수를 실행하지만, 올바른 구현과 이 잘못된 구현을 구분하지 못합니다.
이것이 뮤테이션 테스팅(mutation testing)이 우리가 조사하는 문제입니다.
2. 뮤테이션 테스팅이란 무엇인가요?
뮤테이션 테스팅은 테스트가 특정 결함을 얼마나 효과적으로 감지하는지 평가하는 기법입니다.
단순히 구현에 대해 테스트를 실행하는 대신, 뮤테이션 테스팅 도구는 프로덕션 코드에 의도적으로 작은 변경을 가하고 각 수정된 버전에 대해 테스트를 실행합니다.
이러한 변경들을 _뮤테이션(mutations)_이라고 부릅니다. 각 수정된 버전은 _뮤턴트(mutant)_라고 불립니다.
예를 들어, 뮤테이션 테스팅 도구는 다음과 같은 변경을 가할 수 있습니다:
| 원본 코드 | 변형된 코드 |
|---|---|
a >= b | a > b |
| ... | |
| 도구는 테스트가 이 변화를 감지하는지 관찰합니다. |
여기에는 특히 중요한 두 가지 결과가 있습니다.
킬드 뮤턴트(Killed mutant): 뮤테이션 때문에 적어도 하나의 테스트가 실패합니다. 즉, 테스트가 변화를 감지했다는 의미입니다.
서바이버드 뮤턴트(Survived mutant): 테스트가 여전히 통과합니다. 즉, 테스트가 변화를 감지하지 못했다는 의미입니다.
이것을 저희의 배송 예시에 적용해 보겠습니다.
원본 구현은 다음과 같습니다:
return orderTotal >= 100 ? 0 : 10;
뮤턴트가 이를 다음과 같이 변경합니다:
return orderTotal > 100 ? 0 : 10;
저희의 기존 테스트는 여전히 통과하므로, 뮤턴트는 살아남습니다(survives).
이것은 유용한 발견입니다. 이 결과는 테스트 스위트가 이 두 구현을 구분하지 못한다는 것을 알려줍니다.
우리가 평가하는 것이 무엇인지 주목해 주세요. 단순히 현재 예제에 대해 구현이 작동하는지 묻는 것이 아닙니다. 테스트가 결함을 유발할 수 있는 선택된 변경 사항을 감지할 수 있는지 여부를 묻고 있습니다.
이는 일반적인 단위 테스트(unit testing) 및 코드 커버리지와는 다른 질문입니다.
3. 코드 커버리지가 곧 테스트 효과성을 의미하는 것은 아니다
이미 프로젝트에서 코드 커버리지를 측정하고 있을 수도 있습니다.
커버리지는 테스트 중에 코드의 어떤 부분이 실행되었는지 알려줍니다. 유용하지만, 여러분의 단언(assertion)이 충분히 강력한지 반드시 알려주지는 않습니다.
다음 테스트를 고려해 보세요:
it('calculates shipping', () => {
calculateShipping(100);
});
함수는 실행됩니다. 커버리지 측정에 따라 이 테스트가 분기(branch) 또는 라인(line) 커버리지에 기여할 수 있습니다.
하지만 이 테스트는 예상 결과를 단언하지 않습니다.
구현이 £0 대신 £10을 반환하더라도, 테스트는 여전히 통과할 것입니다.
이제 다음을 고려해 보세요:
it('offers free shipping at exactly £100', () => {
expect(calculateShipping(100)).toBe(0);
});
이 테스트는 실제 비즈니스 규칙을 확인합니다.
또한, 변형된 구현이 £10을 반환하기 때문에 우리의 뮤턴트(mutant)도 제거합니다. (kills)
차이점을 요약할 수 있습니다:
- 코드 커버리지: 테스트가 이 코드를 실행했는가?
- 단언(Assertions): 테스트가 예상되는 동작을 검증했는가?
- 뮤테이션 테스팅 (Mutation testing): 테스트가 구현에 대한 선택된 변경 사항을 감지할 수 있는가?
이러한 기술들은 서로 보완적입니다. 뮤테이션 테스팅은 커버리지를 대체하지 않으며, 뮤테이션 점수(mutation score)가 정확성을 증명하는 것은 아닙니다. 하지만 함께 사용하면 테스트 스위트의 효과성에 대한 더 유용한 증거를 제공할 수 있습니다.
4. StrykerJS 설정하기
JavaScript 및 TypeScript용 뮤테이션 테스팅 프레임워크인 StrykerJS를 사용할 것입니다.
Node.js와 npm이 이미 설치되어 있다고 가정하겠습니다.
처음부터 따라 한다면, 프로젝트를 생성하세요:
mkdir mutation-testing-demo
cd mutation-testing-demo
npm init -y
TypeScript와 Vitest를 설치합니다:
npm install --save-dev typescript vitest
src 디렉토리를 생성하고 이전 섹션에서 가져온 두 파일을 추가하세요.
이제 다음 명령을 실행합니다:
npx vitest run
계속하기 전에 테스트가 통과하는지 확인하세요.
StrykerJS 초기화
다음 명령을 실행합니다:
npx stryker init
프롬프트에 따라 프로젝트에 맞게 Stryker를 구성하세요. 필요한 경우 TypeScript와 Vitest 테스트 러너를 선택하세요.
Stryker의 설정 옵션은 버전과 프로젝트 구성에 따라 다를 수 있으므로, 모든 프로젝트가 동일한 구성을 요구한다고 가정하기보다는 현재 나타나는 프롬프트를 따르세요.
공식 문서: StrykerJS — Getting Started.
초기화가 완료되면, 생성된 구성을 검토하고 뮤테이션 범위에 프로덕션 파일(src/shipping.ts)이 포함되어 있는지 확인하세요:
src/shipping.ts
우리의 테스트 파일인 src/shipping.test.ts는 뮤테이션할 프로덕션 코드로 간주되어서는 안 됩니다.
필요하다면 뮤테이션 범위를 명시적으로 구성할 수 있습니다. 예를 들어, TypeScript Stryker 설정에서 관련 패턴은 다음과 같을 수 있습니다:
mutate: ['src/**/*.ts', '!src/**/*.test.ts'],
설치된 버전이 생성한 구성을 시작점으로 유지하고, 이를 여러분의 리포지토리 컨벤션에 맞게 조정하세요.
첫 번째 뮤테이션 테스트 실행
다음 명령을 실행합니다:
npx stryker run
Stryker는 테스트를 실행하고, 구성된 프로덕션 코드에서 뮤테이션을 생성하며, 결과를 평가할 것입니다.
첫 번째 실행은 Stryker가 여러 뮤턴트(mutants)를 평가해야 하므로 일반적인 단위 테스트보다 오래 걸릴 수 있습니다.
완료되면 터미널 출력과 생성된 HTML 보고서를 검토하세요.
calculateShipping에서 비교 연산자와 관련된 살아남은 뮤턴트를 찾아보세요.
만약 비교 연산자가 >=에서 >로 변경되는데도 모든 테스트가 여전히 통과한다면, 우리가 의도적으로 주입한 취약점을 발견한 것입니다.
축하합니다. 방금 뮤테이션 테스팅을 사용하여 테스트 스위트의 공백(gap)을 발견했습니다.
5. 뮤테이션 보고서 이해하기
뮤테이션 리포트(mutation report)는 단순히 점수 이상의 것을 포함합니다.
가장 이해하기 유용한 결과물은 다음과 같습니다:
Killed (제거됨)
테스트가 뮤테이션을 감지하고 실패했습니다. 이는 일반적으로 의미 있는 행동 변화에 대해 우리가 원하는 것입니다.
Survived (생존함)
뮤테이션에도 불구하고 테스트는 통과했습니다. 이것이 누락된 테스트 커버리지(test coverage)를 드러내는지, 또는 요구사항을 적절하게 검증하지 못하는 단언문(assertion)인지를 조사해야 합니다.
No coverage (커버리지 없음)
뮤테이션된 코드가 관련 테스트에 의해 실행되지 않았습니다. 해당 코드가 아예 테스트되는지 여부를 조사할 필요가 있을 수 있습니다.
Timeout (시간 초과)
도구가 설정된 시간 제한 내에 정상적인 결과를 얻을 수 없었습니다. 이것이 반드시 테스트가 뮤테이션을 감지했다는 것을 의미하지는 않습니다.
Equivalent mutant (동등한 뮤턴트)
뮤테이션이 관련 입력값에 대해 관찰 가능한 동작을 변경하지 않았습니다. 원래 구현과 구별할 수 있는 의미 있는 테스트는 없습니다.
모든 생존하는 뮤턴트가 결함(defect)인 것은 아니며, 모든 뮤턴트를 제거해야 할 필요도 없습니다. 일부 뮤테이션은 동등하고, 일부는 귀하의 요구사항에 대해 의미가 없으며, 일부는 실제로 제약할 필요가 없는 동작을 드러낼 수도 있습니다.
이 리포트의 목적은 테스트에 대한 더 나은 결정을 내리도록 돕는 것입니다.
뮤테이션 점수는 어떤가요?
Stryker는 뮤테이션 테스트 결과를 기반으로 뮤테이션 점수(mutation score)를 계산합니다.
간소화된 공식은 다음과 같습니다:
뮤테이션 점수 = 제거된 뮤턴트 / 유효한 뮤턴트 × 100%
예를 들어, 한 실행에서 100개의 유효한 뮤턴트가 생성되었고 테스트가 그중 80개를 제거했다고 가정해 봅시다. 뮤테이션 점수는 80%입니다.
이것은 유용한 신호일 수 있지만, 이를 보편적인 품질 지표로 취급하는 것은 주의해야 합니다.
두 프로젝트는 매우 다른 수준의 위험을 가지면서도 동일한 뮤테이션 점수를 가질 수 있습니다. 이 점수는 생성된 뮤테이션, 분석되는 코드, 그리고 제외되는 결과에 따라 달라집니다.
결제 승인(payment authorization)에서의 생존하는 뮤턴트는 중요하지 않은 형식 지정 헬퍼(formatting helper)의 생존하는 뮤턴트보다 훨씬 더 중요할 수 있습니다.
어떤 대가를 치르더라도 백분율을 최대화하기보다는, 의미 있는 생존하는 뮤턴트를 이해하는 데 집중하세요.
6. 테스트를 개선하고 뮤테이션 테스팅을 다시 실행하기
우리가 발견한 약점을 수정해 봅시다.
우리 비즈니스 규칙은 명확합니다: £100 이상 주문 건에 대해 무료 배송이 적용됩니다.
우리는 경계 조건을 직접 테스트해야 합니다.
src/shipping.test.ts에 이 테스트를 추가하세요:
it('offers free shipping at exactly £100', () => {
expect(calculateShipping(100)).toBe(0);
});
일반 테스트 스위트를 실행합니다:
npx vitest run
테스트는 올바른 구현에 대해 통과합니다.
이제 뮤테이션 테스팅을 다시 실행합니다:
npx stryker run
>=를 >로 변경하는 뮤테이션은 이제 경계 테스트에 의해 제거되어야 합니다 (killed).
우리는 의미 있는 결함을 감지할 수 있게 되었기 때문에 테스트 스위트를 개선했습니다.
점수 향상을 위해서만 테스트를 추가한 것이 아니라는 점에 주목하세요. 요구사항이 경계 조건에서 무엇이 되어야 하는지를 명시적으로 정의했기 때문에 테스트를 추가했습니다.
이 구분이 중요합니다.
뮤테이션 테스팅 보고서는 무작정 더 많은 단언문(assertions)을 작성하라는 지침이 아니라, 질문의 원천입니다.
살아남은 각 뮤턴트(surviving mutant)에 대해 다음을 물어보세요:
- 이 변경 사항이 중요한 동작에 영향을 미칩니까?
- 요구사항은 올바른 동작이 무엇이어야 한다고 말합니까?
- 테스트 스위트가 이 변경을 감지해야 합니까?
- 만약 그렇다면, 이를 감지할 수 있는 가장 작고 의미 있는 테스트는 무엇입니까?
이 접근 방식은 테스트 주도 개발(Test-Driven Development, TDD)에 자연스럽게 부합합니다.
TDD는 요구사항으로부터 동작을 구현하는 데 도움을 줍니다. 뮤테이션 테스팅은 우리의 테스트가 그 동작으로부터 선택된 편차를 감지할 수 있는지에 도전합니다.
7. 로컬 실험에서 풀 리퀘스트 검증으로
지금까지 우리는 뮤테이션 테스팅을 수동으로 실행해 왔습니다.
다음 단계는 이를 CI(Continuous Integration)에 통합하는 것입니다.
AI 코딩 에이전트가 calculateShipping을 수정하고 여러 단위 테스트를 추가하는 PR(Pull Request)을 생성한다고 상상해 보세요.
일반적인 CI 파이프라인은 다음을 실행합니다:
- 타입 검사(Type-checking).
- 린팅(Linting).
- 단위 및 통합 테스트(Unit and integration tests).
- 기타 프로젝트별 확인 사항.
모든 것이 통과합니다.
이제 Stryker는 설정된 프로덕션 코드를 대상으로 실행됩니다. 테스트가 배송비 계산에서 의미 있는 뮤테이션을 감지하지 못한다는 것을 발견합니다.
PR 보고서는 변경 사항이 병합되기 전에 이러한 약점을 노출할 수 있습니다.
이는 특히 AI가 생성한 코드에 유용합니다. 왜냐하면 구현과 그 테스트가 동일한 에이전트에 의해, 요구사항에 대한 동일한 해석을 기반으로 생산되었을 수 있기 때문입니다.
테스트가 통과한다는 것은 해당 구현이 에이전트가 작성한 단언(assertions)을 충족한다는 증거일 뿐입니다. 그것이 모든 중요한 요구사항을 포착하는 독립적인 증거는 아닙니다.
뮤테이션 테스팅은 또 다른 과제를 추가합니다. 즉, 의도적으로 구현을 변경하고 테스트 스위트가 이를 감지하는지 확인하는 것입니다.
뮤테이션 테스팅이 모든 PR을 차단해야 할까요?
점진적인 접근 방식부터 시작할 것을 권장합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기