양방향 링크 시스템을 테스트 커버리지에 확장하기: @tested / @covers 마커 패턴
문서↔코드 양방향 링크 시스템을 테스트 영역에 확장한 후속편. @tested / @covers 마커 설계, 3계층 테스트 커버리지 선언, CI 검증 스크립트까지 정리합니다.
1. 문제 상황
출발점: 이전 글의 양방향 링크 시스템
이 글은 AI 코딩 어시스턴트 시대의 문서화: 양방향 링크 시스템 구축기의 후속편입니다. 이전 글에서는 문서와 소스 코드 사이에 @handbook / @code 마커를 두어 양방향 참조를 만들었습니다.
/**
* 결제 수단 타입
* @handbook 9.5-discriminated-union
*/
export type PaymentMethod = /* ... */;
이 시스템은 AI 코딩 어시스턴트가 문서와 코드의 맥락을 잃지 않고 수정을 제안하게 해줬습니다. 하지만 실제 프로젝트를 운영하면서 한 가지 빈틈이 드러났습니다.
빈틈: 테스트는 어디로 연결되는가?
소스 코드 ↔ 문서는 연결되었지만, 테스트 파일은 여전히 고립된 섬이었습니다.
┌──────────────┐ @handbook ┌──────────────┐
│ 소스 코드 │ ──────────→│ 문서 │
│ │ ←────────── │ │
└──────────────┘ @code └──────────────┘
↕ (연결 없음) ↕ (연결 없음)
┌──────────────┐ ┌──────────────┐
│ 테스트 파일 │ │ 테스트 가이드 │
└──────────────┘ └──────────────┘
이 고립은 구체적인 문제를 낳았습니다.
- "이 소스 파일을 수정할 때 어느 테스트가 깨질까?" IDE 리팩토링 임팩트 예측은 완벽하지 않고, 리플렉션·동적 호출·문자열 기반 라우팅이 있으면 놓칩니다.
- "이 테스트가 실제로 뭘 검증하는지 한 번에 알기 어렵다."
tools.test.ts라는 파일명만 봐선 어떤 소스를 대상으로 하는지 알 수 없습니다. - "회귀 테스트가 충분한가?" 코드 리뷰에서 자주 나오지만 암묵적 연결에 의존합니다.
이전 시스템에서 빌려올 것
@handbook / @code가 "문서 ↔ 코드"를 해결했듯, "테스트 ↔ 코드" 에도 같은 접근을 씁니다. 다만 이름은 다르게 붙여야 합니다 — 테스트는 문서도 아니고 일반 코드도 아니니까요.
이 글에서는 ghost-mcp 프로젝트에 적용한 @tested / @covers 마커의 설계와 CI 자동 검증 스크립트를 다룹니다. 실제 적용 통계와 grep 기반 쿼리 패턴은 다음 글 소스와 테스트를 grep 한 줄로 연결하기에서 다룹니다.
2. 원인 분석: 왜 "테스트 양방향"이 필요한가
2.1 한 방향 추적의 한계
일반적인 테스트 도구는 한 방향만 지원합니다.
vitest,jest는 "이 테스트가 뭘 실행했는가"를 커버리지 리포트로 보여줍니다- IDE는 "이 테스트로 가기"를 지원합니다
하지만 "이 소스 파일에 해당하는 테스트로 가기" 는 지원이 약합니다. 파일 이름 규칙(foo.ts ↔ foo.test.ts)에 의존하는데, 규칙이 100% 일치하지 않는 프로젝트에선 헤맵니다.
예를 들어 ghost-mcp의 src/tools/tools.test.ts는 세 개의 소스 파일(post-tools.ts, tag-tools.ts, sync-tools.ts)을 동시에 테스트합니다. 파일 이름 규칙으로는 추적이 불가능합니다.
2.2 마커의 비대칭성
문서↔코드 양방향 링크와 달리, 테스트↔코드는 비대칭적입니다.
- 하나의 테스트 파일 ↔ 여러 소스 파일: 통합 테스트는 여러 모듈을 동시에 검증
- 하나의 소스 파일 ↔ 여러 테스트 파일: 단위 테스트 + 통합 테스트 + E2E 테스트
- 소스 코드 ↔ 특정 테스트 함수: "이 함수는 이 describe 블록에서 테스트됨"
마커 문법이 이 비대칭을 수용해야 합니다.
2.3 테스트 계층의 고려
ghost-mcp는 테스트를 세 가지 계층으로 나눕니다.
┌─────────────────────────────────────┐
│ 3. MCP 프로토콜 통합 테스트 │ ← 가장 넓은 범위
│ (tools.test.ts) │
├─────────────────────────────────────┤
│ 2. 도구 단위 테스트 │
│ (post-tools.test.ts 등) │
├─────────────────────────────────────┤
│ 1. 순수 함수 단위 테스트 │ ← 가장 좁은 범위
│ (markdown-parser.test.ts, │
│ validation.test.ts) │
└─────────────────────────────────────┘
테스트 계층이 다르면 보호하는 리스크도 다릅니다.
- 1계층: 알고리즘/로직 버그
- 2계층: 도구 파라미터/응답 형식
- 3계층: MCP 프로토콜/통합 흐름
한 소스 파일이 여러 계층에서 테스트되면, 각 계층의 역할을 마커에 명시해야 "이 코드는 어느 계층 보호까지만 받고 있구나"를 알 수 있습니다.
3. 해결 방법
3.1 마커 정의
두 가지 마커를 정의합니다.
/**
* 소스 파일 맨 위 JSDoc에
* @tested {테스트 파일 경로} [- 설명]
*/
/**
* 테스트 파일 맨 위 JSDoc에
* @covers {소스 파일 경로} [- 설명]
*/
@tested: "이 소스 파일은 아래 테스트 파일들로 보호받고 있다"@covers: "이 테스트 파일은 아래 소스 파일들을 검증한다"
두 마커는 이전 글의 @handbook / @code와 같은 역할의 대칭 쌍입니다. 한쪽을 바꾸면 반대편도 바꿔야 합니다.
자세한 문법 규약(경로 표기, E2E 접두사, 여러 줄 등)은 다음 글의 "마커 문법" 섹션에서 정리합니다. 이 글은 구조적 설계와 CI 자동 검증에 집중합니다.
3.2 통합 테스트의 비대칭 사례
ghost-mcp의 src/tools/tools.test.ts는 대표적인 비대칭 사례입니다.
/**
* @covers src/tools/post-tools.ts - ghost_list_posts, ghost_update_post 등
* @covers src/tools/tag-tools.ts - ghost_analyze_tags
* @covers src/tools/sync-tools.ts - ghost_push_local tag clearing
* @covers src/server.ts - MCP 도구 등록 흐름
*
* MCP 프로토콜 계층의 통합 테스트.
* 각 도구가 올바른 파라미터로 호출되고, 에러 메시지가 정규화되는지 검증.
*/
import { describe, it, expect, vi } from 'vitest';
// ...
네 개의 @covers가 있습니다. 반대로, 이 네 개의 소스 파일에는 각각 이런 @tested 마커가 있습니다.
// src/tools/post-tools.ts
/** @tested src/tools/tools.test.ts - MCP 프로토콜 통합 */
이렇게 하면 한 소스 파일에 여러 계층의 테스트가 있음을 볼 수 있습니다.
// src/tools/sync-tools.ts
/**
* @tested src/tools/tools.test.ts - MCP 프로토콜 통합
* @tested src/tools/sync-tools.test.ts - 단위 테스트 (frontmatter 파싱)
*/
개발자가 sync-tools.ts를 수정할 때 두 개의 테스트 파일을 함께 확인해야 한다는 것을 즉시 알 수 있습니다.
3.3 CI 자동 검증 스크립트: 양방향 일관성
이 글의 하이라이트입니다. 한쪽만 수정하고 반대편을 깜빡하면 링크가 깨지니, 자동 검증이 필수입니다.
// scripts/validate-test-links.ts
import fs from 'fs/promises';
import { glob } from 'glob';
interface Marker {
sourceFile: string;
targetFile: string;
line: number;
type: 'tested' | 'covers';
}
async function extractMarkers(
pattern: string,
tagName: '@tested' | '@covers'
): Promise<Marker[]> {
const files = await glob(pattern);
const markers: Marker[] = [];
for (const file of files) {
const content = await fs.readFile(file, 'utf-8');
const lines = content.split('\n');
lines.forEach((line, idx) => {
const match = line.match(
new RegExp(`${tagName}\\s+([^\\s-]+)(?:\\s*-\\s*.+)?`)
);
if (match) {
markers.push({
sourceFile: file,
targetFile: match[1],
line: idx + 1,
type: tagName === '@tested' ? 'tested' : 'covers',
});
}
});
}
return markers;
}
async function validate() {
// 소스 파일의 @tested 마커
const tested = await extractMarkers('src/**/*.ts', '@tested');
// 테스트 파일의 @covers 마커
const covers = await extractMarkers('src/**/*.test.ts', '@covers');
const errors: string[] = [];
// "소스 → 테스트" 참조가 테스트에 실제로 존재하는가?
for (const t of tested) {
const found = covers.some(
(c) => c.sourceFile === t.targetFile && c.targetFile === t.sourceFile
);
if (!found) {
errors.push(
`${t.sourceFile}:${t.line} @tested ${t.targetFile} — 반대편 @covers 없음`
);
}
}
// "테스트 → 소스" 참조가 소스에 실제로 존재하는가?
for (const c of covers) {
const found = tested.some(
(t) => t.sourceFile === c.targetFile && t.targetFile === c.sourceFile
);
if (!found) {
errors.push(
`${c.sourceFile}:${c.line} @covers ${c.targetFile} — 반대편 @tested 없음`
);
}
}
if (errors.length > 0) {
console.error('❌ 단방향 링크 발견:');
errors.forEach((e) => console.error(' ' + e));
process.exit(1);
}
console.log(`✅ 모든 링크가 양방향 (tested: ${tested.length}, covers: ${covers.length})`);
}
validate().catch(console.error);
이 스크립트를 CI에 통합하면 PR 리뷰에서 "깜빡하고 반대편 마커를 안 달았다"는 사례가 즉시 포착됩니다.
3.4 CI 파이프라인 통합
# .github/workflows/ci.yml
jobs:
validate-links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- name: Validate bidirectional test links
run: node scripts/validate-test-links.ts
마커 누락은 테스트 실패와 동일한 비중으로 취급됩니다. "문서화 편의"가 아니라 "빌드 필수 조건"이 되는 순간, 실제로 유지됩니다.
3.5 IDE 경험
JSDoc의 @tested, @covers는 표준 태그가 아닙니다. VSCode가 자동으로 인식하지는 않지만, JSDoc 주석 안에 있으므로 호버 툴팁에서 보이고, 파일 상단에 배치하면 즉시 시선이 갑니다. Cmd+P로 파일 이름을 치는 것이 충분히 빠릅니다. 커스텀 VSCode 확장을 만들지 않아도 작동합니다.
4. 핵심 개념 정리
@handbook / @code vs @tested / @covers
| 항목 | 문서 ↔ 코드 | 테스트 ↔ 코드 |
|---|---|---|
| 소스 측 마커 | @handbook {섹션} |
@tested {경로} |
| 대상 측 마커 | <!-- @code {경로} --> |
@covers {경로} |
| 매칭 방식 | 섹션 번호 | 파일 경로 |
| 비대칭 허용 | 1:1 / 1:N | 1:N / N:1 / N:N |
| 검증 방식 | grep + 양방향 체크 | CI 스크립트(이 글) |
테스트 계층과 마커의 관계
| 계층 | 테스트 파일 | @tested 설명 예시 |
|---|---|---|
| 단위 | {모듈}.test.ts |
단위 테스트 - 알고리즘 커버 |
| 도구 | {도구}-tools.test.ts |
도구 단위 - 파라미터 검증 |
| 통합 | tools.test.ts |
MCP 프로토콜 통합 |
한 소스 파일이 세 계층 모두에 걸쳐 있다면 세 줄의 @tested 마커가 붙습니다.
5. 베스트 프랙티스
체크리스트
- [ ] 모든 소스 파일에
@tested마커 추가 — 테스트가 없으면 마커도 없음(그 자체가 신호) - [ ] 모든 테스트 파일에
@covers마커 추가 - [ ] 통합 테스트는 여러
@covers를 명시하고 짧은 설명 붙이기 - [ ] 양방향 검증 스크립트를 CI에 포함 — 본 글 §3.3
- [ ] 마커 수정 시 반대편 마커도 같이 수정 — 한쪽만 바꾸면 CI 빌드 실패
- [ ] 테스트가 없는 소스 파일은 이유를 주석으로 남기기 —
@tested none - 타입 정의만 있음
6. FAQ
Q: 이전 @handbook / @code와 마커를 합칠 수는 없나요?
A: 의도적으로 분리했습니다. 이유는 검색과 역할의 분리입니다. @handbook은 "문서를 찾으려는" 맥락에서, @tested는 "회귀 방어를 확인하려는" 맥락에서 쓰입니다. 같은 이름을 쓰면 grep 결과에 섞여 혼란스럽습니다.
Q: 테스트 커버리지 리포트(lcov 등)와 중복 아닌가요?
A: 다릅니다. 커버리지 리포트는 "어느 라인이 실행되었는가" 를 보여주고, 양방향 마커는 "어느 파일을 의도적으로 테스트하는가" 를 선언합니다. 실행되었다고 해서 의도적으로 테스트하는 건 아니며, 의도적으로 테스트한다고 해서 모든 라인이 실행되는 것도 아닙니다. 둘은 보완적입니다. 이 주제는 다음 글에서 @vitest/coverage-v8와의 관계로 더 깊이 다룹니다.
Q: 통합 테스트가 너무 많은 @covers를 가지면 마커가 장황해지지 않나요?
A: 장황해진다는 것은 "통합 테스트의 책임이 너무 크다" 는 신호입니다. @covers가 10개를 넘어가면 통합 테스트를 분할할 시점입니다. 마커는 리팩토링을 촉진하는 압력으로도 작용합니다.
Q: AI 코딩 어시스턴트가 이 마커를 이해하나요?
A: 네, 이전 @handbook / @code와 마찬가지입니다. CLAUDE.md나 .cursorrules에 마커 규약만 추가하면 AI가 소스 수정 시 @tested 마커를 읽고 "수정 후 이 테스트를 돌려야 한다"는 판단을 내립니다.
7. 참고 자료
- AI 코딩 어시스턴트 시대의 문서화: 양방향 링크 시스템 구축기 — 이 시리즈의 출발점
- JSDoc 공식 문서 — 커스텀 태그 가이드
- Vitest 공식 문서
8. 다음 단계
이 글은 설계와 CI 자동 검증에 집중했습니다. 다음 글에서는 Obsidian 플러그인 프로젝트에 실제로 적용한 45 파일 규모의 경험, 5가지 grep 쿼리 패턴, "주석 vs 외부 파일 vs 네이밍 규약" 트레이드오프 분석을 다룹니다.
시리즈 목차:
- AI 코딩 어시스턴트 시대의 문서화: 양방향 링크 시스템 구축기
- 양방향 링크 시스템을 테스트 커버리지에 확장하기: @tested / @covers 마커 패턴 ← 현재 글
- 소스와 테스트를 grep 한 줄로 연결하기: @tested / @covers 양방향 마커 시스템
- 양방향 링크 시스템 회고: 프로젝트 스케일에 따른 도구화 결정