HARD
7주차 과제 체크포인트
기본과제
- 총 11개의 파일, 115개의 단위 테스트를 무사히 작성하고 통과시킨다.
질문
Q. handlersUtils에 남긴 질문에 답변해주세요.
기존 핸들러는 정적 JSON만 돌려줘서, 생성/수정 후에 fetch를 다시 해도 최신 상태가 안 나왔고, 전역 상태를 공유해서 병렬 실행 시 데이터가 섞일 여지가 있었습니다.
그래서 클로저 기반 createMockHandlers 팩토리를 넣었습니다. 테스트마다 createMockHandlers(seed)()를 호출해 독립적인 mockEvents를 갖도록 했고, 그 결과
POST/PUT/DELETE가 실제mockEvents를 업데이트하고GET은 항상 그 시점의 최신 상태를 반환하며- 테스트 간 상태가 섞이지 않아 병렬로 돌려도 안전하게 만들었습니다.
이 패턴 덕분에 테스트 A/B 각각 완전히 분리된 인스턴스를 쉽게 만들 수 있었습니다.
아래는 최종 코드입니다.
export const createMockHandlers = (initialEvents: Event[] = []) => {
return () => {
let mockEvents: Event[] = structuredClone(initialEvents);
return [
http.get('/api/events', () => {
return HttpResponse.json({ events: mockEvents }, { status: 200 });
}),
http.post('/api/events', async ({ request }) => {
const newEvent = (await request.json()) as Omit<Event, 'id'>;
const eventWithId: Event = {
...newEvent,
id: Math.random().toString(36).substring(2, 15),
};
mockEvents.push(eventWithId);
return HttpResponse.json(
{
success: true,
event: eventWithId,
message: '일정이 성공적으로 추가되었습니다.',
},
{ status: 201 }
);
}),
http.put('/api/events/:id', async ({ request, params }) => {
const updatedEvent = (await request.json()) as Partial<Event>;
const index = mockEvents.findIndex((event) => event.id === params.id);
if (index === -1) {
return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 });
}
mockEvents[index] = { ...mockEvents[index], ...updatedEvent, id: params.id as string };
return HttpResponse.json(
{
success: true,
event: mockEvents[index],
message: '일정이 성공적으로 수정되었습니다.',
},
{ status: 200 }
);
}),
http.delete('/api/events/:id', ({ params }) => {
const index = mockEvents.findIndex((event) => event.id === params.id);
if (index === -1) {
return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 });
}
const [deletedEvent] = mockEvents.splice(index, 1);
return HttpResponse.json({ event: deletedEvent }, { status: 200 });
}),
];
};
};
Q. 테스트를 독립적으로 구동시키기 위해 작성했던 설정들을 소개해주세요.
현재 createMockHandlers 함수는 단순히 핸들러 배열을 바로 반환하지 않습니다. 대신 “핸들러 생성기(factory)”를 반환하도록 설계되어 있습니다.
const createHandlers = createMockHandlers(initialEvents); // 첫 번째 호출 → 생성기(factory) 생성
const handlers = createHandlers(); // 두 번째 호출 → 실제 핸들러 배열 반환
그래서 처음 한 번 호출할 때는, 전달받은 초기 상태(seed 이벤트 배열)를 클로저에 담아두는 공장 같은 함수를 만들어내고, 두 번째 호출할 때 그 공장이 실제로 동작해서 핸들러 배열을 반환하게 만들었습니다.
- 첫 호출: 초기 상태를 캡처한 생성기 함수를 만든다
- 두 번째 호출: 그 생성기를 실행해서 실제 MSW 핸들러 배열을 받는다.
이렇게 두 단계로 나눈 이유는 테스트 환경에서 여러 개의 독립적인 인스턴스를 쉽게 만들어내기 위해서인데요. 예를 들어 테스트 A와 테스트 B가 동시에 실행될 때, 각각 createMockHandlers([])()를 호출하면 서로 다른 메모리 공간을 가진 핸들러 인스턴스를 생성하게 되고, 덕분에 데이터 오염 없이 병렬로 안전하게 동작할 수 있게 되었습니다.
또한 여기서 structuredClone()를 사용했는데, 깊은 복사로 초기 데이터를 복제해 두 테스트가 같은 참조를 공유하지 않게 좀더 안전하게 막기 위해서 사용했습니다. 얕은 복사였다면 객체 참조가 이어져서 한쪽 변경이 다른 쪽에 새어 나올 수 있을거라 생각했어요. (하지만 지금 생각해보니 이미 클로저 환경이라 크게 상관없을 것 같습니다.)
import '@testing-library/jest-dom';
import { setupServer } from 'msw/node';
import { handlers } from './__mocks__/handlers';
export const server = setupServer(...handlers);
afterEach(() => {
server.resetHandlers(); // 이전 테스트의 핸들러 제거
});
afterAll(() => {
vi.resetAllMocks(); // 모든 모킹 리셋
server.close(); // 서버 인스턴스 정리
});
그리고 setupTest.ts 파일에서 테스트가 끝나면 이전 테스트의 핸들러를 모두 제거하여 독립성을 확보했습니다.
심화 과제
- App 컴포넌트 적절한 단위의 컴포넌트, 훅, 유틸 함수로 분리했는가?
- 해당 모듈들에 대한 적절한 테스트를 5개 이상 작성했는가?
과제 셀프회고
기술적 성장
원래 저는 테스트코드를 작성해본 경험이 거의 없었습니다. 백엔드에서 로직 검증할 때. 몇 번 짰었는데 프론트에서 테스트코드는 처음이였어요. 단순히 테스트가 필요하다는것 만 알고있었지 실제로 어떻게 작성하고 어떤 원리로 동작하는지 알지 못했습니다.
이번 과제를 통해서 테스트코드를 작성하는 기본 흐름을 배우면서 이게 실제로 필요한 테스트일까? 혹은 어떤 테스트가 꼭 필요할까? 라는 고민을 자연스럽게 해볼 수있어서 좋았습니다.
그리고 act, waitFor 같은 도구가 비동기 로직과 UI 업데이트를 제어하는 역할을 한다는 것을 알게 되었고, 이를 활용해 실제 시나리오 기반의 테스트를 작성해보니 너무 재미있었습니다. 하지만 이부분은 여전히 헷갈리네요. act는 React의 상태관리를 래핑해주는 함수로만 알고있고 waitFor은 비동기나 useEffect처리처럼 바로 나타나지 않는 결과를 처리할때 쓰는거로 공부했는데 어떨때 적절하게 쓸지 아직 감은 못잡았습니다.
또한 테스트 환경에서는 시간에 대한 모킹이 되게 중요하다고 느꼈는데 vi.useFakeTimers, vi.setSystemTime을 사용해 시간을 고정하거나 조작하면서 테스트 환경에서도 실제 시간에 의존하지 않고 안정적으로 검증하는 방법을 배웠습니다.
그리고 MSW를 사용해 서버 없이도 네트워크 요청을 흉내 내고 독립적인 테스트 환경을 구성하는 방법을 배웠습니다. 특히 전역 상태를 그대로 두면 병렬 실행 시 데이터 오염 문제가 생기므로 클로저 기반 상태 관리와 테스트 격리의 중요성도 체감했습니다.
무엇보다 단순히 테스트를 많이 작성하는 것이 아니라 어떤 것이 의미가 있고 어떤것이 불필요할지 구분하는 감각을 조금이나마 얻을 수 있게된것 같습니다.
코드 품질
학습 효과 분석
1. 불필요한 테스트를 과감히 제거하기
처음에는 알림시간이 0인건 즉시 알림이 발생하는줄 알고 “알림 시간이 0이면 즉시 알림이 발생한다.”와 같은 테스트를 작성했습니다. 하지만 해당 테스트는 실패했고 getUpcomingEvents를 확인해보니 알림시간이 0인 부분에 대해서는 따로 예외를 던지거나 처리하고 있는 로직이 없었습니다.
그래서 아래와 같은 테스트를 추가했더니 통과가 되었습니다.
it('알림 시간이 0인 이벤트는 반환되지 않는다.', () => {
const immediateEvent: Event = {
id: '6',
title: '즉시 알림',
date: '2025-07-01',
startTime: '09:00',
endTime: '10:00',
description: '즉시 알림 이벤트',
location: '회의실 C',
category: '회의',
repeat: { type: 'none', interval: 1 },
notificationTime: 0,
};
const eventsWithImmediate = [...mockEvents, immediateEvent];
const now = new Date('2025-07-01T09:00:00');
const notifiedEvents: string[] = [];
const upcomingEvents = getUpcomingEvents(eventsWithImmediate, now, notifiedEvents);
expect(upcomingEvents).toHaveLength(0);
expect(upcomingEvents.find((e) => e.notificationTime === 0)).toBeUndefined();
});
하지만 곰곰히 생각해보니 이 테스트는 오히려 잘못된 동작을 올바른 결과로 착각하게 만들 위험이 있다고 생각했습니다.
- 함수가 버그로 인해 잘못된 결과를 반환하더라도, 이 테스트가 그걸 "맞다"고 인정해버릴 수 있습니다.
- 즉, 테스트 코드가 제 역할을 하지 못하고, 오히려 코드의 신뢰성을 떨어뜨리게 됩니다.
이 경험을 통해 모든 케이스를 억지로 테스트하는 것보다, 의미 없는 테스트는 과감히 제거하는 것이 낫다는 점을 배웠습니다.
다음으로는 알림 메시지 생성 로직을 검증하기 위해 여러 케이스를 추가했습니다.
2. 다양한 케이스 검증
it('특수 문자가 포함된 제목에 대해서도 올바른 메시지를 생성한다', () => {
const event: Event = {
id: '3',
title: '회의 & 세미나 (중요)',
...
};
const message = createNotificationMessage(event);
expect(message).toBe('30분 후 회의 & 세미나 (중요) 일정이 시작됩니다.');
});
// 긴 텍스트에 대한 경계값 테스트
it('긴 제목을 가진 이벤트에 대해서 긴 제목을 포함한 메시지를 생성한다', () => {
const event: Event = {
id: '1',
title:
'매우 긴 제목을 가진 중요한 회의와 세미나가 동시에 진행되는 특별한 이벤트 매우 긴 제목을 가진 중요한 회의와 세미나가 동시에 진행되는 특별한 이벤트',
....
};
const message = createNotificationMessage(event);
expect(message).toBe(
'45분 후 매우 긴 제목을 가진 중요한 회의와 세미나가 동시에 진행되는 특별한 이벤트 매우 긴 제목을 가진 중요한 회의와 세미나가 동시에 진행되는 특별한 이벤트 일정이 시작됩니다.'
);
});
// 이모지 인코딩 테스트
it('이모지를 포함한 이벤트에 대해서 이모지를 포함한 메시지를 생성한다', () => {
const event: Event = {
id: '1',
title: '🔥🚨👉 급한 중요한 회의🏢 💥💣💥 이벤트 1👐👈👊',
};
const message = createNotificationMessage(event);
expect(message).toBe(
'45분 후 🔥🚨👉 급한 중요한 회의🏢 💥💣💥 이벤트 1👐👈👊 일정이 시작됩니다.'
);
});
여기서는 단순히 기능을 확인하는 것을 넘어서, 실제 사용자가 마주할 수 있는 다양한 상황을 검증해보았습니다.
- 특수문자(
&, 괄호 등) - 제목이 긴 경우
- 이모지가 포함된 경우
이런 테스트를 추가하면서, “테스트는 코드만 보는 게 아니라, 실제 사용자 경험을 뒷받침해야 한다”는 점을 다시 느꼈습니다.
3. 여러 이벤트 동시 처리
실제 서비스에서는 여러 이벤트의 알림이 동시에 도래하는 경우도 있습니다. 이를 검증하기 위해 다음과 같은 테스트를 작성했습니다.
it('여러 이벤트의 알림 시간이 동시에 도래하면 모두 반환한다', () => {
const now = new Date('2025-07-01T11:30:00');
const notifiedEvents: string[] = [];
const upcomingEvents = getUpcomingEvents(mockEvents, now, notifiedEvents);
expect(upcomingEvents).toHaveLength(2);
expect(upcomingEvents.map((e) => e.id)).toContain('2');
expect(upcomingEvents.map((e) => e.id)).toContain('3');
});
이 테스트는 동시성 상황에서도 함수가 안정적으로 동작하는지 확인해주는 장치라고 생각했습니다. 실제로 알림 시스템을 운영한다고 생각하면, 꼭 필요한 검증이라는 생각이 들었어요
4. 경계값 테스트: 월을 넘어가는 주간 뷰
마지막으로 경계 조건을 검증하는 테스트도 추가했습니다.
it('주간 뷰에서 월 경계를 넘어가는 이벤트를 올바르게 포함해야 한다', () => {
const { result } = renderHook(() => useSearch(mockEvents, new Date('2025-07-01'), 'week'));
act(() => {
result.current.setSearchTerm('회의');
});
expect(result.current.filteredEvents).toHaveLength(2);
expect(result.current.filteredEvents.map((e) => e.date)).toContain('2025-06-29');
expect(result.current.filteredEvents.map((e) => e.date)).toContain('2025-07-05');
});
달력 기반 UI에서 가장 자주 문제가 되는 부분은 월 말 ~ 월 초에 걸쳐 있는 부분입니다. 이 테스트를 통해 사용자가 실제로 볼 때 문제가 없도록 보장할 수 있었고, 이 부분만 테스트 해보면 적은 수의 테스트로 다른 케이스들의 안정성을 확보할 수 있었습니다.
이번 학습을 통해 크게 네 가지를 배웠습니다.
- 잘못된 동작 A를 하면 tree다(?)고 인정하는 테스트는 오히려 독이 되므로, 불필요한 테스트는 과감히 제거하자.
- 다양한 입력(특수문자, 긴 제목, 이모지 등)을 넣어봄으로써 현실성 있는 안정성을 담보함
- 동시성 상황, 즉 여러 이벤트가 한 번에 발생하는 케이스를 반드시 검증해보면 좋을 것 같다.,
- 경계 조건(월 경계, 시간 경계 등)을 테스트하는 것은 단순 기능 확인보다 훨씬 실제 사용자 경험에 직결되는 테스트이기고 작은 수로 다른 테스트 케이스를 검증할 수 있어서 효과적인거 같다.
과제 피드백
추가적으로 커버리지를 측정해봤습니다.
전체 커버리지 요약
| 항목 | 커버리지 | 통과한 테스트 수 | 총 테스트 수 |
|---|---|---|---|
| Statements | 81.65% | 1,153 | 1,412 |
| Branches | 81.03% | 141 | 174 |
| Functions | 75.47% | 40 | 53 |
| Lines | 81.65% | 1,153 | 1,412 |
테스트 실행 결과
- 테스트 파일: 17개 (모두 통과)
- 테스트 케이스: 186개 (모두 통과)
- 실행 시간: 17.47초
- 환경: jsdom
100% 커버리지 달성 파일
| 파일 경로 | Statements | Branches | Functions | Lines | 테스트 케이스 수 | 주요 기능 |
|---|---|---|---|---|---|---|
fetchHolidays.ts | 100% (33/33) | 100% (2/2) | 100% (0/0) | 100% (33/33) | 4개 | 공휴일 데이터 API 호출 |
dateUtils.ts | 100% | 100% | 100% | 100% | 55개 | 날짜 계산/포맷 유틸 |
eventUtils.ts | 100% | 100% | 100% | 100% | 9개 | 이벤트 유틸 로직 |
eventOverlap.ts | 100% | 100% | 100% | 100% | 16개 | 이벤트 중복 검사 |
notificationUtils.ts | 100% | 100% | 100% | 100% | 10개 | 알림 메시지 생성 |
timeValidation.ts | 100% | 100% | 100% | 100% | 6개 | 시간 유효성 검사 |
그중에서도 utils 함수들은 다양한 케이스를 꼼꼼하게 테스트하기 위해 극단값·에러 케이스까지 고려해서 테스트를 짰습니다. 단순히 숫자 달성에 그치지 않고, 실제로 오류가 발생하기 쉬운 부분들을 세밀하게 커버했다는 점에서 의미가 있다고 생각합니다.
특히 dateUtils는 윤년/월 경계/시작일·종료일 같은 예외 케이스까지 테스트했고, eventOverlap은 겹치는 구간·겹치지 않는 구간·완전히 포함되는 경우 등 분기별로 테스트했습니다. 이런 과정을 통해 단순히 커버리지를 높인 게 아니라 코드 로직의 안정성에 대한 신뢰도 확보할 수 있었습니다.
다만 제가 심화과제때 작성한 컴포넌트에 대한 테스트는 커버리지가 낮았는데 컴포넌트에 대한 테스트들을 작성해본 경험이 많이 없어서 어떻게 하면 의미있는 테스트를 작성할지 좀더 고민을 해서 채워나가야할 것 같습니다
리뷰 받고 싶은 내용
Q1. 통합 테스트의 DOM 탐색 관련
MUI 같은 외부 UI 라이브러리를 쓰다 보면 포털, 비동기 렌더링 같은 특성 때문에 예상치 못한 DOM 구조가 생겨서 테스트 작성이 복잡해지는 경우가 있습니다. 특히 UI 라이브러리를 MUI에서 Antd나 shadcn 같은 다른 걸로 바꾸면 테스트 코드도 크게 수정해야 할 것 같은데요. 실제 실무에서는 이런 DOM 의존적인 테스트에 시간을 많이 쓰는 편인가요? 아니면 테스트 전략을 단순화하거나, UI 라이브러리에 덜 의존하는 방식으로 접근하는 경우가 더 일반적인가요?
Q2. TanStack Query 테스트 질문 현업에서 TanStack Query를 많이 사용하는데, 이런 데이터 페칭 라이브러리를 사용하는 컴포넌트를 테스트할 때 테스트가 복잡하거나 관리하기 번거로운 편인지 궁금합니다. 특히, 캐싱, 비동기 상태, 쿼리 무효화 같은 기능 때문에 테스트 작성이 어려울 것 같은데 이런부분은 어떻게 학습하고 대응하는지 궁금합니다..
Q3. CI에서 간헐적으로 실패하는 테스트가 있습니다. 1주차에서도 그랬고 이번 과제에서도 그랬는데 로컬에서 돌리면 잘 통과하는 테스트들이 CI환경에서는 실패하는 경우가 종종 있더라구요. 이걸 플래키 테스트라고 부르던데 현업에서 이를 잘못된 테스트로 보고 반드시 리팩터링하는지, 아니면 리트라이 등 다른 방식으로 대응하는지 궁금합니다.
과제 피드백
안녕하세요 창준님! 역시 믿고 보는 창준님의 과제네요 ㅎㅎ
act는 React의 상태관리를 래핑해주는 함수로만 알고있고 waitFor은 비동기나 useEffect처리처럼 바로 나타나지 않는 결과를 처리할때 쓰는거로 공부했는데 어떨때 적절하게 쓸지 아직 감은 못잡았습니다.
act는 React의 렌더링 시스템과 연관있는데요, 비동기로 동작하는 react의 렌더링을 동기적으로 치환해주는 역할을 해준답니다 ㅎㅎ waitFor은 일정한 시간동안 waitFor 내부에 정의된 코드가 정상적으로 동작할 때까지 기다리는거라서 큰 차이가 있어요.
무엇보다 단순히 테스트를 많이 작성하는 것이 아니라 어떤 것이 의미가 있고 어떤것이 불필요할지 구분하는 감각을 조금이나마 얻을 수 있게된것 같습니다.
좋은 인사이트를 얻어가셨군요!! 다행입니다!!
MUI 같은 외부 UI 라이브러리를 쓰다 보면 포털, 비동기 렌더링 같은 특성 때문에 예상치 못한 DOM 구조가 생겨서 테스트 작성이 복잡해지는 경우가 있습니다. 특히 UI 라이브러리를 MUI에서 Antd나 shadcn 같은 다른 걸로 바꾸면 테스트 코드도 크게 수정해야 할 것 같은데요. 실제 실무에서는 이런 DOM 의존적인 테스트에 시간을 많이 쓰는 편인가요? 아니면 테스트 전략을 단순화하거나, UI 라이브러리에 덜 의존하는 방식으로 접근하는 경우가 더 일반적인가요?
저는 UI에 대한 테스트는 딱히 하지 않고 있어요 ㅎㅎ 컴포넌트(UI)는 결국 데이터를 표현하는 수단이라고 생각합니다. 그렇다면 컴포넌트가 아니라 컴포넌트 렌더링에 필요한 데이터와 데이터를 변경하는 함수에 대해 테스트를 한다면 컴포넌트에 대한 검증도 자연스럽게 어느정도는 이루어지지 않나!? 라는 생각이라서요. 컴포넌트에 대해 테스트가 꼭 필요한 경우는 인터랙션의 연결이 자연스러운지 검증할때라고 생각해요. 물론 이 부분도 e2e 테스트를 통해 커버하는게 더 자연스럽다고 생각하는 편입니다!
그리고 보통 컴포넌트에 대한 테스트는 스토리북으로 대체하는 경우가 많은 것 같아요 ㅋㅋ
현업에서 TanStack Query를 많이 사용하는데, 이런 데이터 페칭 라이브러리를 사용하는 컴포넌트를 테스트할 때 테스트가 복잡하거나 관리하기 번거로운 편인지 궁금합니다. 특히, 캐싱, 비동기 상태, 쿼리 무효화 같은 기능 때문에 테스트 작성이 어려울 것 같은데 이런부분은 어떻게 학습하고 대응하는지 궁금합니다..
위의 내용과 연계되는 부분인데요, 컴포넌트 자체보단 tanstack-query로 만들어진 코드를 훅으로 래핑한 다음에 훅에 대해 테스트를 하는 편이랍니다!
CI에서 간헐적으로 실패하는 테스트가 있습니다. 1주차에서도 그랬고 이번 과제에서도 그랬는데 로컬에서 돌리면 잘 통과하는 테스트들이 CI환경에서는 실패하는 경우가 종종 있더라구요. 이걸 플래키 테스트라고 부르던데 현업에서 이를 잘못된 테스트로 보고 반드시 리팩터링하는지, 아니면 리트라이 등 다른 방식으로 대응하는지 궁금합니다.
대체로 이런 경우는 UI에 대해 테스트할 때 발생하는 것 같아요. e2e의 경우 패키지 버전에 따라 달라지기도 하고!? 컴포넌트가 아닌 데이터를 다루는 함수에 대해서만 테스트 하는 경우에는 대체로 이런 문제가 발생하지 않았어서요..! 그치만 원인 자체는 알아내고 이를 해결하면서 트러블슈팅을 하는 과정은 필요하다고 생각해요. 내가 알아낸 문제와 해결방법이 다른 사람들에게는 도움이 될 수 있으니까요!