Jenkins 파이프라인으로 CI/CD 자동화 완성하기

Jenkins 파이프라인으로 CI/CD 자동화 완성하기

Jenkins 파이프라인으로 CI/CD 자동화 완성하기 파이프라인 처음 만들던 날 3년 전이었다. Jenkins 설치하고 첫 파이프라인 돌렸다. pipeline { agent any stages { stage('Test') { steps { sh 'pytest' } } } }10줄짜리 코드. 테스트만 돌렸다. 그게 시작이었다. 지금은 300줄이다. 빌드, 테스트, 배포, 알림, 롤백까지. 복잡하다. 하지만 이제 안다. 어떻게 관리하는지.오늘 우리 파이프라인 이야기한다. 실전이다. 선언형 파이프라인 기본 구조 선언형을 쓴다. 스크립트형 말고. 이유는 간단하다. 읽기 쉽다. 유지보수 쉽다. pipeline { agent { docker { image 'python:3.9' args '-v /var/run/docker.sock:/var/run/docker.sock' } } environment { TEST_ENV = 'staging' SLACK_CHANNEL = '#qa-alerts' COVERAGE_THRESHOLD = '80' } options { timestamps() timeout(time: 1, unit: 'HOURS') buildDiscarder(logRotator(numToKeepStr: '30')) disableConcurrentBuilds() } stages { stage('Setup') { steps { sh 'pip install -r requirements.txt' } } stage('Lint') { steps { sh 'flake8 tests/' sh 'pylint tests/' } } stage('Test') { steps { sh 'pytest --junitxml=report.xml' } } } post { always { junit 'report.xml' } } }이게 기본이다. agent는 어디서 돌릴지. environment는 환경변수. options는 파이프라인 설정. stages는 단계들. post는 후처리. 처음엔 agent any 썼다. 아무 노드나. 그런데 환경이 달라서 문제였다. 파이썬 버전, 라이브러리 버전. 다 달랐다. 도커로 바꿨다. 환경 통일됐다. 테스트 결과도 일관됐다. environment는 하드코딩 없애려고. 설정 값들 여기 모았다. 수정할 때 한 곳만 보면 된다. options는 필수다. timeout 없으면 무한 대기한다. 한번 그랬다. 테스트 하나 멈췄는데 8시간 돌았다. 그 후로 무조건 넣는다. disableConcurrentBuilds도 중요하다. 동시 빌드 막는다. DB 테스트할 때 충돌 났었다. 같은 데이터 동시 접근. 이것도 배웠다. 병렬 테스트 실행 구조 테스트가 많다. 1500개. 순차 실행하면 40분. 너무 길다. 병렬로 돌린다. 10분으로 줄었다. stage('Parallel Tests') { parallel { stage('Unit Tests') { steps { sh 'pytest tests/unit -n 4' } } stage('Integration Tests') { steps { sh 'pytest tests/integration -n 2' } } stage('API Tests') { steps { sh 'pytest tests/api --dist loadgroup' } } stage('UI Tests - Chrome') { steps { sh 'pytest tests/ui -k chrome --dist loadscope' } } stage('UI Tests - Firefox') { steps { sh 'pytest tests/ui -k firefox --dist loadscope' } } } }parallel 블록이다. 동시에 돌린다. 처음엔 전부 -n 4로 했다. 4개씩 병렬. 그런데 UI 테스트가 문제였다. 브라우저가 4개 뜨면 메모리 터진다. 노드가 죽었다. 그래서 분리했다. Unit은 빠르니까 4개. Integration은 무거워서 2개. UI는 loadscope로 클래스 단위 분산. 실패율도 줄었다. UI 테스트가 원래 flaky했다. 동시 실행하면 더 불안정했다. 브라우저별로 나눠서 격리했다.메트릭도 본다. 어느 단계가 오래 걸리는지. stage('Performance Check') { steps { script { def startTime = System.currentTimeMillis() sh 'pytest tests/performance' def duration = System.currentTimeMillis() - startTime if (duration > 300000) { // 5분 초과 echo "Warning: Performance tests took ${duration}ms" currentBuild.result = 'UNSTABLE' } } } }5분 넘으면 경고. UNSTABLE로 마킹. 실패는 아니다. 주의만 준다. 이거 넣고 병목 찾았다. API 테스트 중 하나가 2분 걸렸다. DB 초기화 때문이었다. 픽스처 개선했다. 20초로 줄었다. 실패 알림 자동화 시스템 테스트 실패하면 알아야 한다. 빨리. 정확하게. 슬랙 연동했다. post { failure { script { def failedTests = sh( script: "grep -o 'FAILED.*' report.xml | wc -l", returnStdout: true ).trim() def testReport = """ *Build Failed* :x: *Branch:* ${env.GIT_BRANCH} *Build:* ${env.BUILD_NUMBER} *Failed Tests:* ${failedTests} *Duration:* ${currentBuild.durationString} *Triggered by:* ${env.BUILD_USER} *View:* ${env.BUILD_URL} """ slackSend( channel: '#qa-alerts', color: 'danger', message: testReport ) } } success { slackSend( channel: '#qa-success', color: 'good', message: "*Build Success* :white_check_mark: Branch: ${env.GIT_BRANCH}" ) } unstable { slackSend( channel: '#qa-alerts', color: 'warning', message: "*Build Unstable* :warning: Some tests are flaky. Branch: ${env.GIT_BRANCH}" ) } }실패하면 #qa-alerts로. 성공하면 #qa-success로. 채널 분리했다. 노이즈 줄이려고. 처음엔 전부 한 채널이었다. 성공 알림이 너무 많았다. 실패 알림을 못 봤다. 분리하고 나서 놓치는 게 없다. 실패 알림은 상세하게. 어느 브랜치. 몇 개 실패. 누가 트리거. URL까지. def getFailedTestDetails() { def details = sh( script: """ grep 'FAILED' report.xml | \ sed 's/.*FAILED \\(.*\\)::.*/\\1/' | \ sort | uniq | head -5 """, returnStdout: true ).trim() return details }post { failure { script { def failedList = getFailedTestDetails() slackSend( channel: '#qa-alerts', message: """ *Failed Tests:* ``` ${failedList} ``` """ ) } } }실패한 테스트 이름도 보낸다. 최대 5개. 뭐가 깨졌는지 바로 안다. 이메일도 보낸다. 중요한 빌드는. post { failure { emailext( to: 'qa-team@company.com', subject: "Build Failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}", body: """ <h2>Build Failed</h2> <p><b>Branch:</b> ${env.GIT_BRANCH}</p> <p><b>Build URL:</b> <a href="${env.BUILD_URL}">${env.BUILD_URL}</a></p> <p><b>Console Output:</b> <a href="${env.BUILD_URL}console">View</a></p> """, mimeType: 'text/html' ) } }HTML 형식이다. 보기 좋다. 링크 클릭하면 바로 Jenkins로. Jira 티켓도 자동 생성한다. 반복 실패하는 테스트는. def createJiraTicket(testName, buildUrl) { def issue = [ fields: [ project: [key: 'QA'], summary: "Flaky Test: ${testName}", description: "Test failed in build: ${buildUrl}", issuetype: [name: 'Bug'], priority: [name: 'Medium'], labels: ['flaky-test', 'auto-created'] ] ] def response = httpRequest( url: 'https://jira.company.com/rest/api/2/issue', httpMode: 'POST', contentType: 'APPLICATION_JSON', requestBody: groovy.json.JsonOutput.toJson(issue), authentication: 'jira-credentials' ) }post { failure { script { def failureCount = sh( script: "grep '${testName}' failure_log.txt | wc -l", returnStdout: true ).trim() as Integer if (failureCount >= 3) { createJiraTicket(testName, env.BUILD_URL) } } } }3번 연속 실패하면 티켓 생성. 자동으로. QA 백로그에 쌓인다. 주간 회의 때 리뷰한다.복잡한 파이프라인 관리 전략 파이프라인이 커진다. 300줄 넘어가면 관리가 어렵다. 첫 번째 전략. 공유 라이브러리. // vars/testPipeline.groovy def call(Map config) { pipeline { agent any stages { stage('Setup') { steps { script { setupTestEnvironment(config.testEnv) } } } stage('Test') { steps { script { runTests(config.testType) } } } } } }def setupTestEnvironment(env) { sh "pip install -r requirements-${env}.txt" }def runTests(type) { sh "pytest tests/${type} --junitxml=report.xml" }공통 로직을 라이브러리로 뺐다. Jenkinsfile은 간단해진다. @Library('my-shared-library') _testPipeline( testEnv: 'staging', testType: 'integration' )10줄이다. 읽기 쉽다. 두 번째 전략. 파라미터화. pipeline { parameters { choice( name: 'TEST_SUITE', choices: ['all', 'smoke', 'regression', 'api', 'ui'], description: 'Which test suite to run' ) string( name: 'TEST_ENV', defaultValue: 'staging', description: 'Test environment' ) booleanParam( name: 'SKIP_UI', defaultValue: false, description: 'Skip UI tests' ) choice( name: 'BROWSER', choices: ['chrome', 'firefox', 'both'], description: 'Browser for UI tests' ) } stages { stage('Test') { steps { script { if (params.TEST_SUITE == 'all') { sh 'pytest tests/' } else { sh "pytest tests/${params.TEST_SUITE}" } } } } stage('UI Test') { when { expression { !params.SKIP_UI } } steps { script { if (params.BROWSER == 'both') { parallel( chrome: { sh 'pytest tests/ui -k chrome' }, firefox: { sh 'pytest tests/ui -k firefox' } ) } else { sh "pytest tests/ui -k ${params.BROWSER}" } } } } } }유연하다. 상황마다 다르게 돌린다. 전체 테스트는 밤에. 스모크는 PR마다. 세 번째 전략. 단계별 분리. // Jenkinsfile pipeline { stages { stage('Build') { steps { build job: 'build-job', parameters: [string(name: 'BRANCH', value: env.GIT_BRANCH)] } } stage('Test') { steps { build job: 'test-job', parameters: [ string(name: 'BUILD_ID', value: env.BUILD_ID), string(name: 'TEST_ENV', value: 'staging') ] } } stage('Deploy') { when { branch 'main' } steps { build job: 'deploy-job', parameters: [string(name: 'VERSION', value: env.BUILD_ID)] } } } }빌드, 테스트, 배포 각각 job. 독립적이다. 재사용 가능하다. 테스트만 다시 돌리고 싶으면 test-job만. 배포만 하고 싶으면 deploy-job만. 네 번째 전략. 조건부 실행. stage('Integration Test') { when { anyOf { branch 'develop' branch 'main' changeRequest target: 'main' } } steps { sh 'pytest tests/integration' } }stage('E2E Test') { when { allOf { branch 'main' expression { return env.BUILD_NUMBER.toInteger() % 2 == 0 } } } steps { sh 'pytest tests/e2e' } }stage('Performance Test') { when { triggeredBy 'TimerTrigger' } steps { sh 'pytest tests/performance' } }Integration은 develop, main만. E2E는 main에서 2번에 1번. Performance는 스케줄 트리거만. 리소스 아낀다. 필요한 것만 돌린다. 다섯 번째 전략. 결과 아카이빙. post { always { junit 'reports/*.xml' publishHTML([ reportDir: 'htmlcov', reportFiles: 'index.html', reportName: 'Coverage Report' ]) archiveArtifacts artifacts: 'screenshots/*.png', allowEmptyArchive: true script { def coverage = sh( script: "coverage report | grep TOTAL | awk '{print \$4}'", returnStdout: true ).trim() currentBuild.description = "Coverage: ${coverage}" } } }테스트 결과, 커버리지, 스크린샷 다 저장한다. 나중에 볼 수 있다. 빌드 설명에 커버리지 표시. 목록에서 바로 보인다. 파이프라인 최적화 실전 팁 1년 돌리면서 배운 것들. 캐시 활용 pipeline { agent { docker { image 'python:3.9' args '-v /tmp/pip-cache:/root/.cache/pip' } } stages { stage('Install') { steps { sh 'pip install --cache-dir /root/.cache/pip -r requirements.txt' } } } }pip 캐시 볼륨 마운트. 설치 시간 3분 → 30초. 아티팩트 재사용 stage('Build') { steps { sh 'python setup.py bdist_wheel' stash includes: 'dist/*.whl', name: 'wheel' } }stage('Test') { agent { label 'test-node' } steps { unstash 'wheel' sh 'pip install dist/*.whl' sh 'pytest' } }빌드 한 번. 여러 노드에서 테스트. 중복 빌드 안 한다. 조기 실패 stage('Quick Check') { steps { sh 'pytest tests/smoke -x' // 첫 실패에서 멈춤 } }stage('Full Test') { when { expression { currentBuild.result == null } } steps { sh 'pytest tests/' } }스모크 실패하면 전체 테스트 안 돌린다. 시간 절약. 리트라이 로직 stage('Flaky Test') { steps { retry(3) { sh 'pytest tests/ui' } } }UI 테스트는 3번 재시도. Flaky 때문에. 하지만 남용 안 한다. 진짜 flaky만. 나머지는 고친다. 타임아웃 세분화 stage('Unit Test') { options { timeout(time: 5, unit: 'MINUTES') } steps { sh 'pytest tests/unit' } }stage('E2E Test') { options { timeout(time: 30, unit: 'MINUTES') } steps { sh 'pytest tests/e2e' } }각 단계마다 타임아웃. Unit은 5분. E2E는 30분. 적절하게. 로그 정리 post { always { sh 'pytest --tb=short > test_output.log' script { def log = readFile('test_output.log') if (log.size() > 10000) { writeFile file: 'test_output.log', text: log.take(10000) + "\n... (truncated)" } } archiveArtifacts 'test_output.log' } }로그 너무 길면 잘라낸다. Jenkins 느려진다. 실전 문제 해결 사례 케이스 1: 간헐적 실패 UI 테스트가 10번 중 3번 실패했다. 같은 테스트. 로그 봤다. 타임아웃이었다. 페이지 로딩이 늦을 때. // Before driver.find_element(By.ID, 'submit').click()// After wait = WebDriverWait(driver, 20) element = wait.until(EC.element_to_be_clickable((By.ID, 'submit'))) element.click()명시적 대기 추가. 실패율 0%로. 케이스 2: 메모리 부족 병렬 테스트 돌리면 노드가 죽었다. OOM. // Before parallel { stage('Test 1') { ... } stage('Test 2') { ... } stage('Test 3') { ... } stage('Test 4') { ... } }// After stage('Test Batch 1') { parallel { stage('Test 1') { ... } stage('Test 2') { ... } } }stage('Test Batch 2') { parallel { stage('Test 3') { ... } stage('Test 4') { ... } } }2개씩 배치로. 순차 병렬. 메모리 안정. 케이스 3: 느린 빌드 40분 걸렸다. 너무 길다. 프로파일링 했다. def measureStage(stageName, closure) { def start = System.currentTimeMillis() closure() def duration = System.currentTimeMillis() - start echo "${stageName} took ${duration}ms" }stage('Test') { steps { script { measureStage('Unit') { sh 'pytest tests/unit' } measureStage('Integration') { sh 'pytest tests/integration' } } } }Integration이 25분이었다. DB 초기화 때문. 픽스처 개선. 트랜잭션 롤백으로 변경. 5분으로 단축. 총 15분으로 줄었다. 케이스 4: 환경 차이 로컬은 성공. Jenkins는 실패. 환경이 달랐다. 도커 이미지 통일했다. FROM python:3.9RUN apt-get update && apt-get install -y \ chromium \ chromium-driver \ firefox-esr \ && rm -rf /var/lib/apt/lists/*COPY requirements.txt . RUN pip install -r requirements.txtWORKDIR /app로컬도 이 이미지로. Jenkins도. 결과 동일해졌다. 모니터링과 개선 파이프라인도 모니터링한다. 메트릭 수집. def collectMetrics() { def metrics = [ build_number: env.BUILD_NUMBER, duration: currentBuild.duration, result: currentBuild.result, test_count: sh(script: "grep 'tests passed' report.xml | wc -l", returnStdout: true).trim(), failed_count: sh(script: "grep 'FAILED' report.xml | wc -l", returnStdout: true).trim() ] def json = groovy.json.JsonOutput.toJson(metrics) sh "echo '${json}' >> /metrics/pipeline_metrics.jsonl" }post { always { script { collectMetrics() } } }매 빌드마다 기록. duration, result, test count. 주간 분석한다. 트렌드 본다. import pandas as pddf = pd.read_json('pipeline_metrics.jsonl', lines=True)# 평균 빌드 시간 print(f"Average duration: {df['duration'].mean() / 1000 / 60:.2f} minutes")# 실패율 failure_rate = len(df[df['result'] == 'FAILURE']) / len(df) * 100 print(f"Failure rate: {failure_rate:.2f}%")# 가장 느린 빌드 slowest = df.nlargest(5, 'duration') print(slowest[['build_number', 'duration', 'test_count']])데이터 기반 개선. 느린 빌드 찾고. 실패율 높은 테스트 찾고. 대시보드도 만들었다. Grafana로. post { always { influxDbPublisher( selectedTarget: 'jenkins-metrics', customProjectName: env.JOB_NAME, customData: [ build_duration: currentBuild.duration, test_count: testCount, failed_count: failedCount ] ) } }InfluxDB에 쌓고. Grafana로 시각화. 실시간 보인다. 지금 돌아가는 파이프라인 우리 메인 파이프라인이다. 매일 돌아간다. @Library('qa-shared-lib') _pipeline { agent none parameters { choice(name: 'ENVIRONMENT', choices: ['staging', 'production']) booleanParam(name: 'RUN_PERFORMANCE', defaultValue: false) } options { timeout(time: 1, unit: 'HOURS') buildDiscarder(logRotator(numToKeepStr: '50')) } stages { stage('Parallel Setup') { parallel { stage('Lint') { agent { docker 'python:3.9' } steps { sh 'pip install flake8 pylint' sh 'flake8 tests/' } } stage('Security Scan') { agent { docker 'python:3.9' } steps { sh 'pip install bandit' sh 'bandit -r tests/' } } } } stage('Build') { agent { docker 'python:3.9' } steps { sh 'pip install -r requirements.txt' sh 'python setup.py bdist_wheel' stash includes: 'dist/*.whl', name: 'package' } } stage('Test') { parallel { stage('Unit') { agent { docker 'python:3.9' } steps { unstash 'package' sh 'pip install dist/*.whl' sh 'pytest tests/unit --junitxml=unit.xml' } post { always { junit 'unit.xml' } } } stage('Integration') { agent { docker { image 'python:3.9' args '--network test-network' } } steps { unstash 'package' sh 'pip install dist/*.whl' sh 'pytest tests/integration --junitxml=integration.xml' } post { always { junit 'integration.xml' } } } stage('UI') { agent { label 'selenium-node' } steps { unstash 'package' sh 'pip install dist/*.whl' sh '

'자동화했는데 왜 버그가 나왔어요?': 내 억울함을 설득하기

'자동화했는데 왜 버그가 나왔어요?': 내 억울함을 설득하기

"자동화했는데 왜 버그가 나왔어요?": 내 억울함을 설득하기 또 이 질문이다 오늘 아침 스탠드업 미팅. CTO가 물었다. "자동화 커버리지 80%잖아. 그럼 왜 프로덕션에서 버그 나와?" 억울했다. 진짜 억울했다. 설명했다. 자동화는 회귀 테스트용이고, 80%는 단위 테스트 기준이고, 이번 버그는 엣지 케이스였다고. CTO가 고개를 끄덕였다. 이해한 건지 모르겠다. 회의 끝나고 개발팀장이 슬랙을 보냈다. "자동화 더 늘려야 할까요?" 아니다. 문제는 자동화 양이 아니다. 이해의 문제다. 자동화 QA 4년 했다. 이 질문을 100번은 들었다. 이제는 설득하는 법을 알아야 한다. 내 시간을 위해서라도.자동화가 뭔지부터 다시 가장 큰 오해. "자동화했으면 모든 버그를 잡는다." 틀렸다. 자동화 테스트는 내가 짠 시나리오만 검증한다. 로그인 시나리오를 100개 짜면, 그 100개만 체크한다. 101번째 방법? 못 잡는다. 예를 들어보자. 우리 서비스에 로그인 자동화 테스트가 있다.올바른 ID/PW 입력 → 성공 틀린 PW 입력 → 실패 메시지 빈칸 입력 → 경고 메시지 SQL 인젝션 시도 → 차단이게 다 통과했다. 초록불이다. 그런데 지난주 프로덕션에서 버그가 났다. 특수문자가 30개 이상 들어간 비밀번호를 입력하면 서버가 멈췄다. 내 테스트에 없었다. 특수문자 3개는 테스트했다. 30개는 생각 못 했다. 이게 자동화의 한계다. 내가 상상한 것만 테스트된다. 경영진은 이걸 이해 못 한다. "80% 커버리지면 충분하지 않아?" 그 80%가 뭔지 모른다. 코드 라인 커버리지인지, 기능 커버리지인지, 유저 시나리오 커버리지인지. 우리 회사는 코드 라인 기준이다. 코드의 80%를 테스트 코드가 실행했다는 뜻. 그게 모든 버그를 잡는다는 뜻은 아니다. 개발자들은 이해한다. 테스트 커버리지 100%도 버그를 보장 못 한다는 걸. 근데 경영진은 숫자만 본다. 80%면 A학점이라고 생각한다.매뉴얼 테스트가 필요한 이유 두 번째 오해. "자동화했으면 매뉴얼 QA는 필요 없다." 이건 더 위험한 생각이다. 지난달 우리 회사가 신입 QA 채용을 안 하려고 했다. "자동화 있는데 왜 필요해?" 내가 막았다. 1시간짜리 문서를 만들어서 설명했다. 자동화는 반복 작업에 강하다.회귀 테스트: 매 배포마다 똑같은 시나리오 체크 스모크 테스트: 빌드 후 기본 기능 확인 API 테스트: 엔드포인트 검증근데 자동화가 못하는 게 더 많다. 탐색적 테스트 유저가 어떻게 쓸지 모르는 상황. 아무렇게나 눌러보는 거. 이건 스크립트로 못 짠다. 지난주 신기능 출시 전에 매뉴얼 QA 동료가 찾았다. 뒤로가기 버튼을 5번 연속 누르면 앱이 튕겼다. 내 자동화 스크립트엔 "뒤로가기 2번"까지만 있었다. 누가 5번을 누르나? 유저가 누른다. 짜증나서 막 누른다. UX/UI 테스트 버튼이 너무 작다. 폰트가 안 읽힌다. 색상이 이상하다. 이건 사람 눈으로 봐야 한다. Selenium으로 "버튼이 존재하는가?"는 체크할 수 있다. "버튼이 예쁜가?"는 못 한다. 작년에 디자인 개편했을 때. 자동화 테스트는 다 통과했다. 근데 매뉴얼 QA가 발견했다. 다크모드에서 글자가 안 보였다. 자동화는 "글자가 렌더링되는가?"만 체크했다. "글자가 보이는가?"는 체크 안 했다. 신규 기능 테스트 새 기능이 나오면 자동화 스크립트부터 짜야 한다. 그 전에 매뉴얼로 먼저 테스트한다. 뭘 자동화할지 모르니까. 경영진한테 설명했다. "자동화는 알고 있는 걸 반복하는 거예요. 매뉴얼은 모르는 걸 찾는 거고요." 통했다. 신입 QA 2명 뽑았다. 자동화의 실제 가치 그럼 자동화는 왜 하나? 가치가 없는 건 아니다. 명확한 가치가 있다. 시간 절약 회귀 테스트를 매뉴얼로 하면 3일 걸린다. 자동화로 하면 3시간이다. 이건 큰 차이다. 매주 배포하는 우리 회사. 매뉴얼로만 하면 QA팀이 테스트만 한다. 자동화 덕분에 다른 일을 한다. 일관성 사람은 실수한다. 피곤하면 놓친다. 점심 먹고 오면 집중력이 떨어진다. 스크립트는 안 그렇다. 새벽 3시에 돌려도 똑같이 체크한다. 작년에 우리가 찾았다. 매뉴얼 테스트에서 5번 중 1번은 실수가 있었다. 체크리스트 항목을 건너뛰거나 결과를 잘못 기록하거나. 자동화는 그런 실수가 없다. 짠 대로 돌아간다. 빠른 피드백 개발자가 코드를 푸시한다. 5분 뒤 Jenkins가 자동화 테스트를 돌린다. 10분 뒤 결과가 슬랙으로 온다. 깨진 부분이 있으면 바로 안다. 개발자가 지금 뭐 하는지 기억하는 시점에. 매뉴얼로 하면? 다음날 아침에 QA가 출근해서 테스트한다. 개발자는 이미 다른 작업 중이다. 컨텍스트 스위칭 비용이 크다. 반복 작업에서 해방 QA의 가장 큰 스트레스. 똑같은 테스트를 100번 하는 거. 로그인 테스트. 기능이 안 바뀌었는데 매번 해야 한다. 1년 동안 200번 했다. 자동화하면? 스크립트가 한다. 나는 새로운 걸 테스트한다. 우리 팀 후배가 말했다. "자동화 배우고 나서 일이 재미있어졌어요. 반복 작업이 줄어서요." 이게 자동화의 진짜 가치다. 모든 버그를 잡는 게 아니라, 알고 있는 버그를 효율적으로 체크하는 거다.경영진 설득법 구체적으로 어떻게 설득하나? 1. 숫자로 말한다 경영진은 숫자를 좋아한다. 추상적 설명 싫어한다. 잘못된 설명: "자동화는 한계가 있어요." 올바른 설명: "자동화로 회귀 테스트 200개 케이스를 체크합니다. 하지만 가능한 유저 시나리오는 10,000개 이상이에요." 잘못된 설명: "매뉴얼 테스트도 필요해요." 올바른 설명: "작년에 프로덕션 버그 30개 중 18개를 매뉴얼 QA가 찾았어요. 자동화는 12개였고요." 내가 만든 대시보드가 있다. 자동화 테스트 커버리지: 코드 라인 80% 매뉴얼 테스트 커버리지: 유저 시나리오 45% 버그 발견율: 자동화 40%, 매뉴얼 40%, 프로덕션 20%마지막 20%가 중요하다. "자동화해도 20%는 프로덕션에서 나옵니다." 2. 비용으로 환산한다 "자동화 더 늘리면 버그가 줄어들까요?" 아니다. 수익률이 떨어진다. 현재 자동화 커버리지 80%. 여기서 90%로 올리려면?추가 인력 1명 필요 3개월 소요 연간 유지보수 시간 2배그래서 얻는 건? 버그 발견율 5% 상승 예상. 비용 대비 효과가 안 맞는다. 차라리 그 시간에 매뉴얼 QA를 더 하는 게 낫다. 이렇게 설명하면 경영진이 이해한다. 자동화가 만능이 아니라는 걸. 3. 사례를 든다 추상적 설명은 안 먹힌다. 구체적 사례가 필요하다. "지난달 결제 버그 기억하세요? 자동화 테스트는 통과했어요. 왜냐면 '결제 성공' 시나리오만 있었거든요. 근데 실제 버그는 '결제 중 네트워크 끊김'이었어요. 이건 매뉴얼 QA가 찾았습니다." "작년 UI 개편 때요. 자동화는 100% 통과했어요. 근데 유저 불만이 쏟아졌죠. 버튼이 너무 작아서요. 자동화는 '버튼이 클릭 가능한가?'만 체크했거든요." 사례가 3개 이상 쌓이면 패턴이 보인다. 경영진이 납득한다. 4. 대안을 제시한다 문제만 지적하면 안 된다. 해결책도 줘야 한다. "자동화 커버리지를 더 올리는 대신, 리스크 기반 테스트를 강화하겠습니다."핵심 기능: 자동화 + 매뉴얼 둘 다 일반 기능: 자동화만 레거시 기능: 샘플링 테스트"탐색적 테스트 시간을 주 4시간으로 정례화하겠습니다."QA 전원이 동시에 신규 기능을 막 써보는 시간 발견된 버그는 즉시 자동화 스크립트에 추가"테스트 피라미드를 재정비하겠습니다."단위 테스트: 70% (개발자 담당) 통합 테스트: 20% (QA 자동화) E2E 테스트: 10% (QA 매뉴얼)대안이 있으면 경영진이 결정하기 쉽다. 개발팀 설득법 개발자들은 다르게 접근한다. 1. 코드로 보여준다 개발자는 말보다 코드를 믿는다. Flaky 테스트 문제가 있었다. 간헐적으로 실패하는 테스트. 개발팀이 물었다. "테스트 문제 아니에요?" 맞다. 테스트 코드 문제였다. # 문제 있는 코드 element = driver.find_element(By.ID, "submit") element.click()# 개선한 코드 wait = WebDriverWait(driver, 10) element = wait.until( EC.element_to_be_clickable((By.ID, "submit")) ) element.click()코드를 보여주니까 이해했다. "아, 로딩 타이밍 문제였네요." 테스트 코드도 코드다. 버그가 있다. 리팩토링이 필요하다. 개발자들은 이걸 이해한다. 2. 테스트 피라미드를 그린다 개발자들은 아키텍처를 좋아한다. 추상적 구조를 좋아한다. 화이트보드에 피라미드를 그렸다. /\ E2E (느림, 비쌈, 10개) / \ / \ Integration (중간, 50개) / \ /________\ Unit (빠름, 쌈, 500개)"E2E로 모든 걸 테스트하면 너무 느려요. 빌드 시간이 1시간 넘어요." 개발자들이 고개를 끄덕였다. "맞아요. CI 너무 느려지면 안 되죠." "그래서 단위 테스트를 개발자가 더 짜주시면, QA는 통합 테스트에 집중할게요." 이렇게 제안하니까 협력이 됐다. 3. 버그 우선순위를 함께 정한다 개발자와 QA가 싸우는 이유. 우선순위가 다르니까. 개발자: "이건 엣지 케이스예요. 무시해도 돼요." QA: "아니요. 치명적 버그예요." 이제는 함께 정한다. 매주 수요일 30분. 버그 티켓을 놓고 투표한다.Critical: 프로덕션 즉시 롤백 필요 High: 다음 배포 전 수정 필요 Medium: 다다음 배포 Low: 백로그의견이 다르면 토론한다. 데이터를 본다. 이 버그가 몇 명한테 영향을 주나? 얼마나 자주 발생하나? 개발자들이 납득하면 수정을 빨리 한다. 우선순위 싸움이 없으니까. 4. 자동화 한계를 함께 인정한다 개발자도 안다. 테스트가 완벽할 수 없다는 걸. "이번 버그는 제 자동화 테스트가 못 잡았어요. 다음부터는 이 케이스도 추가하겠습니다." 솔직하게 인정하니까 개발자도 솔직해졌다. "저도 단위 테스트를 더 꼼꼼히 짤게요. 이 함수는 테스트 커버리지가 낮았어요." 서로의 한계를 인정하면 협력이 된다. 책임 전가가 아니라 함께 개선한다. 내가 배운 것들 4년 동안 자동화하면서 깨달은 것들. 자동화는 도구다. 목표가 아니다. 초반에 나는 자동화 커버리지에 집착했다. 80%, 90%, 95%... 근데 중요한 건 버그를 줄이는 거다. 커버리지 숫자가 아니라. 지금은 전략적으로 접근한다. 뭘 자동화하고 뭘 매뉴얼로 하나?자주 바뀌는 UI: 매뉴얼 핵심 API: 자동화 복잡한 비즈니스 로직: 둘 다완벽한 테스트는 없다 처음엔 모든 버그를 잡으려고 했다. 불가능하다는 걸 배웠다. QA의 목표는 버그 제로가 아니다. 리스크 관리다. 치명적 버그는 출시 전에 잡는다. 사소한 버그는 우선순위를 낮춘다. 자원이 한정돼 있으니까. 설득은 데이터로 한다 "자동화가 필요해요"보다 "자동화로 회귀 테스트 시간을 3일에서 3시간으로 줄였어요"가 낫다. "매뉴얼 QA도 필요해요"보다 "작년 프로덕션 버그의 60%를 매뉴얼 QA가 찾았어요"가 낫다. 숫자가 없으면 의견이다. 숫자가 있으면 사실이다. QA는 품질 파수꾼이 아니라 품질 조력자다 예전엔 내가 게이트키퍼라고 생각했다. "이 버그 있으면 출시 안 돼요." 지금은 조력자라고 생각한다. "이 리스크가 있어요. 함께 결정해요." 품질은 QA 혼자 만드는 게 아니다. 개발자, 기획자, 디자이너 모두의 책임이다. QA는 그걸 보이게 만드는 사람이다. 마무리: 억울하지 않으려면 "자동화했는데 왜 버그 나와?" 이제 이 질문이 안 억울하다. 예상된 질문이니까. 대답이 준비돼 있다. "자동화는 알려진 시나리오를 체크합니다. 이번 버그는 새로운 패턴이었어요. 지금 이 케이스를 자동화 스크립트에 추가했습니다." 데이터를 보여준다.이번 스프린트 자동화 테스트: 1,234개 통과, 3개 실패 매뉴얼 테스트: 신규 기능 45개 체크, 버그 7개 발견 프로덕션 버그: 2개 (하나는 인프라, 하나는 엣지 케이스)"자동화 커버리지 80%는 코드 라인 기준입니다. 유저 시나리오 기준으로는 약 40%예요. 나머지는 매뉴얼과 프로덕션 모니터링으로 커버합니다." 명확하게 설명하면 이해한다. 경영진도, 개발자도. 억울할 필요 없다. 자동화의 가치와 한계를 모두 보여주면 된다. QA 자동화 엔지니어의 일은 모든 버그를 막는 게 아니다. 효율적으로 품질을 관리하는 거다. 그걸 설득하는 것도 내 일이다.오늘도 자동화 스크립트 3개 추가했다. 지난주 놓친 케이스들. 완벽하진 않지만, 조금씩 나아진다.

Appium으로 모바일 자동화 시작하기: 안드로이드와 iOS의 차이

Appium으로 모바일 자동화 시작하기: 안드로이드와 iOS의 차이

Appium 시작하고 3일 만에 깨달은 것 Appium 공부 시작했다. 회사에서 모바일 앱 자동화하라고 했다. 첫날은 설치만 했다. 두 번째 날은 에러만 봤다. 셋째 날 실행됐는데 안드로이드만 됐다. iOS는 또 다른 세계더라. 크로스 플랫폼이라고 했다. 같은 코드로 두 플랫폼 테스트 가능하다고 했다. 반은 맞다. 반은 거짓말이다. 기본 개념은 같다. 세부 구현은 완전히 다르다. 지금 Appium 3개월 썼다. 안드로이드와 iOS 둘 다 돌린다. 아직도 매일 새로운 이슈 만난다. 이게 모바일 자동화의 현실이다.안드로이드 시작: 상대적으로 쉬운 편 안드로이드부터 했다. 설정이 iOS보다 직관적이다. Android Studio 깔고, SDK 설치하고, AVD 만들면 된다. 에뮬레이터 띄우는 데 10분 걸렸다. UIAutomator2가 기본이다. 요소 찾기가 편하다. resource-id, content-desc, text로 찾으면 대부분 된다. driver.find_element(AppiumBy.ID, "com.app:id/button") 이렇게. 앱 빌드도 간단하다. APK 파일 하나면 끝이다. 개발자한테 디버그 APK 받아서 adb install app.apk 치면 설치된다. 권한 문제도 별로 없다. 실제 코드 짜보니까 금방 돌았다. driver.find_element(AppiumBy.ID, "username").send_keys("test") driver.find_element(AppiumBy.ID, "password").send_keys("1234") driver.find_element(AppiumBy.ID, "login_button").click()이거 5분 만에 작동했다. 문제는 버전이다. 안드로이드 파편화가 심하다. API 28, 29, 30, 31 다 테스트해야 한다. 에뮬레이터 5개 만들었다. 디스크 용량 50GB 날아갔다. 스크롤도 플랫폼마다 다르다. 새로운 UiScrollable 쓰면 되는데 문법이 특이하다. driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR, 'new UiScrollable(new UiSelector()).scrollIntoView(text("원하는텍스트"))') 처음엔 이게 뭔가 싶었다. 그래도 안드로이드는 괜찮다. 디버깅이 쉽다. Appium Inspector로 요소 바로 확인된다. xpath 복잡해도 일단 동작한다.iOS 진입: 벽이 높다 iOS 시작했다. 막혔다. 설정부터 복잡하다. Xcode 깔고, Command Line Tools 설정하고, WebDriverAgent 빌드해야 한다. 첫날 4시간 걸렸다. 제대로 안 됐다. Provisioning Profile이 문제였다. 서명 이슈다. 무료 Apple ID로는 7일마다 재빌드해야 한다. 회사 계정 쓰면 1년인데 설정이 어렵다. 시뮬레이터는 빠르다. 그런데 Mac만 된다. 윈도우 개발자는 테스트 못 돌린다. 우리 팀 절반이 윈도우 쓴다. 문제다. XCUITest가 기본 드라이버다. 요소 찾기가 다르다. 안드로이드처럼 ID 없다. accessibility identifier 써야 한다. driver.find_element(AppiumBy.ACCESSIBILITY_ID, "LoginButton") 개발자가 accessibility 안 넣으면 못 찾는다. 처음엔 xpath만 썼다. //XCUIElementTypeButton[@name="Login"] 느리고 불안정하다. 개발자한테 요청했다. 버튼, 텍스트 필드 타입이 XCUIElement로 시작한다. XCUIElementTypeButton, XCUIElementTypeTextField 이런 식이다. 안드로이드랑 완전히 다르다. 코드 재사용 안 된다. 실기기는 더 복잡하다. UDID 등록해야 한다. idevice_id -l 로 확인하고 capabilities에 넣는다. 케이블로 연결해야 한다. 무선은 불안정하다. 앱 설치도 까다롭다. .app 파일이 시뮬레이터용이다. 실기기는 .ipa 파일 필요하다. 서명도 맞아야 한다. 개발자한테 Ad-hoc 빌드 받아야 한다. iOS 13 이후로 권한 팝업이 많다. "Allow 'App' to access your location?" 이런 거. 자동으로 처리 안 된다. 직접 클릭 코드 짜야 한다. try: alert = driver.find_element(AppiumBy.XPATH, "//XCUIElementTypeButton[@name='Allow']") alert.click() except: pass예외 처리 안 하면 테스트 멈춘다. 한 달 걸렸다. iOS 자동화 제대로 돌리는 데. 안드로이드의 3배 시간 들었다. 아직도 가끔 서명 만료로 깨진다. 플랫폼별 Desired Capabilities: 설정이 다르다 안드로이드 capabilities는 간단하다. android_caps = { "platformName": "Android", "platformVersion": "12", "deviceName": "Pixel_5_API_31", "app": "/path/to/app.apk", "automationName": "UiAutomator2", "appPackage": "com.example.app", "appActivity": ".MainActivity" }appPackage랑 appActivity 넣으면 된다. APK 안 넣고 이미 설치된 앱 실행 가능하다. adb shell pm list packages 로 패키지명 확인한다. iOS는 복잡하다. ios_caps = { "platformName": "iOS", "platformVersion": "16.0", "deviceName": "iPhone 14", "app": "/path/to/app.app", "automationName": "XCUITest", "bundleId": "com.example.app", "xcodeOrgId": "TEAMID", "xcodeSigningId": "iPhone Developer", "udid": "device-udid-here" }실기기는 UDID 필수다. xcodeOrgId도 넣어야 한다. 서명 관련 설정 빠뜨리면 에러 난다. 처음엔 뭐가 필요한지 몰라서 하나씩 추가했다. autoAcceptAlerts 옵션이 있다. 권한 팝업 자동 승인한다. "autoAcceptAlerts": True근데 완벽하지 않다. 커스텀 팝업은 직접 처리해야 한다. noReset 옵션도 중요하다. "noReset": TrueTrue면 앱 상태 유지한다. 로그인 풀리지 않는다. False면 매번 초기화한다. 깨끗하지만 느리다. 테스트 목적에 따라 바꾼다. 로그인 테스트는 noReset False. 로그인 후 기능 테스트는 True. 두 플랫폼 동시에 돌리려면 코드 분기해야 한다. if platform == "android": caps = android_caps else: caps = ios_caps driver = webdriver.Remote("http://localhost:4723", caps)처음엔 하나로 합치려 했다. 안 됐다. 공통 부분만 베이스로 두고 나머지 분리했다. 유지보수가 더 쉬웠다.에뮬레이터 vs 실기기: 무엇을 선택할까 에뮬레이터부터 시작했다. 설정이 쉽다. Android Studio AVD Manager로 몇 번 클릭하면 만들어진다. Xcode 시뮬레이터도 바로 뜬다. 속도가 빠르다. 테스트 실행이 실기기보다 2배 빠르다. CI/CD 파이프라인에 넣기 좋다. Jenkins에서 돌리는데 문제없다. 비용이 없다. 무제한으로 만들 수 있다. 안드로이드 에뮬레이터 5개 띄워서 병렬 테스트 돌린다. 실기기면 불가능하다. 하지만 한계가 있다. 실제 사용자 환경이 아니다. 카메라, GPS, 블루투스 테스트 안 된다. 하드웨어 의존 기능은 실기기 필수다. 성능도 다르다. 에뮬레이터가 너무 빠르다. 실기기에서는 느린 애니메이션 기다려야 하는데 에뮬레이터는 바로 넘어간다. 타이밍 이슈 생긴다. 네트워크도 차이 난다. 에뮬레이터는 호스트 네트워크 쓴다. 실기기는 WiFi나 LTE다. 속도 다르다. 네트워크 지연 테스트는 실기기로 해야 한다. 우리 팀 전략은 이렇다.개발 중: 에뮬레이터로 빠르게 테스트 PR 머지 전: 실기기 1대로 스모크 테스트 릴리즈 전: 실기기 여러 대로 풀 테스트실기기 관리가 일이다. 충전 신경 써야 한다. 케이블 빠지면 테스트 멈춘다. 회사 돈 들여서 기기 팜 만들었다. 안드로이드 실기기 3대, iOS 2대 있다. 삼성, LG, 픽셀 / 아이폰 12, 13 주요 버전 커버한다. USB 허브로 다 연결했다. 동시 테스트 가능하다. Appium Grid 쓰면 병렬로 돌린다. 5대 동시 실행하면 30분 → 6분 걸린다. 기기 이름 라벨 붙였다. 헷갈린다. "Android_Samsung_S21", "iOS_iPhone13" 코드에서 deviceName으로 지정한다. 처음엔 실기기만 고집했다. 시간 오래 걸렸다. 지금은 적절히 섞는다. 상황에 맞게. 완벽한 정답은 없다. 요소 찾기 전략: ID vs Accessibility vs XPath 안드로이드는 resource-id가 최고다. driver.find_element(AppiumBy.ID, "com.app:id/login_button")빠르고 안정적이다. 화면 구조 바뀌어도 동작한다. 개발자한테 고유 ID 달라고 한다. 처음엔 안 넣어줬다. 설득했다. "자동화 안 되면 수동으로 계속 테스트해야 합니다" 그랬더니 넣어줬다. text로 찾는 건 위험하다. driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR, 'text("로그인")')텍스트 바뀌면 깨진다. 다국어 지원하면 더 문제다. 영어 버전 테스트는 "Login"으로 바꿔야 한다. content-desc도 좋다. 접근성 레이블이다. driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_button")안드로이드와 iOS 둘 다 쓸 수 있다. 크로스 플랫폼 코드 작성할 때 유용하다. iOS는 accessibility identifier가 표준이다. driver.find_element(AppiumBy.ACCESSIBILITY_ID, "LoginButton")개발자가 accessibilityIdentifier 속성 넣어줘야 한다. Swift 코드로 이렇게 넣는다고 한다. button.accessibilityIdentifier = "LoginButton"없으면 XPath 써야 한다. XPath는 최후의 수단이다. driver.find_element(AppiumBy.XPATH, "//XCUIElementTypeButton[@name='로그인']")느리다. 화면 구조 바뀌면 바로 깨진다. 3초 걸리던 게 XPath 쓰면 10초 걸린다. 복잡한 XPath는 더 나쁘다. driver.find_element(AppiumBy.XPATH, "//XCUIElementTypeOther[2]/XCUIElementTypeOther[1]/XCUIElementTypeButton[3]")이건 유지보수 불가능하다. 절대 쓰지 말 것. Class name으로도 찾는다. driver.find_element(AppiumBy.CLASS_NAME, "android.widget.Button")여러 개 나온다. 인덱스로 접근해야 한다. buttons = driver.find_elements(AppiumBy.CLASS_NAME, "android.widget.Button") buttons[2].click()위험하다. 버튼 순서 바뀌면 끝이다. 우리 팀 규칙은 이렇다.먼저 ID 또는 accessibility identifier 확인 없으면 개발자한테 요청 급하면 임시로 XPath, 나중에 리팩토링 text는 상수로 관리페이지 오브젝트 패턴 쓴다. class LoginPage: USERNAME = (AppiumBy.ID, "username") PASSWORD = (AppiumBy.ID, "password") LOGIN_BTN = (AppiumBy.ACCESSIBILITY_ID, "login_button") def login(self, user, pw): self.driver.find_element(*self.USERNAME).send_keys(user) self.driver.find_element(*self.PASSWORD).send_keys(pw) self.driver.find_element(*self.LOGIN_BTN).click()로케이터 한 곳에 모았다. 변경 쉽다. 테스트 코드는 깔끔하다. login_page = LoginPage(driver) login_page.login("test", "1234")처음엔 테스트 코드에 직접 썼다. 지옥이었다. 화면 하나 바뀌면 10개 파일 수정했다. 리팩토링하는 데 2주 걸렸다. 후회했다. 플랫폼별 제스처: 스와이프, 탭, 스크롤 터치 제스처가 핵심이다. 모바일은 마우스 없다. 안드로이드와 iOS 구현 방법이 다르다. 안드로이드 스와이프는 TouchAction 쓴다. from appium.webdriver.common.touch_action import TouchActionaction = TouchAction(driver) action.press(x=500, y=1000).wait(1000).move_to(x=500, y=300).release().perform()좌표 기반이다. 화면 크기마다 다르다. 상대 좌표로 바꿨다. size = driver.get_window_size() start_x = size['width'] * 0.5 start_y = size['height'] * 0.8 end_y = size['height'] * 0.2action.press(x=start_x, y=start_y).wait(1000).move_to(x=start_x, y=end_y).release().perform()모든 기기에서 동작한다. iOS는 좀 다르다. W3C Actions API 쓴다. driver.execute_script("mobile: swipe", {"direction": "up"})간단하다. 하지만 커스터마이징 어렵다. 정교한 스와이프는 여전히 좌표 써야 한다. from selenium.webdriver.common.actions import interaction from selenium.webdriver.common.actions.action_builder import ActionBuilder from selenium.webdriver.common.actions.pointer_input import PointerInputactions = ActionBuilder(driver, mouse=PointerInput(interaction.POINTER_TOUCH, "touch")) actions.pointer_action.move_to_location(500, 1000) actions.pointer_action.pointer_down() actions.pointer_action.pause(1) actions.pointer_action.move_to_location(500, 300) actions.pointer_action.release() actions.perform()복잡하다. 처음엔 이해 안 됐다. 스크롤은 더 까다롭다. 안드로이드는 UiScrollable 쓴다. driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR, 'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(text("찾을텍스트"))')텍스트 찾을 때까지 스크롤한다. iOS는 그런 게 없다. 직접 구현해야 한다. def scroll_to_element(driver, element_id): max_swipes = 10 for i in range(max_swipes): try: element = driver.find_element(AppiumBy.ACCESSIBILITY_ID, element_id) return element except: size = driver.get_window_size() driver.execute_script("mobile: scroll", {"direction": "down"}) raise Exception(f"Element {element_id} not found after {max_swipes} swipes")무한 스크롤은 더 어렵다. 이전 요소 저장하고 같으면 멈춘다. prev_page_source = "" while True: current_page_source = driver.page_source if prev_page_source == current_page_source: break # 스크롤 로직 prev_page_source = current_page_source탭과 클릭은 거의 같다. element.click()가끔 안 될 때 있다. 좌표로 직접 탭한다. location = element.location size = element.size x = location['x'] + size['width'] / 2 y = location['y'] + size['height'] / 2action = TouchAction(driver) action.tap(x=x, y=y).perform()더블 탭은 빠르게 두 번 탭한다. action.tap(x=x, y=y).wait(100).tap(x=x, y=y).perform()롱 프레스는 press().wait().release()다. action.press(x=x, y=y).wait(2000).release().perform()2초 누른다. 컨텍스트 메뉴 나온다. 핀치 줌은 복잡하다. 두 손가락 시뮬레이션이다. 거의 안 쓴다. 필요하면 개발자한테 버튼 만들라고 한다. 제스처 코드는 헬퍼 함수로 만들었다. class GestureHelper: @staticmethod def swipe_up(driver): # 구현 @staticmethod def swipe_down(driver): # 구현 @staticmethod def scroll_to_text(driver, text): # 구현테스트에서 이렇게 쓴다. GestureHelper.swipe_up(driver) GestureHelper.scroll_to_text(driver, "설정")깔끔하다. 재사용 쉽다. 크로스 플랫폼 코드 작성: 공통과 분기 처음엔 한 코드로 두 플랫폼 테스트하려 했다. 불가능했다. 요소 찾기부터 다르다. 공통 부분만 추출했다. 테스트 로직이다. def test_login(driver): login_page = get_login_page(driver) login_page.enter_username("test") login_page.enter_password("1234") login_page.click_login() assert login_page.is_logged_in()get_login_page()가 플랫폼 구분한다. def get_login_page(driver): platform = driver.capabilities['platformName'] if platform == 'Android': return AndroidLoginPage(driver) else: return IOSLoginPage(driver)각 페이지 클래스는 같은 인터페이스 구현한다. class LoginPageBase: def enter_username(self, username): raise NotImplementedError def enter_password(self, password): raise NotImplementedError def click_login(self): raise NotImplementedError def is_logged_in(self): raise NotImplementedErrorclass AndroidLoginPage(LoginPageBase): USERNAME = (AppiumBy.ID, "com.app:id/username") PASSWORD = (AppiumBy.ID, "com.app:id/password") LOGIN_BTN = (AppiumBy.ID, "com.app:id/login") def enter_username(self, username): self.driver.find_element(*self.USERNAME).send_keys(username) # ...class IOSLoginPage(LoginPageBase): USERNAME = (AppiumBy.ACCESSIBILITY_ID, "UsernameField") PASSWORD = (AppiumBy.ACCESSIBILITY_ID, "PasswordField") LOGIN_BTN = (AppiumBy.ACCESSIBILITY_ID, "LoginButton") def enter_username(self, username): self.driver.find_element(*self.USERNAME).send_keys(username) # ...로케이터만 다르다. 메서드는 동일하다. 테스트 코드는 플랫폼 몰라도 된다. 제스처도 분기한다. class GestureHelper: @staticmethod def swipe_up(driver): platform = driver.capabilities['platformName'] if platform == 'Android': # 안드로이드 구현 else: # iOS 구현처음엔 if문 남발했다. 코드가 더러웠다. 지금은 클래스 분리했다. 깔끔하다. 설정 파일도 나눴다. # config/android.py ANDROID_CAPS = { "platformName": "Android", # ... }# config/ios.py IOS_CAPS = { "platformName": "iOS", # ... }pytest fixture로 드라이버 생성한다. @pytest.fixture def driver(request): platform = request.config.getoption("--platform") if platform == "android": caps = ANDROID_CAPS else: caps = IOS_CAPS driver = webdriver.Remote("http://localhost:4723", caps) yield driver driver.quit()명령어로 플랫폼 지정한다. pytest --platform=android pytest --platform=ios양쪽 다 돌리려면 parameterize 쓴다. @pytest.mark.parametrize("platform", ["android", "ios"]) def test_login(platform): driver = get_driver(platform) # 테스트 로직CI/CD에서는 병렬로 돌린다. jobs: android: runs-on: ubuntu-latest steps: - run: pytest --platform=android ios: runs-on: macos-latest steps: - run: pytest --platform=ios안드로이드는 우분투에서, iOS는 맥에서 돌린다. 동시에 실행된다. 시간 절반으로 줄었다. 완전한 크로스 플랫폼은 불가능하다. 70% 정도 공통화 가능하다. 나머지는 분기해야 한다. 처음부터 이렇게 설계하면 유지보수 쉽다. 실제로 겪은 문제들과 해결 키보드 문제가 제일 짜증났다. 안드로이드는 키보드 올라오면 화면 가린다. 아래쪽 버튼 못 찾는다. driver.hide_keyboard() 쓴다. element.send_keys("text") driver.hide_keyboard() driver.find_element(AppiumBy.ID, "submit").click()iOS는 키보드 내리기 어렵다. 화면 빈 곳 탭하거나 Return 키 쳐야 한다. driver.find_element(AppiumBy.XPATH, "//XCUIElementTypeButton[@name='Return']").click

Pytest fixture로 테스트 데이터 관리하기: 야근 줄이는 법

Pytest fixture로 테스트 데이터 관리하기: 야근 줄이는 법

Pytest fixture로 테스트 데이터 관리하기: 야근 줄이는 법 야근의 시작 금요일 오후 6시. 퇴근 30분 전이다. "J님, 테스트 스위트 돌리는 데 왜 이렇게 오래 걸려요?" 개발팀장 질문이다. 답은 알고 있다. 매번 디비 초기화하느라 30분씩 날린다. "최적화 좀 해보겠습니다." 그렇게 주말을 픽스처 공부로 보냈다.문제는 반복이었다 월요일 출근. 테스트 코드를 다시 봤다. def test_user_login(): db = create_db_connection() db.clean_all_tables() db.insert_test_user() # 실제 테스트 result = login("test@test.com") assert result.success db.close()def test_user_logout(): db = create_db_connection() db.clean_all_tables() db.insert_test_user() # 실제 테스트 result = logout() assert result.success db.close()똑같은 셋업이 50개 테스트마다 반복된다. 디비 초기화가 30초씩 걸린다. 50개 × 30초 = 25분. 순수 셋업 시간만. "이거 미친 짓이었네." 픽스처 공부한 보람이 있다. 바로 리팩토링 시작했다. fixture 기본부터 conftest.py 파일을 만들었다. import pytest@pytest.fixture def db_connection(): db = create_db_connection() yield db db.close()yield가 핵심이다. 앞은 셋업, 뒤는 티어다운. 자동으로 실행된다. 테스트 코드가 간단해졌다. def test_user_login(db_connection): db = db_connection result = login("test@test.com") assert result.successclose()를 신경 쓸 필요가 없다. 픽스처가 알아서 정리한다. 첫 번째 개선. 5분 절약.scope로 시간 줄이기 문제는 여전했다. 매 테스트마다 디비 연결을 새로 만든다. scope를 알게 됐다. @pytest.fixture(scope="session") def db_connection(): db = create_db_connection() yield db db.close()session scope. 전체 테스트 스위트에서 한 번만 실행된다. 하지만 문제가 생겼다. 테스트끼리 데이터가 꼬인다. "아, 연결은 유지하되 데이터는 초기화해야 하는구나." 다시 수정했다. @pytest.fixture(scope="session") def db_connection(): db = create_db_connection() yield db db.close()@pytest.fixture(scope="function") def clean_db(db_connection): db_connection.clean_all_tables() return db_connection연결은 세션당 한 번. 테이블 초기화는 테스트마다. 실행 시간이 25분에서 8분으로 줄었다. 17분 절약. "이제 좀 사람 사는 거 같네." 테스트 데이터 픽스처 다음 문제. 테스트 데이터 준비가 중복됐다. def test_admin_access(clean_db): admin = User(email="admin@test.com", role="admin") clean_db.insert(admin) result = access_admin_page(admin) assert result.successdef test_admin_delete(clean_db): admin = User(email="admin@test.com", role="admin") clean_db.insert(admin) result = delete_user(admin) assert result.successadmin 유저 생성 코드가 계속 반복된다. 픽스처로 뺐다. @pytest.fixture def admin_user(clean_db): admin = User(email="admin@test.com", role="admin") clean_db.insert(admin) return admindef test_admin_access(admin_user): result = access_admin_page(admin_user) assert result.success테스트 코드가 의도만 남았다. 셋업 코드가 사라졌다. 가독성이 올라갔다. 유지보수도 쉬워졌다. 파라미터로 여러 케이스 일반 유저, 관리자, 게스트. 세 가지 권한 테스트가 필요했다. 처음엔 테스트를 세 개 만들려고 했다. 비효율적이다. @pytest.fixture(params=[ {"email": "user@test.com", "role": "user"}, {"email": "admin@test.com", "role": "admin"}, {"email": "guest@test.com", "role": "guest"} ]) def test_user(request, clean_db): user = User(**request.param) clean_db.insert(user) return userdef test_user_access(test_user): result = access_page(test_user) assert result.success테스트 하나가 자동으로 세 번 실행된다. 각각 다른 유저로. pytest 출력도 깔끔하다. test_user_access[user] PASSED test_user_access[admin] PASSED test_user_access[guest] PASSED코드는 한 번 작성. 케이스는 무한 확장.autouse로 자동 실행 로그 관리가 필요했다. 모든 테스트마다. @pytest.fixture(autouse=True) def setup_logging(): logger = setup_test_logger() yield logger.save_results()autouse=True. 명시 안 해도 자동으로 실행된다. 모든 테스트 함수에 로깅이 적용됐다. 코드 수정 없이. "이건 진짜 마법 같네." 실전 구조 conftest.py를 계층화했다. tests/ conftest.py # 전역 픽스처 api/ conftest.py # API 테스트용 test_user.py ui/ conftest.py # UI 테스트용 test_login.py전역 conftest.py: @pytest.fixture(scope="session") def db_connection(): # 디비 연결 pass@pytest.fixture def clean_db(db_connection): # 테이블 초기화 passapi/conftest.py: @pytest.fixture def api_client(): # API 클라이언트 pass@pytest.fixture def auth_header(api_client): # 인증 헤더 pass필요한 픽스처만 불러온다. 테스트가 가벼워졌다. 픽스처 조합 픽스처끼리 조합할 수 있다. @pytest.fixture def user(clean_db): user = User(email="test@test.com") clean_db.insert(user) return user@pytest.fixture def logged_in_user(user, api_client): token = api_client.login(user) user.token = token return user@pytest.fixture def user_with_data(logged_in_user, clean_db): data = create_test_data() clean_db.insert_for_user(logged_in_user, data) return logged_in_user세 단계 픽스처다. 로그인까지, 데이터까지. 선택 가능하다. def test_simple(user): # 유저만 필요 passdef test_with_login(logged_in_user): # 로그인된 유저 passdef test_full_scenario(user_with_data): # 전부 준비된 상태 pass필요한 만큼만 셋업한다. 시간 절약이다. 실제 성과 리팩토링 전후 비교했다. 전:테스트 50개 실행: 32분 셋업 코드 중복: 200줄 디비 연결: 50번 티어다운 누락: 가끔후:테스트 50개 실행: 8분 픽스처 재사용: 10개 디비 연결: 1번 티어다운: 자동24분 절약. 하루에 테스트 3번 돌린다. 72분 단축. 주 5일이면 6시간. 거의 야근 하루가 사라졌다. 팀원 반응 후배 QA한테 픽스처 가르쳤다. "이거 진짜 편하네요." 개발팀장도 만족했다. "CI 파이프라인이 30분 빨라졌어요." 테크 리드가 물었다. "다른 팀도 적용 가능할까요?" "conftest.py 공유하면 됩니다." 지금은 전사 표준이 됐다. 모든 테스트가 픽스처 기반이다. 주의할 점 픽스처 남용하지 말 것. 간단한 테스트에 복잡한 픽스처는 과하다. 테스트 3줄인데 픽스처 20줄이면 문제다. scope 실수 조심. session scope에 function 데이터 넣으면 망한다. 픽스처 이름 명확하게. data보다 user_test_data가 낫다. 의존성 순환 주의. 픽스처가 서로 부르면 안 된다. 다음 단계 factory_boy 도입 검토 중이다. 픽스처 + 팩토리 패턴. @pytest.fixture def user_factory(clean_db): return UserFactorydef test_multiple_users(user_factory): users = user_factory.create_batch(10) # 10명 유저로 테스트더 유연해진다. 랜덤 데이터도 가능하다. Faker 라이브러리도 붙이면 좋겠다. 실제 같은 데이터로. 지금 시작하기 픽스처 리팩토링 순서:중복 셋업 코드 찾기 (Ctrl+F "setup") conftest.py 만들고 기본 픽스처 작성 yield로 티어다운 자동화 scope 최적화 (function → class → module → session) 파라미터로 케이스 확장 팀 공유하루면 충분하다. 효과는 즉시 나타난다. 마무리 금요일 6시. 테스트 스위트 실행했다. 8분 만에 완료. 초록불. "퇴근 가능." 야근이 사라졌다. 픽스처 덕분이다. 테스트 코드도 코드다. 리팩토링이 필요하다. 반복을 제거하고 재사용하라. 시간은 돌아온다. 야근 대신 정시 퇴근으로.이제 pytest.ini 설정도 정리해야겠다. 커버리지 리포트 경로가 엉망이다.

자동화 테스트도 결국 버그다: 테스트 코드 리뷰 체크리스트

자동화 테스트도 결국 버그다: 테스트 코드 리뷰 체크리스트

자동화 테스트도 결국 버그다: 테스트 코드 리뷰 체크리스트 오늘 아침 CI 알림 확인했다. 빌드 실패. 다시 돌렸다. 성공. 세 번째. 실패. Flaky 테스트다. 또. 테스트 코드에 버그가 있으면 프로덕션 코드 버그는 찾는다. QA니까. 테스트 코드 버그는? 누가 찾나. 지난달 일이다. 결제 로직 수정했다. 자동화 테스트 전부 통과. 배포했다. 다음 날 고객 문의 폭주. 결제 안 된다.테스트 코드를 봤다. assert response.status_code == 200 이게 전부였다. 실제 결제는 안 됐다. 응답만 200이었다. 테스트는 통과. 버그는 프로덕션. 이날 배웠다. 테스트 코드도 결국 코드다. 코드면 버그가 있다. 가짜 안심이 제일 위험하다 자동화 테스트가 있으면 안심한다. 당연하다. 그린 체크 보면 '됐다'고 생각한다. 근데 그 체크가 거짓말이면. 3개월 전 상황. 로그인 테스트 500개. 전부 통과. 근데 실제로는 아무도 로그인 못 했다. 이유? 테스트 환경에는 DB에 테스트 계정이 있었다. 프로덕션에는 없었다. 테스트는 항상 성공. 현실은 실패. def test_login(): response = login("test@test.com", "password") assert response.status_code == 200 # 실제 로그인 됐는지는 안 봄통과한다. 매번. 근데 의미가 없다. 내가 만든 테스트 코드 체크리스트 4년 동안 삽질하면서 만들었다. PR마다 이거 본다. 1. 진짜 검증하는가 # 나쁜 예 def test_user_creation(): response = create_user({"name": "test"}) assert response.status_code == 201# 좋은 예 def test_user_creation(): response = create_user({"name": "test"}) assert response.status_code == 201 user = get_user(response.data.id) assert user.name == "test" assert user.created_at is not None상태 코드만 보면 안 된다. DB도 봐야 한다. 실제 데이터도. 작년에 회원가입 테스트가 있었다. 201 반환하면 통과. 근데 DB에는 안 들어갔다. 트랜잭션이 롤백됐는데 응답은 보냈다. 테스트는 몰랐다. 201만 봤으니까.2. 독립적인가 테스트 순서 바꿔봤나. A 테스트가 B 테스트에 의존하면 안 된다. # 나쁜 예 def test_1_create_product(): global product_id product_id = create_product().iddef test_2_update_product(): update_product(product_id) # test_1에 의존이거 진짜 많다. 병렬 실행하면 깨진다. 순서 바뀌면 깨진다. 2년 전에 이것 때문에 3일 날렸다. test_1이 실패하면 test_2도 실패. test_2가 진짜 문제인지 몰랐다. 각 테스트마다 setup, teardown 해야 한다. 귀찮다. 근데 해야 한다. 3. 명확한가 6개월 후에 내가 봐도 이해되나. # 나쁜 예 def test_api(): r = call(1, 2, True) assert r == 3# 좋은 예 def test_discount_applies_to_premium_users(): user = create_premium_user() product = create_product(price=10000) order = create_order(user, product, use_discount=True) assert order.final_price == 9000 assert order.discount_amount == 1000변수명만 봐도 알아야 한다. 매직 넘버 쓰지 말고. 의도가 보여야 한다. 작년에 후배가 쓴 테스트 봤다. test_case_1, test_case_2, test_case_3 뭘 테스트하는지 몰랐다. 후배도. 4. 빠른가 10분 걸리는 테스트는 안 돌린다. 안 돌리면 의미 없다. 우리 팀 E2E 테스트. 처음엔 45분 걸렸다. 아무도 로컬에서 안 돌렸다. CI에서만 돌렸다. PR 올리고 45분 기다렸다. 지금은 12분. 병렬 처리했다. 불필요한 sleep 제거했다. fixture 재사용했다. # 나쁜 예 def test_workflow(): time.sleep(5) # "혹시 몰라서" check_status() time.sleep(5) # 또# 좋은 예 def test_workflow(): wait_until(lambda: status_is_ready(), timeout=10) check_status()sleep은 악이다. 대부분 필요 없다. 필요하면 조건부로 기다려야 한다. 5. 안정적인가 100번 돌려서 100번 같은 결과 나오나. Flaky 테스트가 제일 짜증난다. 가끔 실패한다. 이유 모르겠다. 다시 돌리면 통과한다.우리 팀 규칙. Flaky 테스트 발견하면 바로 비활성화. 고칠 때까지 안 돌린다. 믿을 수 없는 테스트는 없는 것보다 나쁘다. 실패해도 "또 Flaky겠지" 하면 끝이다. 진짜 버그도 무시하게 된다. 흔한 원인들:타이밍 이슈 (sleep으로 해결하려는 순간 졌다) 공유 리소스 (DB, 파일, 네트워크) 랜덤 데이터 시간 의존성 (datetime.now() 쓰면...) 외부 API실제 PR 리뷰에서 본 것들 Case 1: 아무것도 안 하는 테스트 def test_send_email(): send_email("test@test.com", "subject", "body") # assert 없음이거 놀랍게도 많다. 함수 호출만 한다. 검증 없다. 에러 안 나면 통과. 근데 이메일 안 가도 에러 안 난다. 테스트는 통과. 의미는 없다. Case 2: 너무 많이 테스트 def test_user_api(): # 100줄 # 10가지 검증 # 3개 API 호출 # ...하나 실패하면 뭐가 문제인지 모른다. 테스트 하나는 하나만 검증해야 한다. Case 3: 프로덕션 코드 복사 def calculate_discount(price): return price * 0.9 if price > 10000 else pricedef test_discount(): price = 15000 expected = price * 0.9 if price > 10000 else price assert calculate_discount(price) == expected이건 같은 로직을 두 번 쓴 거다. 둘 다 틀려도 통과한다. 기대값은 명확해야 한다. 계산하지 말고 적어야 한다. def test_discount(): assert calculate_discount(15000) == 13500 assert calculate_discount(5000) == 5000Case 4: try-except 남용 def test_error_handling(): try: dangerous_operation() assert False, "should raise error" except: pass # 통과어떤 에러든 잡는다. AssertionError도 잡는다. 테스트 로직 에러도 잡는다. def test_error_handling(): with pytest.raises(SpecificError): dangerous_operation()이렇게 해야 한다. 구체적인 에러만 기대한다. 테스트 코드 리뷰할 때 내가 보는 것 PR 올라오면 이것들 본다.커버리지가 아니라 의미90% 커버리지는 중요하지 않다 중요한 로직 제대로 검증하나가 중요하다네이밍test_1 같은 거 보이면 바로 코멘트 테스트명이 문서다픽스처 재사용같은 setup 코드 반복하면 안 된다 유지보수 지옥된다외부 의존성API 호출하면 mock해야 한다 DB는 트랜잭션 롤백해야 한다에러 케이스Happy path만 테스트하는 거 많다 실패 케이스가 더 중요하다지난주에 리뷰했던 PR. 성공 케이스 10개. 실패 케이스 0개. "에러 처리는요?" "그건 개발자가 잘 했을 거예요" 안 한다. 개발자는 Happy path만 생각한다. 우리가 엣지 케이스 봐야 한다. 자동화의 함정 자동화하면 다 된 것 같다. 아니다. 자동화는 반복 작업을 줄인다. 판단은 못 한다. 잘못된 자동화는 수동보다 나쁘다. 수동이면 사람이 본다. 뭔가 이상하면 안다. 자동화는 시킨 것만 한다. 틀려도 모른다. 2년 전에 UI 자동화 했다. 버튼 클릭하고 텍스트 확인했다. 텍스트는 맞았다. 근데 레이아웃이 깨졌다. 버튼이 화면 밖으로 나갔다. 텍스트만 보는 테스트는 통과했다. 배포하고 디자이너한테 혼났다. 시각적 회귀 테스트 추가했다. 스크린샷 찍어서 비교한다. 1픽셀 차이도 잡는다. 근데 이것도 문제다. 의도된 변경도 실패로 본다. 매번 베이스라인 업데이트해야 한다. 자동화는 만능이 아니다. 도구일 뿐이다. 테스트 코드도 리팩토링한다 프로덕션 코드는 리팩토링한다. 테스트 코드는? 안 한다. 대부분. "돌아가는데 뭐" 그러다가 테스트가 레거시가 된다. 수정하기 무섭다. 건드리면 깨진다. 우리 팀은 스프린트마다 "테스트 부채" 시간이 있다. 중복 제거한다. 불필요한 테스트 삭제한다. 느린 테스트 최적화한다. 지난달에 500개 테스트를 300개로 줄였다. 실행 시간은 반으로. 커버리지는 그대로. 중복이 많았다. 같은 걸 다르게 테스트했다. 통합했다. 코드 리뷰 템플릿 PR마다 이거 붙인다. ## 테스트 체크리스트- [ ] 각 테스트가 하나의 관심사만 검증하는가? - [ ] 테스트명이 의도를 명확히 드러내는가? - [ ] 테스트 간 의존성이 없는가? - [ ] 외부 의존성을 mock/stub 했는가? - [ ] 에러 케이스를 다루는가? - [ ] Flaky 가능성은 없는가? - [ ] 로컬에서 빠르게 실행되는가? (< 10초) - [ ] Magic number 대신 의미있는 상수를 사용하는가? - [ ] Setup/teardown이 적절한가? - [ ] 실제 버그를 잡을 수 있는 테스트인가?마지막 항목이 제일 중요하다. "실제 버그를 잡을 수 있는가" 이거 아니면 의미 없다. 결국 사람이 본다 자동화가 아무리 좋아도. 결국 사람이 판단한다. 테스트가 실패하면 누가 보나. 테스트가 이상하면 누가 고치나. 테스트가 의미 있는지 누가 판단하나. 전부 사람이다. 자동화는 반복 작업만 줄인다. 생각은 못 한다. 좋은 테스트 코드는 좋은 코드다. 읽기 쉽고. 유지보수 쉽고. 명확하고. 신뢰할 수 있고. 프로덕션 코드만큼 신경 써야 한다. 아니, 더 써야 한다. 테스트 코드가 틀리면 프로덕션 버그를 못 잡는다. 그게 제일 무섭다.오늘도 Flaky 테스트 하나 고쳤다. 원인은 타임아웃이었다. 3초를 5초로 늘렸다. 근본적 해결은 아니다. 언젠가 다시 깨질 것이다. 그때 또 고치겠지.