이전 글에서는 Roslyn을 사용하여 코드 분석을 수행하고, 사용자 정의 규칙을 통해 문제 있는 코드나 코딩 표준에 맞지 않는 코드를 식별하여 커밋을 차단함으로써 팀의 코드 품질을 향상시키는 방법을 다뤘습니다.
이번에는 Roslyn 기술을 활용하여 단위 테스트 코드를 분석하고, 단위 테스트 커버리지와 실행 성공률을 기반으로 제품 출시 품질을 엄격히 관리하는 방법을 살펴보겠습니다. 설정된 기준에 미달하는 경우 테스트 제출이 불가능하도록 제한할 수 있습니다.
단위 테스트란?
단위 테스트(unit testing)는 소프트웨어 내에서 가장 작은 테스트 가능한 단위를 검사하고 검증하는 것을 의미합니다. 언어나 프레임워크에 따라 '단위'의 정의는 달라질 수 있으며, C 언어에서는 함수가 단위가 되고 C#에서는 클래스가 될 수 있습니다.
좋은 단위 테스트는 다음과 같은 특성을 가져야 합니다:
- 애플리케이션의 다양한 시나리오를 포괄적으로 커버
- 구조적으로 잘 설계되어 준비(setup), 실행(execution), 검증(assertion), 정리(teardown) 단계가 명확
- 각 테스트는 하나의 기능이나 코드 단위만을 검사
- 독립적이며 외부 의존성이나 전역 상태에 영향을 받지 않음
- 명확한 이름으로 의도 파악이 용이
- 반복 실행 가능하며 언제 어디서든 동일한 결과 도출
- 빠른 실행 속도 보장
단위 테스트 평가 및 관리 전략
단위 테스트의 품질을 평가하기 위해 일반적으로 다음 지표들을 활용합니다:
- 코드 라인 커버리지
- 클래스, 메서드, 조건문 분기 커버리지
- 정상/비정상/성능/경계값 등 다양한 테스트 유형 커버리지
- 비즈니스 시나리오 커버리지
다만 커버리지 수치만으로 테스트 품질을 판단해서는 안 됩니다. 중요한 것은 실제 테스트의 질이며, 개발자는 무작정 커버리지를 높이기보다는 의미 있는 테스트 케이스를 설계하는 데 집중해야 합니다.
우리 팀은 다음과 같은 원칙에 따라 단위 테스트를 작성하고 관리합니다:
- 핵심 마이크로서비스는 반드시 단위 테스트 포함
- 테스트 시나리오는 최대한 다양한 케이스를 커버
- 각 테스트는 완전한 검증 로직 포함
- 정상 및 비정상 상황, 성능, 경계값 테스트 유형 모두 고려
- 설계 단계에서 테스트 시나리오 정의 및 추적
- 단위 테스트 정보를 중앙에서 관리하여 품질 통제
- 테스트 성공률 100% 달성 후 CI 진행 가능
단위 테스트 어노테이션을 통한 정보 확장
단위 테스트에 추가 정보를 부여하기 위해 사용자 정의 어노테이션(UnitTestAttribute)를 도입했습니다. 이를 통해 마이크로서비스 식별자, 타입, 테스트 세트, 설명, 담당자, 테스트 유형 등의 정보를 기록할 수 있습니다.
[AttributeUsage(AttributeTargets.Method)]
public class UnitTestAttribute : Attribute
{
public string TestCase { get; set; }
public string SequenceNumber { get; set; }
public string TestName { get; set; }
public string Owner { get; set; }
public string ServiceType { get; set; }
public string ServiceId { get; set; }
public string TestType { get; set; }
public UnitTestAttribute(string testCase, string seqNo, string name,
string owner, string serviceType)
{
TestCase = testCase;
SequenceNumber = seqNo;
TestName = name;
Owner = owner;
ServiceType = serviceType;
}
}
Roslyn을 활용한 단위 테스트 코드 분석
Roslyn 컴파일러 플랫폼을 사용하여 단위 테스트 코드를 분석하고 관련 정보를 수집합니다. 전체 분석 프로세스는 다음과 같습니다:
- MSBuildWorkspace를 생성하여 컴파일 작업 공간 구성
- Solution 파일 열기
- 프로젝트 내 문서 순회
- 문법 트리 추출 및 메서드 식별
- UnitTest 어노테이션이 적용된 메서드 필터링 및 정보 수집
public async Task<List<UnitTestInfo>> AnalyzeSolution(string solutionPath)
{
var unitTests = new List<UnitTestInfo>();
var workspace = MSBuildWorkspace.Create();
var solution = await workspace.OpenSolutionAsync(solutionPath);
foreach (var project in solution.Projects)
{
foreach (var document in project.Documents.Where(d => d.Name.EndsWith(".cs")))
{
var syntaxTree = await document.GetSyntaxTreeAsync();
var root = await syntaxTree.GetRootAsync();
var methods = root.DescendantNodes()
.OfType<MethodDeclarationSyntax>()
.Where(m => HasUnitTestAttribute(m));
foreach (var method in methods)
{
var testInfo = ExtractUnitTestMetadata(method, project.Name);
if (testInfo != null)
unitTests.Add(testInfo);
}
}
}
return unitTests;
}
private bool HasUnitTestAttribute(MethodDeclarationSyntax method)
{
return method.AttributeLists
.SelectMany(list => list.Attributes)
.Any(attr => attr.Name.ToString() == "UnitTest");
}
private UnitTestInfo ExtractUnitTestMetadata(MethodDeclarationSyntax method, string projectName)
{
var attribute = method.AttributeLists
.SelectMany(list => list.Attributes)
.FirstOrDefault(attr => attr.Name.ToString() == "UnitTest");
if (attribute?.ArgumentList == null) return null;
var args = attribute.ArgumentList.Arguments;
return new UnitTestInfo
{
ProjectName = projectName,
ClassName = GetContainingClassName(method),
MethodName = method.Identifier.Text,
TestCase = GetArgumentValue(args, 0),
SequenceNumber = GetArgumentValue(args, 1),
TestName = GetArgumentValue(args, 2),
Owner = GetArgumentValue(args, 3),
ServiceType = GetArgumentValue(args, 4),
ServiceId = args.Count > 5 ? GetArgumentValue(args, 5) : string.Empty,
TestType = args.Count > 6 ? GetArgumentValue(args, 6) : string.Empty
};
}
단위 테스트 실행 결과 수집 및 품질 제어
CI 파이프라인에서 단위 테스트 실행 결과를 수집하기 위해 베이스 테스트 클래스를 구현합니다:
[TestClass]
public abstract class BaseUnitTest
{
protected TestContext testContext;
[TestCleanup]
public virtual void Cleanup()
{
if (testContext?.TestMethod != null)
{
var testMethod = this.GetType().GetMethod(testContext.TestName);
var unitTestAttr = testMethod?.GetCustomAttribute<UnitTestAttribute>();
if (unitTestAttr != null)
{
var executionResult = new TestExecutionResult
{
TestName = unitTestAttr.TestName,
ServiceId = unitTestAttr.ServiceId,
ServiceType = unitTestAttr.ServiceType,
Passed = testContext.CurrentTestOutcome == UnitTestOutcome.Passed,
ExecutionTime = DateTime.UtcNow,
Duration = GetTestDuration(),
HostMachine = Environment.MachineName
};
ReportTestResult(executionResult);
}
}
}
private void ReportTestResult(TestExecutionResult result)
{
// HTTP 클라이언트를 통해 결과를 관리 플랫폼으로 전송
using (var client = new HttpClient())
{
var json = JsonSerializer.Serialize(result);
var content = new StringContent(json, Encoding.UTF8, "application/json");
client.PostAsync("https://dev-platform/api/test-results", content);
}
}
}
이러한 구조를 통해 모든 단위 테스트는 자동으로 실행 결과를 보고하게 되며, 설정된 성공률 기준(예: 95%)을 충족하지 못하면 CI 파이프라인에서 패치 생성이 차단됩니다. 또한 관리 플랫폼에서는 핵심 마이크로서비스의 테스트 커버리지와 실행 성공률을 실시간으로 모니터링하여 제품 출시 품질을 효과적으로 제어할 수 있습니다.