모든 서비스를 개발하고 배포할 때, 충분한 개발자 테스트와 QA를 거치기는 어렵다. 특히 요즘에는 AI가 작성하는 코드를 100% 다 이해하고 배포하는 개발자는 매우 적으며, 코드의 품질과 안정성을 가져가는 것은 더욱 어려워지고 있다.
코드 리뷰만으로 이 문제를 해결하기도 어렵다. 한 번의 PR에 10000+ line 이상의 변경이 우습게 발생하는데, 이걸 어떻게 다 읽고 리뷰할 수 있을까? 결국 리뷰도 AI의 도움이 들어가게 되고…. AI의 특성상 “문제를 찾으라”고 명령하면 100번의 검토→개선 루프 후에도 무수한 문제가 발견될 것이다.
이럴 때 사용하기 좋은 것이 코드 커버리지이다. QA가 충분하지 않은 상황에서 최소한의 기준이 될 수 있고 객관적인 “테스트 충실도” 에 대한 지표가 될 수 있다. 실제로 미션 크리티컬 도메인의 여러 표준에서 [코드 커버리지 N% 달성]을 필수 조건으로도 삼고 있다.
- 커버리지는 간단히 말하면, 테스트를 수행했을 때 실제 코드의 어느 line까지 실행되었는지이며, 이는 곧 구문 커버리지가 된다.
- 예를 들어 Branch Coverage가 90%, 95%, 100%에 가까워진다면 적어도 대부분의 분기 결과가 한 번 이상 실행되었다는 사실은 확인할 수 있는 것이다.
개발자의 감각으로 "이 정도면 테스트를 많이 한 것 같은데?"라고 판단하는 대신, 어떤 코드가 실행되었고 어떤 Branch가 아직 실행되지 않았는지를 확인할 수 있다는 점이 Coverage의 장점이다. 특히 AI가 코드를 작성하는 환경에서는 더 유용하다. 개발자가 모든 구현 세부사항을 기억하지 못하더라도 다음과 같은 내용을 Coverage Report에서 바로 확인할 수 있기 때문이다.
- AI가 추가한 예외 처리 경로가 실제 테스트되었는가?
- 새롭게 추가된 Branch가 한 번이라도 실행되었는가?
- 특정 Error Handling 코드가 전혀 테스트되지 않은 것은 아닌가?
하지만 여기서 중요한 점이 하나 있다.
Coverage가 높다고 해서 테스트의 품질까지 높다고 말할 수는 없다는 것이다. Coverage가 직접 측정하는 것은 기본적으로 코드가 실행되었는가이다. Line이 실행되었는지, Branch의 true와 false가 모두 실행되었는지, 각 Condition이 Decision에 영향을 주는 경우가 있었는지는 잘 보여준다.
반면 이 테스트에 대한 INPUT이 실제 real world에서도 중요한 값인지, 현실에서 발생 가능한 상태인지, 실제 시스템이 실행 되면서 다루어질 수 있는 흐름으로 실행된 것인지에 관한 정보는 전혀 알 수 없다.
좀 더 자세히 보자.
Coverage를 위한 테스트와 스텁의 함정
다음과 같은 코드가 있다고 해보자.
Result ProcessInput(void)
{
int value;
Status status = Input_Read(&value);
if (status != STATUS_OK)
{
// 에러 관련 처리...
return RESULT_ERROR;
}
if (value > MAX_VALID_VALUE)
{
// 에러 관련 처리...
return RESULT_ERROR;
}
Update(value);
return RESULT_OK;
}
- 대충 유효한 값인지 판단하는 분기가 있는 코드이다.
- 일반적인 실행 환경에서는 대부분 정상 값이라고 가정하자.
그렇다보니 실제 환경에서는 status != STATUS_OK나 value > MAX_VALID_VALUE 같은 Error Path는 정상적인 환경에서는 쉽게 발생하지 않을 수도 있다. 그렇다고 이런 경로를 테스트하지 않을 수는 없다. 오히려 실제 장애 상황에서 실행되는 코드이기 때문에 정상적으로 동작하는지 확인할 필요가 있다. Unit Test에서는 이런 상황을 만들기 위해 Stub을 자주 사용한다.
Status Input_Read(int *value)
{
*value = g_stub_value;
return g_stub_status;
}
- 스텁은 보통 정처기 공부를 할 때 많이들 배우는데… 하위 모듈이다.
- 더 쉽게 말하자면 테스트 코드에서 호출하는 실제 소스 코드의 Input_Read 라는 함수를 내가 정의한 임의의 코드로 대체하겠다 라는 것이다.
- 스텁의 목적은 주로 테스트 코드에 원하는 상태를 만들 수 있다는 것에 있다.
- (참고) 그 외에도 고립화나 빌드를 위한 스텁 정도로 사용할 수 있다.
- 스텁의 목적은 주로 테스트 코드에 원하는 상태를 만들 수 있다는 것에 있다.
스텁을 통해 실제로는 발생하기 힘든 아래와 같은 상태를 직접 만들어서 주입할 수 있다.
Case status value
| 1 | STATUS_OK | 10 |
| 2 | STATUS_ERROR | 0 |
| 3 | STATUS_OK | 999999 |
정상 경로뿐 아니라 Error Path도 의도적으로 실행할 수 있고, 실제 환경에서 재현하기 어려운 오류도 테스트할 수 있다. Stub이나 Mock을 이용해 이런 상황을 만드는 것 자체는 전혀 이상한 방법이 아니다. 실무에서도 재현하기 어려운 오류나 의존 모듈의 실패를 검증하기 위해 흔히 사용한다.
조금 더 생각을 해보면 사실 커버리지 100% 달성은 어떤 코드던, 스텁만 잘 쓴다면 무조건 가능하다.
- 아무리 잘못 짜여도 모든걸 스텁화해버리고 INPUT을 조작하여 테스트 케이스를 필요한만큼 늘린다면 커버리지는 얼마든지 높일 수 있다는 것이다.
하지만 이는 그렇게 유효한 테스트는 아니다. 실제로 일어나는 시스템의 흐름과 동떨어져 있고, 거짓된 오류 상황을 주입하게 될 수 있다는 것이 주된 문제이다. 이 경우 실제 시스템에서는 테스트로 확인한 오류에 대한 대처와는 다르게 코드가 동작할 가능성이 존재하기 때문이다.
말이 조금 길어졌는데, 중요한 것은 코드 커버리지만으로는 코드의 품질을 완벽히 보장할 수는 없고, 테스트의 충실도 지표 정도로 사용하는 것이 적합하다는 것이다.
그럼 이제 QA가 없어도 코드 품질을 확보할 수 있는 최소한의 테스트 기법을 알아보자.
1. Coverage
Coverage는 테스트를 수행하면서 프로그램의 어떤 코드 구조가 실제로 실행되었는지 측정하는 지표다. 대표적으로 다음과 같은 종류가 있다.
- Statement Coverage: 각 Statement(코드 라인 한 줄)가 최소 한 번 이상 실행되었는지 측정
- Branch Coverage: if, switch 등의 각 분기 결과가 실행되었는지 측정
- Condition Coverage: 하나의 Decision을 구성하는 각각의 Condition이 true, false를 경험했는지 측정
- MC/DC: 각각의 Condition이 전체 Decision 결과에 독립적으로 영향을 주는지를 확인
예를 들어 다음 코드가 있다고 해보자.
if (A && B)
{
Run();
}
Statement Coverage만 본다면 Run()이 한 번 실행되는 것으로 해당 Statement는 실행된 것으로 볼 수 있다.
Branch Coverage에서는 Decision의 true, false 결과를 모두 실행해야 한다.
MC/DC는 A와 B가 각각 독립적으로 Decision 결과를 바꿀 수 있다는 것을 보여주는 조합이 필요하다.
A B 결과
| true | true | true |
| false | true | false |
| true | false | false |
2. Fuzzing
Fuzzing Test는 컴퓨터가 기계적으로 무수한 Input case를 만드는 방식이다.
기본적으로 랜덤으로 INPUT을 수만 개 생성하며 다음과 같은 케이스를 찾는다.
- Crash
- Memory Error
- Assertion Failure
- Hang
- Timeout
- Undefined Behavior
아주 간단하면서도 꽤 효과적인 방식이다.
3. Mutation Testing
Fuzzing이 다양한 Input을 만들어 프로그램의 약점을 찾는다면, Mutation Testing은 반대로 코드 자체를 일부러 바꿔본다. 예를 들어 다음 코드가 있다고 해보자.
bool IsValid(int value)
{
return value >= 10;
}
Mutation Testing 도구가 이 코드를 다음과 같이 변경할 수 있다.
return value > 10;
이렇게 실제 코드에서 특정 부분을 바꿔버리고, 이를 테스트로 감지할 수 있는지를 테스트하는 방식이다. 코드의 품질 보다는 테스트 코드의 품질을 따지는 쪽에 가깝다.
이 Mutant로 인해 테스트가 실패한다면 해당 Mutant는 테스트로 인해 감지되어 죽어버렸으므로 Killed Mutant라 한다.
반대로 모든 테스트가 성공했다면 Survived Mutant라 하고, 뮤턴트 테스트의 목적은 살아 있는 뮤턴트가 없게끔 테스트를 촘촘히 설계하는 것에 있다.
정리해보자.
Test Quality
┌──────────┼──────────┐
│ │ │
Coverage Fuzzing Mutation
│ │ │
▼ ▼ ▼
Code Structure Input Space Test Suite
Coverage는 코드의 어느 부분까지 실행했는지를 본다.
Fuzzing은 어떤 Input을 아직 놓치고 있는지를 찾는다.
Mutation Testing은 현재 Test Suite가 실제 결함을 감지할 수 있는지를 확인한다.
Coverage는 테스트의 시작점으로 좋은 지표다. 특히 개발자가 전체 코드를 모두 이해하기 어려운 환경에서는 테스트되지 않은 코드가 어디인지 자동으로 보여준다는 것만으로도 충분한 가치가 있다.
하지만 Coverage 숫자를 높이는 것만으로 테스트의 품질을 모두 설명할 수는 없다. 좋은 테스트는 단순히 코드를 많이 실행하는 테스트가 아니다. 충분한 코드 영역을 실행하고, 다양한 Input을 탐색하고, 실제 결함이 들어왔을 때 실패할 수 있어야 한다. 그런 관점에서 Coverage는 테스트 품질의 끝이라기보다 어디에서부터 더 살펴봐야 하는지를 알려주는 첫 번째 기준에 가깝다.