AI 에이전트에 테스트 작성 위임
요즘 사이드 프로젝트 코드는 AI 에이전트(Claude Code)와 같이 씁니다. 테스트 작성도 에이전트에 맡겨 왔습니다. 기능을 요청할 때 테스트도 같이 만들어 달라고 하고, 작업이 끝나면 pnpm test가 통과했다는 보고를 받는 식입니다. 그렇게 몇 달이 지나니 테스트 파일이 꽤 쌓였습니다.
| 테스트 파일 | 테스트 케이스(대략) | |
|---|---|---|
| 습관 기록 앱 | 113 | 1,287 |
| 퍼즐 게임 앱 | 102 | 929 |
그런데 누가 "이 앱에는 어떤 테스트가 있어요?"라고 물으면 대답을 못 하겠더라고요. 단위 테스트, 통합 테스트, E2E 같은 말은 알고 있었습니다. 하지만 내 저장소의 어떤 파일이 어디에 속하는지, 테스트가 통과했다는 게 정확히 무엇을 보장하는지는 몰랐습니다. 테스트 요청도 "테스트도 만들어 줘" 한 줄이 전부였습니다.
그래서 에이전트가 만든 테스트를 층별로 분석하면서 공부하기로 했습니다. 이 글은 그 첫 번째 정리입니다. 각 층의 정의를 적고, 저장소의 실제 테스트 파일을 하나씩 열어 무엇을 확인하는지 봤습니다.
네 가지 층
테스트를 나누는 기준은 여러 가지가 있습니다. 가장 많이 쓰는 건 한 번에 얼마나 넓은 범위를 실행하느냐입니다. ISTQB 같은 테스트 교재에서 쓰는 "테스트 레벨"이 이 기준이고, 보통 네 단계로 나눕니다.
| 층 | 한 번에 실행하는 범위 | 확인하는 것 |
|---|---|---|
| 단위 테스트 | 함수 하나, 모듈 하나 | 그 코드가 입력에 맞는 값을 내는가 |
| 통합 테스트 | 여러 모듈 + DB, HTTP 같은 바깥 자원 | 맞물렸을 때도 맞게 동작하는가 |
| 시스템 테스트 | 빌드한 앱 전체 | 완성된 앱이 명세대로 동작하는가 |
| 인수 테스트 | 앱 전체 (사용자 관점) | 요구사항을 만족해서 받아들일 수 있는가 |
아래로 갈수록 실제 사용 환경에 가까워지고, 대신 느려지고, 실패했을 때 원인을 찾기 어려워집니다.
헷갈렸던 부분이 두 가지 있었습니다.
- E2E는 층 이름이 아닙니다. E2E(End-to-End)는 사용자 입장에서 앱을 처음부터 끝까지 조작해 보는 방식을 말합니다. 웹 프로젝트에서는 시스템 테스트를 Playwright 같은 도구로 E2E 방식으로 쓰는 경우가 대부분이라 둘이 거의 같은 뜻으로 쓰입니다.
- 인수 테스트는 도구로 구분되지 않습니다. 내 저장소의 인수 테스트도 결국 Playwright 파일입니다. 시스템 테스트와 다른 점은 무엇을 기준으로 쓰였느냐입니다. 인수 테스트는 요구사항 문서의 "완료 기준"을 그대로 옮긴 것이고, 시스템 테스트는 개발자가 앱 전체가 잘 도는지 확인하려고 쓴 것입니다.
컴포넌트 테스트처럼 이 네 층 사이에 걸치는 테스트도 있습니다. 아래에서 따로 다룹니다.
AI로 테스트 분류 분석
분석도 에이전트를 활용했습니다. 에이전트가 만든 테스트를, 다시 에이전트에게 "어떤 층의 테스트인지" 분류하게 한 것입니다. 기준은 파일 안에 무엇이 들어 있는지였습니다. Playwright를 쓰면 E2E, 로컬 DB를 띄우거나 HTTP 요청을 보내면 통합, 순수 함수만 부르면 단위로 봤습니다.
에이전트는 이 기준으로 파일을 훑는 분류 스크립트를 만들어 돌렸습니다. 첫 결과에서는 저장소 테스트 몇 개가 단위로 잘못 들어갔습니다. 테스트 도우미 파일 안에서 가짜 DB를 만들고 있어서, 테스트 파일만 보면 순수 함수 테스트처럼 보였기 때문입니다. 도우미 파일까지 열어 확인하게 한 뒤 분류를 고쳤습니다. 자동 분류 결과를 그대로 믿으면 안 된다는 것도 여기서 확인했습니다.
| 층 | 실제로 무엇을 붙이고 도는가 | 습관 기록 앱 | 퍼즐 게임 앱 |
|---|---|---|---|
| 단위 | 순수 함수만. DB, 네트워크 없음 | 약 64 | 약 62 |
| 통합 (저장소) | 메모리 IndexedDB에 실제 스키마로 읽고 쓰기 | 8 | 7 |
| 통합 (API) | 로컬 D1에 실제 마이그레이션 적용 + 서버 앱에 HTTP 요청 | 7 | 6 |
| 컴포넌트 | 컴포넌트를 HTML 문자열로 그려서 확인 | 1 | 1 |
| 시스템 (E2E) | 빌드한 웹앱을 브라우저로 조작 | 27 | 18 |
| ↳ 그중 인수 성격 | 명세의 "완료 기준"을 하나씩 확인 | 14 | 5 |
이제 층마다 하나씩 살펴보겠습니다. 예시는 모두 습관 기록 앱에서 가져왔습니다.
예시 규칙: 오전 4시 이후 지우기 제한
습관 기록 앱은 판에 칸을 하나씩 칠하는 앱입니다. 실수로 칠한 칸은 지울 수 있는데, 조건이 있습니다. 앱의 하루는 오전 4시에 바뀌고(자정에는 아직 전날입니다), 오늘 칠한 칸만 지울 수 있습니다. 기능 명세에는 이렇게 적혀 있습니다.
## 완료 기준
- [ ] 오늘 칠한 칸만 시트가 열린다
- [ ] 오전 4시 직전에 칠한 칸이 4시가 지나면 지워지지 않는다
- [ ] 지우면 톰스톤(`deletedAt`)이 남고 화면에서는 사라진다
- [ ] 키보드로 지울 수 있다
## 테스트
- core: `canUndo` — `docs/reference/logical-date.fixtures.json`의 `canUndo` 벡터
- storage: 지운 칸이 조회에서 빠지고, 같은 칸을 다시 칠하면 `deletedAt`이 `null`로 돌아온다
명세의 "테스트" 절에는 어느 층에서 무엇을 확인할지가 이미 나뉘어 있었습니다. 전에는 이 절을 별 생각 없이 넘겼습니다. 이 규칙 하나를 따라가면 층마다 하는 일이 잘 보입니다.
단위 테스트
정의
함수나 모듈 하나를 다른 것과 떼어 놓고 실행합니다. 입력을 넣고 결과를 확인합니다. DB, 네트워크, 화면, 현재 시각처럼 바깥에서 오는 것은 쓰지 않거나 테스트가 직접 값을 넣어 줍니다.
실제 파일
"오늘"을 정하는 함수 logicalDate의 테스트입니다.
export function logicalDate(now: Date | number): string {
const t = toDate(now);
const today = format(t.getFullYear(), t.getMonth() + 1, t.getDate());
return t.getHours() < DAY_START_HOUR ? addDays(today, -1) : today;
}
describe('logicalDate', () => {
it.each(fixtures.logicalDate)('$local → $expected (fixture)', ({ local: l, expected }) => {
expect(logicalDate(local(l))).toBe(expected);
});
it('오전 4시 직전과 직후가 다른 날이다', () => {
expect(logicalDate(local('2026-10-07T03:59:59.999'))).toBe('2026-10-06');
expect(logicalDate(local('2026-10-07T04:00:00.000'))).toBe('2026-10-07');
});
it('자정 직후는 아직 전날이다', () => {
expect(logicalDate(local('2026-10-07T00:00:00'))).toBe('2026-10-06');
});
});
현재 시각을 함수 안에서 읽지 않고 인자로 받기 때문에, 테스트가 원하는 시각을 마음대로 넣을 수 있습니다. 단위 테스트를 쓰기 쉬운 코드는 이렇게 바깥 값을 인자로 받는 모양이 됩니다.
03:59:59.999와 04:00:00.000을 나란히 넣은 부분은 경계값 테스트입니다. 버그는 경계에서 많이 나옵니다. <를 <=로 잘못 쓰면 4시 정각 하나만 틀리는데, 경계 바로 앞뒤를 넣어 두면 그런 실수가 바로 드러납니다. 저장소의 CLAUDE.md에도 "날짜가 걸린 테스트는 반드시 오전 4시 앞뒤를 포함한다"는 규칙이 있습니다.
it.each(fixtures.logicalDate)는 JSON 파일에 적힌 입력과 기댓값 목록을 한 줄씩 테스트로 만듭니다. 지울 수 있는지 판단하는 canUndo도 같은 파일에 벡터가 있습니다.
"canUndo": [
{ "filledAtLocal": "2026-09-30T21:00:00", "nowLocal": "2026-09-30T23:00:00", "expected": true },
{ "filledAtLocal": "2026-09-30T21:00:00", "nowLocal": "2026-10-01T03:59:00", "expected": true },
{ "filledAtLocal": "2026-09-30T21:00:00", "nowLocal": "2026-10-01T04:00:00", "expected": false },
{ "filledAtLocal": "2026-10-01T02:00:00", "nowLocal": "2026-10-01T05:00:00", "expected": false }
]
이 파일은 웹앱과 안드로이드 위젯이 같이 씁니다. 두 쪽이 같은 규칙으로 "오늘"을 계산하는지 같은 표로 확인하는 셈입니다.
확인 범위와 한계
- 잡는 것: 계산 실수, 경계값 실수, 예외 입력 처리. 실패하면 어느 함수의 어느 입력이 틀렸는지 바로 보입니다.
- 놓치는 것: 이 함수를 부르는 쪽의 실수.
canUndo가 맞아도, 지우기 버튼이canUndo를 안 부르거나 다른 시각을 넘기면 단위 테스트는 모릅니다.
실행 시간
core 패키지 단위 테스트 17개 파일, 416개 케이스가 0.45초에 끝났습니다. 코드를 고칠 때마다 돌려도 부담이 없습니다.
통합 테스트
정의
여러 모듈을 이어 붙이거나, DB나 HTTP 같은 바깥 자원과 함께 실행합니다. 단위 테스트가 부품 하나를 확인한다면, 통합 테스트는 부품끼리 연결했을 때도 맞는지 확인합니다.
어디까지 진짜를 붙일지는 테스트마다 다릅니다. 이 저장소에는 두 종류가 있었습니다.
저장소 통합 테스트: 메모리 IndexedDB
앱은 기록을 브라우저의 IndexedDB에 저장합니다. 저장소 테스트는 fake-indexeddb라는 라이브러리로 메모리에 IndexedDB를 만들고, 실제 스키마 그대로 읽고 씁니다.
export function createTestDb(): HankanDB {
return createDb({ indexedDB: new IDBFactory(), IDBKeyRange });
}
describe('fillCell', () => {
it('이미 칠한 칸은 덮지 않는다', async () => {
const b = await createBoard(db, countInput(), at(2026, 10, 1), { newId });
const first = await fillCell(db, { boardId: b.id, idx: 0, stroke: null }, at(2026, 10, 2));
await expectCode(
fillCell(db, { boardId: b.id, idx: 0, stroke }, at(2026, 10, 3)),
'cell-already-filled',
);
expect(await db.cells.get([b.id, 0])).toEqual(first);
});
it('범위 밖 칸 번호는 막는다', async () => {
const b = await createBoard(db, countInput(), at(2026, 10, 1), { newId }); // 7칸
for (const idx of [-1, 7, 1.5, Number.NaN]) {
await expectCode(
fillCell(db, { boardId: b.id, idx, stroke: null }, at(2026, 10, 2)),
'cell-out-of-range',
);
}
expect(await db.cells.count()).toBe(0);
});
});
마지막 줄 expect(await db.cells.count()).toBe(0)이 통합 테스트다운 부분입니다. 함수가 오류를 던졌는지만 보면 단위 테스트로도 충분합니다. 여기서는 오류가 난 뒤 DB에 아무것도 남지 않았는지까지 확인합니다. 트랜잭션이나 인덱스, 키 모양처럼 DB와 맞물려야 드러나는 실수는 이 층에서 잡힙니다.
자동 분류에서 이 테스트들이 단위로 잘못 들어갔던 이유가 여기 있습니다. 테스트 파일에는 createTestDb() 호출만 보이고, 가짜 DB를 만드는 코드는 도우미 파일 안에 있었습니다.
API 통합 테스트: 로컬 D1 + 실제 마이그레이션
서버는 Cloudflare Workers에서 도는 Hono 앱이고, DB는 D1(SQLite)입니다. API 테스트는 Miniflare로 로컬 D1을 띄우고, 실제 migrations/*.sql을 적용한 다음, 앱에 HTTP 요청을 보냅니다.
/*
* API 테스트 환경. Miniflare의 로컬 D1에 migrations/*.sql을 적용하고 Hono 앱에 요청을 보낸다.
* 테스트 파일마다 새 DB다.
*/
it('두 기기가 같은 칸을 칠하면 늦게 칠한 쪽이 남고 칸은 하나다', async () => {
const a = await member('a');
await push(a, {
boards: [board()],
cells: [cell({ updatedAt: T0 + 2, logicalDate: '2026-10-10' })],
});
await push(a, { boards: [], cells: [cell({ updatedAt: T0 + 1, logicalDate: '2026-10-09' })] });
const got = await pull(a);
expect(got.cells).toHaveLength(1);
expect(got.cells[0]?.logicalDate).toBe('2026-10-10');
});
it('다른 계정의 판은 보낼 수도 받을 수도 없다(400, 아무것도 쓰지 않음)', async () => {
const a = await member('a');
const b = await member('b');
await push(a, { boards: [board()], cells: [cell()] });
// ... b가 a의 판을 덮어쓰려는 요청 두 가지
expect((await pull(b)).boards).toEqual([]);
expect((await pull(a)).boards.map((x) => x.title)).toEqual(['걷기']);
});
여기에는 로그인 세션, 요청 검증, SQL, 마이그레이션이 모두 한 번에 들어갑니다. 마이그레이션 파일에서 컬럼 하나를 빠뜨리면 단위 테스트는 모두 통과해도 이 테스트는 실패합니다. 두 번째 테스트는 권한 검사를 확인합니다. 다른 사람의 데이터를 덮어쓸 수 없는지는 실제 요청을 보내 봐야 알 수 있습니다.
확인 범위와 한계
- 잡는 것: 모듈 사이의 약속이 어긋난 경우, SQL과 스키마 실수, 권한 검사 누락, 오류가 난 뒤 데이터가 남는 문제.
- 놓치는 것: 화면. 버튼이 이 API를 맞게 부르는지, 응답을 받아 화면에 맞게 보여 주는지는 모릅니다. 그리고 가짜 IndexedDB나 Miniflare가 실제 브라우저, 실제 Cloudflare와 다르게 동작하는 부분도 놓칩니다.
실행 시간
| 파일 | 케이스 | 시간 | |
|---|---|---|---|
| 저장소 (메모리 IndexedDB) | 8 | 167 | 0.50초 |
| API (로컬 D1) | 10 | 118 | 12.77초 |
메모리 DB는 단위 테스트와 거의 같은 속도입니다. API 테스트는 파일마다 새 D1을 띄우고 마이그레이션을 적용하느라 25배쯤 느립니다. 그래도 매번 돌릴 수 있는 정도입니다.
컴포넌트 테스트
네 층 사이에 걸치는 테스트입니다. React 컴포넌트 하나를 그려서 결과를 확인합니다. 화면을 다루니 단위보다는 넓고, 앱 전체를 띄우지는 않으니 시스템 테스트보다는 좁습니다.
이 저장소의 손그림 UI 패키지에는 컴포넌트 테스트가 하나 있습니다. 브라우저 DOM 없이 renderToStaticMarkup으로 HTML 문자열을 만들어 확인합니다.
it('원은 cellShape(seed).ring 그대로다. 같은 시드는 같은 마크업', () => {
const a = html(<HandCircle seed={seed} diameter={32} />);
expect(attrs(a, 'd')).toEqual([cellShape(seed).ring]);
expect(html(<HandCircle seed={seed} diameter={32} />)).toBe(a);
expect(html(<HandCircle seed={seed + 1} diameter={32} />)).not.toBe(a);
});
it('Math.random을 부르지 않는다', () => {
const spy = vi.spyOn(Math, 'random').mockImplementation(() => {
throw new Error('Math.random 금지');
});
html(<HandCircle seed={seed} diameter={32} mark="preset" highlight drawIn />);
// ...
});
손그림 원은 칸마다 모양이 조금씩 다른데, 같은 칸은 서버에서 그리든 브라우저에서 그리든 항상 같은 모양이어야 합니다. 그래서 같은 시드면 같은 마크업이 나오는지, Math.random을 부르지 않는지를 확인합니다.
이 방식은 클릭이나 포커스 같은 상호작용은 확인하지 못합니다. 그 부분은 E2E가 맡습니다. DOM 없이 컴포넌트를 테스트하는 방법은 jsdom 없이 React 컴포넌트 테스트하기에 따로 정리했습니다.
시스템 테스트 (E2E)
정의
빌드한 앱 전체를 실제와 같은 환경에서 실행하고, 사용자처럼 조작합니다. 이 저장소에서는 Playwright가 빌드된 웹앱과 로컬 API 서버(wrangler dev + 로컬 D1)를 띄우고 크로미움 브라우저로 클릭하고 입력합니다. 휴대폰 화면 크기와 데스크톱 화면 크기 두 가지로 돕니다.
실제 파일
같은 "오전 4시" 규칙을 E2E로 확인하는 테스트입니다.
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: NOW });
});
test.describe('오전 4시 직전에 칠한 칸', () => {
// 10월 1일 03:58에 칸 5를 칠했다 — 논리적 날짜는 아직 9월 30일.
test.beforeEach(async ({ page }) => {
await page.clock.setSystemTime(kst('2026-10-01T03:59:00'));
await seed(page, [makeBoard({ id: 'b1' })], [paintedAt(5, '2026-10-01T03:58:00')]);
await openBoard(page);
});
test('시트를 연 채 4시가 지나면 [지우기]를 눌러도 지워지지 않는다', async ({ page }) => {
await hit(page, 5).click({ button: 'right' });
await expect(sheet(page)).toBeVisible();
await page.clock.runFor('01:30');
await sheet(page).getByRole('button', { name: '지우기' }).click();
await expect(toast(page)).toHaveText(NOT_TODAY);
await expect(sheet(page)).toHaveCount(0);
await expect(art(page, 5)).toHaveAttribute('data-state', 'filled');
const row = await cellRow(page, 5);
expect(row?.deletedAt).toBeNull();
expect(row?.updatedAt).toBe(kst('2026-10-01T03:58:00'));
});
});
page.clock은 Playwright가 브라우저의 시계를 대신 돌려 주는 기능입니다. 테스트는 시각을 03:59로 맞추고, 03:58에 칠한 칸을 넣어 둔 채 화면을 엽니다. 4시가 되기를 실제로 기다릴 수는 없으니, 시트를 연 뒤 시계를 1분 30초 앞으로 돌립니다. 단위 테스트에서 시각을 인자로 넘겼던 것과 같은 생각을 브라우저 전체에 적용한 것입니다.
이 테스트는 단위 테스트가 볼 수 없던 것을 확인합니다.
- 칸을 우클릭하면 시트가 실제로 열리는가
- 시트를 열어 둔 사이에 4시가 지나면, 지우기 버튼이 그 시점의 시각으로 다시 판단하는가
- 안내 토스트가 뜨고, 칸은 화면에 칠해진 채 남는가
- IndexedDB의 실제 행도 지워지지 않았는가
두 번째 항목이 E2E에서만 잡히는 버그입니다. canUndo 함수가 맞더라도, 화면이 시트를 열 때 계산한 결과를 기억해 두었다가 지우기 버튼에서 그대로 쓰면 4시가 지난 뒤에도 지워집니다. 단위 테스트와 저장소 테스트는 모두 통과하는데 사용자는 버그를 겪습니다.
데이터를 준비하는 방법도 볼 만했습니다. 테스트마다 화면을 눌러 판을 만들면 느리고, 판 만들기 화면이 고장 나면 상관없는 테스트까지 다 실패합니다. 그래서 seed 도우미가 브라우저의 IndexedDB에 판과 칸을 직접 넣은 뒤 화면을 엽니다.
확인 범위와 한계
- 잡는 것: 화면과 로직을 잇는 부분의 실수, 상태를 기억해 두는 타이밍 문제, 접근성 이름(
getByRole로 찾기 때문에 버튼 이름이 바뀌면 실패합니다), 여러 기능이 이어지는 흐름. - 놓치는 것: 실패했을 때 원인. "토스트가 안 떴다"는 실패는 시계 처리, 시트 컴포넌트, 저장소 중 어디가 문제인지 알려 주지 않습니다. 실제 기기의 터치 입력이나 진짜 Cloudflare 환경과의 차이도 놓칩니다. 이 파일의 "진짜 터치로 길게 누르기" 테스트는 데스크톱 프로젝트에서는 건너뜁니다.
실행 시간
이 파일 하나(데스크톱·모바일 합쳐 29개 통과, 1개 건너뜀)가 11.2초 걸렸습니다. 빌드가 이미 되어 있는 상태에서 잰 시간이고, 빌드와 API 서버 준비 시간은 빠져 있습니다. 같은 4시 규칙을 단위 테스트는 1밀리초도 안 걸려 확인합니다. E2E가 27개 파일이니 매번 돌리기는 부담스럽고, 이 저장소는 CI에서만 전부 돌립니다.
인수 테스트
정의
요구사항을 만족하는지, 그래서 이 기능을 "다 됐다"고 받아들일 수 있는지 확인합니다. 원래는 사용자나 기획자가 직접 확인하는 단계를 가리키는 말이었습니다. 지금은 요구사항 문서의 기준을 자동화된 테스트로 옮겨 두는 경우가 많습니다.
실제 파일
이 저장소에는 "인수 테스트"라는 이름의 폴더가 없습니다. 그런데 E2E 파일 27개 중 14개는 맨 위에 이런 주석이 있습니다.
/*
* F05 되돌리기 — 세부명세 "완료 기준"을 하나씩 확인한다.
*/
그리고 describe 이름이 명세의 완료 기준 문장과 같습니다.
test.describe('오늘 칠한 칸만 시트가 열린다', () => { /* ... */ });
test.describe('지우면 톰스톤이 남고 화면에서는 사라진다', () => { /* ... */ });
명세의 체크리스트 한 줄이 describe 하나가 되고, 그 안의 test들이 그 기준을 여러 상황에서 확인합니다. 도구는 시스템 테스트와 같은 Playwright지만, 명세에서 출발했다는 점이 다릅니다. 그래서 이 14개는 인수 테스트로 분류했습니다.
확인 범위와 한계
- 잡는 것: "만들긴 했는데 명세와 다르다"는 버그. 기준 문장이 테스트 이름이 되니, 테스트 결과 목록이 그대로 명세 점검표가 됩니다.
- 놓치는 것: 명세에 없는 것. 그리고 명세의 모든 기준이 테스트로 옮겨졌는지는 아무도 확인하지 않았습니다. F05의 "키보드로 지울 수 있다"는
Delete,Backspace테스트로 덮여 있었지만, 나머지 25개 기능 명세를 다 대조해 본 적은 없습니다. 이건 다음 글에서 직접 대조해 볼 생각입니다.
층별 비교
"오전 4시가 지나면 지울 수 없다"는 규칙 하나를 정리하면 이렇습니다.
| 층 | 무엇을 확인하나 | 이 층이 없으면 놓치는 버그 | 시간 |
|---|---|---|---|
| 단위 | logicalDate, canUndo가 경계 앞뒤에서 맞는 값을 내는가 |
<와 <= 혼동, 자정 처리 실수 |
1ms 미만 |
| 통합 (저장소) | 지운 칸이 조회에서 빠지고, 다시 칠하면 deletedAt이 돌아오는가 |
톰스톤을 쓰고도 조회에 남는 문제 | 수 ms |
| 시스템 (E2E) | 시트를 연 채 4시가 지나도 지워지지 않는가 | 화면이 판단 결과를 기억해 두는 문제 | 수백 ms + 브라우저 실행 |
| 인수 | 명세 완료 기준 4줄이 모두 확인되는가 | 기준 하나를 빠뜨리고 "완료"라고 하는 문제 | E2E와 같음 |
같은 규칙을 네 번 테스트하는 게 낭비처럼 보였는데, 표로 보니 층마다 막는 버그가 다릅니다. 반대로, 같은 버그를 여러 층에서 반복해서 확인하는 테스트가 있다면 그쪽이 낭비입니다. 경계값 조합을 전부 E2E로 확인하면 느리기만 합니다. 조합은 단위 테스트에 몰고, E2E는 "화면이 그 함수를 제때 부르는가"만 보면 됩니다.
흔히 말하는 테스트 피라미드가 이 이야기입니다. 아래(단위)를 넓게, 위(E2E)를 좁게 쌓습니다. 이 저장소도 대략 그 모양이었습니다. 케이스 수로 보면 단위 416개, 저장소 통합 167개, API 통합 118개, E2E 27개 파일입니다.
분석 결과 확인한 문제
- 평소에는 E2E가 돌지 않습니다. 두 저장소 모두 CLAUDE.md의 "작업을 끝내기 전에" 규칙이
pnpm test까지였습니다. 그래서 Claude는 작업을 마칠 때 단위·통합 테스트만 돌리고, E2E는 따로 요청해야 돌렸습니다. "테스트 통과했습니다"라는 보고가 어디까지를 뜻하는지 이번에 처음 알았습니다. - 퍼즐 게임 앱에는 CI가 없습니다. E2E 18개가 있지만 자동으로 돌리는 곳이 없어서, 마지막으로 모두 통과한 게 언제인지 모릅니다. 습관 기록 앱은 CI에서 lint, 타입 검사, 단위·통합, E2E를 모두 돌립니다.
- CI의 재시도가 문제를 가릴 수 있습니다. 습관 기록 앱의 CI는 실패한 E2E를 두 번까지 다시 돌립니다. 가끔만 실패하는 테스트가 있어도 초록불로 지나갈 수 있습니다.
- 테스트가 정말 버그를 잡는지는 확인한 적이 없습니다. 코드를 일부러 망가뜨렸을 때 실패하는지 본 적이 없습니다. 커버리지 기준도 core 패키지의 90% 하나뿐입니다.
에이전트에 테스트 요청하는 방식 개선
이번 분석을 바탕으로 에이전트에 테스트를 요청하는 문장을 바꿔 보려고 합니다.
- "테스트도 만들어 줘" 대신 층을 지정합니다. "경계값 조합은 core 단위 테스트로, 화면에서 시트를 연 채 시각이 바뀌는 경우는 E2E 한 개로"처럼요.
- 무엇을 막는 테스트인지 묻습니다. "이 테스트는 어떤 버그가 생기면 실패해?"라고 물었을 때 대답이 다른 테스트와 같다면 중복입니다.
- 명세의 완료 기준과 대조해 달라고 합니다. "F05 완료 기준 각 줄이 어느 테스트에서 확인되는지 표로 보여 줘"라고 하면 빠진 기준이 보입니다.
- "통과했다"의 범위를 확인합니다. 어떤 명령을 돌렸는지, E2E도 돌렸는지 같이 보고하게 합니다.
다음 글부터는 층 하나씩 더 깊게 들어갑니다. 순서는 단위 테스트, 테스트 더블(mock·stub·fake), 통합 테스트, E2E, 인수 테스트, 그리고 테스트가 정말 버그를 잡는지 코드를 일부러 망가뜨려 보는 실험입니다.