코드 변경을 분석하여 단위 테스트와 통합 테스트를 자동으로 생성하는 시스템을 구축하고, 테스트 품질을 검증하는 방법을 다룹니다.
테스트 작성은 소프트웨어 품질을 보장하는 핵심 활동이지만, 개발자들이 가장 미루기 쉬운 작업이기도 합니다. 새로운 기능을 구현한 후 테스트를 작성하는 것은 시간이 소요되고, 기존 코드의 테스트 커버리지를 높이는 것은 더욱 어렵습니다.
AI 기반 테스트 생성은 이 문제를 두 가지 측면에서 해결합니다.
첫째, PR의 변경 사항을 분석하여 해당 변경에 대한 테스트를 자동으로 생성합니다. 개발자가 기능을 구현하면, AI가 해당 기능의 경계 조건, 에러 시나리오, 정상 흐름에 대한 테스트를 제안합니다.
둘째, 기존 코드베이스에서 테스트가 부족한 영역을 식별하고, 해당 영역의 테스트를 생성합니다. 이를 통해 점진적으로 전체 커버리지를 높일 수 있습니다.
기존 테스트 작성 흐름:
개발자가 기능 구현 --> 개발자가 테스트 작성 --> 리뷰 요청
문제: 테스트 누락, 경계 조건 미흡, 시간 소모
AI 보조 테스트 흐름:
개발자가 기능 구현 --> AI가 테스트 초안 생성 --> 개발자가 검증/보완 --> 리뷰 요청
장점: 기본 커버리지 보장, 경계 조건 포함, 시간 절약테스트 생성 시스템은 코드 분석, 테스트 전략 수립, 테스트 코드 생성, 검증의 네 단계로 구성됩니다.
테스트를 생성하기 전에 변경된 코드를 깊이 있게 분석해야 합니다. 함수의 시그니처, 의존성, 분기 조건, 에러 처리 패턴 등을 파악합니다.
interface FunctionAnalysis {
name: string
filePath: string
// 함수 시그니처
parameters: ParameterInfo[]
returnType: string
// 함수의 복잡도 정보
cyclomaticComplexity: number
// 의존성 (호출하는 외부 함수/모듈)
dependencies: string[]
// 분기 조건
branches: BranchInfo[]
// 에러 처리
errorHandling: ErrorHandlingInfo[]
// 부수 효과 (DB, API, 파일 I/O)
sideEffects: SideEffect[]
}
interface BranchInfo {
type: "if" | "switch" | "ternary" | "guard"
condition: string
lineNumber: number
}
interface SideEffect {
type: "database" | "api" | "filesystem" | "network"
description: string
}
function analyzeFunction(
sourceCode: string,
functionName: string
): FunctionAnalysis {
// AST 기반 분석 또는 LLM 기반 분석
// 분기 복잡도 계산
const complexity = calculateCyclomaticComplexity(
sourceCode, functionName
)
// 의존성 추출
const deps = extractDependencies(sourceCode, functionName)
// 부수 효과 식별
const effects = identifySideEffects(sourceCode, functionName)
return {
name: functionName,
filePath: "",
parameters: extractParameters(sourceCode, functionName),
returnType: inferReturnType(sourceCode, functionName),
cyclomaticComplexity: complexity,
dependencies: deps,
branches: extractBranches(sourceCode, functionName),
errorHandling: extractErrorHandling(
sourceCode, functionName
),
sideEffects: effects,
}
}코드 분석 결과를 바탕으로 어떤 테스트를 생성할지 전략을 수립합니다.
interface TestStrategy {
// 테스트할 함수
targetFunction: string
// 테스트 유형
testType: "unit" | "integration" | "e2e"
// 테스트 케이스 목록
testCases: TestCaseSpec[]
// 필요한 모킹
mocks: MockSpec[]
// 테스트 프레임워크
framework: "jest" | "vitest" | "pytest" | "mocha"
}
interface TestCaseSpec {
description: string
category: "happy-path" | "edge-case" | "error-case" | "boundary"
inputs: Record<string, unknown>
expectedOutput: unknown
priority: "high" | "medium" | "low"
}
function buildTestStrategy(
analysis: FunctionAnalysis
): TestStrategy {
const testCases: TestCaseSpec[] = []
// 1. 정상 경로 테스트 (필수)
testCases.push({
description: analysis.name + "의 정상 동작 검증",
category: "happy-path",
inputs: generateTypicalInputs(analysis.parameters),
expectedOutput: null, // LLM이 추론
priority: "high",
})
// 2. 경계값 테스트
for (const param of analysis.parameters) {
const boundaryInputs = generateBoundaryInputs(param)
for (const input of boundaryInputs) {
testCases.push({
description: param.name + "의 경계값 (" + input.label + ")",
category: "boundary",
inputs: input.values,
expectedOutput: null,
priority: "medium",
})
}
}
// 3. 에러 케이스 테스트
for (const errorCase of analysis.errorHandling) {
testCases.push({
description: "에러 처리: " + errorCase.description,
category: "error-case",
inputs: generateErrorTriggerInputs(errorCase),
expectedOutput: null,
priority: "high",
})
}
// 4. 분기별 테스트
for (const branch of analysis.branches) {
testCases.push({
description: "분기 조건: " + branch.condition,
category: "edge-case",
inputs: generateBranchInputs(branch),
expectedOutput: null,
priority: "medium",
})
}
// 모킹 전략 결정
const mocks = analysis.sideEffects.map(effect => ({
target: effect.description,
type: effect.type,
behavior: "stub" as const,
}))
return {
targetFunction: analysis.name,
testType: analysis.sideEffects.length > 0
? "integration"
: "unit",
testCases,
mocks,
framework: "vitest",
}
}테스트 생성을 위한 프롬프트는 코드 분석 결과와 테스트 전략을 모두 포함해야 합니다.
function buildTestGenerationPrompt(
sourceCode: string,
analysis: FunctionAnalysis,
strategy: TestStrategy,
existingTests: string | null
): string {
let prompt = `다음 함수에 대한 테스트 코드를 생성해 주세요.
## 대상 함수
\`\`\`typescript
` + sourceCode + `
\`\`\`
## 함수 분석
- 순환 복잡도: ` + analysis.cyclomaticComplexity + `
- 의존성: ` + analysis.dependencies.join(", ") + `
- 부수 효과: ` + analysis.sideEffects.map(e => e.type).join(", ") + `
## 요청 테스트 케이스
`
for (const tc of strategy.testCases) {
prompt += "- [" + tc.priority + "] " + tc.description
+ " (" + tc.category + ")\n"
}
prompt += `
## 테스트 작성 규칙
1. 테스트 프레임워크: ` + strategy.framework + `
2. 각 테스트는 독립적으로 실행 가능해야 합니다
3. 외부 의존성은 모킹합니다
4. 테스트명은 "should + 기대 동작" 형식입니다
5. Arrange-Act-Assert 패턴을 따릅니다
6. 타입 안전한 코드를 작성합니다
`
if (existingTests) {
prompt += `
## 기존 테스트 (스타일 참고)
\`\`\`typescript
` + existingTests + `
\`\`\`
기존 테스트의 스타일과 패턴을 따라 작성하세요.
`
}
prompt += `
응답은 실행 가능한 테스트 코드만 포함하세요. 설명은 코드 주석으로 포함합니다.
`
return prompt
}LLM이 생성하는 테스트 코드의 예시를 확인합니다. 다음은 사용자 인증 함수에 대해 생성된 테스트입니다.
import { describe, it, expect, vi, beforeEach } from "vitest"
import { authenticateUser } from "./auth"
import { findUserByEmail } from "./user-repository"
import { comparePassword } from "./crypto"
import { generateToken } from "./token"
// 외부 의존성 모킹
vi.mock("./user-repository")
vi.mock("./crypto")
vi.mock("./token")
describe("authenticateUser", () => {
const mockUser = {
id: "user-1",
email: "test@example.com",
passwordHash: "hashed-password",
isActive: true,
}
beforeEach(() => {
vi.clearAllMocks()
})
// 정상 경로 테스트
it("should return token when credentials are valid", async () => {
// Arrange
vi.mocked(findUserByEmail).mockResolvedValue(mockUser)
vi.mocked(comparePassword).mockResolvedValue(true)
vi.mocked(generateToken).mockReturnValue("mock-token")
// Act
const result = await authenticateUser(
"test@example.com",
"correct-password"
)
// Assert
expect(result).toEqual({
success: true,
token: "mock-token",
user: { id: "user-1", email: "test@example.com" },
})
})
// 에러 케이스: 존재하지 않는 사용자
it("should throw when user is not found", async () => {
vi.mocked(findUserByEmail).mockResolvedValue(null)
await expect(
authenticateUser("unknown@example.com", "password")
).rejects.toThrow("Invalid credentials")
})
// 에러 케이스: 잘못된 비밀번호
it("should throw when password is incorrect", async () => {
vi.mocked(findUserByEmail).mockResolvedValue(mockUser)
vi.mocked(comparePassword).mockResolvedValue(false)
await expect(
authenticateUser("test@example.com", "wrong-password")
).rejects.toThrow("Invalid credentials")
})
// 경계 케이스: 비활성 계정
it("should throw when account is deactivated", async () => {
const inactiveUser = { ...mockUser, isActive: false }
vi.mocked(findUserByEmail).mockResolvedValue(inactiveUser)
vi.mocked(comparePassword).mockResolvedValue(true)
await expect(
authenticateUser("test@example.com", "correct-password")
).rejects.toThrow("Account is deactivated")
})
// 경계 케이스: 빈 입력
it("should throw when email is empty", async () => {
await expect(
authenticateUser("", "password")
).rejects.toThrow("Email is required")
})
it("should throw when password is empty", async () => {
await expect(
authenticateUser("test@example.com", "")
).rejects.toThrow("Password is required")
})
})AI가 생성한 테스트가 실제로 유효한지 검증하는 과정이 필수적입니다. 생성만 하고 검증하지 않으면 오히려 거짓 안정감(False Confidence)을 줄 수 있습니다.
생성된 테스트를 실제로 실행하여 통과 여부를 확인합니다.
import { execSync } from "child_process"
interface ValidationResult {
passed: boolean
totalTests: number
passedTests: number
failedTests: number
errors: TestError[]
coverage: CoverageInfo | null
}
interface TestError {
testName: string
error: string
type: "compilation" | "assertion" | "runtime"
}
async function validateGeneratedTests(
testFilePath: string,
sourceFilePath: string
): Promise<ValidationResult> {
// 1단계: 타입 검사
const typeCheckResult = typeCheck(testFilePath)
if (!typeCheckResult.passed) {
return {
passed: false,
totalTests: 0,
passedTests: 0,
failedTests: 0,
errors: typeCheckResult.errors.map(e => ({
testName: "compilation",
error: e,
type: "compilation" as const,
})),
coverage: null,
}
}
// 2단계: 테스트 실행
try {
const output = execSync(
"npx vitest run " + testFilePath + " --reporter=json",
{ encoding: "utf-8", timeout: 30000 }
)
const result = JSON.parse(output)
return parseVitestResult(result)
} catch (error) {
return parseTestFailure(error)
}
}생성된 테스트가 단순히 통과하는 것을 넘어, 실제로 버그를 잡을 수 있는지 확인하기 위해 뮤테이션 테스트(Mutation Testing)를 활용합니다.
뮤테이션 테스트 원리:
1. 원본 코드에 의도적인 변경(뮤턴트)을 삽입
예: if (x > 0) --> if (x >= 0)
return a + b --> return a - b
2. 변경된 코드에 대해 테스트 실행
3. 테스트가 실패하면 뮤턴트가 "죽음" (killed)
테스트가 통과하면 뮤턴트가 "생존" (survived)
4. 뮤테이션 점수 = 죽인 뮤턴트 / 전체 뮤턴트
높은 뮤테이션 점수 = 테스트가 실제 버그를 잡는 능력이 높음interface MutationResult {
mutationScore: number // 0.0 ~ 1.0
totalMutants: number
killedMutants: number
survivedMutants: number
// 생존한 뮤턴트 정보 (테스트 보완 필요)
survivors: MutantInfo[]
}
interface MutantInfo {
type: string // 뮤테이션 유형
location: {
file: string
line: number
}
original: string // 원본 코드
mutated: string // 변경된 코드
}
async function runMutationTest(
sourceFile: string,
testFile: string
): Promise<MutationResult> {
// Stryker를 사용한 뮤테이션 테스트 실행
const output = execSync(
"npx stryker run --files " + sourceFile
+ " --testFiles " + testFile
+ " --reporters json",
{ encoding: "utf-8", timeout: 120000 }
)
return parseMutationResult(output)
}뮤테이션 점수가 80% 이상이면 테스트 품질이 양호하다고 판단할 수 있습니다. 생존한 뮤턴트 정보를 LLM에 제공하면, 해당 뮤턴트를 잡기 위한 추가 테스트를 생성할 수 있습니다.
생성된 테스트가 실패하면, 에러 정보를 LLM에 전달하여 수정을 시도합니다.
async function generateAndValidateTests(
sourceCode: string,
analysis: FunctionAnalysis,
maxRetries: number = 3
): Promise<string | null> {
let attempt = 0
let testCode: string | null = null
let lastError: string | null = null
while (attempt < maxRetries) {
// 테스트 생성 (이전 에러가 있으면 맥락에 포함)
const prompt = lastError
? buildRetryPrompt(sourceCode, analysis, testCode, lastError)
: buildTestGenerationPrompt(
sourceCode, analysis,
buildTestStrategy(analysis), null
)
testCode = await generateTestWithLLM(prompt)
// 테스트 검증
const tempFile = writeTempFile(testCode)
const result = await validateGeneratedTests(
tempFile, analysis.filePath
)
if (result.passed) {
return testCode
}
lastError = result.errors
.map(e => e.testName + ": " + e.error)
.join("\n")
attempt++
}
// 모든 재시도 실패 시 null 반환
return null
}
function buildRetryPrompt(
sourceCode: string,
analysis: FunctionAnalysis,
previousTest: string | null,
error: string
): string {
return `이전에 생성한 테스트에서 오류가 발생했습니다. 수정해 주세요.
## 대상 함수
\`\`\`typescript
` + sourceCode + `
\`\`\`
## 이전 테스트 코드
\`\`\`typescript
` + (previousTest || "") + `
\`\`\`
## 발생한 오류
` + error + `
오류를 수정한 전체 테스트 코드를 다시 작성해 주세요.
`
}테스트 생성 시스템을 GitHub Actions에 통합하여 PR마다 자동으로 테스트를 제안합니다.
name: AI Test Generation
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: write
pull-requests: write
jobs:
generate-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: $GITHUB_HEAD_REF_VALUE
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm install
- name: Analyze changes and generate tests
env:
ANTHROPIC_API_KEY: $ANTHROPIC_KEY_VALUE
run: node scripts/generate-tests.js
- name: Run generated tests
run: npx vitest run --reporter=verbose
- name: Commit generated tests
run: |
git config user.name "AI Test Bot"
git config user.email "ai-test-bot@example.com"
git add "**/*.test.ts" "**/*.spec.ts"
git diff --staged --quiet || git commit -m "test: add AI-generated tests"
git pushAI가 생성한 테스트를 자동으로 커밋하는 것은 팀의 합의가 필요합니다. 일부 팀에서는 테스트를 PR의 코멘트로 제안하고, 개발자가 검토 후 수동으로 추가하는 방식을 선호합니다. 팀의 문화와 코드 소유권 정책에 맞게 선택하세요.
이 장에서는 AI 기반 테스트 자동 생성 시스템을 구축했습니다. 코드 분석, 테스트 전략 수립, LLM을 활용한 테스트 생성, 검증 루프, GitHub Actions 통합까지 전체 파이프라인을 다루었습니다.
핵심 내용을 정리합니다.
다음 장에서는 AI 기반 문서화 자동화를 다룹니다. 코드 변경에 따라 API 문서, README, 변경 로그를 자동으로 갱신하는 시스템을 구축합니다.
이 글이 도움이 되셨나요?
코드 변경에 따라 API 문서, README, 변경 로그를 AI로 자동 갱신하는 시스템을 구축하고, 문서와 코드의 동기화를 유지하는 전략을 다룹니다.
GitHub Actions를 활용하여 PR에 자동으로 AI 코드 리뷰를 수행하는 시스템을 직접 구축하고, 실전에서 활용 가능한 수준으로 완성합니다.
PR의 변경 범위와 위험도를 AI로 분석하고, 리뷰어에게 구조화된 인사이트를 제공하는 시스템을 구축합니다.