정의
타입 수준 테스트는 코드를 실행해서 결과를 확인하는 게 아니라, 컴파일이 되는지 안 되는지를 확인하는 테스트예요.
보통의 테스트가 "이 함수를 부르면 어떤 값이 나오나"를 묻는다면, 타입 수준 테스트는 "이 코드가 애초에 작성 가능한가"를 물어요. 파일 확장자로 .test-d.ts(JSX가 있으면 .test-d.tsx)를 쓰고, 실행되지 않는 대신 타입 검사기가 통과 여부를 판정해요.
왜 필요한가
타입을 복잡하게 만들어 놓고 "이제 안전해요"라고 말하기는 쉬워요. 문제는 그 주장이 대부분 검증되지 않는다는 거예요.
구체적인 시나리오를 볼게요. 컬럼 배열에서 정렬 키 union을 파생하는 타입을 만들었어요. 그런데 누가 리팩터링하다가 제네릭 제약을 살짝 손댔고, 그 결과 union이 string으로 넓어졌어요.
프로덕션 코드는 여전히 전부 컴파일돼요. columnId: 'ordreNo' 같은 오타도 통과해요. 보호가 사라진 걸 아무도 몰라요. 다음에 오타를 낸 사람이 QA에서 걸리거나, 안 걸리면 배포돼요.
타입 수준 테스트는 이 침묵을 없애요. 판정 질문은 하나예요.
만들 수 있었던 잘못된 코드 중 무엇이 이제 컴파일되지 않나요?
이 질문에 구체적으로 답할 수 없는 타입 복잡성은 애초에 넣지 않는 게 나아요. 답할 수 있다면 그 답이 그대로 테스트 케이스가 되고요.
동작 원리
핵심 도구는 @ts-expect-error 한 줄이에요. 이건 "다음 줄에 타입 오류가 반드시 나야 한다"는 표시예요.
// @ts-expect-error 존재하지 않는 컬럼 id
const bad: SortKey = "ordreNo";
오류가 나면 이 줄이 오류를 삼켜서 통과해요. 그런데 오류가 사라지면 — 즉 보호가 무력화되면 — 이 줄이 "사용되지 않는 @ts-expect-error"라는 별도 오류(TS2578)를 내면서 테스트가 빨개져요.
보호가 사라지는 것이 곧 실패가 되는 구조예요. 이게 @ts-ignore와의 결정적 차이예요. @ts-ignore는 오류가 있든 없든 조용히 넘어가서 아무것도 지키지 못해요.
증거는 다섯 종류를 갖춰요
| 증거 | 기준 | 없으면 생기는 일 |
|---|---|---|
| positive | 대표 정상 호출이 명시적 타입 인자 없이 컴파일된다 | 아무도 안 쓰는 안전한 API가 된다 |
| negative | 이 API가 닫는 경계 축마다 1개씩, 각각 한 줄로 표시된다 | 무엇을 막는지 알 수 없다 |
| edge | any·unknown·never·readonly 튜플 등 경계값 동작 | 경계에서만 뚫리는 구멍이 남는다 |
| mutation | 계약을 약화하면 테스트가 실제로 RED가 된다 | 테스트가 아무것도 안 지켜도 알 수 없다 |
| soundness gap | 타입으로 못 막은 구멍을 문서에 적는다 | 거짓 안전감이 생긴다 |
환경이 곧 판정 기준이에요
"컴파일되지 않는다"는 tsconfig의 함수예요. strict가 꺼져 있으면 막힐 코드가 안 막히고, TypeScript 버전이 낮으면 const 타입 파라미터나 NoInfer 자체를 못 써요.
그래서 타입 계약을 처음 만드는 저장소에서는 tsc --showConfig로 확인한 실효 설정과 lockfile이 실제로 resolve한 컴파일러 버전을 먼저 기록해요. Playground나 최신 버전에서만 통과한 결과는 증거가 아니에요.
케이스를 고르는 기준
무슨 케이스를 쓸지는 개수가 아니라 경계로 정해요. "부정 케이스 3개"처럼 개수를 목표로 두면 같은 경계를 세 번 건드린 케이스가 3개 쌓여요.
경계는 타입 격자의 극단이에요 — union 멤버와 비멤버, 리터럴과 넓어진 string, 속성 생략과 명시적 undefined 같은 자리요. 이번 API가 실제로 닫는 경계마다 통과 witness 1개와 @ts-expect-error 1개를 두고, 닫지 않는 경계에는 만들지 않아요.
고르는 방법은 별도 문서가 다뤄요.
실무 적용
// columns.test-d.ts
import { defineColumns, type ColumnIdOf } from "./columns";
const cols = defineColumns([
{ id: "orderNo", header: "주문번호" },
{ id: "total", header: "합계" },
]);
type SortKey = ColumnIdOf<typeof cols>;
// ── positive: 정상 호출이 타입 인자 없이 추론돼요
const ok: SortKey = "total";
// ── negative: 축마다 하나씩, 한 줄에 오용 하나
// @ts-expect-error 오타 — 존재하지 않는 컬럼
const typo: SortKey = "ordreNo";
// @ts-expect-error 다른 표의 컬럼 id
const foreign: SortKey = "userId";
// @ts-expect-error 넓은 string은 허용되지 않아요
const wide: SortKey = "total" as string;
mutation 검증 절차
테스트를 만들었다고 끝이 아니에요. 그 테스트가 실제로 살아 있는지 확인해야 해요.
- 계약을 일부러 약화해요.
<const T extends ...>에서const를 지우거나,NoInfer<T>를T로 바꿔요. - 타입 검사를 돌려요. 테스트가 RED가 되어야 해요.
- 되돌리고 GREEN을 확인해요.
RED가 안 되면 그 테스트는 아무것도 지키지 않고 있어요. 이 절차는 코드가 아니라 판단이라, 한 번 하고 결과를 기록해 두면 돼요.
CI에 실제로 포함되는지 확인
.test-d.ts 파일은 실행되지 않아서, tsconfig.json의 include에서 빠져 있으면 아무도 검사하지 않아요. 파일만 있고 검사는 안 되는 상태가 꽤 흔해요.
# 이 파일이 검사 대상에 들어 있는지 확인
pnpm exec tsc --noEmit --listFiles | grep test-d
트레이드오프
얻는 것은 보호가 사라졌을 때 시끄럽게 실패한다는 거예요. 타입 리팩터링의 가장 무서운 점이 "에러 없이 보호만 없어지는 것"인데, 이걸 막아 줘요.
치르는 비용은 두 가지예요.
첫째, 타입 검사 시간이 늘어나요. 복잡한 타입을 여러 각도로 검사하니까요. 재귀 타입이 섞이면 체감될 만큼 느려질 수 있어요.
둘째, 테스트 자체가 유지보수 대상이 돼요. API를 의도적으로 넓혔을 때 @ts-expect-error가 빨개지는데, 이게 진짜 회귀인지 의도된 변경인지는 사람이 판단해야 해요.
다만 이 두 번째 비용은 사실 이득이기도 해요. 계약 파일을 완화하는 건 구현 결정이 아니라 정책 변경이거든요. 그게 리뷰에 드러나는 게 맞아요.
사용하면 안 되는 경우
- 앱 내부의 짧은 타입. 호출부가 몇 개고 전부 같은 파일에 있으면, 타입 테스트를 쓰는 비용이 얻는 것보다 커요.
- 동작 검증을 대신하려는 시도.
type-valid는behavior-correct가 아니에요. 타입이 통과했다고 동작이 맞다고 보고하면 그건 잘못된 보고예요. - 비동기 순서를 증명하려는 시도. 요청 순서 역전, 중복 제출, 언마운트 후 도착 같은 시간축 문제는 타입으로 증명되지 않아요. union 하나 만들어 놓고 "순서 문제 해결됨"이라고 쓰면 거짓이에요. 이건 런타임 테스트가 소유해요.
- 외부 입력 검증을 대신하려는 시도. API 응답이나
localStorage값은 타입 단언이 아니라 런타임 파서가 검사해요.as나satisfies를 검증으로 보고하면 안 돼요.
흔한 실수
- 개수를 기준으로 케이스를 골라요. 같은 경계를 세 번 건드린 케이스가 3개 쌓여요. 경계로 고르는 방법은 type-level-bva가 다뤄요.
@ts-expect-error한 줄에 여러 오용을 담아요. 그러면 무관한 오류 하나만 나도 통과해 버려요. 오용 하나당 한 줄이에요.@ts-ignore를 써요. 오류가 사라져도 조용해서 아무것도 지키지 않아요. 항상@ts-expect-error를 써요.- mutation 검증을 안 해요. 테스트가 살아 있는지 한 번도 확인하지 않으면, 처음부터 죽어 있었어도 알 수 없어요.
- 정상 호출 테스트를 빼먹어요. 오용만 막고 정상 호출이 타입 인자 없이 추론되지 않는다면, 그건 좋은 API가 아니에요. 아무도 안 쓸 거예요.
.test-d.ts가 검사 대상에 안 들어 있어요. 파일은 있는데 CI가 안 보는 상태예요. 실행되는 테스트가 아니라 눈에 안 띄어요.- 구현 diff가
@ts-expect-error를 지워서 GREEN을 만들어요. 이건 통과가 아니라 계약을 몰래 넓힌 거예요. 리뷰에서 별도로 다뤄야 해요.
관련 개념
- type-level-bva — 어떤 케이스를 쓸지 경계에서 고르는 방법
- test-oracle — "무엇이 정답인가"의 출처 문제. 타입 수준에서는 "무엇이 컴파일되면 안 되는가"가 그 오라클이에요
- mutation-testing — 테스트가 실제로 결함을 잡는지 역으로 검증하는 같은 발상
- derive-types-from-values — 파생이 조용히 무력화되는 걸 잡아내는 대표적인 적용처