[Android] Gemini를 활용해 테스트 자동 생성 시스템 만들기

gay0ung·2025년 7월 29일
0

Android

목록 보기
11/14

테스트 코드 작성이 중요한 건 알지만 매번 손으로 다 짜기엔 시간이 너무 아까웠다. 그래서 AI를 활용해서 Git 커밋 기반으로 자동으로 테스트 코드를 생성하는 시스템을 만들어봤음. 생각보다 잘 동작해서 공유해보려고 함.

시스템 작동 방식

전체적인 워크플로우는 이렇게 돌아감:

  1. Git에서 최근 커밋의 변경된 파일들을 자동으로 감지
  2. Gemini AI가 각 파일을 분석해서 테스트 코드 생성
  3. 생성된 테스트를 실행해서 결과 확인
  4. 필요하면 Fastlane 배포 프로세스에도 통합

핵심은 변경된 코드에 대해서만 테스트를 생성한다는 점임. 전체 프로젝트를 다 분석하는 게 아니라 실제로 수정된 부분만 타겟팅하니까 효율적임.

환경 설정부터 시작

Python 가상 환경 구축

macOS에서 시스템 Python이 보호되어 있어서 가상 환경이 필수임:

# 프로젝트 루트에서 실행
python3 -m venv venv
source venv/bin/activate
pip install google-generativeai

API 키 설정

local.properties 파일에 Gemini API 키를 추가함:

sdk.dir=/Users/username/Library/Android/sdk
gemini.api.key=YOUR_GEMINI_API_KEY_HERE

보안상 이 파일은 절대 커밋하면 안 됨. .gitignore에 이미 들어가 있겠지만 확인해보는 게 좋음.

핵심 Python 스크립트 작성

scripts/gemini_test_gen.py 파일을 만들어서 실제 AI 테스트 생성 로직을 구현했음:

import sys
import os
import google.generativeai as genai
from pathlib import Path

class GeminiTestGenerator:
    def __init__(self):
        api_key = self.get_api_key()
        if not api_key:
            raise ValueError("API 키를 찾을 수 없습니다!")
        
        genai.configure(api_key=api_key)
        self.model = genai.GenerativeModel('gemini-1.5-pro')
    
    def get_api_key(self):
        # 환경 변수에서 먼저 확인
        api_key = os.getenv('GEMINI_API_KEY') or os.getenv('GOOGLE_API_KEY')
        if api_key:
            return api_key
        
        # local.properties에서 읽기
        script_dir = Path(__file__).parent.parent
        local_properties_path = script_dir / 'local.properties'
        
        if local_properties_path.exists():
            with open(local_properties_path, 'r') as f:
                for line in f:
                    line = line.strip()
                    if line.startswith('gemini.api.key='):
                        return line.split('=', 1)[1].strip()
        
        return None
    
    def generate_test(self, file_path):
        with open(file_path, 'r', encoding='utf-8') as f:
            code = f.read()
        
        # UI 컴포넌트가 있는지 체크해서 테스트 타입 결정
        is_ui = 'Activity' in code or 'Fragment' in code or 'Composable' in code
        
        prompt = f"""
        Android 코드에 대한 테스트를 생성해주세요.
        
        파일: {file_path}
        코드:
        ```kotlin
        {code}
        ```
        
        요구사항:
        {'- Espresso UI 테스트 포함' if is_ui else ''}
        - JUnit4 사용
        - Mockito로 의존성 모킹
        - 한글 테스트 메서드명 (백틱 사용)
        - @Test 어노테이션 필수
        - Given-When-Then 패턴
        """
        
        response = self.model.generate_content(prompt)
        return response.text
    
    def save_test(self, original_path, test_code):
        # main 디렉토리를 test로 바꿔서 테스트 파일 경로 생성
        test_path = original_path.replace('/main/', '/test/')
        test_path = test_path.replace('.kt', 'Test.kt')
        
        Path(test_path).parent.mkdir(parents=True, exist_ok=True)
        
        with open(test_path, 'w', encoding='utf-8') as f:
            f.write(test_code)
        
        return test_path

코드를 보면 UI 컴포넌트 여부를 자동으로 감지해서 Espresso 테스트를 포함할지 결정하는 로직이 들어가 있음. 이런 세부사항들이 실제 사용할 때 중요함.

Gradle 태스크로 자동화

app/build.gradle.kts에 커스텀 태스크들을 추가해서 명령어 한 번으로 전체 프로세스가 돌아가도록 만들었음:

// Python 환경 자동 설정
tasks.register("setupPythonEnv") {
    group = "ai testing"
    description = "Setup Python virtual environment"
    
    doLast {
        val venvDir = file("${project.rootDir}/venv")
        
        if (!venvDir.exists()) {
            println("Python 가상 환경 생성 중...")
            
            exec {
                commandLine("python3", "-m", "venv", "venv")
                workingDir = project.rootDir
            }
            
            exec {
                commandLine("${project.rootDir}/venv/bin/pip", "install", "google-generativeai")
            }
            
            println("Python 환경 설정 완료!")
        }
    }
}

// 최근 커밋 변경사항 기반 테스트 생성
tasks.register("aiTestLastCommit") {
    group = "ai testing"
    description = "Generate AI tests for files in recent commits"
    
    dependsOn("setupPythonEnv")
    
    doLast {
        // API 키 읽어오기
        val localProperties = java.util.Properties()
        val localPropertiesFile = rootProject.file("local.properties")
        
        var apiKey: String? = null
        if (localPropertiesFile.exists()) {
            localProperties.load(localPropertiesFile.inputStream())
            apiKey = localProperties.getProperty("gemini.api.key")
        }
        
        if (apiKey == null) {
            throw GradleException("Gemini API 키가 필요합니다!")
        }
        
        val pythonCmd = "${project.rootDir}/venv/bin/python"
        val scriptPath = "${project.rootDir}/scripts/gemini_test_gen.py"
        
        // 분석할 커밋 개수 (기본값: 3개)
        val commitCount = (project.findProperty("commits") as? String)?.toIntOrNull() ?: 3
        
        println("최근 ${commitCount}개 커밋 분석 중...")
        
        // Git 명령 실행 헬퍼 함수
        fun runGitCommand(vararg args: String): String {
            val process = ProcessBuilder("git", *args)
                .directory(project.rootDir)
                .redirectOutput(ProcessBuilder.Redirect.PIPE)
                .start()
            
            return process.inputStream.bufferedReader().readText().trim()
        }
        
        // 변경된 파일 목록 가져오기
        val changedFiles = runGitCommand("diff", "--name-only", "HEAD~${commitCount}", "HEAD")
            .lines()
            .filter { it.endsWith(".kt") || it.endsWith(".java") }
            .filter { it.isNotBlank() }
            .filter { file("${project.rootDir}/$it").exists() }
        
        if (changedFiles.isEmpty()) {
            println("변경된 Kotlin/Java 파일이 없습니다.")
            return@doLast
        }
        
        println("\n변경된 파일:")
        changedFiles.forEach { println("  $it") }
        
        var successCount = 0
        var failCount = 0
        
        // 각 파일에 대해 테스트 생성
        changedFiles.forEach { filePath ->
            val fullPath = "${project.rootDir}/$filePath"
            println("\n테스트 생성 중: ${file(filePath).name}")
            
            val processBuilder = ProcessBuilder(pythonCmd, scriptPath, fullPath)
                .directory(project.rootDir)
                .redirectOutput(ProcessBuilder.Redirect.PIPE)
                .redirectError(ProcessBuilder.Redirect.PIPE)
            
            // 환경 변수로 API 키 전달
            val env = processBuilder.environment()
            env["GEMINI_API_KEY"] = apiKey
            
            val process = processBuilder.start()
            val exitCode = process.waitFor()
            
            if (exitCode == 0) {
                successCount++
                println("완료!")
            } else {
                failCount++
                println("실패!")
                val error = process.errorStream.bufferedReader().readText()
                if (error.isNotEmpty()) {
                    println("에러: $error")
                }
            }
        }
        
        println("\n결과: 성공 $successCount 개, 실패 $failCount 개")
        
        // 생성된 테스트 실행
        if (successCount > 0) {
            println("\n테스트 실행 중...")
            ProcessBuilder("./gradlew", "app:test")
                .directory(project.rootDir)
                .inheritIO()
                .start()
                .waitFor()
        }
    }
}

편의를 위해 자주 쓰는 패턴들을 별도 태스크로 만들어뒀음:

// 최근 1개 커밋만
tasks.register("aiTest1") {
    group = "ai testing"
    dependsOn("aiTestLastCommit")
    doFirst {
        project.ext.set("commits", "1")
    }
}

// 최근 5개 커밋
tasks.register("aiTest5") {
    group = "ai testing"
    dependsOn("aiTestLastCommit")
    doFirst {
        project.ext.set("commits", "5")
    }
}

실제 사용법

설정이 끝나면 사용법은 정말 간단함:

# 초기 설정 (딱 한 번만)
./gradlew setupPythonEnv

# 최근 3개 커밋의 변경사항에 대해 테스트 생성
./gradlew aiTestLastCommit

# 최근 1개 커밋만
./gradlew aiTest1

# 최근 5개 커밋
./gradlew aiTest5

# 특정 개수 직접 지정
./gradlew aiTestLastCommit -Pcommits=10

Android Studio에서도 쓸 수 있음. Gradle 탭에서 app → Tasks → ai testing 폴더를 열면 태스크들이 보이고, 더블클릭하면 실행됨.

Fastlane 통합 (선택사항)

배포 프로세스에 통합하고 싶다면 fastlane/Fastfile에 추가할 수 있음:

platform :android do
  desc "AI가 생성한 테스트 실행"
  lane :ai_test do
    gradle(task: "aiTestLastCommit")
    gradle(task: "test")
    
    slack(
      message: "AI 테스트 생성 및 실행 완료!",
      success: true
    )
  end
  
  desc "배포 전 AI 테스트 포함 전체 검증"
  lane :deploy do
    ai_test
    gradle(task: "bundleRelease")
    upload_to_play_store
  end
end

장점과 한계

장점

  • 시간 절약: 수동으로 테스트 코드 짜는 시간이 확실히 줄어듬
  • 일관성: 항상 같은 패턴으로 테스트가 생성되니까 코드 스타일이 통일됨
  • 커버리지 향상: 빼먹기 쉬운 부분까지 자동으로 테스트 생성
  • CI/CD 통합: 배포 프로세스에 자연스럽게 포함시킬 수 있음

주의할 점

  • 코드 검토: AI가 만든 테스트라도 반드시 리뷰는 해야 함
  • 보안: API 키 관리 주의. 절대 커밋하면 안 됨

실제 사용 경험

몇 주 써보니까 정말 편함. 특히 리팩토링 후에 기존 테스트가 깨졌을 때 빠르게 새 테스트를 생성할 수 있어서 좋았음. AI가 생성한 기본 테스트에 도메인 지식을 좀 더하면 꽤 쓸만한 테스트 스위트가 만들어짐.

완벽하진 않아서 수정이 꽤 많이 필요하긴 하지만,,, 테스트 작성의 초기 부담을 확실히 덜어줌. 특히 반복적인 CRUD 테스트나 기본적인 UI 테스트 같은 경우는 거의 그대로 써도 될 정도로 품질이 괜찮았음.

마무리

전체 소스 코드는 바로 프로젝트에 적용할 수 있도록 구성해뒀음. 팀 상황에 맞게 커스터마이징해서 쓰면 될 것 같다. 테스트 작성이 부담스러웠던 개발자들에게는 정말 도움이 될 거라고 생각함.

AI 도구들이 점점 발전하고 있으니까 이런 자동화 시스템들도 계속 개선될 것 같다. 지금부터 도입해서 익숙해지면 나중에 더 큰 도움이 될 듯!

0개의 댓글