From bff966ef404effa439b3d085116569bf862e84ee Mon Sep 17 00:00:00 2001 From: kyj0503 Date: Sat, 1 Aug 2026 23:19:22 +0900 Subject: [PATCH 01/10] =?UTF-8?q?fix(infra):=20dev-prod=20compose=EC=97=90?= =?UTF-8?q?=20=EB=82=A8=EC=95=84=20=EC=9E=88=EB=8D=98=20API=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EC=A4=91=20=EC=A0=91=EB=91=90=EC=82=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 042dc34에서 compose.dev.yaml의 VITE_API_BASE_URL 기본값을 빈 값으로 고쳤으나 형제 파일인 compose.dev-prod.yaml은 그대로 `:-/api`였다. 서비스 레이어가 axios에 전체 경로(/api/v1/...)를 넘기는 계약이므로 여기에 /api가 들어가면 /api/api/v1/backtest가 된다. 현재는 client.ts의 인터셉터가 막아주지만 그것은 설정 오류에 대한 방어막이지 정상 경로가 아니다. dev-prod만 상시 방어막에 의존해 도는 상태였다. compose.dev.yaml과 동일하게 `${VITE_API_BASE_URL-}`로 맞춘다. Co-Authored-By: Claude Opus 5 (1M context) --- compose.dev-prod.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/compose.dev-prod.yaml b/compose.dev-prod.yaml index 4ae17bab..d763e3f2 100644 --- a/compose.dev-prod.yaml +++ b/compose.dev-prod.yaml @@ -48,7 +48,10 @@ services: env_file: - .env environment: - - "VITE_API_BASE_URL=${VITE_API_BASE_URL:-/api}" + # 비워 두어야 한다. 서비스 레이어가 전체 경로(/api/v1/...)를 넘기므로 + # /api를 넣으면 /api/api/v1/backtest가 되어 프록시에 매칭되지 않는다. + # ':-'가 아닌 '-'를 써서 빈 값이 그대로 유지되도록 한다. + - "VITE_API_BASE_URL=${VITE_API_BASE_URL-}" - "API_PROXY_TARGET=${API_PROXY_TARGET:-http://backtest-be-fast:8000}" - "FASTAPI_PROXY_TARGET=${FASTAPI_PROXY_TARGET:-http://backtest-be-fast:8000}" command: ["npm", "run", "dev"] From c178a5941f86d42108382748a3aa5959a62221ad Mon Sep 17 00:00:00 2001 From: kyj0503 Date: Sat, 1 Aug 2026 23:19:36 +0900 Subject: [PATCH 02/10] =?UTF-8?q?test(fe):=208=EA=B0=9C=EC=9B=94=EA=B0=84?= =?UTF-8?q?=20=EA=B9=A8=EC=A0=B8=20=EC=9E=88=EB=8D=98=20DCA=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=ED=98=84?= =?UTF-8?q?=EC=9E=AC=20API=EB=A1=9C=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit d00730c(2025-11-11, DCA 주기 계산 로직 개선 및 레거시 함수 제거)에서 getDcaWeeks가 삭제되고 DcaFrequency 유니온이 weekly_1|weekly_2| monthly_1|2|3|6|12로 바뀌었으나 테스트가 옛 API 그대로 남아 있었다. 수정 내용: - getDcaWeeks describe 블록 → calculateDcaPeriods 테스트로 대체. 삭제된 함수의 의도(빈도 → 주기 매핑 검증)에 대응하는 현재 함수이며 그동안 테스트가 없었다. 현재 7개 빈도를 모두 커버한다. - weekly_4/8/12 → monthly_1/2/3. 기대값은 구현 (floor(기간일수 / 주기일수) + 1, weekly=7일 / monthly=30일 근사)에서 역산했다. 279일 기준 monthly_1=10회, monthly_2=5회, monthly_3=4회로 기존 테스트가 의도했던 10/5/4회 시나리오가 그대로 보존된다. - 산술을 설명하는 주석을 실제 일수 기준으로 갱신했다. 주의할 점: weekly_4를 쓰던 기존 테스트들이 "통과"하고 있었던 것은 getDcaPeriodInfo가 미등록 빈도에 대해 monthly_1(30일)로 조용히 폴백하기 때문이었다. 즉 monthly_1의 답을 우연히 계산하고 있었을 뿐 검증하는 바가 없었다. weekly_8/12가 실패한 것도 같은 폴백 탓이다. 프로덕션 코드는 건드리지 않았다. 검증: test:run 113 tests, portfolioCalculations 12건 전부 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/portfolioCalculations.test.ts | 80 ++++++++++--------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/backtest_fe/src/features/backtest/utils/__tests__/portfolioCalculations.test.ts b/backtest_fe/src/features/backtest/utils/__tests__/portfolioCalculations.test.ts index 5e489206..8042c83b 100644 --- a/backtest_fe/src/features/backtest/utils/__tests__/portfolioCalculations.test.ts +++ b/backtest_fe/src/features/backtest/utils/__tests__/portfolioCalculations.test.ts @@ -3,19 +3,18 @@ import { getDcaAdjustedTotal, getDcaAmountFromWeight, } from '../portfolioCalculations'; -import { getDcaWeeks } from '../../model/strategyConfig'; +import { calculateDcaPeriods } from '../calculateDcaPeriods'; import type { DcaFrequency } from '../../model/strategyConfig'; describe('portfolioCalculations', () => { - // 테스트 날짜: 39주 (279일) - // 2025-01-01 ~ 2025-10-07 = 279일 = 39주 + // 테스트 기간: 2025-01-01 ~ 2025-10-07 = 279일 const startDate = '2025-01-01'; const endDate = '2025-10-07'; - // DCA 횟수 계산: - // - weekly_4: 39주 / 4주 = 9, +1 = 10회 - // - weekly_8: 39주 / 8주 = 4, +1 = 5회 - // - weekly_12: 39주 / 12주 = 3, +1 = 4회 + // DCA 횟수 계산 (calculateDcaPeriods: floor(기간일수 / 주기일수) + 1, weekly=7일/monthly=30일 근사): + // - monthly_1 (30일): 279 / 30 = 9, +1 = 10회 + // - monthly_2 (60일): 279 / 60 = 4, +1 = 5회 + // - monthly_3 (90일): 279 / 90 = 3, +1 = 4회 describe('getDcaAdjustedTotal', () => { it('should calculate DCA-adjusted total correctly', () => { @@ -27,7 +26,7 @@ describe('portfolioCalculations', () => { { amount: 10000, investmentType: 'dca', - dcaFrequency: 'weekly_4', + dcaFrequency: 'monthly_1', }, { amount: 10000, @@ -37,7 +36,7 @@ describe('portfolioCalculations', () => { const total = getDcaAdjustedTotal(portfolio, startDate, endDate); - // 39주 / 4주 = 9, +1 = 10회 DCA + // 279일 / 30일 = 9, +1 = 10회 DCA // AAPL DCA: 10,000 × 10 = 100,000 // GOOGL lump_sum: 10,000 × 1 = 10,000 // 총액: 110,000 @@ -97,7 +96,7 @@ describe('portfolioCalculations', () => { { amount: 10000, investmentType: 'dca', - dcaFrequency: 'weekly_4', + dcaFrequency: 'monthly_1', }, { amount: 10000, @@ -120,12 +119,12 @@ describe('portfolioCalculations', () => { describe('getDcaAmountFromWeight', () => { it('should calculate DCA per-period amount from weight', () => { // 총 $20,000, AAPL 60% = $12,000 - // 39주 / 4주 = 9, +1 = 10회 DCA + // 279일 / 30일 = 9, +1 = 10회 DCA // 회당 금액 = 12,000 / 10 = 1,200 const amount = getDcaAmountFromWeight( 60, 20000, - 'weekly_4', + 'monthly_1', startDate, endDate ); @@ -139,40 +138,40 @@ describe('portfolioCalculations', () => { const amount = getDcaAmountFromWeight( 40, 20000, - 'weekly_4', + 'monthly_1', startDate, endDate ); // GOOGL 40% = $8,000 - // DCA로 계산: 8,000 / 10 = 800 + // DCA로 계산: 8,000 / 10회 = 800 expect(amount).toBe(800); }); it('should handle different DCA frequencies', () => { - // 8주 주기: 39주 / 8주 = 4, +1 = 5회 - const amount_8weeks = getDcaAmountFromWeight( + // 2개월 주기(60일): 279 / 60 = 4, +1 = 5회 + const amount_2months = getDcaAmountFromWeight( 60, 20000, - 'weekly_8', + 'monthly_2', startDate, endDate ); - // 12주 주기: 39주 / 12주 = 3, +1 = 4회 - const amount_12weeks = getDcaAmountFromWeight( + // 3개월 주기(90일): 279 / 90 = 3, +1 = 4회 + const amount_3months = getDcaAmountFromWeight( 60, 20000, - 'weekly_12', + 'monthly_3', startDate, endDate ); // 12,000 / 5 = 2,400 - expect(amount_8weeks).toBe(2400); + expect(amount_2months).toBe(2400); // 12,000 / 4 = 3,000 - expect(amount_12weeks).toBe(3000); + expect(amount_3months).toBe(3000); }); it('should return same amount when no dates provided', () => { @@ -180,27 +179,30 @@ describe('portfolioCalculations', () => { const amount = getDcaAmountFromWeight( 50, 20000, - 'weekly_4' + 'monthly_1' ); expect(amount).toBe(10000); // 50% of 20,000 }); }); - describe('getDcaWeeks', () => { - it('should return correct weeks for each frequency', () => { - expect(getDcaWeeks('weekly_1')).toBe(1); - expect(getDcaWeeks('weekly_2')).toBe(2); - expect(getDcaWeeks('weekly_4')).toBe(4); - expect(getDcaWeeks('weekly_8')).toBe(8); - expect(getDcaWeeks('weekly_12')).toBe(12); - expect(getDcaWeeks('weekly_24')).toBe(24); - expect(getDcaWeeks('weekly_48')).toBe(48); + describe('calculateDcaPeriods', () => { + it('should return correct period count for each frequency', () => { + // 기간 279일 기준, floor(279 / 주기일수) + 1 (첫 투자 포함) + expect(calculateDcaPeriods(startDate, endDate, 'weekly_1')).toBe(40); // 7일: 39 + 1 + expect(calculateDcaPeriods(startDate, endDate, 'weekly_2')).toBe(20); // 14일: 19 + 1 + expect(calculateDcaPeriods(startDate, endDate, 'monthly_1')).toBe(10); // 30일: 9 + 1 + expect(calculateDcaPeriods(startDate, endDate, 'monthly_2')).toBe(5); // 60일: 4 + 1 + expect(calculateDcaPeriods(startDate, endDate, 'monthly_3')).toBe(4); // 90일: 3 + 1 + expect(calculateDcaPeriods(startDate, endDate, 'monthly_6')).toBe(2); // 180일: 1 + 1 + expect(calculateDcaPeriods(startDate, endDate, 'monthly_12')).toBe(1); // 360일: 0 + 1 }); - it('should default to 1 week for unknown frequency', () => { - // getDcaWeeks는 DcaFrequency 타입으로 제한되므로 존재하는 빈도만 테스트 - expect(getDcaWeeks('weekly_1')).toBe(1); + it('should return at least 1 period when the range is shorter than one interval', () => { + // 기간이 주기보다 짧아도 첫 투자는 발생하므로 최소 1회 + expect(calculateDcaPeriods('2025-01-01', '2025-01-05', 'monthly_1')).toBe(1); + // 시작일 = 종료일인 경우에도 최소 1회 (Math.max(1, ...)) + expect(calculateDcaPeriods('2025-01-01', '2025-01-01', 'weekly_1')).toBe(1); }); }); @@ -215,7 +217,7 @@ describe('portfolioCalculations', () => { { amount: 10000, investmentType: 'dca', - dcaFrequency: 'weekly_4', + dcaFrequency: 'monthly_1', }, { amount: 10000, @@ -249,7 +251,7 @@ describe('portfolioCalculations', () => { const aapl_amount = getDcaAmountFromWeight( aapl_weight, totalInvestment, - 'weekly_4', + 'monthly_1', startDate, endDate ); @@ -257,12 +259,12 @@ describe('portfolioCalculations', () => { const googl_amount = getDcaAmountFromWeight( googl_weight, totalInvestment, - 'weekly_4', + 'monthly_1', startDate, endDate ); - // 39주 / 4주 = 9, +1 = 10회 + // 279일 / 30일 = 9, +1 = 10회 // AAPL: 60% × $20,000 = $12,000 / 10회 = $1,200 // GOOGL: 40% × $20,000 = $8,000 / 10회 = $800 expect(aapl_amount).toBe(1200); From b50d6589f9f53fd44f7aebc63fe2fb2802b0f032 Mon Sep 17 00:00:00 2001 From: kyj0503 Date: Sat, 1 Aug 2026 23:19:50 +0900 Subject: [PATCH 03/10] =?UTF-8?q?refactor(fe):=20df9b66c=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=86=93=EC=B9=9C=20=EC=A3=BD=EC=9D=80=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EB=B0=8F=20stale=20=EC=84=A4=EC=A0=95=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. src/shared/config/index.ts 삭제 (소비자 0개) grep으로 shared/config, AppConfig, WS_BASE_URL 모두 파일 자신 외 참조가 없음을 확인했다. 단순히 미사용인 것을 넘어 유해했다. 이 파일의 buildApiUrl은 기본값이 `${origin}/api`로 base.ts/client.ts가 세운 계약(빈 문자열)과 정반대이고, WS_BASE_URL은 이 프로젝트에 존재하지 않는 WebSocket 엔드포인트를 가리킨다. 다음 사람이 이걸 실제 설정으로 오해할 여지가 있었다. 2. base.ts의 export된 buildApiUrl 제거 (호출자 0개) 유일한 참조가 위에서 삭제한 죽은 모듈의 동명 지역 함수였다. client.ts가 쓰는 getApiBaseUrl은 유지한다. 3. backtest_fe/__tests__/recalcAmountsByWeight.test.ts를 src/features/backtest/model/__tests__/로 이동 src 밖에 있던 유일한 테스트 파일이다. tsconfig의 include가 "src"라 그동안 타입 체크를 전혀 받지 않고 vitest 실행만 되고 있었다. 이동으로 처음 타입 체크 범위에 들어온다. 함께 남아 있던 no-explicit-any 2건도 실제 타입(DcaFrequency, Stock)으로 교체했다. Stock.weight가 optional이라 reducer 원본과 동일하게 기본값 0으로 처리했다(수집 조건상 도달 불가, 동작 변화 없음). 4. vite.config.ts의 /api/v1/naver-news 프록시 규칙 제거 뉴스 기능 코드는 df9b66c에서 전부 삭제됐고 src에 참조가 없다. 검증: type-check 0 errors, eslint 3 problems(0 errors, 3 warnings) — lint 에러 2건 → 0, build 성공, test:run 113 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/recalcAmountsByWeight.test.ts | 25 ++++----- backtest_fe/src/shared/api/base.ts | 8 --- backtest_fe/src/shared/config/index.ts | 51 ------------------- backtest_fe/vite.config.ts | 4 -- 4 files changed, 13 insertions(+), 75 deletions(-) rename backtest_fe/{ => src/features/backtest/model}/__tests__/recalcAmountsByWeight.test.ts (89%) delete mode 100644 backtest_fe/src/shared/config/index.ts diff --git a/backtest_fe/__tests__/recalcAmountsByWeight.test.ts b/backtest_fe/src/features/backtest/model/__tests__/recalcAmountsByWeight.test.ts similarity index 89% rename from backtest_fe/__tests__/recalcAmountsByWeight.test.ts rename to backtest_fe/src/features/backtest/model/__tests__/recalcAmountsByWeight.test.ts index 0e7baf90..e8de6760 100644 --- a/backtest_fe/__tests__/recalcAmountsByWeight.test.ts +++ b/backtest_fe/src/features/backtest/model/__tests__/recalcAmountsByWeight.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { getDcaPeriodInfo } from '../src/features/backtest/model/constants/dcaConfig'; +import { DcaFrequency, getDcaPeriodInfo } from '../constants/dcaConfig'; +import { Stock } from '../types/backtest-form-types'; // DCA 주기를 근사 일수로 변환하는 헬퍼 함수 -const getDcaApproxDays = (frequency: string): number => { - const { type, interval } = getDcaPeriodInfo(frequency as any); +const getDcaApproxDays = (frequency: DcaFrequency): number => { + const { type, interval } = getDcaPeriodInfo(frequency); if (type === 'weekly') { return interval * 7; } else if (type === 'monthly') { @@ -13,7 +14,7 @@ const getDcaApproxDays = (frequency: string): number => { }; // reducer의 recalcAmountsByWeight 함수 복사 -const recalcAmountsByWeight = (portfolio: any[], totalInvestment: number, startDate?: string, endDate?: string) => { +const recalcAmountsByWeight = (portfolio: Stock[], totalInvestment: number, startDate?: string, endDate?: string) => { if (!startDate || !endDate || totalInvestment === 0) { // 날짜 정보 없으면 기본 계산 return portfolio.map(s => @@ -43,12 +44,12 @@ const recalcAmountsByWeight = (portfolio: any[], totalInvestment: number, startD weightIndices.forEach((index, pos) => { const s = portfolio[index]; const isLastWeightItem = pos === weightIndices.length - 1; - const totalAmountForStock = (s.weight / 100) * totalInvestment; + const totalAmountForStock = ((s.weight ?? 0) / 100) * totalInvestment; if (isLastWeightItem) { // 마지막 weight 항목: 오차 보정 (totalInvestment - 이전까지 누적) const correctedTotalAmount = totalInvestment - accumulatedTotal; - + if (s.investmentType === 'dca') { const intervalDays = getDcaApproxDays(s.dcaFrequency || 'monthly_1'); const dcaPeriods = Math.max(1, Math.floor(days / intervalDays) + 1); @@ -84,7 +85,7 @@ const recalcAmountsByWeight = (portfolio: any[], totalInvestment: number, startD describe('recalcAmountsByWeight', () => { it('should calculate correct DCA amounts for 50/50 portfolio with $10,000', () => { - const portfolio = [ + const portfolio: Stock[] = [ { symbol: 'AAPL', amount: 0, @@ -104,23 +105,23 @@ describe('recalcAmountsByWeight', () => { ]; const result = recalcAmountsByWeight(portfolio, 10000, '2025-01-01', '2025-10-31'); - + console.log('Portfolio after recalc:', result); - + // AAPL: $5,000 / 11 periods = $454.55 → $454 // GOOGL: ($10,000 - $4,994) / 11 = $455 expect(result[0].amount).toBeGreaterThan(0); expect(result[1].amount).toBeGreaterThan(0); - + // 검증: 각 종목의 총 투자액 계산 const aapl_total = result[0].amount * 11; // 회당 금액 × 11 periods const googl_total = result[1].amount * 11; // 회당 금액 × 11 periods const combined_total = aapl_total + googl_total; - + console.log(`AAPL: $${result[0].amount}/period × 11 = $${aapl_total}`); console.log(`GOOGL: $${result[1].amount}/period × 11 = $${googl_total}`); console.log(`Total: $${combined_total}`); - + // 총 투자액이 $10,000 근처여야 함 (±5%) expect(combined_total).toBeGreaterThanOrEqual(9500); expect(combined_total).toBeLessThanOrEqual(10500); diff --git a/backtest_fe/src/shared/api/base.ts b/backtest_fe/src/shared/api/base.ts index 7990e81b..9127c223 100644 --- a/backtest_fe/src/shared/api/base.ts +++ b/backtest_fe/src/shared/api/base.ts @@ -6,11 +6,3 @@ export const getApiBaseUrl = (): string => { // 빈 문자열 반환: backtestService.ts에서 전체 경로(/api/v1/...)를 사용 return ''; }; - -export const buildApiUrl = (path: string): string => { - const base = getApiBaseUrl(); - if (!path.startsWith('/')) { - return `${base}/${path}`; - } - return `${base}${path}`; -}; diff --git a/backtest_fe/src/shared/config/index.ts b/backtest_fe/src/shared/config/index.ts deleted file mode 100644 index 921c4a2e..00000000 --- a/backtest_fe/src/shared/config/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * 환경 변수 및 애플리케이션 설정 - */ - -interface AppConfig { - readonly API_BASE_URL: string; - readonly WS_BASE_URL: string; - readonly NODE_ENV: 'development' | 'production' | 'test'; - readonly IS_DEVELOPMENT: boolean; - readonly IS_PRODUCTION: boolean; - readonly IS_TEST: boolean; - readonly APP_VERSION: string; - readonly BUILD_TIME: string; -} - -// API URL 빌드 함수 -const buildApiUrl = (): string => { - const baseUrl = import.meta.env.VITE_API_BASE_URL; - - // 상대 경로인 경우 현재 origin 사용 - if (baseUrl?.startsWith('/')) { - return `${window.location.origin}${baseUrl}`; - } - - // 절대 URL인 경우 그대로 사용 - if (baseUrl?.startsWith('http')) { - return baseUrl; - } - - // 기본값: 현재 origin의 /api - return `${window.location.origin}/api`; -}; - -// WebSocket URL 빌드 함수 -const buildWsUrl = (): string => { - const baseUrl = buildApiUrl(); - return baseUrl.replace(/^http/, 'ws') + '/ws'; -}; - -export const config: AppConfig = { - API_BASE_URL: buildApiUrl(), - WS_BASE_URL: buildWsUrl(), - NODE_ENV: (import.meta.env.MODE as AppConfig['NODE_ENV']) || 'development', - IS_DEVELOPMENT: import.meta.env.MODE === 'development', - IS_PRODUCTION: import.meta.env.MODE === 'production', - IS_TEST: import.meta.env.MODE === 'test', - APP_VERSION: import.meta.env.VITE_APP_VERSION || '1.0.0', - BUILD_TIME: import.meta.env.VITE_BUILD_TIME || new Date().toISOString(), -}; - -export default config; \ No newline at end of file diff --git a/backtest_fe/vite.config.ts b/backtest_fe/vite.config.ts index 1fb2748e..5b2225c2 100644 --- a/backtest_fe/vite.config.ts +++ b/backtest_fe/vite.config.ts @@ -40,10 +40,6 @@ export default defineConfig(({ mode }) => ({ target: FASTAPI_TARGET, changeOrigin: true, }, - '/api/v1/naver-news': { - target: FASTAPI_TARGET, - changeOrigin: true, - }, } }, build: { From c0ecbce73e86bd27b1f9fdb5860b3847115acc93 Mon Sep 17 00:00:00 2001 From: kyj0503 Date: Sat, 1 Aug 2026 23:26:34 +0900 Subject: [PATCH 04/10] =?UTF-8?q?test(fe):=20=EB=8B=A4=ED=81=AC=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=ED=86=A0=EA=B8=80=20opt-in=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=9D=B4=ED=9B=84=20=EB=B0=A9=EC=B9=98=EB=90=9C=20?= =?UTF-8?q?ThemeSelector=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f0ba94a(2025-11-11, "다크 모드 토글 비활성화")에서 토글이 showDarkModeToggle prop(기본값 false)으로 감싸졌으나 테스트는 를 prop 없이 렌더한 채 남아 있었다. 즉 토글 버튼이 아예 마운트되지 않았고, getByRole이 이름 불일치가 아니라 대상 부재로 실패하고 있었다. 그날 이후 계속 실패 상태였다. 프로덕션은 정상이다. 유일한 소비자인 Header.tsx도 showDarkModeToggle={false}를 명시하고 있어 opt-in이 의도된 설계다. Tailwind 4 마이그레이션(38d6a8e)과는 무관하다 — 다크 모드는 useTheme의 classList 기반이라 영향을 받지 않는다. 테스트를 showDarkModeToggle을 켜서 렌더하도록 고치고, 클릭 시 toggleDarkMode가 1회 호출되는지 검증하는 원래 의도는 유지했다. 추가로 기본 렌더에서는 토글이 없음을 먼저 확인하도록 보강해 opt-in 계약이 양방향으로 고정되게 했다. 검증: test:run 113 passed / 0 failed (16 files). Co-Authored-By: Claude Opus 5 (1M context) --- .../components/layout/__tests__/ThemeSelector.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backtest_fe/src/shared/components/layout/__tests__/ThemeSelector.test.tsx b/backtest_fe/src/shared/components/layout/__tests__/ThemeSelector.test.tsx index 4bf42755..43c74887 100644 --- a/backtest_fe/src/shared/components/layout/__tests__/ThemeSelector.test.tsx +++ b/backtest_fe/src/shared/components/layout/__tests__/ThemeSelector.test.tsx @@ -137,7 +137,13 @@ describe('ThemeSelector', () => { it('toggles dark mode when the toggle button is clicked', async () => { const user = userEvent.setup() - render() + + // 다크 모드 토글은 opt-in이라 기본값에서는 렌더링되지 않는다 + const { unmount } = render() + expect(screen.queryByRole('button', { name: /라이트|다크/ })).not.toBeInTheDocument() + unmount() + + render() await user.click(screen.getByRole('button', { name: /라이트/ })) From 2ab58fbc21d819340bd9d236fdd57d651da65aad Mon Sep 17 00:00:00 2001 From: kyj0503 Date: Sat, 1 Aug 2026 23:26:59 +0900 Subject: [PATCH 05/10] =?UTF-8?q?chore(fe):=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=ED=83=80=EC=9E=85=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=20=EB=8F=84=EC=9E=85=20(tsconfig.test.json)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 배경 테스트 파일은 그동안 타입 체크를 전혀 받지 않았다. type-check는 tsconfig.build.json 기준인데 이 설정이 *.test.ts(x)와 __tests__/를 제외하고, vitest는 타입 체크를 하지 않는다. 그 결과 삭제된 함수를 import하는 테스트가 8개월간 방치됐다 (c178a59 참고). 런타임 실패로만 드러나므로 발견이 늦는다. ## 변경 - tsconfig.test.json 추가. build 설정이 제외하는 것(테스트 파일, src/test/**, vite/vitest config)을 정확히 반대로 포함한다. globals: true에 맞춰 types에 vitest/globals, node, @testing-library/jest-dom를 지정하고, types를 좁히면 vite/client가 빠지므로 src/vite-env.d.ts를 명시적으로 포함한다. references: []로 tsconfig.node.json 프로젝트 참조를 끊는다. - npm script type-check:test 추가. 기존 type-check는 그대로 둔다. - 이로써 드러난 타입 오류 41건 수정. strict 옵션은 하나도 낮추지 않았고 any/ts-ignore/ts-expect-error도 쓰지 않았다. ## 오류 유형별 수정 - Record 제약 위반 10건 (useForm.test.ts): interface는 암묵적 인덱스 시그니처를 못 받으므로 지역 타입을 interface → type으로 바꿨다. 프로덕션 제약은 정당하므로 유지. - 배열 인덱싱 possibly undefined 21건: `!` 대신 assert.isDefined와 길이/식별자 검증을 앞에 두는 방식으로 고쳤다. 타입을 좁히면서 런타임 검증도 늘어난다. - 구조가 어긋난 mock 6건 (UnifiedInfoSection.test.tsx): VolatilityEvent의 필수 필드 volume이 빠져 있었다. 이미 발생해 있던 drift다. satisfies로 고정해 이후 타입 변경 시 컴파일에서 깨지게 했다. - MSW 요청 바디 무타입 1건: http.post로 소스에서 타입을 주도록 바꿨다. - toISOString().split('T')[0] 1건 → slice(0, 10). ## 부수 효과: 테스트가 실제로 강해진 곳 - backtestFormReducer / useBacktestForm: portfolio[0] 순서를 암묵적으로 가정하던 단언이 이제 길이와 symbol을 먼저 검증한다. 리듀서가 항목을 재배열하거나 누락시키면 명확한 메시지로 실패한다. - recalcAmountsByWeight: 비중 재계산이 포트폴리오 형태를 보존하는지 검증하는 단언이 없었는데 추가됐다. ## 검증 type-check:test 0 errors, type-check 0 errors, eslint 3 problems(0 errors, 3 warnings), test:run 113 passed, build 성공. 동작 확인: 삭제된 함수를 import하도록 일부러 되돌려 보면 type-check:test가 TS2305로 실패하고(EXIT=2), 같은 상태에서 기존 type-check는 EXIT=0으로 통과한다. 메우려던 구멍이 실재했음이 확인된다. ## 남은 과제 (이번 범위 밖) recalcAmountsByWeight.test.ts는 backtestFormReducer.ts 안의 module-private 함수를 손으로 복사해 두고 그 복사본을 테스트한다. 원본과 조용히 갈라질 수 있다. 실제 함수를 export하고 복사본을 지우는 후속 작업이 필요하다. Co-Authored-By: Claude Opus 5 (1M context) --- backtest_fe/package.json | 1 + .../__tests__/UnifiedInfoSection.test.tsx | 8 +++-- .../hooks/__tests__/useBacktestForm.test.ts | 11 ++++-- .../__tests__/backtestFormReducer.test.ts | 34 +++++++++++++----- .../__tests__/recalcAmountsByWeight.test.ts | 36 ++++++++++--------- .../backtestService.integration.test.ts | 2 +- .../__tests__/portfolioCalculations.test.ts | 10 ++++-- .../src/lib/__tests__/chartUtils.test.ts | 4 +-- .../shared/hooks/__tests__/useForm.test.ts | 6 ++-- backtest_fe/tsconfig.test.json | 21 +++++++++++ 10 files changed, 94 insertions(+), 39 deletions(-) create mode 100644 backtest_fe/tsconfig.test.json diff --git a/backtest_fe/package.json b/backtest_fe/package.json index f409f48c..c6551b3f 100644 --- a/backtest_fe/package.json +++ b/backtest_fe/package.json @@ -89,6 +89,7 @@ "lint:fix": "eslint . --fix", "preview": "vite preview --host", "type-check": "tsc --noEmit -p tsconfig.build.json", + "type-check:test": "tsc --noEmit -p tsconfig.test.json", "clean": "rm -rf dist node_modules/.vite", "test": "vitest", "test:run": "vitest run", diff --git a/backtest_fe/src/features/backtest/components/results/__tests__/UnifiedInfoSection.test.tsx b/backtest_fe/src/features/backtest/components/results/__tests__/UnifiedInfoSection.test.tsx index b2983dac..b1446cc7 100644 --- a/backtest_fe/src/features/backtest/components/results/__tests__/UnifiedInfoSection.test.tsx +++ b/backtest_fe/src/features/backtest/components/results/__tests__/UnifiedInfoSection.test.tsx @@ -14,6 +14,8 @@ import { describe, it, expect } from 'vitest'; import { render } from '@testing-library/react'; import UnifiedInfoSection from '../UnifiedInfoSection'; +import type { NewsItem } from '../../../model/types/backtest-result-types'; +import type { VolatilityEvent } from '../../../model/types/volatility-news-types'; describe('UnifiedInfoSection', () => { const mockVolatilityEvents = { @@ -22,10 +24,11 @@ describe('UnifiedInfoSection', () => { date: '2023-01-05', daily_return: 7.5, close_price: 150.25, + volume: 98_000_000, event_type: '급등', }, ], - }; + } satisfies Record; const mockLatestNews = { AAPL: [ @@ -36,7 +39,7 @@ describe('UnifiedInfoSection', () => { pubDate: '2023-01-12', }, ], - }; + } satisfies Record; describe('정상 렌더링', () => { it('급등락 이벤트와 뉴스가 있을 때 컴포넌트를 렌더링한다', () => { @@ -73,6 +76,7 @@ describe('UnifiedInfoSection', () => { date: '2023-01-08', daily_return: 5.8, close_price: 105.30, + volume: 21_000_000, event_type: '급등', }], }; diff --git a/backtest_fe/src/features/backtest/hooks/__tests__/useBacktestForm.test.ts b/backtest_fe/src/features/backtest/hooks/__tests__/useBacktestForm.test.ts index 22d8ab04..caf234cc 100644 --- a/backtest_fe/src/features/backtest/hooks/__tests__/useBacktestForm.test.ts +++ b/backtest_fe/src/features/backtest/hooks/__tests__/useBacktestForm.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, assert } from 'vitest' import { renderHook, waitFor, act } from '@testing-library/react' import { useBacktestForm } from '../useBacktestForm' @@ -41,8 +41,13 @@ describe('useBacktestForm', () => { }) expect(result.current.state.portfolioInputMode).toBe('weight') - expect(result.current.state.portfolio[0].amount).toBe(10000) - expect(result.current.state.portfolio[1].amount).toBe(10000) + expect(result.current.state.portfolio).toHaveLength(2) + const [first, second] = result.current.state.portfolio + assert.isDefined(first) + assert.isDefined(second) + expect(second.symbol).toBe('MSFT') + expect(first.amount).toBe(10000) + expect(second.amount).toBe(10000) expect(result.current.helpers.getTotalAmount()).toBe(20000) }) diff --git a/backtest_fe/src/features/backtest/model/__tests__/backtestFormReducer.test.ts b/backtest_fe/src/features/backtest/model/__tests__/backtestFormReducer.test.ts index 209893d6..cbf25135 100644 --- a/backtest_fe/src/features/backtest/model/__tests__/backtestFormReducer.test.ts +++ b/backtest_fe/src/features/backtest/model/__tests__/backtestFormReducer.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, assert } from 'vitest' import { backtestFormReducer, backtestFormHelpers } from '../backtestFormReducer' import { initialBacktestFormState, type BacktestFormState } from '../types/backtest-form-types' import { ASSET_TYPES } from '../strategyConfig' @@ -31,10 +31,16 @@ describe('backtestFormReducer', () => { payload: 'weight', }) - expect(nextState.portfolio[0].weight).toBe(60) - expect(nextState.portfolio[1].weight).toBe(40) - expect(nextState.portfolio[0].amount).toBe(6000) - expect(nextState.portfolio[1].amount).toBe(4000) + expect(nextState.portfolio).toHaveLength(2) + const [aapl, msft] = nextState.portfolio + assert.isDefined(aapl) + assert.isDefined(msft) + expect(aapl.symbol).toBe('AAPL') + expect(msft.symbol).toBe('MSFT') + expect(aapl.weight).toBe(60) + expect(msft.weight).toBe(40) + expect(aapl.amount).toBe(6000) + expect(msft.amount).toBe(4000) expect(nextState.portfolioInputMode).toBe('weight') }) @@ -67,8 +73,14 @@ describe('backtestFormReducer', () => { }) expect(nextState.totalInvestment).toBe(20000) - expect(nextState.portfolio[0].amount).toBe(12000) - expect(nextState.portfolio[1].amount).toBe(8000) + expect(nextState.portfolio).toHaveLength(2) + const [aapl, msft] = nextState.portfolio + assert.isDefined(aapl) + assert.isDefined(msft) + expect(aapl.symbol).toBe('AAPL') + expect(msft.symbol).toBe('MSFT') + expect(aapl.amount).toBe(12000) + expect(msft.amount).toBe(8000) }) it('clears weight when updating amounts directly in amount mode', () => { @@ -90,8 +102,12 @@ describe('backtestFormReducer', () => { payload: { index: 0, field: 'amount', value: 15000 }, }) - expect(updated.portfolio[0].amount).toBe(15000) - expect(updated.portfolio[0].weight).toBeUndefined() + expect(updated.portfolio).toHaveLength(1) + const [updatedStock] = updated.portfolio + assert.isDefined(updatedStock) + expect(updatedStock.symbol).toBe('AAPL') + expect(updatedStock.amount).toBe(15000) + expect(updatedStock.weight).toBeUndefined() }) }) diff --git a/backtest_fe/src/features/backtest/model/__tests__/recalcAmountsByWeight.test.ts b/backtest_fe/src/features/backtest/model/__tests__/recalcAmountsByWeight.test.ts index e8de6760..ca0c3d95 100644 --- a/backtest_fe/src/features/backtest/model/__tests__/recalcAmountsByWeight.test.ts +++ b/backtest_fe/src/features/backtest/model/__tests__/recalcAmountsByWeight.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, assert } from 'vitest'; import { DcaFrequency, getDcaPeriodInfo } from '../constants/dcaConfig'; import { Stock } from '../types/backtest-form-types'; @@ -30,20 +30,19 @@ const recalcAmountsByWeight = (portfolio: Stock[], totalInvestment: number, star const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24)); // Step 1: weight 항목들만 먼저 처리해서 총 투자액 누적 - const weightIndices: number[] = []; + const weightEntries: { index: number; stock: Stock }[] = []; let accumulatedTotal = 0; const results = new Map(); // index -> amount portfolio.forEach((s, index) => { if (typeof s.weight === 'number') { - weightIndices.push(index); + weightEntries.push({ index, stock: s }); } }); // Step 2: weight 항목들의 비중 기반 투자액 계산 (마지막은 오차 보정) - weightIndices.forEach((index, pos) => { - const s = portfolio[index]; - const isLastWeightItem = pos === weightIndices.length - 1; + weightEntries.forEach(({ index, stock: s }, pos) => { + const isLastWeightItem = pos === weightEntries.length - 1; const totalAmountForStock = ((s.weight ?? 0) / 100) * totalInvestment; if (isLastWeightItem) { @@ -76,8 +75,9 @@ const recalcAmountsByWeight = (portfolio: Stock[], totalInvestment: number, star // Step 3: 최종 결과 반영 return portfolio.map((s, index) => { - if (results.has(index)) { - return { ...s, amount: results.get(index)! }; + const amount = results.get(index); + if (amount !== undefined) { + return { ...s, amount }; } return s; }); @@ -106,22 +106,24 @@ describe('recalcAmountsByWeight', () => { const result = recalcAmountsByWeight(portfolio, 10000, '2025-01-01', '2025-10-31'); - console.log('Portfolio after recalc:', result); + // 입력 종목 수만큼 그대로 반환되어야 한다 (항목 유실/추가 금지) + expect(result).toHaveLength(2); + const [aapl, googl] = result; + assert.isDefined(aapl); + assert.isDefined(googl); + expect(aapl.symbol).toBe('AAPL'); + expect(googl.symbol).toBe('GOOGL'); // AAPL: $5,000 / 11 periods = $454.55 → $454 // GOOGL: ($10,000 - $4,994) / 11 = $455 - expect(result[0].amount).toBeGreaterThan(0); - expect(result[1].amount).toBeGreaterThan(0); + expect(aapl.amount).toBeGreaterThan(0); + expect(googl.amount).toBeGreaterThan(0); // 검증: 각 종목의 총 투자액 계산 - const aapl_total = result[0].amount * 11; // 회당 금액 × 11 periods - const googl_total = result[1].amount * 11; // 회당 금액 × 11 periods + const aapl_total = aapl.amount * 11; // 회당 금액 × 11 periods + const googl_total = googl.amount * 11; // 회당 금액 × 11 periods const combined_total = aapl_total + googl_total; - console.log(`AAPL: $${result[0].amount}/period × 11 = $${aapl_total}`); - console.log(`GOOGL: $${result[1].amount}/period × 11 = $${googl_total}`); - console.log(`Total: $${combined_total}`); - // 총 투자액이 $10,000 근처여야 함 (±5%) expect(combined_total).toBeGreaterThanOrEqual(9500); expect(combined_total).toBeLessThanOrEqual(10500); diff --git a/backtest_fe/src/features/backtest/services/__tests__/backtestService.integration.test.ts b/backtest_fe/src/features/backtest/services/__tests__/backtestService.integration.test.ts index c68a2fa2..f59fa0b6 100644 --- a/backtest_fe/src/features/backtest/services/__tests__/backtestService.integration.test.ts +++ b/backtest_fe/src/features/backtest/services/__tests__/backtestService.integration.test.ts @@ -68,7 +68,7 @@ describe('BacktestService (integration)', () => { } server.use( - http.post(`${TEST_BASE_URL}/api/v1/backtest`, async ({ request }) => { + http.post(`${TEST_BASE_URL}/api/v1/backtest`, async ({ request }) => { capturedBody = await request.json() return HttpResponse.json(mockResponse) }) diff --git a/backtest_fe/src/features/backtest/utils/__tests__/portfolioCalculations.test.ts b/backtest_fe/src/features/backtest/utils/__tests__/portfolioCalculations.test.ts index 8042c83b..6c1bbde6 100644 --- a/backtest_fe/src/features/backtest/utils/__tests__/portfolioCalculations.test.ts +++ b/backtest_fe/src/features/backtest/utils/__tests__/portfolioCalculations.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, assert } from 'vitest'; import { getDcaAdjustedTotal, getDcaAmountFromWeight, @@ -226,8 +226,12 @@ describe('portfolioCalculations', () => { ]; const total = getDcaAdjustedTotal(portfolio, startDate, endDate); - const aapl_weight = (portfolio[0].amount / total) * 100; - const googl_weight = (portfolio[1].amount / total) * 100; + expect(portfolio).toHaveLength(2); + const [dcaEntry, lumpSumEntry] = portfolio; + assert.isDefined(dcaEntry); + assert.isDefined(lumpSumEntry); + const aapl_weight = (dcaEntry.amount / total) * 100; + const googl_weight = (lumpSumEntry.amount / total) * 100; // 총액: 110,000 // AAPL: 10,000 / 110,000 = 9.09% diff --git a/backtest_fe/src/lib/__tests__/chartUtils.test.ts b/backtest_fe/src/lib/__tests__/chartUtils.test.ts index e5741aaa..2183ef56 100644 --- a/backtest_fe/src/lib/__tests__/chartUtils.test.ts +++ b/backtest_fe/src/lib/__tests__/chartUtils.test.ts @@ -25,8 +25,8 @@ const formatChartDate = (dateString: string, format: 'short' | 'long' = 'short') // 짧은 형식: MM/DD return `${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')}` } else { - // 긴 형식: YYYY-MM-DD - return date.toISOString().split('T')[0] + // 긴 형식: YYYY-MM-DD (ISO 문자열의 앞 10자) + return date.toISOString().slice(0, 10) } } diff --git a/backtest_fe/src/shared/hooks/__tests__/useForm.test.ts b/backtest_fe/src/shared/hooks/__tests__/useForm.test.ts index 3bb51c2b..0c764b85 100644 --- a/backtest_fe/src/shared/hooks/__tests__/useForm.test.ts +++ b/backtest_fe/src/shared/hooks/__tests__/useForm.test.ts @@ -6,7 +6,9 @@ import { describe, it, expect, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useForm } from '../useForm' -interface TestFormData { +// `useForm` requires `T extends Record`. +// A type alias of an object literal gets an implicit index signature; an interface does not. +type TestFormData = { name: string email: string age: number @@ -151,7 +153,7 @@ describe('useForm', () => { }) it('should create checkbox handlers', () => { - interface CheckboxForm { + type CheckboxForm = { isChecked: boolean } diff --git a/backtest_fe/tsconfig.test.json b/backtest_fe/tsconfig.test.json new file mode 100644 index 00000000..0467e6b0 --- /dev/null +++ b/backtest_fe/tsconfig.test.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + /* vitest.config.ts sets `globals: true`, so describe/it/expect/vi are ambient. */ + /* vite.config.ts / vitest.config.ts use `process` and `__dirname` -> node types. */ + "types": ["vitest/globals", "node", "@testing-library/jest-dom"] + }, + "include": [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/__tests__/**/*.ts", + "src/**/__tests__/**/*.tsx", + "src/test/**/*.ts", + "src/test/**/*.tsx", + "src/vite-env.d.ts", + "vite.config.ts", + "vitest.config.ts" + ], + "references": [] +} From efd5960063f693595a100b5cca91889dc7530b45 Mon Sep 17 00:00:00 2001 From: kyj0503 Date: Sun, 2 Aug 2026 10:27:00 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix(fe):=20isolate:false=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=20flaky=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8A=A4=EC=9C=84=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 커밋에서 npm run test:run을 반복하면 113 passed와 3 failed가 번갈아 나온다. 4회 연속 실행 결과: 113 / 110 / 113 / 110. 깨끗한 도커 빌드에서는 최대 9건까지 실패했다 (routing 5, ThemeSelector 3, backtestService.integration 1). 원인은 vitest.config.ts의 isolate: false다. 모든 테스트 파일이 하나의 happy-dom 환경을 공유하는데, vitest는 직전 실행의 파일별 소요시간을 캐시해 실행 순서를 조정한다. 순서가 실행마다 바뀌면서 오염 양상이 달라지고, 그 결과 통과 여부가 뒤집힌다. 개별 파일만 돌리면 항상 통과하므로(routing 5 passed) 테스트 자체나 프로덕션 코드의 문제가 아니다. 전체 스위트를 공유 환경에서 돌릴 때만 드러난다. isolate를 vitest 기본값인 true로 되돌린다. 격리 비용이 붙지만 결과를 신뢰할 수 있는 편이 우선이다. 검증: dev 컨테이너에서 5회 연속 113 passed (편차 없음). 깨끗한 도커 빌드에서도 113 passed. 주의: 이 커밋 이전의 "113 전건 통과" 검증 결과들은 운이었다. 같은 명령이 실행마다 다른 답을 냈으므로 증거로서 가치가 없었다. Co-Authored-By: Claude Opus 5 (1M context) --- backtest_fe/vitest.config.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backtest_fe/vitest.config.ts b/backtest_fe/vitest.config.ts index 3f0ae2da..1b923561 100644 --- a/backtest_fe/vitest.config.ts +++ b/backtest_fe/vitest.config.ts @@ -17,7 +17,15 @@ export default defineConfig({ pool: 'forks', // Vitest 4: poolOptions 제거됨. singleFork: true === maxWorkers: 1 + isolate: false maxWorkers: 1, - isolate: false, + // isolate: false는 쓰지 않는다. + // + // 모든 테스트 파일이 하나의 happy-dom 환경을 공유하게 되는데, vitest는 + // 직전 실행 소요시간을 캐시해 파일 실행 순서를 조정하므로 순서가 실행마다 + // 바뀐다. 그 결과 스위트가 flaky해진다 — 같은 커밋에서 113 passed와 + // 3 failed가 번갈아 나오는 것을 확인했다(routing / ThemeSelector / + // backtestService.integration, 최대 9건까지 실패). + // 격리 비용보다 결과를 믿을 수 있는 편이 중요하다. + isolate: true, sequence: { concurrent: false, }, From 7fa1154165cc1e37ea9184f2c421861c9853c228 Mon Sep 17 00:00:00 2001 From: kyj0503 Date: Sun, 2 Aug 2026 10:27:29 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix(fe):=20=EB=A1=9C=EC=BB=AC=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=EA=B0=80=20=EA=B0=9C=EB=B0=9C=EC=9A=A9=20React=20?= =?UTF-8?q?=EB=B2=88=EB=93=A4=EC=9D=84=20=EB=82=B4=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev 컨테이너에서 npm run build를 돌리면 Jenkins와 산출물이 달랐다. 로컬: 2511 modules, react-vendor 422.18 kB, CSS 73.42 kB Jenkins: 2506 modules, react-vendor 229.94 kB, CSS 70.78 kB 원인은 Dockerfile.dev의 ENV NODE_ENV=development다. dev 서버에는 맞는 설정이지만 docker compose exec으로 실행한 빌드까지 새어 들어가, vite가 이미 설정된 NODE_ENV를 존중해 React 개발 빌드를 번들에 포함시키고 있었다. Jenkins는 NODE_ENV가 없어 정상 동작했으므로 배포물에는 영향이 없었다. 문제는 로컬 빌드 검증이 CI와 다른 산출물을 대상으로 이뤄졌다는 점이다. build 스크립트에서 NODE_ENV=production을 명시해 주변 환경과 무관하게 결정적으로 동작하게 한다. 검증: NODE_ENV=development인 dev 컨테이너에서 빌드해도 2506 modules / react-vendor 229.94 kB로, Jenkins 빌드와 청크 해시까지 동일하다(react-vendor-BsCdM4_4, chart-vendor-DwUT2tqu). Co-Authored-By: Claude Opus 5 (1M context) --- backtest_fe/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backtest_fe/package.json b/backtest_fe/package.json index c6551b3f..7cee4c24 100644 --- a/backtest_fe/package.json +++ b/backtest_fe/package.json @@ -83,7 +83,7 @@ }, "scripts": { "dev": "vite --host", - "build": "tsc -p tsconfig.build.json && vite build", + "build": "tsc -p tsconfig.build.json && NODE_ENV=production vite build", "build:analyze": "tsc -p tsconfig.build.json && vite build --mode analyze", "lint": "eslint . --report-unused-disable-directives --max-warnings 0", "lint:fix": "eslint . --fix", From 3c80c84e71c860bd4701e4ff51cb2a299881711d Mon Sep 17 00:00:00 2001 From: kyj0503 Date: Sun, 2 Aug 2026 10:27:50 +0900 Subject: [PATCH 08/10] =?UTF-8?q?chore(infra):=20CI=EC=97=90=20lint=C2=B7?= =?UTF-8?q?=ED=83=80=EC=9E=85=EC=B2=B4=ED=81=AC=C2=B7=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지금까지 Jenkins는 빌드/푸시/배포와 헬스체크만 했다. 테스트가 깨져도 배포됐고, 실제로 8개월간 깨진 테스트가 배포를 막지 못했다. lint 에러 0 / 테스트 전건 통과가 된 지금이 게이트를 걸 시점이다. ## 방식 Jenkins 에이전트에 node/python이 있다고 가정하지 않도록, 각 Dockerfile에 test 스테이지를 두고 CI가 --target test로 호출한다. - backtest_fe/Dockerfile: deps → test / build → nginx로 분리. test는 lint, type-check, type-check:test, vitest를 각각 별도 RUN으로 돌려 어디서 깨졌는지 로그에서 바로 보이게 한다. - backtest_be_fast/Dockerfile: base → test / runtime으로 분리. DB가 필요 없는 pytest tests/unit만 돌린다. 두 test 스테이지 모두 최종 이미지의 의존 경로에 없다. 따라서 docker build(타깃 미지정)로는 실행되지 않아 기존 빌드 동작과 산출물이 그대로다. BE runtime 이미지에 tests가 포함되지 않는 것도 확인했다. deps/base 레이어는 뒤이은 이미지 빌드가 재사용하므로 의존성 설치가 두 번 돌지 않는다. Jenkinsfile에는 Login GHCR 앞에 Quality Gate 스테이지를 두고 FE/BE를 parallel로 돌린다. 실패하면 이미지 빌드와 배포에 도달하지 못한다. ## lint 상한을 0 → 3으로 npm run lint는 --max-warnings 0이라 exhaustive-deps 경고 3건 때문에 현재 어떤 경우에도 통과할 수 없었다. 통과 불가능한 게이트는 무의미하고, 그렇다고 동작이 바뀔 수 있는 훅 수정을 이 커밋에 섞는 것은 더 나쁘다. 현재 개수를 상한으로 고정하는 래칫으로 바꾼다. 에러 0은 그대로 강제하고 경고가 늘어나는 것을 막는다. 남은 3건을 해소하면서 상한을 0까지 내리는 것이 목표다. src/features/backtest/hooks/useStrategyParams.ts:55, :90 src/shared/hooks/useAsync.ts:85 ## .dockerignore 추가 (FE) FE에는 .dockerignore가 아예 없어 node_modules, dist, .git이 빌드 컨텍스트로 들어가고 있었다. Jenkins는 매번 새로 clone하므로 드러나지 않았지만 로컬 빌드에서는 호스트 산출물이 섞일 수 있다. ## 검증 - FE/BE 각 --target test 통과 (FE 113 passed, BE 141 passed). - 기본 타깃 빌드 정상, 배포 이미지 산출물 동일. - 차단 동작 확인: 일부러 실패하는 테스트를 넣으면 FE/BE 게이트 모두 exit 1로 빌드가 중단된다. 원복 후 다시 통과. Co-Authored-By: Claude Opus 5 (1M context) --- Jenkinsfile | 23 +++++++++++++++++++++++ backtest_be_fast/Dockerfile | 20 +++++++++++++++++++- backtest_fe/.dockerignore | 8 ++++++++ backtest_fe/Dockerfile | 24 +++++++++++++++++++++--- backtest_fe/package.json | 2 +- 5 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 backtest_fe/.dockerignore diff --git a/Jenkinsfile b/Jenkinsfile index 89ad6af2..ccdda125 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -21,6 +21,29 @@ pipeline { } } + stage('Quality Gate') { + steps { + script { + // 각 Dockerfile의 test 스테이지를 돌린다. + // - FE: lint / type-check / type-check:test / vitest + // - BE: pytest tests/unit (DB 불필요) + // 실패하면 이미지 빌드와 배포에 도달하지 못한다. + // + // test 스테이지는 최종 이미지의 의존 경로에 없으므로 --target으로 + // 명시해야 실행된다. deps/base 레이어는 뒤이은 이미지 빌드가 + // 그대로 재사용하므로 의존성 설치가 두 번 돌지 않는다. + parallel( + 'Frontend': { + sh 'docker build --target test ./backtest_fe' + }, + 'Backend': { + sh 'docker build --target test ./backtest_be_fast' + } + ) + } + } + } + stage('Login GHCR') { steps { script { diff --git a/backtest_be_fast/Dockerfile b/backtest_be_fast/Dockerfile index ff04ca8c..2f225664 100644 --- a/backtest_be_fast/Dockerfile +++ b/backtest_be_fast/Dockerfile @@ -1,6 +1,7 @@ # syntax=docker/dockerfile:1 # Python 3.11 슬림 이미지 사용 -FROM python:3.11-slim +# base: 의존성까지만 설치한 공통 기반 (test / runtime이 공유) +FROM python:3.11-slim AS base # 작업 디렉터리 설정 WORKDIR /app @@ -40,6 +41,23 @@ COPY requirements-test.txt . RUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements-test.txt +# 품질 게이트 +# +# runtime이 base에서 갈라져 나오므로 이 스테이지는 최종 이미지의 의존 경로에 +# 없다. 따라서 `docker build`(타깃 미지정)로는 실행되지 않고, CI가 +# `docker build --target test`로 명시적으로 호출한다. +# DB가 필요 없는 unit 마커만 돌린다 (tests/integration은 MySQL을 요구). +FROM base AS test + +COPY app ./app +COPY tests ./tests +COPY pytest.ini ./ + +RUN pytest tests/unit -q + +# runtime: 실제 배포 이미지 (마지막 스테이지 = 기본 빌드 타깃) +FROM base AS runtime + # 애플리케이션 코드 복사 COPY app ./app COPY scripts ./scripts diff --git a/backtest_fe/.dockerignore b/backtest_fe/.dockerignore new file mode 100644 index 00000000..2bb3a1f7 --- /dev/null +++ b/backtest_fe/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +coverage +playwright-report +test-results +.git +.env +.env.* diff --git a/backtest_fe/Dockerfile b/backtest_fe/Dockerfile index b9a0a88f..e6d5bc58 100644 --- a/backtest_fe/Dockerfile +++ b/backtest_fe/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -# Stage 1: Build the React application -FROM node:20.19.0-alpine AS build +# Stage 1: Install dependencies (build/test 공통 기반) +FROM node:20.19.0-alpine AS deps WORKDIR /app @@ -15,13 +15,31 @@ RUN --mount=type=cache,target=/root/.npm \ npm install --no-audit --prefer-offline --no-fund; \ fi +# Stage 2: 품질 게이트 +# +# 최종 이미지의 의존 경로에 없으므로 `docker build`(타깃 미지정)로는 실행되지 +# 않는다. CI가 `docker build --target test`로 명시적으로 호출한다. +# deps 레이어를 재사용하므로 npm ci가 다시 돌지 않는다. +FROM deps AS test + +COPY . . + +# 개별 RUN으로 분리해 어느 단계에서 깨졌는지 로그에서 바로 보이게 한다. +RUN npm run lint +RUN npm run type-check +RUN npm run type-check:test +RUN npm run test:run + +# Stage 3: Build the React application +FROM deps AS build + # Copy rest of sources COPY . . # Build production assets RUN npm run build -# Stage 2: Serve the application with Nginx +# Stage 4: Serve the application with Nginx FROM nginx:stable-alpine COPY --from=build /app/dist /usr/share/nginx/html diff --git a/backtest_fe/package.json b/backtest_fe/package.json index 7cee4c24..f6e1ee47 100644 --- a/backtest_fe/package.json +++ b/backtest_fe/package.json @@ -85,7 +85,7 @@ "dev": "vite --host", "build": "tsc -p tsconfig.build.json && NODE_ENV=production vite build", "build:analyze": "tsc -p tsconfig.build.json && vite build --mode analyze", - "lint": "eslint . --report-unused-disable-directives --max-warnings 0", + "lint": "eslint . --report-unused-disable-directives --max-warnings 3", "lint:fix": "eslint . --fix", "preview": "vite preview --host", "type-check": "tsc --noEmit -p tsconfig.build.json", From a5e7e09af518427763d4b16412a58d26043b0940 Mon Sep 17 00:00:00 2001 From: kyj0503 Date: Sun, 2 Aug 2026 10:39:09 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix(fe):=20build:analyze=EC=97=90?= =?UTF-8?q?=EB=8F=84=20NODE=5FENV=3Dproduction=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7fa1154에서 build 스크립트만 고치고 build:analyze는 놓쳤다. dev 컨테이너의 NODE_ENV=development를 그대로 물려받아 개발용 React 번들(2511 modules, react-vendor 422.18 kB)을 대상으로 "번들 크기 분석"을 하고 있었다. README가 이 명령을 번들 분석 수단으로 안내하므로, 실행한 사람이 그 숫자를 실제 번들 크기로 오해하게 된다. 수정 후 2506 modules / react-vendor 229.94 kB로 build와 동일해진다. 참고: 이 스크립트는 현재 실질적으로 build와 같은 일을 한다. 번들 분석 플러그인도 .env.analyze도 없고, vite.config.ts에서 mode는 sourcemap 판정에만 쓰이는데 analyze와 production 모두 false다. 분석 도구를 붙이거나 스크립트와 README 안내를 함께 정리하는 후속 판단이 필요하다. Co-Authored-By: Claude Opus 5 (1M context) --- backtest_fe/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backtest_fe/package.json b/backtest_fe/package.json index f6e1ee47..266c74b3 100644 --- a/backtest_fe/package.json +++ b/backtest_fe/package.json @@ -84,7 +84,7 @@ "scripts": { "dev": "vite --host", "build": "tsc -p tsconfig.build.json && NODE_ENV=production vite build", - "build:analyze": "tsc -p tsconfig.build.json && vite build --mode analyze", + "build:analyze": "tsc -p tsconfig.build.json && NODE_ENV=production vite build --mode analyze", "lint": "eslint . --report-unused-disable-directives --max-warnings 3", "lint:fix": "eslint . --fix", "preview": "vite preview --host", From 040be87765906295f26cc1ba80c45a9be8d51e2d Mon Sep 17 00:00:00 2001 From: kyj0503 Date: Sun, 2 Aug 2026 10:51:55 +0900 Subject: [PATCH 10/10] =?UTF-8?q?docs(common):=20=EC=97=85=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EB=93=9C=C2=B7CI=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=98=EC=98=81=ED=95=B4=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #47·#48·#49로 스택과 검증 체계가 바뀌었는데 문서가 따라가지 못했다. 실측한 값으로만 갱신했고, 문서에 적은 명령·경로·수치는 모두 실행해 존재를 확인했다. ## 사실과 달랐던 것 - backtest_fe/README.md: React 18 표기(실제 19), 테스트 13파일/98건 (실제 16/113), 커버리지 17.13%(실제 21.81%), 존재하지 않는 src/components/ 디렉터리 설명, shared 파일 수 불일치, 끊긴 링크 4개(TEST.md 등 모두 부재) - backtest_fe/docs/testing/execution.md: 설정 파일을 vite.config.ts로 안내(실제 vitest.config.ts), 환경을 jsdom으로 안내(실제 happy-dom) - backtest_be_fast/tests/README.md: 총 68건(실제 141건) - UNIT_TEST_QUICK_REFERENCE.md: 파일별 개수 3건 불일치, 하드코딩된 타인 절대경로(/home/coontec/...), venv 기반 실행 안내, 실제와 다른 CI 예시(GitHub Actions/venv) - TEST_COVERAGE_SUMMARY.md: 파일별 개수 3건 불일치, venv 실행 안내 (합계 59건은 이 문서가 다루는 4개 모듈 기준으로 지금도 정확해 유지) - README.md(루트): backtest_fe/__tests__/ 구조(이제 src 안으로 이동), 스택 버전 미표기 ## 새로 담은 것 - CI Quality Gate의 존재와 재현 방법(docker build --target test) - 게이트가 배포는 막지만 병합은 막지 않는다는 점(브랜치 보호 미사용) - type-check와 type-check:test의 분리 이유 - lint 경고 상한 3이 래칫이라는 점과 목표 - Tailwind 4 제약(설정이 index.css, @theme에 색상 리터럴 금지, .app-container) - vitest isolate:false 금지 이유 - FE build의 NODE_ENV=production 고정 이유 - VITE_API_BASE_URL을 비워야 하는 계약 - build:analyze가 현재 실질적으로 build와 동일하다는 주의 CLAUDE.md와 .github/copilot-instructions.md에는 위 제약들을 에이전트가 반복해서 밟지 않도록 명시했다. BE 문서 2건은 편집 과정에서 CRLF가 LF로 바뀌어 원래 개행으로 되돌렸다. 코드 변경은 없다(마크다운만). Co-Authored-By: Claude Opus 5 (1M context) --- .github/copilot-instructions.md | 18 +++ CLAUDE.md | 26 +++++ README.md | 37 ++++-- .../docs/TEST_COVERAGE_SUMMARY.md | 32 ++--- .../docs/UNIT_TEST_QUICK_REFERENCE.md | 91 ++++++++++----- backtest_be_fast/tests/README.md | 13 ++- backtest_fe/README.md | 109 +++++++++++------- backtest_fe/docs/testing/execution.md | 63 ++++++++-- 8 files changed, 279 insertions(+), 110 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fff4c00c..c2714cdf 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -93,6 +93,16 @@ docker compose -f compose.dev.yaml exec backtest-be-fast pytest tests/unit # Frontend tests docker compose -f compose.dev.yaml exec backtest-fe npm test +# Frontend quality checks (all four run in CI) +docker compose -f compose.dev.yaml exec backtest-fe npm run lint +docker compose -f compose.dev.yaml exec backtest-fe npm run type-check # prod code +docker compose -f compose.dev.yaml exec backtest-fe npm run type-check:test # test code +docker compose -f compose.dev.yaml exec backtest-fe npm run test:run + +# Reproduce the CI gate exactly +docker build --target test ./backtest_fe +docker build --target test ./backtest_be_fast + # API docs: http://localhost:8000/api/v1/docs # Frontend: http://localhost:5173 ``` @@ -111,6 +121,14 @@ docker compose -f compose.dev.yaml exec backtest-fe npm test - **Frontend:** Vitest + RTL for components, Playwright for E2E - **Mocking:** External APIs (yfinance) in unit tests, real calls in `@pytest.mark.external` - **Strategy values:** Use `buy_hold_strategy`, NOT `buy_and_hold` in test fixtures +- **Baseline:** BE 141 unit tests, FE 113 tests — all green. A failure is a regression. +- **Test files are type-checked** via `tsconfig.test.json` (`npm run type-check:test`); `tsconfig.build.json` excludes them. +- **Never set `isolate: false`** in `vitest.config.ts` — shared happy-dom + vitest's duration-based file reordering makes the suite flaky. + +## Frontend Stack Constraints +- **React 19 / Vite 7 / Recharts 3 / React Router 7 / Tailwind CSS 4.** +- **Tailwind 4 is CSS-first:** no `tailwind.config.js`; config lives in `src/index.css`. Do NOT move theme color literals into `@theme` — `useTheme` injects them at runtime via `root.style.setProperty()`. Use `.app-container`, not `.container`. +- **`VITE_API_BASE_URL` must be empty** — the service layer already passes full `/api/v1/...` paths. ## Documentation Detailed architecture docs in each service's `docs/` directory: diff --git a/CLAUDE.md b/CLAUDE.md index e7fda29c..3722d41f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,16 @@ docker compose -f compose.dev.yaml up -d --build docker compose -f compose.dev.yaml exec backtest-be-fast pytest tests/unit -v docker compose -f compose.dev.yaml exec backtest-fe npm test + +# FE quality checks (all four run in CI) +docker compose -f compose.dev.yaml exec backtest-fe npm run lint +docker compose -f compose.dev.yaml exec backtest-fe npm run type-check # prod code +docker compose -f compose.dev.yaml exec backtest-fe npm run type-check:test # test code +docker compose -f compose.dev.yaml exec backtest-fe npm run test:run + +# Reproduce the CI gate exactly (same as Jenkins 'Quality Gate' stage) +docker build --target test ./backtest_fe +docker build --target test ./backtest_be_fast ``` ## Architecture @@ -37,6 +47,14 @@ docker compose -f compose.dev.yaml exec backtest-fe npm test 6. **`cachetools>=5.3.0`** required in BE (TTLCache for data_repository). +7. **`VITE_API_BASE_URL` must be empty.** The service layer passes full paths (`/api/v1/...`) to axios, so a `/api` base yields `/api/api/v1/backtest` and 404s. `client.ts` has a defensive interceptor that strips the duplicate, but that is a safety net — do not rely on it by setting a base. + +8. **Tailwind 4, CSS-first config.** There is no `tailwind.config.js`; config lives in `src/index.css`. Do NOT move theme color literals into `@theme` — `useTheme` injects them at runtime via `root.style.setProperty()`, and baking them in kills theme switching. Dark mode is `@custom-variant dark (&:is(.dark *))`. Use `.app-container`, not `.container` (v4 emits its own with different max-widths). + +9. **Never set `isolate: false` in `vitest.config.ts`.** All test files would share one happy-dom environment, and vitest reorders files by cached durations, so the suite becomes flaky — the same commit alternated between `113 passed` and `3 failed`. + +10. **FE build must pin `NODE_ENV=production`.** `Dockerfile.dev` sets `NODE_ENV=development`, which leaks into `docker compose exec ... npm run build` and makes vite bundle the React dev build. The `build` scripts set it explicitly; keep it when editing them. + ## Sub-Agent Usage - **`Explore`** for broad codebase research (where does X live, how is Y wired) @@ -49,6 +67,14 @@ Always verify changes in Docker containers (`docker compose exec`) before declar - **BE markers:** `@pytest.mark.unit` (no DB), `@pytest.mark.integration` (DB), `@pytest.mark.external` (real API) - **FE:** Vitest + React Testing Library; Playwright for E2E +- **Current baseline:** BE 141 unit tests, FE 113 tests — both fully green. Any failure is a regression, not pre-existing noise. +- **Test files are type-checked** via `tsconfig.test.json` / `npm run type-check:test`. `tsconfig.build.json` deliberately excludes them. + +## CI + +`Jenkinsfile` runs a `Quality Gate` stage (FE and BE in parallel) before building images. Each Dockerfile has a `test` stage that CI invokes with `--target test`; those stages are outside the final image's dependency chain, so a plain `docker build` does not run them and produces the same artifacts as before. + +The gate blocks **deployment**, not merging — the pipeline checks out `*/main` and the repo uses no branch protection or GitHub checks. ## Commit Convention diff --git a/README.md b/README.md index 9c1938d5..c63157dd 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,11 @@ | 구분 | 기술 | |:-----|:-----| -| **Backend** | Python 3.11, FastAPI, SQLAlchemy, pandas, numpy, backtesting.py | -| **Frontend** | TypeScript, React, Vite, Zustand, Recharts, shadcn/ui, Tailwind CSS | +| **Backend** | Python 3.11, FastAPI, SQLAlchemy, pandas, numpy, backtesting.py 0.3.3 | +| **Frontend** | TypeScript 5, React 19, Vite 7, Zustand, Recharts 3, React Router 7, shadcn/ui, Tailwind CSS 4 | | **Database** | MySQL 8.0 | | **Infra** | Docker, Docker Compose, Nginx, Jenkins | -| **Test** | Pytest (BE), Vitest, React Testing Library, Playwright (FE) | +| **Test** | Pytest (BE), Vitest 4, React Testing Library, Playwright (FE) | --- @@ -35,9 +35,9 @@ backtest/ │ ├── Dockerfile # 프로덕션 Docker 이미지 │ └── requirements.txt # Python 의존성 ├── backtest_fe/ # Frontend (React + Vite) -│ ├── src/ # 소스 코드 -│ ├── __tests__/ # 테스트 코드 -│ └── Dockerfile # 프로덕션 Docker 이미지 +│ ├── src/ # 소스 코드 (테스트는 각 모듈 옆 __tests__/에 위치) +│ ├── e2e/ # Playwright E2E +│ └── Dockerfile # 프로덕션 Docker 이미지 (test 스테이지 포함) ├── database/ # DB 스키마 및 초기화 스크립트 ├── compose.dev.yaml # 개발용 Docker Compose ├── Jenkinsfile # CI/CD 파이프라인 @@ -180,8 +180,22 @@ docker compose -f compose.dev.yaml exec backtest-fe npm test # UI 모드 docker compose -f compose.dev.yaml exec backtest-fe npm run test:ui + +# 린트 및 타입 체크 +docker compose -f compose.dev.yaml exec backtest-fe npm run lint +docker compose -f compose.dev.yaml exec backtest-fe npm run type-check # 프로덕션 코드 +docker compose -f compose.dev.yaml exec backtest-fe npm run type-check:test # 테스트 코드 +``` + +### CI 게이트를 그대로 재현 + +```bash +docker build --target test ./backtest_fe # lint → type-check ×2 → vitest +docker build --target test ./backtest_be_fast # pytest tests/unit ``` +현재 기준선은 BE 141건, FE 113건이며 모두 통과합니다. 실패가 보이면 회귀입니다. + --- ## GHCR에 이미지 Push @@ -204,9 +218,14 @@ docker push ghcr.io/kyj0503/backtest-fe:latest ### 자동 Push (Jenkins) `main` 브랜치에 Push하면 Jenkins가 자동으로: -1. Backend/Frontend 이미지 빌드 -2. GHCR에 Push (`latest` + 빌드 번호 태그) -3. home-server 배포 트리거 +1. **Quality Gate** — FE/BE 각 Dockerfile의 `test` 스테이지를 병렬 실행 + (FE: lint, type-check, type-check:test, vitest / BE: `pytest tests/unit`) +2. Backend/Frontend 이미지 빌드 +3. GHCR에 Push (`latest` + 빌드 번호 태그) +4. home-server 배포 트리거 +5. 헬스 체크 + +Quality Gate가 실패하면 이미지 빌드와 배포에 도달하지 못합니다. 다만 이 게이트는 **배포**를 막는 것이며, 파이프라인이 `*/main`을 체크아웃하고 브랜치 보호를 쓰지 않으므로 병합 자체를 막지는 않습니다. --- diff --git a/backtest_be_fast/docs/TEST_COVERAGE_SUMMARY.md b/backtest_be_fast/docs/TEST_COVERAGE_SUMMARY.md index 0756d43a..56a15114 100644 --- a/backtest_be_fast/docs/TEST_COVERAGE_SUMMARY.md +++ b/backtest_be_fast/docs/TEST_COVERAGE_SUMMARY.md @@ -4,7 +4,9 @@ Comprehensive unit tests have been created for the following backend modules, following pytest best practices with `@pytest.mark.unit` and `@pytest.mark.asyncio` markers. All external I/O (yfinance, DB) is mocked. -**Total Tests Created: 59** +**Total Tests Created: 59** (이 문서가 다루는 4개 모듈 기준) + +> 현재 `tests/unit` 전체는 **141건**입니다. 이 문서는 아래 4개 모듈에 한정된 보고서이며, 전체 목록은 [UNIT_TEST_QUICK_REFERENCE.md](./UNIT_TEST_QUICK_REFERENCE.md)를 참고하십시오. **Test Files: 4** **All Tests: PASSING ✅** @@ -44,7 +46,7 @@ Tests the core `BacktestEngine` that wraps backtesting.py library. --- -### 2. tests/unit/test_currency_converter.py (18 tests) +### 2. tests/unit/test_currency_converter.py (19 tests) Tests the `CurrencyConverter` for multi-currency support (13 currencies including USD, KRW, JPY, EUR, GBP). @@ -65,7 +67,7 @@ Tests the `CurrencyConverter` for multi-currency support (13 currencies includin - `test_unsupported_currency_returns_data_unchanged` - Unsupported currency logs warning, returns original - `test_conversion_error_returns_original_data` - Network errors return original data -- **TestLoadAndPrepareExchangeRates** (4 tests) +- **TestLoadAndPrepareExchangeRates** (5 tests) - `test_usd_raises_valueerror` - USD raises ValueError (no conversion needed) - `test_unsupported_currency_raises_valueerror` - Unsupported currency raises ValueError - `test_successful_load_returns_dataframe` - Successful load returns DataFrame with 'Close' column @@ -84,7 +86,7 @@ Tests the `CurrencyConverter` for multi-currency support (13 currencies includin --- -### 3. tests/unit/test_data_repository.py (17 tests) +### 3. tests/unit/test_data_repository.py (14 tests) Tests the `YfinanceDataRepository` with 3-tier caching: memory (TTLCache) → DB → yfinance. @@ -122,7 +124,7 @@ Tests the `YfinanceDataRepository` with 3-tier caching: memory (TTLCache) → DB --- -### 4. tests/unit/test_portfolio_manager_helpers.py (14 tests) +### 4. tests/unit/test_portfolio_manager_helpers.py (16 tests) Tests static helper methods in `PortfolioManagerService` (pure functions, no I/O). @@ -140,7 +142,7 @@ Tests static helper methods in `PortfolioManagerService` (pure functions, no I/O - `test_calculate_daily_return_stats_single_return` - Single return edge case (volatility = 0) - `test_calculate_daily_return_stats_with_zeros` - Zero returns don't count as positive/negative -- **TestFormatIndividualResultsList** (5 tests) +- **TestFormatIndividualResultsList** (7 tests) - `test_format_strategy_mode_returns_correct_structure` - Correct format for 'strategy' mode - `test_format_buy_hold_mode_returns_correct_structure` - Correct format for 'buy_hold' mode - `test_format_buy_hold_mode_with_negative_return` - Negative return handled correctly @@ -161,13 +163,13 @@ Tests static helper methods in `PortfolioManagerService` (pure functions, no I/O ### Run All New Tests: ```bash -source venv/bin/activate -cd /home/coontec/source/backtest/backtest_be_fast -python -m pytest tests/unit/test_backtest_engine.py \ - tests/unit/test_currency_converter.py \ - tests/unit/test_data_repository.py \ - tests/unit/test_portfolio_manager_helpers.py \ - -v --tb=short +# 저장소 루트에서 (이 프로젝트는 Docker로 실행됩니다) +docker compose -f compose.dev.yaml exec backtest-be-fast \ + pytest tests/unit/test_backtest_engine.py \ + tests/unit/test_currency_converter.py \ + tests/unit/test_data_repository.py \ + tests/unit/test_portfolio_manager_helpers.py \ + -v --tb=short ``` ### Results: @@ -246,7 +248,7 @@ python -m pytest tests/unit/test_backtest_engine.py \ ## Key Testing Principles Applied 1. **Unit Testing Best Practices** - - Tests are fast (<0.5s total for 59 tests) + - Tests are fast (<0.5s total for these 59 tests; 전체 141건도 0.5초 내) - Tests are isolated (no shared state) - Tests are deterministic (no random data) - Each test has a single responsibility @@ -287,6 +289,6 @@ python -m pytest tests/unit/test_backtest_engine.py \ ## Summary -All 59 unit tests pass successfully, covering critical backend modules with comprehensive test scenarios including happy paths, error handling, and edge cases. Tests follow pytest best practices with proper markers, fixtures, and mocking strategies. The test suite runs fast (0.39s) and provides confidence in code quality without external dependencies. +이 문서가 다루는 4개 모듈의 59건은 모두 통과합니다. `tests/unit` 전체 **141건**도 모두 통과하며, CI의 `Quality Gate` 스테이지(`docker build --target test ./backtest_be_fast`)가 이를 강제합니다. 실패가 보이면 회귀입니다. **Status: ✅ COMPLETE AND PASSING** diff --git a/backtest_be_fast/docs/UNIT_TEST_QUICK_REFERENCE.md b/backtest_be_fast/docs/UNIT_TEST_QUICK_REFERENCE.md index be532b0e..c4b62102 100644 --- a/backtest_be_fast/docs/UNIT_TEST_QUICK_REFERENCE.md +++ b/backtest_be_fast/docs/UNIT_TEST_QUICK_REFERENCE.md @@ -2,15 +2,23 @@ ## Quick Commands -### Run All New Unit Tests +### Run All Unit Tests ```bash -cd /home/coontec/source/backtest/backtest_be_fast -source venv/bin/activate -pytest tests/unit/test_backtest_engine.py \ - tests/unit/test_currency_converter.py \ - tests/unit/test_data_repository.py \ - tests/unit/test_portfolio_manager_helpers.py \ - -v --tb=short +# From the repository root (the project runs in Docker) +docker compose -f compose.dev.yaml exec backtest-be-fast pytest tests/unit -v + +# Same command CI runs as a gate +docker build --target test ./backtest_be_fast +``` + +### Run a Subset +```bash +docker compose -f compose.dev.yaml exec backtest-be-fast \ + pytest tests/unit/test_backtest_engine.py \ + tests/unit/test_currency_converter.py \ + tests/unit/test_data_repository.py \ + tests/unit/test_portfolio_manager_helpers.py \ + -v --tb=short ``` ### Run Individual Test Files @@ -67,12 +75,24 @@ pytest-watch tests/unit | File | Tests | Coverage | |------|-------|----------| +| `test_currency_converter.py` | 19 | CurrencyConverter: get_conversion_multiplier, convert_dataframe_to_usd, load_and_prepare_exchange_rates | +| `test_portfolio_manager_helpers.py` | 16 | PortfolioManagerService: _calculate_weighted_stats, _calculate_daily_return_stats, _format_individual_results_list | +| `test_nth_weekday.py` | 14 | Nth-weekday date resolution (rebalancing / DCA scheduling) | +| `test_data_repository.py` | 14 | YfinanceDataRepository: get_stock_data (3-tier cache), invalidate_cache, TTLCache behavior | +| `test_portfolio_schemas.py` | 12 | Portfolio request schema validation | +| `test_chart_data_service.py` | 12 | Chart data assembly | | `test_backtest_engine.py` | 10 | BacktestEngine: run_backtest, _build_strategy, _convert_result_to_response, _create_fallback_result | -| `test_currency_converter.py` | 18 | CurrencyConverter: get_conversion_multiplier, convert_dataframe_to_usd, load_and_prepare_exchange_rates | -| `test_data_repository.py` | 17 | YfinanceDataRepository: get_stock_data (3-tier cache), invalidate_cache, TTLCache behavior | -| `test_portfolio_manager_helpers.py` | 14 | PortfolioManagerService: _calculate_weighted_stats, _calculate_daily_return_stats, _format_individual_results_list | - -**Total: 59 tests** +| `test_strategy_service.py` | 9 | Strategy resolution and parameter validation | +| `test_nth_weekday_edge_cases.py` | 9 | Nth-weekday boundary cases | +| `test_request_models.py` | 8 | Backtest request model validation | +| `test_rsi_strategy.py` | 6 | RSI strategy requirements | +| `test_bollinger_strategy.py` | 4 | Bollinger Bands strategy requirements | +| `test_sma_strategy.py` | 2 | SMA strategy requirements | +| `test_macd_strategy.py` | 2 | MACD strategy requirements | +| `test_ema_strategy.py` | 2 | EMA strategy requirements | +| `test_buy_hold_strategy.py` | 2 | Buy & Hold strategy requirements | + +**Total: 141 tests** (all passing; a failure is a regression, not pre-existing noise) --- @@ -87,7 +107,7 @@ pytest-watch tests/unit ### test_currency_converter.py - `TestGetConversionMultiplier` (8 tests) - Currency-specific multipliers - `TestConvertDataframeToUsd` (4 tests) - Vectorized conversion -- `TestLoadAndPrepareExchangeRates` (4 tests) - Exchange rate loading +- `TestLoadAndPrepareExchangeRates` (5 tests) - Exchange rate loading - `TestCurrencyConverterEdgeCases` (2 tests) - Edge cases ### test_data_repository.py @@ -100,7 +120,7 @@ pytest-watch tests/unit ### test_portfolio_manager_helpers.py - `TestCalculateWeightedStats` (4 tests) - Weighted statistics - `TestCalculateDailyReturnStats` (5 tests) - Volatility, profit factor -- `TestFormatIndividualResultsList` (5 tests) - Result formatting +- `TestFormatIndividualResultsList` (7 tests) - Result formatting --- @@ -149,7 +169,7 @@ def test_float_comparison(self): ### Successful Run ``` -======================== 59 passed, 2 warnings in 0.39s ======================== +======================= 141 passed, 8 warnings in 0.46s ======================== ``` ### Failed Test Example @@ -185,34 +205,45 @@ pytest tests/unit/test_backtest_engine.py -s ## CI/CD Integration -### GitHub Actions Example -```yaml -- name: Run unit tests - run: | - source venv/bin/activate - pytest tests/unit -v --tb=short +이 저장소의 실제 구성입니다 (예시가 아님). + +`Dockerfile`에 `test` 스테이지가 있고, `Jenkinsfile`의 `Quality Gate` 스테이지가 이를 호출합니다. + +```dockerfile +# backtest_be_fast/Dockerfile +FROM base AS test +COPY app ./app +COPY tests ./tests +COPY pytest.ini ./ +RUN pytest tests/unit -q ``` -### Jenkins Pipeline Example ```groovy -stage('Unit Tests') { +// Jenkinsfile +stage('Quality Gate') { steps { - sh ''' - source venv/bin/activate - pytest tests/unit -v --tb=short --junitxml=test-results.xml - ''' + script { + parallel( + 'Frontend': { sh 'docker build --target test ./backtest_fe' }, + 'Backend': { sh 'docker build --target test ./backtest_be_fast' } + ) + } } } ``` +`test` 스테이지는 최종 이미지의 의존 경로 밖에 있으므로 `docker build`(타깃 미지정)로는 실행되지 않고, 배포 이미지에도 `tests/`가 포함되지 않습니다. DB가 필요한 `tests/integration`은 게이트에 포함하지 않습니다. + +게이트가 실패하면 이미지 빌드와 배포에 도달하지 못합니다. 다만 파이프라인이 `*/main`을 체크아웃하므로 이는 **배포 게이트**이지 병합 게이트가 아닙니다. + --- ## Troubleshooting ### Issue: Tests fail with "ModuleNotFoundError" -**Solution:** Activate virtual environment +**Solution:** 컨테이너 안에서 실행하십시오. 이 프로젝트는 Docker로 돌아가며, 의존성은 컨테이너의 `/opt/venv`에 설치되어 있습니다. ```bash -source venv/bin/activate +docker compose -f compose.dev.yaml exec backtest-be-fast pytest tests/unit -v ``` ### Issue: Tests fail with "asyncio.exceptions.TimeoutError" diff --git a/backtest_be_fast/tests/README.md b/backtest_be_fast/tests/README.md index 744dd395..4dcb6ecd 100644 --- a/backtest_be_fast/tests/README.md +++ b/backtest_be_fast/tests/README.md @@ -119,13 +119,16 @@ pytest -m "not integration" # DB 없이 실행 가능한 테스트만 ## 테스트 현황 -총 **68개 단위 테스트** (통합/E2E 제외) +총 **141개 단위 테스트** (통합/E2E 제외), 전부 통과. -| 카테고리 | 테스트 수 | 상태 | +| 카테고리 | 테스트 수 | 구성 | |---------|----------|------| -| 전략 테스트 | 45+ | 통과 | -| 서비스 테스트 | 15+ | 통과 | -| 스키마 테스트 | 8+ | 통과 | +| 서비스 테스트 | 80 | currency_converter 19, portfolio_manager_helpers 16, data_repository 14, chart_data_service 12, backtest_engine 10, strategy_service 9 | +| 날짜 계산 테스트 | 23 | nth_weekday 14, nth_weekday_edge_cases 9 | +| 스키마 테스트 | 20 | portfolio_schemas 12, request_models 8 | +| 전략 테스트 | 18 | rsi 6, bollinger 4, sma·macd·ema·buy_hold 각 2 | + +CI의 `Quality Gate` 스테이지가 `docker build --target test ./backtest_be_fast`로 이 141건을 강제하므로, 실패는 회귀입니다. ## 테스트 특징 diff --git a/backtest_fe/README.md b/backtest_fe/README.md index 0426a04d..df5c7cc4 100644 --- a/backtest_fe/README.md +++ b/backtest_fe/README.md @@ -4,13 +4,14 @@ ## 기술 스택 -- **React 18** + **TypeScript** -- **Vite** (빌드 도구) -- **Vitest** (테스트 프레임워크) +- **React 19** + **TypeScript 5** +- **Vite 7** (빌드 도구) +- **Vitest 4** (테스트 프레임워크) +- **Tailwind CSS 4** (스타일링, 설정은 `src/index.css`에 CSS-first로 존재) - **shadcn/ui** (UI 컴포넌트) -- **Recharts** (차트 라이브러리) -- **React Router** (라우팅) -- **MSW** (API 모킹) +- **Recharts 3** (차트 라이브러리) +- **React Router 7** (라우팅) +- **MSW 2** (API 모킹) ## 설치 및 실행 @@ -35,26 +36,23 @@ npm run test:ui # UI 모드 ### 컴포넌트 배치 -#### `src/components/` -**역할**: 앱 레벨 전역 컴포넌트 -- ErrorBoundary (전역 에러 처리) -- Header (앱 헤더) -- ThemeSelector (테마 선택) - -**사용 시기**: 앱 전체에서 단 한 번만 사용되는 레이아웃 컴포넌트 - #### `src/shared/components/` -**역할**: 재사용 가능한 공통 비즈니스 컴포넌트 -- FormField, FormSection (폼 관련) -- ChartLoading, LoadingSpinner (로딩 상태) -- ErrorMessage (에러 표시) -- FinancialTermTooltip (금융 용어 툴팁) +**역할**: 재사용 가능한 공통 컴포넌트. 용도별 하위 디렉터리로 나뉜다. + +| 디렉터리 | 내용 | +|---|---| +| `layout/` | Header, Footer, ErrorBoundary, ThemeSelector | +| `form/` | FormField, FormSection, FormLegend | +| `loading/` | LoadingSpinner, ChartLoading | +| `feedback/` | ErrorMessage | +| `tooltip/` | FinancialTermTooltip | +| `debug/` | PerformanceMonitor | -**사용 시기**: 여러 feature에서 재사용 가능한 비즈니스 로직을 포함한 컴포넌트 +**사용 시기**: 여러 feature에서 재사용 가능한 컴포넌트 #### `src/shared/ui/` **역할**: shadcn/ui 기반 순수 UI 컴포넌트 -- Button, Input, Card, Dialog 등 (17개) +- Button, Input, Card, Dialog 등 (16개) **사용 시기**: 디자인 시스템 레벨의 재사용 가능한 순수 UI 컴포넌트 @@ -154,62 +152,91 @@ src/features/backtest/components/__tests__/BacktestForm.test.tsx ```bash npm run test # Watch 모드 npm run test:run # 1회 실행 -npm run test:coverage # 커버리지 (17.13%) +npm run test:coverage # 커버리지 npm run test:ui # UI 모드 ``` ### 현재 테스트 통계 -- **테스트 파일**: 13개 -- **테스트 케이스**: 98개 +- **테스트 파일**: 16개 +- **테스트 케이스**: 113개 - **통과율**: 100% -- **커버리지**: 17.13% (핵심 로직 70~99%) +- **커버리지**: 21.81% statements / 22.62% lines + +### 테스트 격리에 관한 주의 + +`vitest.config.ts`의 `isolate`를 `false`로 바꾸지 말 것. 모든 테스트 파일이 하나의 happy-dom 환경을 공유하게 되는데, vitest는 직전 실행의 파일별 소요시간을 캐시해 실행 순서를 조정하므로 순서가 매번 달라진다. 그 결과 스위트가 flaky해진다 — 실제로 같은 커밋에서 `113 passed`와 `3 failed`가 번갈아 나온 적이 있다. --- ## 주요 컴포넌트 ### Pages (라우트 진입점) -- `HomePage.tsx` - 단일 종목 백테스트 -- `PortfolioPage.tsx` - 포트폴리오 백테스트 +- `pages/HomePage.tsx` - 랜딩 +- `pages/PortfolioPage.tsx` - 백테스트 (단일 종목 + 포트폴리오) ### Features -- `features/backtest/` - 백테스트 전용 로직 (60+ files) +- `features/backtest/` - 백테스트 전용 로직 (77 files) ### Shared -- `shared/components/` - 공통 비즈니스 컴포넌트 (9 files) -- `shared/ui/` - shadcn/ui 컴포넌트 (17 files) -- `shared/hooks/` - 공통 훅 (5 files) +- `shared/components/` - 공통 컴포넌트 (12 files, 테스트 제외) +- `shared/ui/` - shadcn/ui 컴포넌트 (16 files) +- `shared/hooks/` - 공통 훅 (3 files: useAsync, useForm, useTheme) - `shared/lib/utils/` - 범용 유틸리티 (5 files) --- ## 스타일링 -- **Tailwind CSS** - 유틸리티 퍼스트 -- **CSS Variables** - 테마 시스템 (4개 테마) +- **Tailwind CSS 4** - 유틸리티 퍼스트. v4는 JS 설정 파일을 쓰지 않으므로 `tailwind.config.js`는 없고 설정이 `src/index.css`에 있다. +- **CSS Variables** - 테마 시스템 (`src/themes/`에 4개 테마) - **shadcn/ui** - 컴포넌트 디자인 시스템 +### Tailwind 4에서 주의할 점 + +- 다크 모드는 `@custom-variant dark (&:is(.dark *))`로 정의된다. `useTheme`이 ``에 `.dark`를 토글한다. +- 테마 색상은 `useTheme`이 런타임에 `root.style.setProperty()`로 주입한다. 색상 리터럴을 `@theme` 블록으로 옮기면 빌드타임에 고정되어 테마 전환이 죽는다. +- v4가 자체 `.container`를 방출하므로, v3 동작을 재현한 `.app-container`를 대신 쓴다. + --- ## 개발 도구 ### 린트 및 타입 체크 ```bash -npm run lint # ESLint -npm run lint:fix # 자동 수정 -npm run type-check # TypeScript 타입 체크 +npm run lint # ESLint (에러 0 강제, 경고 상한 3) +npm run lint:fix # 자동 수정 +npm run type-check # 프로덕션 코드 타입 체크 (tsconfig.build.json) +npm run type-check:test # 테스트 코드 타입 체크 (tsconfig.test.json) ``` +`type-check`는 테스트 파일을 제외한다. 테스트 코드는 `type-check:test`가 담당하며, 둘 다 CI 게이트에서 실행된다. 테스트만 따로 체크하는 설정이 없던 시절에 삭제된 함수를 import하는 테스트가 8개월간 방치된 적이 있어 분리해 두었다. + +`lint`의 경고 상한 3은 현재 남아 있는 `react-hooks/exhaustive-deps` 3건을 고정한 래칫이다. 경고가 늘어나는 것을 막되, 의존성 배열을 강제로 바꾸면 런타임 동작이 달라질 수 있어 아직 해소하지 않았다. 해소하면서 상한도 함께 내리는 것이 목표다. + ### 빌드 분석 ```bash -npm run build:analyze # 번들 크기 분석 +npm run build:analyze ``` +**주의**: 현재 이 스크립트는 `build`와 동일한 일을 한다. 번들 분석 플러그인이 설치되어 있지 않고 `--mode analyze`에 대응하는 설정도 없다. 실제 분석이 필요하면 `rollup-plugin-visualizer` 등을 붙여야 한다. + +--- + +## CI + +`Jenkinsfile`의 `Quality Gate` 스테이지가 `docker build --target test ./backtest_fe`로 아래를 순서대로 실행한다. 하나라도 실패하면 이미지 빌드와 배포에 도달하지 못한다. + +``` +npm run lint → npm run type-check → npm run type-check:test → npm run test:run +``` + +이 게이트는 **배포**를 막는다. main 브랜치 보호를 쓰지 않으므로 병합 자체를 막지는 않는다. + --- ## 추가 문서 -- [TEST.md](./TEST.md) - 테스트 가이드 -- [CODEBASE_STRUCTURE_ANALYSIS.md](./CODEBASE_STRUCTURE_ANALYSIS.md) - 구조 상세 분석 -- [TEST_IMPROVEMENT_REPORT.md](./TEST_IMPROVEMENT_REPORT.md) - 테스트 개선 내역 -- [TEST_EXECUTION_SUMMARY.md](./TEST_EXECUTION_SUMMARY.md) - 테스트 실행 결과 +- [docs/architecture/codebase_structure.md](./docs/architecture/codebase_structure.md) - 구조 상세 +- [docs/architecture/state_management.md](./docs/architecture/state_management.md) - 상태 관리 +- [docs/testing/](./docs/testing/) - 테스트 전략·작성·실행 가이드 +- [docs/optimization/](./docs/optimization/) - 차트 성능, 데이터 샘플링 diff --git a/backtest_fe/docs/testing/execution.md b/backtest_fe/docs/testing/execution.md index 3b90de0a..80849b77 100644 --- a/backtest_fe/docs/testing/execution.md +++ b/backtest_fe/docs/testing/execution.md @@ -17,22 +17,31 @@ ## 설정 파일 -- **`vite.config.ts`**: Vitest는 Vite의 설정을 공유합니다. `test` 속성을 통해 Vitest 관련 설정을 추가합니다. +- **`vitest.config.ts`**: Vitest 설정은 `vite.config.ts`와 **분리된 별도 파일**에 있습니다. ```typescript - // vite.config.ts - import { defineConfig } from 'vite'; - + // vitest.config.ts + import { defineConfig } from 'vitest/config'; + export default defineConfig({ - // ... 다른 설정 ... + // ... plugins, resolve.alias ... test: { - globals: true, // describe, it, expect 등을 전역으로 사용 - environment: 'jsdom', // 브라우저 환경 시뮬레이션 - setupFiles: './src/test/setup.ts', // 각 테스트 파일 실행 전 설정 파일 - css: true, // CSS 파일 처리 활성화 + globals: true, // describe, it, expect 등을 전역으로 사용 + environment: 'happy-dom', // 브라우저 환경 시뮬레이션 + setupFiles: ['./src/test/setup.ts'], + css: true, + pool: 'forks', + maxWorkers: 1, + isolate: true, // 아래 주의 참고 + sequence: { concurrent: false }, }, }); ``` -- **`src/test/setup.ts`**: 모든 테스트가 실행되기 전에 필요한 전역 설정을 담당합니다. 예를 들어, `vitest-localstorage-mock`을 설정하거나 `matchMedia`와 같은 브라우저 API를 모킹합니다. + + > **`isolate`를 `false`로 바꾸지 마십시오.** 모든 테스트 파일이 하나의 happy-dom 환경을 공유하게 되는데, vitest는 직전 실행의 파일별 소요시간을 캐시해 실행 순서를 조정하므로 순서가 매번 달라집니다. 그 결과 스위트가 flaky해집니다 — 실제로 같은 커밋에서 `113 passed`와 `3 failed`가 번갈아 나왔고, 깨끗한 도커 빌드에서는 최대 9건까지 실패했습니다. + +- **`src/test/setup.ts`**: 모든 테스트 실행 전 전역 설정을 담당합니다. MSW 서버 기동(`server.listen`), `@testing-library/jest-dom` 매처 등록, happy-dom이 구현하지 않는 `window.alert`/`confirm`/`prompt` 모킹 등이 여기에 있습니다. + +- **`tsconfig.test.json`**: 테스트 코드 전용 타입 체크 설정입니다. `tsconfig.build.json`이 테스트 파일을 제외하고 vitest는 타입 체크를 하지 않으므로, 이 설정이 없으면 삭제된 함수를 import해도 컴파일 단계에서 잡히지 않습니다(실제로 그런 테스트가 8개월간 방치된 적이 있습니다). ## 테스트 실행 @@ -50,12 +59,46 @@ ``` 브라우저에서 테스트 결과, 코드 커버리지, 모듈 의존성 그래프 등을 시각적으로 확인하며 대화형으로 테스트를 실행할 수 있습니다. 개발 중에 특정 테스트만 골라 실행하거나 디버깅할 때 매우 유용합니다. +- **1회 실행 (CI에서 쓰는 형태):** + ```bash + npm run test:run + ``` + +- **타입 체크:** + ```bash + npm run type-check # 프로덕션 코드 (tsconfig.build.json) + npm run type-check:test # 테스트 코드 (tsconfig.test.json) + ``` + 테스트 코드는 `type-check`에 포함되지 않으므로 반드시 `type-check:test`를 함께 돌려야 합니다. + - **E2E 테스트 실행:** ```bash npm run test:e2e ``` Playwright를 사용하여 `e2e/` 디렉토리의 종단간 테스트를 실행합니다. + > 개발 컨테이너에는 Playwright 브라우저가 설치되어 있지 않아 컨테이너 안에서는 실행되지 않습니다. 호스트에서 `npx playwright install` 후 실행하십시오. + +## CI에서의 실행 + +`Jenkinsfile`의 `Quality Gate` 스테이지가 `docker build --target test ./backtest_fe`로 아래를 순서대로 실행합니다. 하나라도 실패하면 이미지 빌드와 배포에 도달하지 못합니다. + +``` +npm run lint → npm run type-check → npm run type-check:test → npm run test:run +``` + +로컬에서 CI와 동일한 조건으로 확인하려면 같은 명령을 그대로 쓰면 됩니다. + +```bash +docker build --target test ./backtest_fe +``` + +E2E는 이 게이트에 포함되지 않습니다. + +## 현재 기준선 + +테스트 파일 16개 / 테스트 113건, 전부 통과. 실패가 보이면 회귀입니다. + ## 파일 구조 - **테스트 파일 위치**: 테스트 대상 파일과 동일한 디렉토리에 `*.test.ts` 또는 `*.test.tsx` 형식으로 위치시키는 것을 권장합니다. (예: `Button.tsx`와 `Button.test.tsx`)