Back

LLM이 짜준 테스트가 다 그 모양인 이유

금요일에 TSBM(TypeScript Backend Meetup)에 다녀왔다. 테스팅 세션에서 나온 말이 찔렸다.

“LLM한테 그냥 테스트 짜줘 시키면 모킹 5개 달고 함수 스펙 그대로 베낍니다. 구현을 복사한 테스트, 마음의 안정감 전혀 없어요.”

solvesk 테스트 파일이 머릿속에 바로 떠올랐다. vi.mock 여러 개 달려 있고, 뭔가 테스트가 있긴 한데 실제로 믿어도 되는지 확신이 없던 것들.


그럼 solvesk는?

집에 와서 테스트 파일들 훑었다. 예상대로였다.

comment.service.test.ts를 열었더니 상단에 이런 게 있었다.

vi.mock('drizzle-orm', () => ({
  eq: vi.fn(),
  and: vi.fn(),
}))
vi.mock('@/db', () => ({
  db: { query: { comments: { findFirst: vi.fn() }, ... }, insert: vi.fn() }
}))
vi.mock('@/lib/permissions-config', () => ({ CUSTOMER_CONSTRAINTS: { ... } }))
// ...더 있음

드리즐 ORM 내부까지 모킹하고 있었다. 이러면 ORM 쿼리 방식을 바꾸거나 리팩터링을 하면 동작은 그대로인데 테스트가 깨진다. 실제로 뭔가 고장 난 게 아닌데.

권한 판단 로직은 서비스 안에 인라인으로 박혀 있었다. 서비스에 권한 로직이 섞여 있으니 테스트하려면 DB 모킹이랑 권한 로직을 같이 처리해야 했다. 단위 테스트로 뭔가를 격리해서 보기가 어려운 구조.


책에서는 뭐라고 하나

chapterlog이라는 책장 프로젝트를 운영하고 있다. 책을 챕터별로 먼저 정리해두고 그걸 읽는 방식으로 운영하는 곳인데, 블라디미르 코리코프의 “단위 테스트”를 여기에 정리해두고 읽었다. 밋업 세션이랑 내용이 겹치는 게 너무 많아서 이참에 이론 정리하기 딱 좋은 타이밍이었다.

책이 테스팅 유파를 두 개로 나눈다. 런던파는 의존성을 전부 모킹한다. 파일마다 테스트 파일 하나, 협력 객체 있으면 무조건 격리. LLM이 기본으로 뱉는 스타일이다. 고전파는 레포/게이트웨이만 모킹하고 나머지는 메모리에서 같이 돌린다.

런던파의 문제는 테스트가 구현을 모사하게 된다는 거다. 어떤 쿼리를 쓰는지, 어떤 함수를 몇 번 호출하는지를 테스트가 알아야 한다. 이 결합 때문에 생기는 게 거짓 양성 — 실제로 고장 난 게 없는데 테스트가 빨간불을 켠다. 거짓 양성이 반복되면 테스트를 신뢰하지 않게 되고, 결국 아무도 안 본다.

책에서 좋은 테스트의 핵심으로 리팩터링 내성을 꼽는다. 구현 방식이 바뀌어도 테스트가 깨지지 않아야 한다. 중간 과정이 아니라 최종 결과만 봐야 한다는 것. 단위 테스트로 잡기 좋은 건 복잡도는 높고 협력 객체는 적은 것들 — 권한 판단 같은 순수 로직이 딱 여기 해당한다.

solvesk의 문제가 이제 명확했다. 권한 판단이 DB 접근 옆에 인라인으로 붙어 있어서, 분리 없이 둘 다 모킹해서 테스트하고 있었던 것.


수정

권한 판단을 순수 함수로 뽑아냈다.

// comment.permissions.ts
export function canCreateInternalComment(user: AuthenticatedUser): boolean {
  return user.role !== "customer";
}

export function canUpdateComment(
  user: AuthenticatedUser,
  comment: { authorId: string },
): boolean {
  return comment.authorId === user.id;
}

export function canDeleteComment(
  user: AuthenticatedUser,
  comment: { authorId: string },
): boolean {
  return user.role === "admin" || comment.authorId === user.id;
}

입력 있고 출력 있고 부수 효과 없다. 이런 함수는 테스트가 제일 쉽다.

describe("canDeleteComment", () => {
  it("allows admin to delete any comment", () => {
    expect(canDeleteComment(adminUser, { authorId: "other-user" })).toBe(true);
  });

  it("rejects non-admin from deleting other user comment", () => {
    expect(canDeleteComment(agentUser, { authorId: "other-user" })).toBe(false);
  });
});

모킹 제로. DB 없음. 테스트 자체가 명세로 읽힌다.

서비스에서는 권한 판단 인라인 코드를 이 함수들로 교체했다. 서비스는 “DB 접근 + 권한 위임”만 남고, 권한 로직이 어디 있는지 명확해진다.

결과적으로 테스트 파일에서 vi.mock 수가 확 줄었다. 권한 관련 테스트는 모킹이 아예 없다. 280개 테스트 전부 통과.


밋업 다녀온 날 찔려서, 집에 와서 책 정리하고, 코드 고치고 — 하루 안에 다 맞물렸다. 세션 후기보다 결국 직접 고친 게 더 기억에 남는다.