테스트 하네스 강제
테스트가 구조를 우회하면, 테스트는 구조를 검증하지 않는다. 그리고 AI는 테스트 코드를 정본 예시로 참조한다.
두 가지 위험
테스트에서 구조를 생략할 때 생기는 문제는 두 종류다.
첫째, 테스트가 실제 실행 경로를 검증하지 않는다. Provider 없이 렌더링된 테스트는 Provider가 있는 실제 환경에서 컴포넌트가 어떻게 동작하는지 보장하지 않는다. Planner 없이 실행된 테스트는 Planner를 통한 실행 흐름이 올바른지 검증하지 않는다. 테스트가 통과해도 production에서 실패하는 이유가 여기 있다.
둘째, AI가 테스트 코드를 정본 예시로 참조한다. AI는 인접 코드에서 패턴을 학습한다. 테스트에서 task.status = "completed"가 허용되면, AI는 production 코드에서도 같은 방식으로 상태를 바꾼다. 테스트의 우회 패턴이 production 코드로 복제된다.
이 두 위험은 서로 다른 문제처럼 보이지만 같은 원인에서 나온다. 테스트에서 경계를 완화하는 것. 해결책도 하나다. 테스트에서도 실제 경계가 그대로 작동하게 만든다.
문제 1: Provider 없는 렌더링
Glif (TypeScript / React)
// 위반: Provider 없이 직접 렌더링
import { render } from "@testing-library/react";
test("document title displays correctly", () => {
render(<DocumentEditor docId="doc-1" />);
// DocumentEditor 내부에서 useStore(), useTheme()을 사용하는데
// Provider가 없으면 두 가지 중 하나가 발생:
// 1. 오류 — Provider required
// 2. 조용히 undefined 반환 (worst case)
});테스트가 통과되더라도, useStore()가 실제 Store를 받지 않은 채 렌더링됐다는 의미다.
// 합법: 모든 테스트에서 동일한 Provider 구조 사용
import { renderWithProviders } from "@/test/utils";
test("document title displays correctly", () => {
renderWithProviders(<DocumentEditor docId="doc-1" />, {
initialState: { documents: [{ id: "doc-1", title: "Test Doc" }] },
});
// 실제 환경과 동일한 Provider 계층 안에서 렌더링됨
});renderWithProviders는 애플리케이션에서 사용되는 모든 Provider를 포함한다.
// @/test/utils.tsx
export function renderWithProviders(
ui: React.ReactElement,
{ initialState, ...options }: RenderOptions = {}
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<StoreProvider initialState={initialState}>
<ThemeProvider>
<RouterProvider>
{children}
</RouterProvider>
</ThemeProvider>
</StoreProvider>
);
}
return render(ui, { wrapper: Wrapper, ...options });
}ESLint로 @testing-library/react의 render를 직접 사용하는 것을 차단한다.
{
"no-restricted-imports": ["error", {
"name": "@testing-library/react",
"importNames": ["render"],
"message": "Use renderWithProviders from @/test/utils instead of raw render."
}]
}이제 render를 직접 사용하면 빌드가 실패한다. 모든 테스트는 renderWithProviders를 통해야 한다.
문제 2: 상태 직접 설정
Atlas (C#)
// 위반: reflection으로 private 상태 직접 변경
var statusField = typeof(Document)
.GetField("_status", BindingFlags.NonPublic | BindingFlags.Instance);
statusField.SetValue(doc, DocumentStatus.Published);
// 또는: 테스트용 생성자로 상태 직접 초기화
var doc = Document.CreateForTesting(
id: "doc-1",
status: DocumentStatus.Published // 전이 없이 초기화
);전이 통제가 production 코드에서 아무리 철저해도, 테스트에서 이런 우회를 허용하면 전이 경로 자체가 검증되지 않는다.
// 합법: 테스트도 실제 전이 경로 사용
var doc = Document.Create(id: "doc-1", content: "Test");
// Draft → Review → Published 전이를 실제로 통과
doc.TransitionTo(DocumentStatus.Review);
doc.TransitionTo(DocumentStatus.Published);
// 또는 UseCase를 통해
var command = new PublishDocumentCommand(docId: "doc-1");
await _planner.ExecuteAsync(command);
var published = await _repository.LoadAsync(new ValidDocId("doc-1"), ct);
Assert.Equal(DocumentStatus.Published, published.Status);Glif (TypeScript)
// 위반: 상태를 직접 초기화
const task = { id: "1", status: "completed" as TaskStatus, title: "Test" };
renderWithProviders(<TaskCard task={task} />);
// 위반: store를 직접 조작
store.dispatch({ type: "SET_STATUS_DIRECTLY", payload: "completed" });// 합법: 동일한 액션 경로로 상태 변경
renderWithProviders(<TaskCard taskId="1" />, {
initialState: { tasks: [{ id: "1", status: "draft", title: "Test" }] },
});
// 상태 변경도 실제 액션을 통해
await userEvent.click(screen.getByRole("button", { name: "Submit for Review" }));
await userEvent.click(screen.getByRole("button", { name: "Approve" }));
// task.status가 "completed"가 됐는지 검증
expect(screen.getByText("Completed")).toBeInTheDocument();문제 3: Planner/dispatch 우회
Atlas (C#)
// 위반: Planner 없이 Executor 직접 접근
// (내부 접근이 테스트를 위해 public으로 변경된 경우)
await _executor.SaveAsync(doc);
// 위반: UseCase 없이 Repository 직접 조작
await _repository.SaveAsync(modifiedDoc);// 합법: Planner를 통한 실행
await _planner.ExecuteAsync(new SaveDocumentCommand(
docId: "doc-1",
content: "Updated content"));
// 결과 검증은 Repository를 통해 (읽기는 허용)
var saved = await _repository.LoadAsync(new ValidDocId("doc-1"), ct);
Assert.Equal("Updated content", saved.Content);Glif (TypeScript)
// 위반: dispatch 없이 직접 실행
await saveDocument(doc);
await publishDocument(doc);
// 합법: dispatch를 통한 실행
await dispatch({ type: "save", docId: "doc-1", content: "Updated" });
await dispatch({ type: "publish", docId: "doc-1" });테스트에서도 ESLint 규칙이 동일하게 적용된다. executor를 직접 import하면 테스트 파일에서도 빌드가 실패한다.
Fake vs Mock
테스트에서 외부 의존성을 대체할 때 Fake가 Mock보다 낫다.
Mock: 호출 여부를 검증한다. 계약의 동작 방식을 구현하지 않는다.
Fake: 계약을 실제로 구현하되 영속성 없이 메모리에서 동작한다. 계약의 동작 방식이 검증된다.
Atlas (C#)
// Mock: 호출만 확인
var mockRepo = new Mock<IDocumentRepository>();
mockRepo.Setup(r => r.LoadAsync(It.IsAny<ValidDocId>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(testDoc);
// Fake: 계약을 실제로 구현
public sealed class FakeDocumentRepository : IDocumentRepository
{
private readonly Dictionary<string, Document> _store = new();
public Task<Document> LoadAsync(ValidDocId id, CancellationToken ct)
{
if (!_store.TryGetValue(id.Value, out var doc))
throw new DocumentNotFoundException(id.Value);
return Task.FromResult(doc);
}
public Task SaveAsync(Document doc, CancellationToken ct)
{
_store[doc.Id] = doc;
return Task.CompletedTask;
}
}Fake를 쓰면 “저장 후 불러올 수 있는가”, “없는 문서를 불러오면 예외가 발생하는가” 같은 계약의 동작이 테스트된다. Mock을 쓰면 LoadAsync가 호출됐는지만 확인된다.
Glif (TypeScript)
// Fake: 계약을 실제로 구현
class FakeDocumentRepository implements DocumentRepository {
private store = new Map<string, Document>();
async get(id: string): Promise<Document> {
const doc = this.store.get(id);
if (!doc) throw new NotFoundError(`Document not found: ${id}`);
return doc;
}
async save(doc: Document): Promise<void> {
this.store.set(doc.id, doc);
}
async list(): Promise<Document[]> {
return [...this.store.values()];
}
}Fake를 Provider를 통해 주입한다.
const fakeRepo = new FakeDocumentRepository();
renderWithProviders(<DocumentEditor docId="doc-1" />, {
repositories: { documents: fakeRepo }, // Provider가 주입을 처리
});Global override나 module mock으로 주입하면 Provider 경계 바깥에서 변경이 일어나 구조가 깨진다.
Storybook도 동일하게 적용한다
Storybook story는 컴포넌트 문서이기도 하지만, AI가 컴포넌트 사용 방식을 참조하는 예시이기도 하다. Provider 없는 story는 AI에게 “Provider 없이 컴포넌트를 사용해도 된다"는 신호를 준다.
// 위반: Provider 없는 story
export const Default: Story = {
render: () => <DocumentEditor docId="doc-1" />,
};// 합법: 모든 story가 동일한 Provider 구조를 사용
// .storybook/preview.ts
export const decorators = [
(Story: StoryFn) => (
<StoryProviders>
<Story />
</StoryProviders>
),
];
// story에서는 Provider를 신경 쓸 필요 없음
export const Default: Story = {
args: { docId: "doc-1" },
};StoryProviders는 renderWithProviders와 같은 Provider 구조를 사용한다. 테스트와 Storybook이 동일한 환경을 공유한다.
테스트 구조 정렬 체크리스트
-
renderWithProviders가 존재하고render직접 사용이 ESLint로 차단되는가? - 모든 테스트가 실제 Provider 계층 안에서 실행되는가?
- 상태 직접 설정(reflection, 테스트 생성자)이 금지되어 있는가?
- Planner/dispatch를 우회하는 실행이 테스트에서도 차단되는가?
- Fake가 Mock보다 우선적으로 사용되는가?
- Fake가 Provider를 통해 주입되는가? (global override 아님)
- Storybook이 테스트와 동일한 Provider 구조를 사용하는가?
- 빌드 타임 규칙이 테스트 파일에도 동일하게 적용되는가?