글 목록으로개발 경험

테스트 코드를 도입하면서 세운 기준

함수의 입력과 출력, 폼의 제출 요청, 브라우저의 이동 결과를 각각 확인해요.

함수의 입력과 출력, 폼의 제출 요청, 브라우저의 이동 결과를 각각 확인해요.

기능을 추가하거나 구조를 바꿀 때 기존 동작이 깨지지 않았는지 매번 손으로 확인하기는 어려웠어요. 맡고 있는 서비스에 테스트를 도입한 이유예요. 그런데 테스트가 통과해도 등록 요청은 서버에서 거절되고, 화면에서 사라진 컴포넌트를 확인하는 단언은 계속 성공하는 일을 겪었어요.

테스트를 더 붙이기 전에 무엇을 실제로 실행했는지부터 다시 읽게 됐어요. 제출을 대신하는 Harness를 걷어내고, Mock에 서버 제약을 옮기고, URL뿐 아니라 도착한 화면도 확인했어요. 지금 쓰는 기준은 이런 빈틈을 고치면서 생겼어요.

테스트는 대상 파일 옆에 둬요

파일을 수정할 때 그 동작을 설명하는 테스트도 바로 옆에서 읽을 수 있게 두었어요.

text
program-form.tsx
program-form.spec.tsx
program-form.test.tsx

기능의 요구사항은 spec, 운영, QA에서 발견한 오류는 test에 적어요. 파일을 옮기거나 지우면 관련 테스트도 같은 diff에서 눈에 띄고, 기능을 수정할 때 확인해야 할 동작을 함께 읽을 수 있어요.

통합 테스트는 실제 제출 코드를 거치게 해요

글로벌 CMS의 첫 통합 테스트에서는 폼 필드만 렌더하고 제출 처리는 Harness가 대신 실행했어요. 아래 코드는 공개용으로 줄였고 이름도 바꿨어요.

tsx
function CreateHarness({ onCreated }: { onCreated: (id: number) => void }) {
  // 등록 Drawer가 하는 변환·요청을 테스트가 다시 쓰고 있었다
  const handleFinish = async (values: PartnerFormValues) => {
    const { id } = await createPartner(toRequest(values));
    onCreated(id);
  };
 
  return <PartnerFormFields initialValues={initialValues} onFinish={handleFinish} />;
}

이 테스트에서 성공한 건 등록 Drawer가 아니라 Harness의 제출 코드였어요. 제품 코드의 변환이나 요청이 깨져도 복사해 둔 테스트 구현만 맞으면 통과해요. 실제 등록 Drawer를 렌더해서 입력부터 제출까지 같은 코드를 거치도록 바꿨어요.

Provider나 라우터처럼 화면을 준비하는 코드는 공통화했어요. 다만 무엇을 언제 서버로 보낼지는 실제 제품 코드가 결정해야 했어요. 제출 처리를 테스트에 다시 쓰면 두 구현 중 하나만 고쳐도 테스트는 성공할 수 있었거든요.

무엇이든 받아 주는 Mock을 서버 계약으로 착각했어요

글로벌 서비스는 백엔드 API가 준비되기 전에 MSW로 화면을 만들었어요. handler는 본문을 보지 않고 200을 돌려줬고, 실제 API에 연결하자 통합 테스트가 통과한 요청이 거절됐어요.

파트너 등록에는 화면에 없던 순서 값과 7일 전체 운영시간이 필요했어요. 프로그램 등록은 프론트가 서버 생성 값이라고 여긴 코드를 필수로 받았고, 가격 정책 enum 이름도 달랐어요. 성공 응답만 돌려주던 Mock에서는 드러나지 않던 차이였어요.

Mock 시드도 서버의 초기 데이터와 따로 자라고 있었어요. 글로벌 서비스 사용자 화면(CMS와 다른 저장소)의 '전체' 카테고리 탭은 Mock 시드에만 있고 백엔드 초기 데이터에는 없었는데, E2E는 그 탭이 서버에서 온다는 틀린 전제로 통과하고 있었어요.

그래서 백엔드 DTO와 validation에서 확인한 제약만 Mock에 옮겨, 서버가 거절하는 요청은 Mock도 400으로 거절하게 했어요. 아래 코드는 공개용으로 줄였고 partnerId는 가명이에요.

typescript
// 서버 DTO가 거절하는 조건만 옮긴다
function programViolations(body: ProgramSaveRequest): Violation[] {
  const violations: Violation[] = [];
  const required = (field: keyof ProgramSaveRequest, isValid: boolean) => {
    if (!isValid) violations.push({ field, code: 'VALIDATION.REQUIRED' });
  };
 
  required('partnerId', Boolean(body.partnerId));
  required('programCode', Boolean(body.programCode?.trim()));
 
  if (body.listPrice < body.salePrice) {
    violations.push({ field: 'validPrice', code: 'VALIDATION.INVALID' });
  }
 
  return violations;
}
 
http.post('/programs', async ({ request }) => {
  const body = (await request.json()) as ProgramSaveRequest;
  const violations = programViolations(body);
 
  if (violations.length > 0) {
    return errorJson(400, 'COMMON.VALIDATION_FAILED', violations);
  }
 
  return ok({ id: programStore.create(body) });
});

새 요청에 handler가 없을 때도 조용히 넘어가지 않도록 onUnhandledRequest: 'error'를 켰어요. 화면을 열며 자식 컴포넌트가 새 API를 부르면 그 자리에서 실패해요. Mock에 적어 둔 계약을 벗어난 호출을 알 수 있게 한 거예요.

Mock을 고친 뒤 프로그램 코드 값을 요청에서 빼 봤어요. 기존 통합 테스트가 실패했어요. dev API에도 값을 모두 채운 입력과 최소 입력을 보내 계약을 확인했어요. 이후 Mock의 진료 분야, 카테고리, 언어, 지역 시드도 서버 초기 데이터에 맞췄어요. 진료 분야 코드는 기존 Mock과 서버가 전혀 겹치지 않고 있었어요.

그래도 Mock이 계약의 정본은 아니에요. dev API 서버에서 새로운 제약을 발견하면 폼 검증과 Mock handler, 통합 테스트에 다시 반영해 다음 작업부터 로컬에서 먼저 실패하게 했어요.

아무것도 확인하지 않는 단언이 통과하고 있었어요

테스트를 옆에 둔다고 책임을 옮겼을 때 자동으로 따라가지는 않았어요. 글로벌 CMS의 탭과 신규 등록, Drawer를 목록 View에서 page.tsx로 올렸는데 테스트는 여전히 View만 그렸어요. 기대하던 DOM이 없어 테스트가 깨진 반면, 'id가 없으면 Drawer가 닫혀 있다'는 검사는 계속 통과했어요. Drawer 자체가 없으니 닫혀 있다는 조건만 만족한 거예요.

페이지가 맡게 된 동작은 page.tsx 옆 테스트로 옮겼어요. 코드를 옮길 때 테스트도 새 위치의 실제 컴포넌트를 그리는지 같이 확인하게 됐어요.

E2E에도 단언 한 줄이 아무것도 확인하지 않는 경우가 있었어요. Back-Office의 광고 배너 등록 E2E는 '업로드 중...' 문구가 사라지길 기다리고 있었는데, 저장소 어디에도 없는 문구라 Playwright가 곧바로 통과시켰어요. 이미지 값이 폼에 실리기 전에 다음 단계로 넘어갈 수 있었던 거예요. 코드 리뷰에서 드러났고, 실제 업로드 진행 항목이 사라지는 걸 기다리도록 고쳤어요.

테스트 계층마다 확인할 범위를 나눴어요

함수 계산은 맞는데 브라우저 뒤로 가기에서 깨지는 일과, 서버 계약은 다른데 Mock이 받아 주는 일은 같은 테스트로 잡히지 않았어요. 각 테스트가 실제로 실행하는 부분을 기준으로 범위를 나눴어요.

  • 단위 테스트는 순수 함수, Zod 스키마, 상태 전이와 경곗값을 확인해요.
  • 컴포넌트, 통합 테스트는 실제 UI와 Query, 폼, MSW 계약이 연결되는지 확인해요.
  • .live.test.ts(x)는 요청과 응답이 dev API 서버의 계약에 맞는지 확인해요.
  • Playwright E2E는 직접 URL 진입, 새로고침, 뒤로 가기, 쿠키와 서버 게이트처럼 브라우저에서만 드러나는 흐름을 맡아요.

서버 컴포넌트나 Provider가 많이 필요한 화면을 억지로 jsdom에 올리지는 않아요. 컴포넌트 안의 계산을 순수 함수로 분리해 단위 테스트하거나, 실제 브라우저가 필요한 문제라면 E2E로 옮겼어요.

dev API 서버를 우선하고 Mock은 만들기 어려운 상태에 썼어요

기능을 dev API 서버에서 확인할 수 있다면 그 환경을 먼저 써요. API 계약과 데이터가 실제로 연결된 상태를 볼 수 있으니까요. Mock은 로그인 단계, 특정 오류 응답, 빈 목록처럼 dev API 서버에서 안전하고 반복적으로 만들기 어려운 조건을 고정할 때 썼어요.

복지몰의 회원가입 E2E도 모든 요청을 Mock으로 바꾸지 않았어요. 사용자 상태와 인증 흐름처럼 테스트가 직접 제어해야 하는 요청만 MSW가 처리하고, 나머지 요청은 dev API 서버로 보냈어요.

관리자 서비스 모노레포에서는 dev API 서버 검증이 필요한 테스트를 일반 Vitest 실행과 분리해 *.live.test.ts(x)와 별도 설정으로 돌려요. 일반 테스트처럼 MSW가 켜진 채로 live 테스트가 돌면 서버를 검증했다고 착각할 수 있거든요. dev API 서버는 계정과 데이터를 여럿이 함께 쓰니 파일을 직렬로 실행하고, 다른 사람의 데이터를 임의로 바꾸는 흐름은 자동화하지 않았어요.

dev API에서 찾은 제약은 live 테스트에만 남기지 않았어요. 로컬 폼 검증과 Mock, 통합 테스트에도 반영했어요. 다음 수정에서는 dev 서버까지 가지 않아도 같은 잘못된 요청을 발견할 수 있게 한 거예요.

예외도 있어요. 글로벌 CMS는 나중에 dev API 테스트 러너를 걷어내면서, 돌릴 수단이 없어진 live 테스트 파일도 같이 지웠어요. 남겨 두면 그 계약이 여전히 확인되고 있다고 착각하게 되니까요. 지금은 서버 규칙을 옮긴 Mock으로만 확인해요.

새 테스트가 실제로 실패하는지 확인했어요

새 테스트 패턴을 만들 때는 보호하려는 동작의 제품 코드를 일부러 틀리게 바꿔 봤어요. 결제 금액 계산이라면 이런 식이에요.

typescript
// 정상
const finalAmount = paymentAmount - couponDiscount;
 
// 테스트 확인을 위해 잠시 변경
const finalAmount = paymentAmount + couponDiscount;

실패를 본 뒤에는 제품 코드를 원래대로 되돌렸어요. 앞에서 프로그램 코드 전송을 빼 본 것도 같은 확인이에요. 정상 구현과 테스트가 함께 틀렸을 때 계속 초록색으로 남는 일을 피하려고 새 패턴에 이런 뮤테이션 체크를 해요.

리팩터링에서는 순서도 중요했어요. 먼저 현재 동작을 테스트로 고정하고 구조를 바꿨어요. 구현을 바꾼 뒤 테스트를 쓰면 새 동작을 그대로 정답으로 만들 수 있거든요.

E2E는 브라우저에서만 보이는 경계에 뒀어요

복지몰의 회원가입 퍼널은 URL 직접 진입, 새로고침, 뒤로 가기와 인증 상태가 함께 움직였어요. 함수 테스트만으로는 이 조합을 확인하기 어려웠죠. 순서대로 가입하는 흐름뿐 아니라 중간 URL로 바로 들어가거나, 로그아웃한 뒤 다시 들어오거나, 팝업이 차단된 조건을 Playwright로 확인했어요.

URL만 맞다고 성공으로 보지도 않았어요. 로그아웃한 뒤 주소는 로그인 화면으로 바뀌었는데 화면에는 에러 페이지가 떠 있을 수 있거든요.

typescript
await page.getByRole('button', { name: '로그아웃' }).click();
 
await expect(page).toHaveURL(/\/sign(\?|$)/);
await expect(page.getByRole('button', { name: '카카오로 계속하기' })).toBeVisible();

주소가 로그인으로 바뀌고 카카오 버튼도 보이는지 함께 확인해요. 리다이렉트만 성공하고 가드나 화면 렌더링이 깨진 경우를 여기서 구분할 수 있어요.

한 앱에서 끝나지 않는 흐름은 두 앱을 잇는 E2E로 봤어요. 병원 관리자의 프로그램 등록, 검수 요청과 Back-Office의 검수 화면은 처음 만들 때 테스트가 없었고, 테스트 체계를 들인 뒤에 등록부터 검수중 전환, 반려, 승인까지 이어지는 E2E를 추가했어요. 두 앱을 함께 띄우고 dev API 서버를 그대로 호출해요. 과정은 AI 작업 흐름을 다룬 글에 적었어요.

실행 환경도 문제에 맞춰 골랐어요. 복지몰 E2E는 기본적으로 Next 개발 서버를 띄워요. build 후 start로 실행하는 경로도 뒀지만, 쿠키를 읽는 정적 생성 라우트의 동시 요청 문제가 있어 기본값으로 두지는 못했어요. Back-Office 검색 오류는 dev 실행에서 나타나지 않아 build 후 start로 확인했어요(당시 기록).

테스트에서는 핵심 행동이 보이도록 반복을 남겼어요

테스트에서는 반복되는 클릭도 그대로 남길 때가 있어요. 복지몰 검색 E2E에서 필터를 누르고 URL을 확인하는 부분이에요. 예제의 헬퍼 이름은 바꿨고, 실제 테스트에는 이벤트 전송 확인도 있어요.

typescript
await gotoSearchPage(page);
 
await page.getByRole('button', { name: '특가전', exact: true }).click();
 
await expect(page).toHaveURL(/hasEvent=true/);

필터를 누르고 URL을 확인하는 동작이 다른 테스트에도 반복되지만, 핵심 행동을 expectFilterScenario() 같은 범용 헬퍼 안에 숨기지 않았어요. 화면 진입이나 로그인처럼 이 테스트의 관심사가 아닌 준비만 감췄어요. 테스트 이름과 본문에서 입력, 행동과 기대 결과를 바로 읽을 수 있어야 AI가 보고한 "성공"의 의미도 빠르게 확인할 수 있었거든요.

기능 명세와 버그 재현을 구분해요

새 기능의 요구사항은 *.spec.ts, *.spec.tsx에 BDD 형식으로 적어요. 어떤 조건에서 어떤 행동을 했을 때 무엇이 보여야 하는지 드러나게 쓰고, 컴포넌트별로 필요한 동작이 빠지지 않았는지 확인해요. 운영, QA에서 발견한 기능 오류와 console warning은 *.test.ts, *.test.tsx에 재현해서 같은 문제가 다시 생기지 않도록 해요.

에이전트가 제품 코드와 테스트를 함께 만들 때도 제출을 대신하는 준비 코드나 대상 없이 성공하는 단언을 다시 봐요. 앞서 놓쳤던 두 경우라서, 테스트 결과만 받지 않고 실제 코드와 대조하고 있어요. 명세에서 빠진 조건이 있으면 보완한 뒤 다시 확인해요.

직접 만든 lint rule도 RuleTester와 __fixture__, mock으로 테스트 환경을 만들어 검증해요. 저장소의 작성 기준을 자동으로 검사하는 코드도 정상 코드를 잘못 막거나 위반 코드를 놓칠 수 있어서예요.

병렬 작업에서는 전체 검증을 매번 돌리지 않았어요

여러 worktree에서 전체 lint, 타입 검사와 테스트를 동시에 돌리면 CPU 경합이 크게 생겼어요. 관리자 서비스 모노레포에서는 한 번의 전체 lint가 여러 패키지를 함께 검사하고, linked worktree마다 Turbo와 TypeScript 캐시가 다시 만들어지거든요. 실제로 여러 검증이 겹치면서 프로세스가 강제 종료돼 통과인지 실패인지조차 판정할 수 없는 상황이 있었어요.

작업 중에는 바뀐 부분부터 검사해 에이전트의 수정과 리뷰를 이어가요. 그 결과를 제가 diff에서 읽고 화면에서도 눌러 본 뒤 전체 CI와 QA 확인으로 넘어가요. 여러 세션이 매번 전체 검사를 동시에 시작하지 않도록 순서를 바꾼 거예요.

로컬 검증도 도구마다 범위가 달라요. ESLint는 변경 파일만, 테스트는 변경 파일에 딸린 것만 보고, Knip은 두 저장소 모두 전체 참조 그래프를 봐요. 타입 검사는 복지몰에서는 앱 전체를, 관리자 서비스에서는 변경 파일이 속한 패키지를 보고, 공용 패키지의 API를 바꿨으면 그 패키지를 쓰는 앱도 함께 검사해요. 타입 검사를 "변경 파일만 본다"고 표현하면 의존 관계를 놓치게 돼요.

CI에서도 실행 범위를 따로 정했어요

자동으로 실행할 범위도 운영 환경에 맞춰 조정했어요. 관리자 서비스에 Vitest PR 검증을 붙였지만, CI 러너가 dev 환경과 한 인스턴스를 함께 써서 전 패키지의 MSW 테스트가 dev 환경까지 멈추게 했어요. 그래서 MSW 통합 테스트는 push 전 변경분 검증에서 실행하도록 바꿨어요.

관리자 서비스 CI에는 lint와 타입 검사, Knip, MSW를 쓰지 않는 테스트가 남았어요. dev API live 테스트는 별도로 실행해요. 서버 배포나 데이터 변경에도 실패할 수 있어 PR을 막지는 않고, 결과에서 계약 차이를 읽어요. 복지몰 CI는 lint와 타입 검사, Knip을 실행해요. 두 저장소의 E2E는 CI 밖에서 확인하고 있어요.

참고