# PlanitAI KPI 개발기 v10: 테스트 전략 > 시리즈: PlanitAI KPI 개발 여정 (10/16) > 작성일: 2024년 12월 ## 개요 안정적인 SaaS 서비스를 위해서는 체계적인 테스트 전략이 필수입니다. 특히 **AI 응답**과 **KPI 계산 엔진**의 정확성을 검증하는 것이 핵심입니다. 이번 글에서는 PlanitAI KPI의 테스트 전략을 상세히 다룹니다. --- ## 1. 테스트 피라미드 ### 1.1 구조 ``` ┌─────────┐ │ E2E │ ← 5% (핵심 시나리오만) │ Tests │ ┌───┴─────────┴───┐ │ Integration │ ← 25% │ Tests │ ┌───┴─────────────────┴───┐ │ Unit Tests │ ← 70% └─────────────────────────┘ ``` ### 1.2 테스트 범위 | 레이어 | 대상 | 도구 | 목표 커버리지 | |--------|------|------|---------------| | Unit | 비즈니스 로직, 유틸리티 | pytest | 90%+ | | Integration | API, DB, 외부 서비스 | pytest + testcontainers | 80%+ | | E2E | 핵심 사용자 시나리오 | Playwright | 핵심 플로우 100% | --- ## 2. 유닛 테스트 ### 2.1 KPI 계산 엔진 테스트 ```python # tests/unit/test_kpi_engine.py import pytest from decimal import Decimal from src.kpi.engine import ( FormulaParser, KPICalculationEngine, KPIBottleneckAnalyzer ) from src.kpi.models import KPINode, KPINodeType, KPICategory class TestFormulaParser: """수식 파서 테스트""" @pytest.fixture def parser(self): return FormulaParser() @pytest.mark.parametrize("expression,values,expected", [ # 기본 사칙연산 ("{a} + {b}", {"a": 10, "b": 5}, 15), ("{a} - {b}", {"a": 10, "b": 5}, 5), ("{a} * {b}", {"a": 10, "b": 5}, 50), ("{a} / {b}", {"a": 10, "b": 5}, 2), # 복합 연산 ("{a} * {b} + {c}", {"a": 10, "b": 5, "c": 3}, 53), ("({a} + {b}) * {c}", {"a": 10, "b": 5, "c": 2}, 30), # 소수점 ("{a} / {b}", {"a": 10, "b": 3}, pytest.approx(3.333, rel=0.01)), # 퍼센트 계산 ("{a} / {b} * 100", {"a": 25, "b": 100}, 25), ]) def test_evaluate_formula(self, parser, expression, values, expected): """수식 계산 테스트""" result = parser.evaluate(expression, values) assert result == expected def test_division_by_zero(self, parser): """0으로 나누기 처리""" result = parser.evaluate("{a} / {b}", {"a": 10, "b": 0}) assert result == 0 # 또는 None, 설계에 따라 def test_missing_variable(self, parser): """누락된 변수 처리""" with pytest.raises(ValueError, match="Missing variable"): parser.evaluate("{a} + {b}", {"a": 10}) def test_invalid_expression(self, parser): """잘못된 수식 처리""" with pytest.raises(ValueError, match="Invalid expression"): parser.evaluate("{a} ++ {b}", {"a": 10, "b": 5}) def test_security_no_code_execution(self, parser): """보안: 코드 실행 차단""" dangerous_expressions = [ "__import__('os').system('ls')", "eval('1+1')", "exec('print(1)')", "open('/etc/passwd').read()", ] for expr in dangerous_expressions: with pytest.raises(ValueError): parser.evaluate(expr, {}) class TestKPICalculationEngine: """KPI 계산 엔진 테스트""" @pytest.fixture def engine(self): return KPICalculationEngine() @pytest.fixture def simple_tree(self): """간단한 KPI 트리 (매출 = 단가 × 수량)""" revenue = KPINode( id="revenue", name="매출", node_type=KPINodeType.KGI, formula="{unit_price} * {quantity}" ) unit_price = KPINode( id="unit_price", name="단가", node_type=KPINodeType.INPUT, parent=revenue ) quantity = KPINode( id="quantity", name="수량", node_type=KPINodeType.INPUT, parent=revenue ) revenue.children = [unit_price, quantity] return revenue @pytest.fixture def complex_tree(self): """ 복잡한 KPI 트리: 매출(KGI) = 계약수 × 평균단가 계약수 = 상담수 × 성약률 상담수 = 리드수 × 상담전환율 """ revenue = KPINode(id="revenue", name="매출", node_type=KPINodeType.KGI, formula="{contracts} * {avg_price}") contracts = KPINode(id="contracts", name="계약수", node_type=KPINodeType.KPI, formula="{meetings} * {close_rate}", parent=revenue) avg_price = KPINode(id="avg_price", name="평균단가", node_type=KPINodeType.INPUT, parent=revenue) meetings = KPINode(id="meetings", name="상담수", node_type=KPINodeType.KPI, formula="{leads} * {meeting_rate}", parent=contracts) close_rate = KPINode(id="close_rate", name="성약률", node_type=KPINodeType.INPUT, parent=contracts) leads = KPINode(id="leads", name="리드수", node_type=KPINodeType.INPUT, parent=meetings) meeting_rate = KPINode(id="meeting_rate", name="상담전환율", node_type=KPINodeType.INPUT, parent=meetings) # 트리 구조 연결 revenue.children = [contracts, avg_price] contracts.children = [meetings, close_rate] meetings.children = [leads, meeting_rate] return revenue def test_simple_calculation(self, engine, simple_tree): """간단한 계산 테스트""" input_values = { "unit_price": 1000, "quantity": 50 } result = engine.calculate(simple_tree, input_values) assert result["revenue"] == 50000 def test_complex_calculation(self, engine, complex_tree): """복잡한 트리 계산 테스트""" input_values = { "leads": 1000, "meeting_rate": 0.3, # 30% "close_rate": 0.2, # 20% "avg_price": 500000 # 50만원 } result = engine.calculate(complex_tree, input_values) # 상담수 = 1000 × 0.3 = 300 assert result["meetings"] == 300 # 계약수 = 300 × 0.2 = 60 assert result["contracts"] == 60 # 매출 = 60 × 500000 = 3억 assert result["revenue"] == 30_000_000 def test_calculation_order(self, engine, complex_tree): """계산 순서 (위상정렬) 테스트""" order = engine.get_calculation_order(complex_tree) # INPUT 노드가 먼저, KGI 노드가 마지막 input_positions = [order.index(n) for n in ["leads", "meeting_rate", "close_rate", "avg_price"]] kgi_position = order.index("revenue") assert all(pos < kgi_position for pos in input_positions) def test_circular_dependency_detection(self, engine): """순환 참조 감지 테스트""" # A → B → C → A 순환 참조 node_a = KPINode(id="a", formula="{c} + 1") node_b = KPINode(id="b", formula="{a} + 1") node_c = KPINode(id="c", formula="{b} + 1") with pytest.raises(ValueError, match="Circular dependency"): engine.validate_tree([node_a, node_b, node_c]) class TestKPIBottleneckAnalyzer: """병목 분석 테스트""" @pytest.fixture def analyzer(self): return KPIBottleneckAnalyzer() def test_identify_bottleneck(self, analyzer): """병목 KPI 식별 테스트""" performance_data = [ {"node_id": "revenue", "actual": 80, "target": 100, "type": "KGI"}, {"node_id": "contracts", "actual": 40, "target": 50, "type": "KPI"}, {"node_id": "avg_price", "actual": 100000, "target": 100000, "type": "INPUT"}, {"node_id": "meetings", "actual": 100, "target": 150, "type": "KPI"}, # 병목 {"node_id": "close_rate", "actual": 0.4, "target": 0.33, "type": "INPUT"}, # 초과달성 ] bottlenecks = analyzer.identify(performance_data) assert len(bottlenecks) > 0 assert bottlenecks[0]["node_id"] == "meetings" # 가장 큰 병목 assert bottlenecks[0]["achievement_rate"] == pytest.approx(66.67, rel=0.1) def test_impact_calculation(self, analyzer): """영향도 계산 테스트""" # meetings가 목표 달성시 매출 영향 impact = analyzer.calculate_impact( node_id="meetings", current_value=100, target_value=150, tree_formula="{meetings} * {close_rate} * {avg_price}", current_values={"meetings": 100, "close_rate": 0.4, "avg_price": 100000} ) # 현재: 100 × 0.4 × 100000 = 4,000,000 # 목표: 150 × 0.4 × 100000 = 6,000,000 # 영향: +2,000,000 assert impact == 2_000_000 ``` ### 2.2 AI 응답 파싱 테스트 ```python # tests/unit/test_ai_response.py import pytest import json from src.ai.response_processor import ResponseProcessor class TestResponseProcessor: """AI 응답 처리 테스트""" @pytest.fixture def processor(self): return ResponseProcessor() def test_parse_valid_json_response(self, processor): """정상 JSON 응답 파싱""" response = ''' 분석 결과입니다: ```json { "summary": "매출이 전월 대비 15% 증가했습니다", "trends": [ {"kpi_name": "매출", "trend": "improving", "change_rate": "+15%"} ] } ``` ''' result = processor.parse_json(response) assert result["summary"] == "매출이 전월 대비 15% 증가했습니다" assert len(result["trends"]) == 1 assert result["trends"][0]["trend"] == "improving" def test_parse_malformed_json(self, processor): """잘못된 JSON 처리""" response = ''' ```json { "summary": "테스트", "trends": [ } ``` ''' result = processor.parse_json(response) assert result.get("parsed") is False assert "raw_response" in result def test_parse_no_json_block(self, processor): """JSON 블록 없는 응답 처리""" response = "매출이 증가하고 있습니다. 계속 모니터링이 필요합니다." result = processor.parse_json(response) assert result.get("parsed") is False def test_validate_trend_values(self, processor): """트렌드 값 검증""" valid_trends = ["improving", "stable", "declining"] for trend in valid_trends: assert processor.validate_trend(trend) is True assert processor.validate_trend("unknown") is False assert processor.validate_trend("") is False assert processor.validate_trend(None) is False def test_clean_response(self, processor): """응답 정리""" response = '''Here is the analysis: ```json {"summary": "test"} ``` Let me know if you need more details.''' cleaned = processor.extract_json_block(response) assert cleaned == '{"summary": "test"}' ``` --- ## 3. 통합 테스트 ### 3.1 데이터베이스 통합 테스트 ```python # tests/integration/test_kpi_repository.py import pytest from datetime import datetime, timedelta from testcontainers.postgres import PostgresContainer from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from src.models import Base, KPITree, KPINode, KPIData from src.repositories import KPITreeRepository, KPIDataRepository @pytest.fixture(scope="module") def postgres_container(): """PostgreSQL 테스트 컨테이너""" with PostgresContainer("postgres:15") as postgres: yield postgres @pytest.fixture(scope="module") def db_engine(postgres_container): """데이터베이스 엔진""" engine = create_engine(postgres_container.get_connection_url()) Base.metadata.create_all(engine) return engine @pytest.fixture def db_session(db_engine): """데이터베이스 세션""" Session = sessionmaker(bind=db_engine) session = Session() yield session session.rollback() session.close() class TestKPITreeRepository: """KPI 트리 저장소 테스트""" @pytest.fixture def repository(self, db_session): return KPITreeRepository(db_session) def test_create_tree(self, repository): """트리 생성""" tree = KPITree( name="Sales KPI Tree", organization_id="org-1", description="영업팀 KPI 트리" ) created = repository.create(tree) assert created.id is not None assert created.name == "Sales KPI Tree" assert created.created_at is not None def test_add_nodes_to_tree(self, repository, db_session): """트리에 노드 추가""" # 트리 생성 tree = repository.create(KPITree( name="Test Tree", organization_id="org-1" )) # 노드 추가 root = KPINode( tree_id=tree.id, name="매출", node_type="KGI", formula="{contracts} * {avg_price}" ) db_session.add(root) db_session.flush() child = KPINode( tree_id=tree.id, parent_id=root.id, name="계약수", node_type="KPI" ) db_session.add(child) db_session.commit() # 조회 retrieved = repository.get_with_nodes(tree.id) assert len(retrieved.nodes) == 2 assert retrieved.nodes[0].children[0].name == "계약수" def test_find_trees_by_organization(self, repository): """조직별 트리 조회""" # 여러 트리 생성 for i in range(3): repository.create(KPITree( name=f"Tree {i}", organization_id="org-test" )) trees = repository.find_by_organization("org-test") assert len(trees) == 3 class TestKPIDataRepository: """KPI 데이터 저장소 테스트""" @pytest.fixture def repository(self, db_session): return KPIDataRepository(db_session) @pytest.fixture def sample_node(self, db_session): """샘플 노드""" tree = KPITree(name="Test", organization_id="org-1") db_session.add(tree) db_session.flush() node = KPINode(tree_id=tree.id, name="Test Node", node_type="KPI") db_session.add(node) db_session.commit() return node def test_save_data(self, repository, sample_node): """데이터 저장""" data = KPIData( node_id=sample_node.id, period="2024-01", actual=100, target=120 ) saved = repository.save(data) assert saved.id is not None assert saved.actual == 100 def test_get_time_series(self, repository, sample_node): """시계열 데이터 조회""" # 6개월 데이터 생성 for i in range(6): repository.save(KPIData( node_id=sample_node.id, period=f"2024-{i+1:02d}", actual=100 + i * 10 )) # 조회 series = repository.get_time_series( node_id=sample_node.id, start_period="2024-01", end_period="2024-06" ) assert len(series) == 6 assert series[0].actual == 100 assert series[5].actual == 150 def test_bulk_insert(self, repository, sample_node): """대량 데이터 삽입""" data_list = [ KPIData(node_id=sample_node.id, period=f"2023-{i:02d}", actual=i*10) for i in range(1, 13) ] count = repository.bulk_insert(data_list) assert count == 12 ``` ### 3.2 API 통합 테스트 ```python # tests/integration/test_api.py import pytest from fastapi.testclient import TestClient from httpx import AsyncClient from src.main import app from src.auth import create_access_token @pytest.fixture def client(): return TestClient(app) @pytest.fixture def auth_headers(): """인증 헤더""" token = create_access_token({"sub": "test-user", "org_id": "org-1"}) return {"Authorization": f"Bearer {token}"} class TestKPITreeAPI: """KPI 트리 API 테스트""" def test_create_tree(self, client, auth_headers): """트리 생성 API""" response = client.post( "/api/v1/trees", json={ "name": "Sales KPI", "description": "영업 KPI 트리" }, headers=auth_headers ) assert response.status_code == 201 data = response.json() assert data["name"] == "Sales KPI" assert "id" in data def test_get_tree(self, client, auth_headers): """트리 조회 API""" # 생성 create_response = client.post( "/api/v1/trees", json={"name": "Test Tree"}, headers=auth_headers ) tree_id = create_response.json()["id"] # 조회 response = client.get( f"/api/v1/trees/{tree_id}", headers=auth_headers ) assert response.status_code == 200 assert response.json()["id"] == tree_id def test_unauthorized_access(self, client): """인증 없는 접근 차단""" response = client.get("/api/v1/trees") assert response.status_code == 401 def test_add_node_to_tree(self, client, auth_headers): """노드 추가 API""" # 트리 생성 tree_response = client.post( "/api/v1/trees", json={"name": "Test Tree"}, headers=auth_headers ) tree_id = tree_response.json()["id"] # 노드 추가 response = client.post( f"/api/v1/trees/{tree_id}/nodes", json={ "name": "매출", "node_type": "KGI", "formula": "{contracts} * {price}" }, headers=auth_headers ) assert response.status_code == 201 assert response.json()["name"] == "매출" class TestAnalysisAPI: """분석 API 테스트""" @pytest.fixture def mock_gemini(self, mocker): """Gemini API 모킹""" mock = mocker.patch("src.ai.client.GeminiClient.analyze") mock.return_value = { "summary": "테스트 분석 결과", "trends": [ {"kpi_name": "매출", "trend": "improving"} ] } return mock def test_trend_analysis(self, client, auth_headers, mock_gemini): """트렌드 분석 API""" response = client.post( "/api/v1/analysis/trends", json={ "tree_id": "tree-1", "period_start": "2024-01", "period_end": "2024-06" }, headers=auth_headers ) assert response.status_code == 200 data = response.json() assert "result" in data assert data["result"]["summary"] == "테스트 분석 결과" def test_analysis_caching(self, client, auth_headers, mock_gemini): """분석 결과 캐싱 테스트""" request_data = { "tree_id": "tree-1", "period_start": "2024-01", "period_end": "2024-06" } # 첫 번째 요청 response1 = client.post( "/api/v1/analysis/trends", json=request_data, headers=auth_headers ) assert response1.json()["from_cache"] is False # 두 번째 요청 (캐시) response2 = client.post( "/api/v1/analysis/trends", json=request_data, headers=auth_headers ) assert response2.json()["from_cache"] is True # Gemini는 한 번만 호출됨 assert mock_gemini.call_count == 1 ``` --- ## 4. E2E 테스트 ### 4.1 Playwright 설정 ```typescript // playwright.config.ts import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests/e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: 'html', use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'Mobile Safari', use: { ...devices['iPhone 13'] }, }, ], webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, }, }); ``` ### 4.2 핵심 시나리오 테스트 ```typescript // tests/e2e/kpi-tree.spec.ts import { test, expect } from '@playwright/test'; test.describe('KPI Tree Management', () => { test.beforeEach(async ({ page }) => { // 로그인 await page.goto('/login'); await page.fill('[data-testid="email"]', 'test@example.com'); await page.fill('[data-testid="password"]', 'password123'); await page.click('[data-testid="login-button"]'); await page.waitForURL('/dashboard'); }); test('사용자는 새 KPI 트리를 생성할 수 있다', async ({ page }) => { // 트리 생성 페이지로 이동 await page.click('[data-testid="create-tree-button"]'); await page.waitForURL('/trees/new'); // 트리 정보 입력 await page.fill('[data-testid="tree-name"]', 'Sales KPI 2024'); await page.fill('[data-testid="tree-description"]', '2024년 영업팀 KPI'); // 저장 await page.click('[data-testid="save-tree-button"]'); // 성공 확인 await expect(page.locator('[data-testid="success-toast"]')).toBeVisible(); await expect(page).toHaveURL(/\/trees\/[a-z0-9-]+$/); }); test('사용자는 KPI 노드를 추가할 수 있다', async ({ page }) => { // 기존 트리로 이동 await page.goto('/trees/test-tree-id'); // 노드 추가 버튼 클릭 await page.click('[data-testid="add-node-button"]'); // 노드 정보 입력 await page.fill('[data-testid="node-name"]', '월간 매출'); await page.selectOption('[data-testid="node-type"]', 'KGI'); await page.fill('[data-testid="node-formula"]', '{contracts} * {avg_price}'); // 저장 await page.click('[data-testid="save-node-button"]'); // 트리에 노드가 추가되었는지 확인 await expect(page.locator('[data-testid="kpi-node-월간 매출"]')).toBeVisible(); }); test('사용자는 KPI 데이터를 입력할 수 있다', async ({ page }) => { await page.goto('/trees/test-tree-id'); // 데이터 입력 모드로 전환 await page.click('[data-testid="data-entry-tab"]'); // 데이터 입력 await page.fill('[data-testid="input-leads"]', '1000'); await page.fill('[data-testid="input-meeting_rate"]', '0.3'); await page.fill('[data-testid="input-close_rate"]', '0.2'); await page.fill('[data-testid="input-avg_price"]', '500000'); // 계산 실행 await page.click('[data-testid="calculate-button"]'); // 계산 결과 확인 await expect(page.locator('[data-testid="result-revenue"]')).toContainText('30,000,000'); }); }); test.describe('AI Analysis', () => { test('사용자는 AI 분석을 요청할 수 있다', async ({ page }) => { await page.goto('/trees/test-tree-id/analysis'); // 분석 유형 선택 await page.click('[data-testid="analysis-type-bottleneck"]'); // 기간 선택 await page.fill('[data-testid="period-start"]', '2024-01'); await page.fill('[data-testid="period-end"]', '2024-06'); // 분석 실행 await page.click('[data-testid="run-analysis-button"]'); // 로딩 표시 확인 await expect(page.locator('[data-testid="analysis-loading"]')).toBeVisible(); // 결과 표시 (타임아웃 30초) await expect(page.locator('[data-testid="analysis-result"]')).toBeVisible({ timeout: 30000 }); // 결과에 필수 요소 포함 확인 await expect(page.locator('[data-testid="bottleneck-list"]')).toBeVisible(); await expect(page.locator('[data-testid="recommendations"]')).toBeVisible(); }); }); ``` --- ## 5. AI 응답 테스트 전략 ### 5.1 스냅샷 테스트 ```python # tests/unit/test_ai_prompts.py import pytest from src.ai.prompts import PromptLibrary class TestPromptSnapshots: """프롬프트 스냅샷 테스트""" def test_trend_analysis_prompt_snapshot(self, snapshot): """트렌드 분석 프롬프트 스냅샷""" prompt = PromptLibrary.TREND_ANALYSIS.render( period="2024-01 ~ 2024-06", target_kpis='["매출", "계약수"]', tree_structure='{"name": "test"}', performance_data='[{"node": "매출", "value": 100}]' ) snapshot.assert_match(prompt, "trend_analysis_prompt.txt") def test_system_prompt_unchanged(self, snapshot): """시스템 프롬프트 변경 감지""" snapshot.assert_match( PromptLibrary.SYSTEM_PROMPT, "system_prompt.txt" ) ``` ### 5.2 Golden File 테스트 ```python # tests/integration/test_ai_golden.py import pytest import json from pathlib import Path from src.ai.engine import AIAnalysisEngine class TestAIGoldenFiles: """AI 응답 Golden File 테스트""" GOLDEN_DIR = Path(__file__).parent / "golden_files" @pytest.fixture def engine(self, mock_gemini_client): return AIAnalysisEngine(mock_gemini_client) @pytest.mark.parametrize("scenario", [ "high_performance", "low_performance", "mixed_performance" ]) def test_bottleneck_analysis_scenarios(self, engine, scenario): """다양한 시나리오별 병목 분석""" # 입력 데이터 로드 input_path = self.GOLDEN_DIR / f"bottleneck_{scenario}_input.json" with open(input_path) as f: input_data = json.load(f) # 분석 실행 result = engine.analyze_bottlenecks_sync(input_data) # 예상 결과 로드 expected_path = self.GOLDEN_DIR / f"bottleneck_{scenario}_expected.json" with open(expected_path) as f: expected = json.load(f) # 구조 검증 (정확한 값은 AI 응답에 따라 다를 수 있음) assert "bottlenecks" in result assert len(result["bottlenecks"]) > 0 # 핵심 필드 존재 확인 for bottleneck in result["bottlenecks"]: assert "kpi_name" in bottleneck assert "impact_score" in bottleneck assert 1 <= bottleneck["impact_score"] <= 10 ``` --- ## 6. 테스트 자동화 ### 6.1 GitHub Actions 설정 ```yaml # .github/workflows/test.yml name: Test Suite on: push: branches: [main, develop] pull_request: branches: [main] jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install dependencies run: | pip install -r requirements.txt pip install -r requirements-dev.txt - name: Run unit tests run: | pytest tests/unit -v --cov=src --cov-report=xml - name: Upload coverage uses: codecov/codecov-action@v4 with: file: ./coverage.xml integration-tests: runs-on: ubuntu-latest services: postgres: image: postgres:15 env: POSTGRES_PASSWORD: postgres options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 redis: image: redis:7 ports: - 6379:6379 steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install dependencies run: pip install -r requirements.txt -r requirements-dev.txt - name: Run integration tests env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test REDIS_URL: redis://localhost:6379 run: pytest tests/integration -v e2e-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install dependencies run: npm ci - name: Install Playwright run: npx playwright install --with-deps - name: Run E2E tests run: npx playwright test - name: Upload test results uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report/ ``` --- ## 7. 테스트 커버리지 목표 ### 7.1 모듈별 목표 | 모듈 | 목표 커버리지 | 우선순위 | |------|--------------|---------| | `kpi/engine.py` | 95% | 최상 | | `ai/prompts.py` | 90% | 상 | | `repositories/` | 85% | 상 | | `api/routes/` | 80% | 중 | | `utils/` | 70% | 하 | ### 7.2 커버리지 리포트 ```python # pytest.ini [pytest] addopts = --cov=src --cov-report=html --cov-report=term-missing testpaths = tests python_files = test_*.py python_functions = test_* [coverage:run] branch = True source = src [coverage:report] exclude_lines = pragma: no cover def __repr__ raise NotImplementedError if TYPE_CHECKING: ``` --- ## 8. まとめ ### 테스트 전략 요약 | 테스트 유형 | 도구 | 핵심 포인트 | |------------|------|-----------| | Unit | pytest | 계산 정확성, 에지 케이스 | | Integration | testcontainers | DB/Redis 연동, API 동작 | | E2E | Playwright | 사용자 시나리오 | | AI | Golden Files | 응답 구조 검증 | ### 次回予告 v11에서는 **보안 및 인증**을 다룹니다: - JWT 인증 구현 - RBAC 권한 관리 - 보안 베스트 프랙티스 --- *PlanitAI KPI - AI가 당신의 KPI를 계획하고 분석합니다*