Testing 전략
Backend = JUnit5 + Testcontainers PG · Frontend = vitest + Testing Library + Playwright.
Backend (linkmusic-msa-space-was)
슬라이스 테스트 — @DataJpaTest
JPA repository · entity 검증 (인증 없이 DB 만):
@DataJpaTest
class HqEntityTest @Autowired constructor(
private val hqRepository: HqRepository,
) {
@Test
fun `INDEPENDENT 본사는 partial unique 로 중복 INSERT 차단`() {
// given Flyway 가 가상 본사 1개 INSERT
// when 같은 type INDEPENDENT 두 번째 INSERT
val dup = Hq(name = "X", type = HqType.INDEPENDENT, plan = null, billingAnchorDay = null)
// then
assertThatThrownBy { hqRepository.saveAndFlush(dup) }
.isInstanceOf(DataIntegrityViolationException::class.java)
}
}Testcontainers PostgreSQL — 실 PG 인스턴스. 인메모리 H2 사용 안 함 (PG 특이 기능 검증).
통합 테스트 — @SpringBootTest
REST endpoint · transaction · security 검증:
@SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureMockMvc
class AuthIntegrationTest {
@Test
fun `login 성공 시 access·refresh 발급`() {
mockMvc.post("/api/v1/auth/login") { ... }
.andExpect { status().isOk; jsonPath("$.accessToken").exists() }
}
@Test
fun `refresh 시 prev token revoked`() { ... }
@Test
fun `CORS 허용 origin 만`() { ... }
}Coverage 정책 (현재)
- 도메인 invariant · 인증 핵심 path · partial unique 같은 race condition 차단 → 반드시 테스트.
- 단순 CRUD · DTO mapping → 생략 가능.
- v1 목표 — line coverage 70% 이상 (현재 측정 X).
실행
./gradlew test # 전체
./gradlew test --tests "*AuthIntegrationTest"
./gradlew ktlintCheck test build # 풀 검증Frontend (linkmusic-frontend-space)
단위 — vitest + Testing Library
위치: *.test.ts(x) 가 같은 디렉토리.
// apps/admin/src/app/(auth)/login/login-form.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./login-form";
it("이메일 미입력 시 submit 버튼 disabled", async () => {
render(<LoginForm />);
expect(screen.getByRole("button", { name: "로그인" })).toBeDisabled();
await userEvent.type(screen.getByLabelText("이메일"), "u@x.com");
await userEvent.type(screen.getByLabelText("비밀번호"), "secret123");
expect(screen.getByRole("button", { name: "로그인" })).not.toBeDisabled();
});설정:
vitest.config.ts—environment: jsdom,setupFiles: ['./vitest.setup.ts']vitest.setup.ts—@testing-library/jest-domimporttsconfig.jsontypes —["node", "vitest/globals", "@testing-library/jest-dom"]
e2e — Playwright (2계층)
위치: e2e/. Playwright 프로젝트가 두 개이고 게이트도 서로 다르다.
| 계층 | 프로젝트 | 위치 | 필요 인프라 | 실행 | CI |
|---|---|---|---|---|---|
| 풀스택 happy-path | chromium | e2e/*.spec.ts | docker compose(pg → backend → frontend) | pnpm test:e2e --project=chromium | e2e.yml (논블로킹 · BACKEND_CHECKOUT_TOKEN 없으면 skip) |
| 브라우저 단독 회귀 | browser | e2e/browser/** | 없음(테스트가 자체 http 서버를 띄운다) | pnpm test:e2e:browser | ci.yml 의 browser-regression job — 항상 실행 |
chromium 프로젝트는 testIgnore: "**/browser/**" 로 브라우저 단독 몫을 제외한다. 그래서
pnpm test:e2e(프로젝트 미지정)는 두 계층을 모두 돌리고, 워크플로우는 각자 자기 몫만 명시한다.
// e2e/login.spec.ts — 풀스택 계층
import { test, expect } from "@playwright/test";
test("로그인 → 대시보드 진입 → /auth/me 호출", async ({ page }) => {
await page.goto("/login");
await page.fill('[name="email"]', "dev@chilloen.com");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
});브라우저 단독 회귀 (e2e/browser/) — 언제 여기에 써야 하나
단위 테스트로는 원리적으로 검증되지 않는 결함을 고정하는 자리다. 판별 기준: 재현에 브라우저 고유 기제(HTTP 캐시 · CORS · Cache Storage · 미디어 스택)의 상호작용이 필요한가. 필요하다면 jsdom + 스텁은 그 상호작용을 표현할 수 없어 “통과해도 아무것도 검증하지 않는” 초록이 된다.
- 정본 사례 — SPEC #179 음원 캐시 적중률 0.0%(전 매장 기기 · 1주일 무증상):
<audio src>는crossOrigin이 없으면 no-cors 로 나가고, Azure Blob 은 요청에Origin이 있을 때만 ACAO 를 붙이므로 그 응답이 ACAO·Vary없이 HTTP 캐시에 저장된다. 이후fetch(url, { mode: "cors" })는 그 엔트리를 재사용해 서버에 요청조차 보내지 않고 CORS 로 실패한다. 기존 단위 테스트는 전부Map기반 FakeCache + fetch 스텁이라 이 3자 상호작용이 테스트에 아예 없었다. →e2e/browser/audio-cache-http-cache-poisoning.spec.ts(Azure 모사 오리진 + 앱 오리진 = 로컬 http 서버 2개. 실 모듈을ts.transpileModule로 그대로 싣는다 — 복제본을 만들면 프로덕션 코드가 검증되지 않는다.) - BE 시크릿에 게이트된 워크플로우에 얹지 않는다.
e2e.yml은BACKEND_CHECKOUT_TOKEN이 없으면 통째로 skip 되므로, 회귀 게이트를 거기에 두면 시크릿에 인질로 잡힌다. 이 계층은 BE·compose 없이 수 초면 끝나므로ci.yml의 별도 job 에서 항상 돈다. - 한 쌍으로 쓴다 — “고장난 조건에서도 동작한다” 만 쓰면 과잉 수정(예: 항상
cache: "reload") 으로 되돌려도 통과한다. “정상 조건에서는 요청 1회” 를 짝으로 둬 반대 방향 회귀도 막는다. - 부수 단언으로 게이트를 무너뜨리지 않는다. 미디어 스택의 요청 수 같은 값은 하드코딩(
toHaveLength(1)) 하지 말고 스냅샷 + 상대 비교(toBeGreaterThan)로 쓴다. 단, 그 수 자체가 핵심 단언인 경우 (reload-first 회귀 차단)는 예외다. - 전제가 재현되지 않으면 “무효” 로 죽인다. 예:
canplaythrough는 HTTP 캐시 write 커밋 신호가 아니라서 커밋 전이면 cors fetch 가 그냥 성공한다 →expect.poll(timeout 5s)로 커밋을 기다리고, 끝내 오염이 재현되지 않으면 “오염 재현 실패 — 테스트 무효” 메시지로 실패시킨다 (가짜 초록도 가짜 빨강도 만들지 않는다).
설정:
playwright.config.ts(projects:chromium·browser)- 실행 —
pnpm test:e2e/pnpm test:e2e:browser(vitest 와 분리) - 베타 단계 — 풀스택 계층은 핵심 path (login · hq-onboarding · impersonate) 만 작성
사용자 관점 검증
getByRole·getByLabelText우선 (DOM 구조 의존 X)- 접근성 확인 자동화 —
@axe-core/playwright도입 후속
실행
pnpm -r test # 모든 패키지 단위 테스트
pnpm test:e2e # Playwright 전체(풀스택 + 브라우저 단독) — 풀스택은 compose 스택 필요
pnpm test:e2e:browser # 브라우저 단독 회귀만 (서버 불필요 · 수 초)
pnpm test --watch # watchCI 정합
ci.yml(PR·push 대상 develop/main — 항상 실행):api-drift— 커밋된openapi.json으로 typed client 재생성 후 diff 검사docs-lint— 앵커·baseline driftbrowser-regression—pnpm test:e2e:browser(브라우저 단독 회귀 · 서버 불필요)verify—pnpm install --frozen-lockfile→ lint · typecheck · test · build
e2e.yml(풀스택 harness): develop 대상 PR·수동 dispatch에서만,BACKEND_CHECKOUT_TOKEN미등록이면 skip. 현재 논블로킹(required check 미등록). 실행은pnpm test:e2e --project=chromium—browser프로젝트는ci.yml몫이라 여기서 중복 실행하지 않는다.
검증 후속 — /verify skill
워크스페이스 skill /verify:
- BE 변경 →
./gradlew ktlintCheck build test - FE 변경 →
pnpm -r lint typecheck test build - 의존성 / 타입 깨짐 사전 적발
Roadmap
- visual regression (Chromatic · Percy)
- mutation testing (PIT-Kotlin)
- coverage badge
References
- SPEC #003 §4 (AuthIntegrationTest 구조)
linkmusic-msa-space-was/build.gradle.ktslinkmusic-frontend-space/apps/admin/vitest.config.ts.claude/rules/frontend.mdtesting 섹션