콘텐츠로 이동

Radix dismiss happy-dom 한계 — Dialog 부분 mock + 2층 테스트 구조

맥락

Radix UI의 DialogContent는 onPointerDownOutside와 onEscapeKeyDown prop으로 "배경 클릭 시 닫힘 차단(isPending 가드)" 로직을 연결한다. 이 로직을 통합 테스트에서 fireEvent.pointerDown(document.body)로 검증하면 happy-dom 환경에서 Radix의 dismiss 메커니즘이 발동하지 않아 pass-through 테스트가 된다.

pass-through 테스트란: isPending=false로 바꿔도 동일하게 통과하는 테스트 — 검증 가치 없음.

이 문서는 pass-through를 탐지하고 결정론적 테스트로 전환하는 2층 구조를 다룬다. 자산관리 프로젝트 Phase 9-C.5에서 AssignBatchModal의 isPending 가드 테스트 작성 중 evaluator 2차 리뷰에서 pass-through가 발견되어 C안(유닛 테스트 별도 파일)으로 전환했다.

가이드

왜 happy-dom이 한계인가

Radix @radix-ui/react-dismissable-layer는 Portal로 마운트된 Overlay에서 pointer-events를 자체 레이어에서 처리한다. happy-dom은 이 메커니즘을 시뮬레이트하지 못한다. fireEvent.pointerDown(document.body)는 DOM 이벤트를 발생시키지만 Radix의 capture phase 리스너에 도달하지 않는다.

jsdom으로 환경을 바꾸는 방법도 있으나 Radix + jsdom + user.pointer 조합이 안정적이지 않다는 보고가 있고, 환경 혼합 시 environmentMatchGlobs 관리 부채가 증가한다.

2층 테스트 구조

층 파일 방식 검증 대상
통합 테스트 Modal.test.tsx 실제 Radix 렌더링 + userEvent 취소 버튼 클릭, ESC key (DOM 이벤트로 발동 가능한 것)
유닛 테스트 Modal.handlers.test.tsx Dialog 부분 mock + prop 직접 호출 isPending 가드 핸들러 결정론

mock이 vi.mock으로 파일 전역에 적용되므로 두 테스트 파일을 반드시 분리한다. 한 파일에 섞으면 통합 테스트도 mock이 적용되어 실제 렌더링 검증이 불가능해진다.

Dialog 부분 mock 패턴 (vi.hoisted 포함)

// ─── capturedProps — vi.hoisted로 선언 (factory 내 안전한 참조) ──────────────
interface CapturedDialogProps {
  onOpenChange?: (open: boolean) => void;
  onEscapeKeyDown?: (e: { preventDefault: () => void }) => void;
  onPointerDownOutside?: (e: { preventDefault: () => void }) => void;
}

const { capturedProps, mockState } = vi.hoisted(() => ({
  capturedProps: {} as CapturedDialogProps,
  mockState: { isPending: false },
}));

// ─── Dialog 서브컴포넌트만 mock (Radix 런타임 제거) ───────────────────────────
vi.mock("@/components/ui/dialog", () => ({
  Dialog: ({ children, onOpenChange }: any) => {
    capturedProps.onOpenChange = onOpenChange;
    return <>{children}</>;
  },
  DialogContent: ({ children, onEscapeKeyDown, onPointerDownOutside }: any) => {
    capturedProps.onEscapeKeyDown = onEscapeKeyDown;
    capturedProps.onPointerDownOutside = onPointerDownOutside;
    return <div role="dialog">{children}</div>;
  },
  DialogHeader: ({ children }: any) => <div>{children}</div>,
  DialogTitle: ({ children }: any) => <h2>{children}</h2>,
}));

// 다른 훅 mock — isPending 제어
vi.mock("@/hooks/useAssignBatch", () => ({
  useAssignBatch: () => ({
    isPending: mockState.isPending,
    assignBatch: vi.fn().mockResolvedValue(true),
  }),
}));

핸들러 결정론 검증

describe("AssignBatchModal — isPending 가드 핸들러 유닛", () => {
  beforeEach(() => {
    capturedProps.onOpenChange = undefined;
    capturedProps.onEscapeKeyDown = undefined;
    capturedProps.onPointerDownOutside = undefined;
    mockState.isPending = false;
  });

  it("isPending=true + onPointerDownOutside 호출 → preventDefault 호출됨", () => {
    mockState.isPending = true;
    render(<AssignBatchModal onClose={vi.fn()} />, { wrapper: createWrapper() });

    const mockEvent = { preventDefault: vi.fn() };
    capturedProps.onPointerDownOutside!(mockEvent);

    expect(mockEvent.preventDefault).toHaveBeenCalledOnce();
  });

  it("isPending=false + onPointerDownOutside 호출 → preventDefault 미호출", () => {
    mockState.isPending = false;
    render(<AssignBatchModal onClose={vi.fn()} />, { wrapper: createWrapper() });

    const mockEvent = { preventDefault: vi.fn() };
    capturedProps.onPointerDownOutside!(mockEvent);

    expect(mockEvent.preventDefault).not.toHaveBeenCalled();
  });
});

pass-through 탐지 방법

테스트 작성 후 반드시 isPending 값을 반대로 바꿔 실행한다. - 바꿔도 통과 → pass-through (검증 가치 없음 → 이 패턴으로 교체) - 바꾸면 실패 → 결정론적 (유지)

이 "역방향 실험"은 테스트 품질을 검증하는 핵심 절차다. evaluator 리뷰 체크리스트에 포함할 것.

왜 중요한가

pass-through 테스트는 "커버리지를 채우지만 버그를 잡지 못하는" 테스트다. isPending 가드는 배치 작업 중 모달이 강제 닫혀 데이터 손실이 발생하는 것을 막는 핵심 로직이다. 이 로직의 테스트가 pass-through이면 가드가 깨져도 테스트가 통과한다.

Dialog mock + prop 직접 호출 방식은 Radix 런타임 의존을 제거하므로 환경(happy-dom/jsdom)에 무관하게 결정론적으로 동작한다.

2층 구조의 추가 가치: 통합 테스트는 "사용자 관점 상호작용"을, 유닛 테스트는 "핸들러 로직 자체"를 검증한다. React Testing Library 공식 가이드의 "대부분은 통합, 특수 로직은 유닛" 원칙과 일치한다.

적용 시기

  • Radix UI 기반 모달에서 onPointerDownOutside / onEscapeKeyDown 핸들러를 테스트해야 할 때
  • fireEvent.pointerDown(document.body) 테스트가 isPending 값에 무관하게 통과할 때 (pass-through 의심)
  • Phase 9-D1 이후 새 모달에 isPending 가드 패턴을 적용할 때 → 이 파일의 handlers.test.tsx 구조를 복사
  • vi.mock이 파일 전역 적용된다는 점 때문에 통합/유닛 테스트를 한 파일에 섞어야 한다면 → 반드시 분리

관련 자료