Yuyeol 님의 상세페이지[8팀 정유열] Chapter 3-1. 프론트엔드 테스트 코드

HARD

7주차 과제 체크포인트

기본과제

  • 총 11개의 파일, 115개의 단위 테스트를 무사히 작성하고 통과시킨다.

질문

Q1. handlersUtils: 이벤트는 생성, 수정 되면 fetch를 다시 해 상태를 업데이트 합니다. 이를 위한 제어가 필요할 것 같은데요. 어떻게 작성해야 테스트가 병렬로 돌아도 안정적이게 동작할까요?

여러 테스트가 동시에 실행될 때, 한 테스트에서 이벤트 추가/수정 등 액션을 하면 다른 테스트에도 영향을 줘서 테스트가 깨질 수 있으니 개별 테스트가 독립적으로 동작해야하는 점을 말씀하시는 것 같습니다.

MSW는 메모리상에서 가짜 서버를 만들어 실제 네트워크 요청을 가로채는데, 이때 mockEvents 배열이 모든 테스트가 공유하는 전역 상태가 되어 버립니다.

예를들어:

  • 테스트 A: 이벤트 1개 추가 > 총 2개 기대
  • 테스트 B: 이벤트 1개 삭제 > 총 0개 기대

동시에 실행되면:

  • 테스트 A가 추가한 이벤트를 테스트 B가 보게 되어 예상과 다른 결과가 나옴

해결하려면:

  1. 테스트마다 데이터 초기화
// handlers.ts - events.json을 직접 사용
let mockEvents = [...events] as Event[]; // 원본 복사본

export const resetMockEvents = () => {
  mockEvents = [...events]; // 새로운 복사본 생성
};
beforeEach(() => {
  // events.json의 원본 상태로 eventData를 복구
  resetMockEvents();
});
  1. 테스트 후 정리
afterEach(() => {
  // handlersUtils에 구현된 setupMockHandlerCreation()와 같은 요청을 server.use로 사용했을 때, 그 흔적을 지움
  server.resetHandlers();
  // mock 함수들의 호출 기록 제거
  vi.clearAllMocks();
});
  1. 실제 적용 예시
beforeEach(() => {
  resetMockEvents();
});

afterEach(() => {
  server.resetHandlers();
  vi.clearAllMocks();
});

it('실패 테스트', () => {
  setupMockHandlerCreation(); // server.use()로 임시 404 핸들러 설정
});

핵심 원리:

  • MSW의 server.use()는 기존 핸들러를 임시적으로 덮어씀
  • 테스트마다 resetHandlers()로 원래 핸들러(handlers.ts)의 동작으로 리셋
  • resetMockEvents()로 매번 동일한 초기 데이터 사용

결과: 테스트 실행 순서나 병렬 실행 여부에 관계없이 각 테스트가 격리된 환경에서 일관된 결과를 얻습니다.

Q2. 테스트를 독립적으로 구동시키기 위해 작성했던 설정들을 소개해주세요.

Q1에서 설명한 부분이 포함됩니다.

  • resetMockEvents()로 테스트마다 events.json 원본 상태 복원
  • server.use() + setupMockHandlerCreation() 등으로 테스트별 임시 핸들러 설정
  • server.resetHandlers()vi.clearAllMocks()로 핸들러 사용 후 정리

그리고 MSW와 Express로 테스트환경을 실제 사용환경의 분리하여 테스트 독립성을 확보하였습니다.

환경 분리에 대한 시행착오 과정:

처음에는 MSW와 Express가 서로 다른 역할을 한다는 것을 전혀 몰랐습니다. 당연히 테스트에 MSW가 필요하니까 개발환경에서도 같이 쓰는거 아닌가? 라고 생각했던 것 같습니다.

그래서 main.tsx에서 개발환경에도 MSW를 활성화했습니다.

if (import.meta.env.DEV) {
  import('./__mocks__/browser.ts').then(({ worker }) => {
    worker.start();
  });
}

당시 생각은 이랬습니다: 개발할 때도 백엔드 API가 없을테니 MSW로 모킹해주는 게 맞다.

실제 애플리케이션에서 이벤트 삭제를 테스트하다가 이상한 현상을 발견했습니다.

브라우저 콘솔에서 이런 로그가 나타났습니다:

[MSW] Warning: intercepted a request without a matching request handler:
• GET /

[MSW] 21:49:33 DELETE /api/events/2b7545a6-ebee-426c-b906-2329bc8d62bd (204 No Content)
[MSW] 21:49:33 GET /api/events (200 OK)

분명 Express 서버로 요청을 보냈는데 MSW가 모든 요청을 가로채고 있었고, realEvents.json의 여러 아이템이 한번에 삭제되어버려서 무슨 일인가 싶었습니다.

구조를 더 자세히 파악해보고 알게 된 것:

  • Express 서버가 port 3000에서 실행
  • realEvents.json 파일에 실제 CRUD 작업이 반영
  • MSW는 로컬 개발환경과는 별개의 용도로 사용

MSW는 어디에 사용되어야 하나?

  • MSW가 네트워크 레벨에서 요청을 가로채서 모킹
  • Express 서버는 아예 실행되지 않음 (내가 처음에 예상한것과 달랐던 지점)
  • events.json의 고정 데이터로 격리된 테스트에 사용

MSW는 네트워크 요청 자체를 가로채기 때문에, 테스트 환경에서는 실제 Express 서버가 사용되지 않았다는 점을 깨닫는게 중요했던것 같습니다.

정리하자면, 각 환경의 역할이 완전히 다름을 이해했습니다:

  • Express: 실제 파일 시스템과 상호작용하는 진짜 서버
  • MSW: 네트워크 레벨에서 가짜 응답을 주입하는 모킹 도구

이런 시행착오를 통해 테스트와 실제 환경을 명확히 분리한다는 개념과 그 방법에 대해 좀 더 자세히 이해하고 구현해 볼 수 있었습니다.

심화 과제

  • App 컴포넌트 적절한 단위의 컴포넌트, 훅, 유틸 함수로 분리했는가?
  • 해당 모듈들에 대한 적절한 테스트를 5개 이상 작성했는가?

과제 셀프회고

테스트코드를 처음 접해본 뉴비의 이번 과제에 대한 느낀점

테스트코드 작성을 직접해본 것이 처음이었기 때문에 거의 모든 것이 새로운 경험이었습니다.

이 프로젝트를 통해 배운 것... 이라기보단 경험해본 것들

  • Vitest와 Testing Library를 활용한 React 컴포넌트 테스트 방법
  • MSW를 사용한 API 모킹과 네트워크 요청 가로채기
  • beforeEach, afterEach를 통한 테스트 환경 초기화와 정리로 병렬 환경에서 테스트 독립성 유지
  • describe, it 구조로 테스트 케이스를 체계적으로 구성하는 방법
  • render, fireEvent, waitFor 등 Testing Library의 핵심 API 사용법
  • 훅 테스트를 위한 renderHook과 act 사용법
  • 순수함수 테스트와 통합 테스트의 차이점 체감

물론 테스트 describe와 it은 거의 미리 짜져있었지만, 실제 구현하면서 각 테스트가 왜 필요한지 이해할 수 있었습니다.

테스트 작성의 일관성과 범위 설정이 어려운것 같다

훅과 순수함수를 각각 테스트할 때 중복으로 테스트하게 되는 경우가 많았습니다.

예를 들어 easy.timeValidation.spec.ts에서 validateTime 함수에 대한 상세 테스트를 진행했는데, 다른 테스트 useEventTimeManager.spec.ts에서도 비슷한 시간 검증 로직을 또 테스트하게 되는 상황이었습니다:

// easy.timeValidation.spec.ts에서 검증에 대한 상세 테스트를 진행중이므로 여기서는 생략했음.
describe.skip('validateTime', () => {
  it('올바른 시간일 때 에러가 없다', () => {});
  it('시작 시간이 종료 시간보다 늦을 때 에러가 발생한다', () => {});
  it('빈 값일 때 에러가 없다', () => {});
});

이런 경우 어떤 기준으로 테스트를 분리해야 할지, 어느 레벨에서 무엇을 테스트해야 하는지에 대한 명확한 기준이 필요하다는 생각이 들었습니다.

테스트 품질에 대한 아쉬움은 어쩔수 없는듯

유효하고 유지보수 가능한 테스트를 작성했다는 자신감이 전혀 없습니다. 그저 요구사항에 맞춰 '테스트를 작성할 수 있었다' 정도의 수준으로 과제를 마친 것 같아서 아쉽습니다. 하지만 이런 부분은 시간을 들여가며 경험을 쌓아야 할 영역이 아닐까 하는 생각도 들기 때문에 꾸준히 연습해 나가고 싶습니다.

API 모킹의 복잡성 체감

API 모킹을 병행한 테스트 코드는 실제와 유사한 환경의 모킹이 필요하다보니, 실제 프로젝트에서 구축한다면 상당히 까다로울 것 같다는 생각이 들었습니다.

이번 과제에서 Express 사용과 테스트용 handlers와 handlersUtils로 MSW를 통한 events.json 환경 분리를 통해 원리들을 이해해 보녀서도 실제 프로덕트에서는 얼마나 더 많은 것들을 고려해야할까? 라는 생각이 들었습니다.

되도록이면 AI 사용하지 말라고 했지만

AI 사용을 지양하라고 하셨지만, 그러지 못했습니다. 코파일럿이 궁금하지 않아도 자꾸 힌트를 주었고, 과제 분량이 많다보니 테스트 코드를 작성해본 경험이 없어 더더욱 시간이 촉박해지면서 AI에 의존하는 비중이 점점 커졌습니다. 심화 파트를 할 시점에는 이미 내가 AI를 조종하는 것이 아니라, AI가 나를 조종하고 있는 상황이 펼쳐진 것 같은 아이러니한 상황이 펼쳐졌습니다. 그래도 중요한것은 테스트의 흐름을 이해하고 AI를 통해 올바른 테스트를 작성하기 위해 집중했다는 점입니다.

코드 품질

이번 과제에 시간을 많이 할애하지 못하기도 했고, 테스트를 작성하는데에도 시간이 부족해서 코드 품질을 제대로 챙기지 못한 것 같습니다.

테스트 설명(it)을 읽으면서 실제 구현과 대조해보니 이해가 되지 않는 부분들이 많았습니다. 그래서 기능의 실제 동작에 맞춰 명확하게 수정하려고 노력했습니다.

애매한 설명을 구체적으로 개선:

// 기존: 무엇을 "적절히" 처리하는지 모호
it('유효하지 않은 월에 대해 적절히 처리한다', () => {});

// 개선: 실제 반환값까지 명시 + 함수 로직 수정(ex. 월이 13이면 -1을 반환하도록)
it('유효하지 않은 월에 대해 -1을 반환한다', () => {
  const daysInMonth = getDaysInMonth(2024, 13);
  expect(daysInMonth).toBe(-1);
});
// 기존: "주중"이라는 표현이 애매
it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {});

// 개선: 구체적인 입력과 기대 결과 명시
it('수요일에 해당하는 날짜를 입력했을때 그 주의 일~토까지의 날짜를 반환한다', () => {
  const wednesday = new Date(2024, 0, 3);
  const weekDates = getWeekDates(wednesday);
  expectValidWeekDates(weekDates);
});

통합테스트 중복 로직을 커스텀 액션으로 분리:

통합테스트를 작성하다보니 이벤트 폼 입력, 목록에서 이벤트 검증, 콤보박스 선택 등 동일한 패턴이 계속 반복되었습니다.

그래서 이런 반복 로직들을 재사용 가능한 헬퍼 함수 파일medium.integration.utils.ts로 분리했습니다:

// 이벤트 폼 입력을 한 번에 처리
export const fillEventForm = async (user, eventData, shouldClear = false) => {
  const fieldMappings = [
    { field: '제목', value: eventData.title },
    { field: '날짜', value: eventData.date },
    // ...
  ];
  for (const { field, value } of fieldMappings) {
    const element = screen.getByLabelText(field);
    if (shouldClear) await user.clear(element);
    await user.type(element, value);
  }
};

// 이벤트 목록에서 데이터 검증을 한 번에 처리
export const verifyEventInList = async (eventList, eventData) => {
  await within(eventList).findByText(eventData.title);
  expect(within(eventList).getByText(eventData.date)).toBeInTheDocument();
  // ...
};

과제 피드백

  • 이번 과제는 하드 기준으로는 분량 자체가 많게 느껴졌습니다. 통합테스트까지 완료하면서 충분히 배가 부르다 싶었지만, 리팩토링과, 거기서 뽑아낸 컴포넌트와 로직들도 테스트를 추가해줘야 해서 예상보다 완성한 과제를 다듬고 복기해볼 여유가 없었던 것 같습니다.

리뷰 받고 싶은 내용

MSW handlers와 handlersUtils의 역할을 잘 분리한건지 잘 모르겠습니다.

  • handlers.ts: 기본적으로는 성공하는 API 응답이 필요 (대부분의 테스트용)
  • handlersUtils.ts: 특정 테스트에서만 API 실패 상황을 테스트해야 함 (에러 처리 검증용)

아래처럼 각각을 작성했는데요:

// handlers.ts - 성공 케이스만 처리
export const handlers = [
  http.post('/api/events', async ({ request }) => {
    const newEvent = await request.json();
    return HttpResponse.json({ id: '123', ...newEvent }, { status: 201 });
  }),
];
// handlersUtils.ts - 특정 테스트에서 에러 상황 테스트용
export const setupMockHandlerCreation = () => {
  // 기존 성공 핸들러를 임시로 404 에러 핸들러로 교체
  server.use(http.post('/api/events', () => new HttpResponse(null, { status: 404 })));
};

// 테스트에서 사용
it('API 실패시 에러 메시지를 표시한다', () => {
  setupMockHandlerCreation(); // 이 테스트에서만 404 에러 발생
  // ... 테스트 로직
});

handlers에서 저처럼 성공케이스만 처리하는게 아니라 api의 에러케이스를 모두 다루는게 맞는건지, 그게 맞다면 handlersUtils의 에러케이스와 중복이 되는데 원래 그게 맞는것인지 궁금합니다.

테스트 코드를 작성해보면서 궁금한 것이 있어서 질문도 남겨봅니다.

질문1: 테스트를 작성하다보니 단위테스트에서 이미 검증한 로직을 통합테스트에서 다시 검증하게 되는 경우가 있었습니다. 테스트를 단위테스트와 통합테스트 모두에 작성하게 될 경우 기능 수정 시 여러 곳을 동시에 수정해야 하는 유지보수 부담을 만들지 않을까 라는 생각이 듭니다.

그냥 일차원적으로 생각해보면

  • 단위테스트: 비즈니스 로직의 상세한 검증
  • 통합테스트: 전체 흐름과 컴포넌트 간 상호작용 검증 (비즈니스 로직은 더이상 검증하지 않음)

이런 식으로 분리하면 되지않나? 라는 생각이 들지만 실무에서는 중복을 감수하면서도 안전한 테스트를 만드는 경우가 있는지 궁금합니다.

질문2: 케이스에따라 차이가 클 지 모르겠지만, 작은 규모의 서비스를 운영하는 스타트업의 경우 테스트가 전혀 없는 기존 프로젝트에 테스트를 도입할 때, 리소스가 제한된 상황에서 '최소한 이것만은 테스트하는 것을 권장한다'는 기준이 있을까요? 또는 도입을 추천하는지 아닌지도 궁금합니다.

과제 피드백

안녕하세요 유열님! 7주차 과제 잘 진행해주셨네요 ㅎㅎ 고생하셨씁니다! MSW에 대해 깊은 고찰(?)을 해보셨군요 ㅋㅋㅋ 이렇게 하나씩 알아가는거죠!

AI 사용을 지양하라고 하셨지만, 그러지 못했습니다. 코파일럿이 궁금하지 않아도 자꾸 힌트를 주었고, 과제 분량이 많다보니 테스트 코드를 작성해본 경험이 없어 더더욱 시간이 촉박해지면서 AI에 의존하는 비중이 점점 커졌습니다. 심화 파트를 할 시점에는 이미 내가 AI를 조종하는 것이 아니라, AI가 나를 조종하고 있는 상황이 펼쳐진 것 같은 아이러니한 상황이 펼쳐졌습니다. 그래도 중요한것은 테스트의 흐름을 이해하고 AI를 통해 올바른 테스트를 작성하기 위해 집중했다는 점입니다.

사실 AI의 등장 이후에 개발자들이 제일 큰 도움을 받는 영역이 테스트라고 생각해요. 떼어내고 싶으도 떼어내기가 힘든 구간이죠 ㅎㅎ 잘 써보는것도 좋은 시도라고 생각합니다!

MSW handlers와 handlersUtils의 역할을 잘 분리한건지 잘 모르겠습니다. handlers에서 저처럼 성공케이스만 처리하는게 아니라 api의 에러케이스를 모두 다루는게 맞는건지, 그게 맞다면 handlersUtils의 에러케이스와 중복이 되는데 원래 그게 맞는것인지 궁금합니다.

기본적으로 성공케이스를 반환하도록 하고, handlerUtils를 통해 특정 상황에서 오류 케이스에 대한 처리가 필요하다면 이를 만들어서 사용하는거죠 ㅎㅎ 잘 진행해주셨습니다!

아 그리고 테스트 데이터를 독립적으로 사용할 때에도 이용해볼 수 있을 것 같아요. 기본 설정으로 만들어놓은 테스트 데이터를 사용하는게 아닌 특적 테스트에서만 필요한 데이터를 만들어서 handlerUtils에 넘겨준 다음 이를 사용하도록 하는거죠.

테스트를 작성하다보니 단위테스트에서 이미 검증한 로직을 통합테스트에서 다시 검증하게 되는 경우가 있었습니다. 테스트를 단위테스트와 통합테스트 모두에 작성하게 될 경우 기능 수정 시 여러 곳을 동시에 수정해야 하는 유지보수 부담을 만들지 않을까 라는 생각이 듭니다. 그냥 일차원적으로 생각해보면 단위테스트: 비즈니스 로직의 상세한 검증 / 통합테스트: 전체 흐름과 컴포넌트 간 상호작용 검증 (비즈니스 로직은 더이상 검증하지 않음) 이런 식으로 분리하면 되지않나? 라는 생각이 들지만 실무에서는 중복을 감수하면서도 안전한 테스트를 만드는 경우가 있는지 궁금합니다.

말씀해주신 것 처럼 진행해주셔도 좋을 것 같아요! 그치만 중복을 없애려는 노력이 중복을 허용하고 유지보수하는 노력이 더 저렴할 때도 많답니다 ㅋㅋ 저는 테스트가 결국 "문제를 빠르게 발견하기 위한 수단"이라고 생각해요. 이에 대한 조건만 잘 충족할 수 있다면 어떤 방식이든 크게 중요하지 않다는 입장입니다.

케이스에따라 차이가 클 지 모르겠지만, 작은 규모의 서비스를 운영하는 스타트업의 경우 테스트가 전혀 없는 기존 프로젝트에 테스트를 도입할 때, 리소스가 제한된 상황에서 '최소한 이것만은 테스트하는 것을 권장한다'는 기준이 있을까요? 또는 도입을 추천하는지 아닌지도 궁금합니다.

적어도 여기저기서 많이 참조하고 많이 쓰이는 유틸함수에 대해서는 테스트를 작성하면 좋답니다 ㅎㅎ 라이브러리 성격을 가지고 있는 것들이요! 가령 우리는 리액트를 사용하고 있는데, 리액트라는 라이브러리를 의존하는 프로젝트가 많기 때문에 변화의 여파가 굉장히 커요. 이런 경우에는 테스트가 필수겠죠?

마찬가지로 디자인 시스템 내부에서 쓰이는 UI 인터랙션 로직에 대해서도 테스트가 필요할 수 있고... 그렇습니다. 그리고 자주 변경되는 로직에 대해서는 테스트를 작성하는게 부담이 될 수 있어요. 유지보수해야할게 많아지고 변경이 자주 발생하기 때문에 테스트를 작성해봤자 큰 의미가 없을 수 있달까..!?

그리고 사실 이제 AI가 있어서 테스트 작성 비용이 굉장히 저렴해졌어요. 백그라운드로 돌려도 되고, playwright mcp 같은게 있어서 문장으로 정의된 요구사항 혹은 스펙을 토대로 e2e 테스트를 만들 수 있답니다! 결론은... 이런 시대에 테스트를 작성하지 않는건 큰 손해라고 생각해요 ㅋㅋ 가성비 + 효과가 제일 좋은 도구가 되지 않았나!? 라는 입장입니다.