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-dom import
  • tsconfig.json types — ["node", "vitest/globals", "@testing-library/jest-dom"]

e2e — Playwright (2계층)

위치: e2e/. Playwright 프로젝트가 두 개이고 게이트도 서로 다르다.

계층프로젝트위치필요 인프라실행CI
풀스택 happy-pathchromiume2e/*.spec.tsdocker compose(pg → backend → frontend)pnpm test:e2e --project=chromiume2e.yml (논블로킹 · BACKEND_CHECKOUT_TOKEN 없으면 skip)
브라우저 단독 회귀browsere2e/browser/**없음(테스트가 자체 http 서버를 띄운다)pnpm test:e2e:browserci.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         # watch

CI 정합

  • ci.yml (PR·push 대상 develop/main — 항상 실행):
    • api-drift — 커밋된 openapi.json 으로 typed client 재생성 후 diff 검사
    • docs-lint — 앵커·baseline drift
    • browser-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.kts
  • linkmusic-frontend-space/apps/admin/vitest.config.ts
  • .claude/rules/frontend.md testing 섹션