HARD
7주차 과제 체크포인트
기본과제
- 총 11개의 파일, 115개의 단위 테스트를 무사히 작성하고 통과시킨다.
질문
Q. handlersUtils에 남긴 질문에 답변해주세요.
각기 다른 테스트가 하나의 값을 참조해서 서로에게 간섭하는 상황을 방지해야 합니다. 이를 위해 각 테스트 별로 별도의 공간을 만들어서 격리된 데이터를 각자 사용하게끔 만들어야 합니다.
Q. 테스트를 독립적으로 구동시키기 위해 작성했던 설정들을 소개해주세요.
각 테스트에서 server.use(...setupMockHandlerCreation(initialEvents))로 독립 초기 상태를 주입하고, 핸들러 내부는 store.current(mutable ref)로 상태를 유지합니다. 테스트 간 간섭을 막기 위해 beforeEach로 재설정하고 필요 시 server.resetHandlers()를 사용합니다. 초기 상태는 deep clone(structuredClone)으로 안전 복제합니다.
심화 과제
- App 컴포넌트 적절한 단위의 컴포넌트, 훅, 유틸 함수로 분리했는가?
- 해당 모듈들에 대한 적절한 테스트를 5개 이상 작성했는가?
과제 셀프회고
지난 과제들을 수행해가면서 테스트코드를 지속해서 접할 수 있었습니다. 동시에 왜 중요하고, 어떤 용도로 쓰이는 지도 파악했습니다.
기술적 성장
테스트 코드 작성에 앞서서 vitest, RTL과 같은 툴들에 대한 기본 이해도도 높이고자 했습니다. 기존에 알고 있던 MSW 지식과 더불어 handlersUtil을 통해 MSW 핸들러를 어떻게 다루고 사이드 이펙트를 줄일 수 있는지 학습했습니다.
코드 품질
테스트 코드를 구현하면서 두가지 방향에서 고민하고 기준을 세웠습니다.
- 테스트라는 목적에 충실하기 위한 개별 테스트의 일관된 로직
- 문서화로서의 테스트를 위한 명시성과 가독성, 응집성
엄밀하고 뾰족한 테스트
- 테스트를 통과했는데 기능이 실제로는 안되는 경우가 있어서는 안됩니다.
- 반대로 기능이 실제로 되는데 테스트를 통과하지 않는 경우에도 생산성의 하락을 불러옵니다.
전자의 경우를 방지하기 위해 경계 테스트가 중요합니다. 요구사항의 경계에 있는 값들을 시험하여 정확한 시점에서 작동/미작동이 전환되는지 체크해야합니다.
it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {
const date = new Date(2025, 6, 1); // 2025-07-01
const firstDayOfMonth = {
title: '',
description: '',
location: '',
date: '2025-07-01',
} as Event;
const lastDayOfMonth = {
title: '',
description: '',
location: '',
date: '2025-07-31',
} as Event;
const monthDay = [firstDayOfMonth, lastDayOfMonth];
const lastDayOfPrevMonth = {
title: '',
description: '',
location: '',
date: '2025-6-30',
} as Event;
const firstDayOfnextMonth = {
title: '',
description: '',
location: '',
date: '2025-8-01',
} as Event;
const notMonthDay = [lastDayOfPrevMonth, firstDayOfnextMonth];
const searchView = 'month';
const filteredEvents = getFilteredEvents([...monthDay, ...notMonthDay], '', date, searchView);
expect(filteredEvents).toHaveLength(2);
expect(filteredEvents).toMatchObject([{ date: '2025-07-01' }, { date: '2025-07-31' }]);
});
후자의 경우에는 테스트간 독립성을 보장하고 싶었습니다. 즉, 다른 기능의 오류로 인해 개별 테스트가 실패하는 경우를 최소화하고자 했습니다.
그래서 테스트 환경 내에서의 모듈화를 최소화했습니다.
- 입력 데이터는 해당 테스트에 사용되는 값만 사용하고자 했습니다.
- 테스트 코드 안에서 환경이 정의되고 실행되는 과정이 전부 담기는 것을 추구했습니다.
util함수 혹은 다른 곳에서 정의된 유틸 함수를 사용하지 않았습니다.
- 해당 유틸 함수의 실패로 별개의 테스트가 실패할 가능성을 없에기 위해서입니다.
- 각 테스트 별로 다른 경우를 대입할 수 있습니다.
유틸함수는 라이브러리의 함수를 해당 라이브러리의 테스트에 의존하듯이 테스트를 거치면 되는 것이 아닌가 하는 고민이 들었습니다. 하지만 직접 수정이 가능하고 그 테스트가 선제된다는 확신이 없는 상황에서 유틸함수 또한 재사용하지 않는 것이 맞다는 결론을 내렸습니다.
또한 기대값을 정의할때는 스태틱하고 1차적으로 속성이 노출된 가공하지 않은(중복 선언하지 않은) 값을 기대값으로 사용했습니다
it("검색어 '이벤트'와 날짜 '2025-07-01'에 대하여 7월 첫째주에 발생하였고 이벤트 단어가 포함된 모든 이벤트를 반환한다.", () => {
const currentDate = new Date(2025, 6, 1); // 2025-07-01
const containedEvents = [
{ title: '이벤트 1', description: '이벤트 2', location: '', date: '2025-07-01' } as Event,
{
title: '이벤트 2',
description: '이벤트 3',
location: '',
date: '2025-07-02',
} as Event,
{ title: '이벤트 3', description: '', location: '이벤트 23', date: '2025-07-05' } as Event,
];
const notContainedEvents = [
{ title: '파울로 벤투', description: '', location: '', date: '2025-06-30' } as Event,
{ title: '', description: '아반테', location: '', date: '2025-07-03' } as Event,
{ title: '', description: '이벤트', location: '', date: '2025-10-15' } as Event,
];
const searchView = 'week';
const filteredEvents = getFilteredEvents(
[...containedEvents, ...notContainedEvents],
'이벤트',
currentDate,
searchView
);
expect(filteredEvents).toHaveLength(3);
expect(filteredEvents).toMatchObject([
{ title: '이벤트 1', date: '2025-07-01' },
{ title: '이벤트 2', date: '2025-07-02' },
{ title: '이벤트 3', date: '2025-07-05' },
]);
});
겹치는 기능이 들어가는 경우 입력 데이터의 제한을 통해 테스트의 독립성을 보장하고자 했습니다.
it('검색어에 맞는 이벤트만 필터링해야 한다', () => {
...
const dailyMeetingEvent = makeEvent({ title: '일간 회의', date: '2025-10-15' });
const weeklyMeetingEvent = makeEvent({ title: '주간 회의', date: '2025-10-15' });
const weeklyStudyEvent = makeEvent({ title: '주간 스터디', date: '2025-10-15' });
const monthlyMeetingEvent = makeEvent({ title: '월간 회의', date: '2025-10-15' });
const yearMeetingEvent = makeEvent({ title: '연간 회의', date: '2025-10-15' });
...
expect(result.current.filteredEvents).toHaveLength(2);
expect(result.current.filteredEvents).toMatchObject([
{ title: '주간 회의' },
{ title: '주간 스터디' },
]);
});
it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {
...
const uncontainedEvent = makeEvent({ title: '월간 회의', date: '2025-10-15' });
const containedTitleEvent = makeEvent({ title: '주간 회의', date: '2025-10-15' });
const containedDescriptionEvent = makeEvent({ description: '주간 스터디', date: '2025-10-15' });
const containedLocationEvent = makeEvent({ location: '주간 회의장', date: '2025-10-15' });
...
expect(result.current.filteredEvents).toHaveLength(3);
expect(result.current.filteredEvents).toMatchObject([
{ title: '주간 회의' },
{ description: '주간 스터디' },
{ location: '주간 회의장' },
]);
});
it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {
...
const afterOneDayEvent = makeEvent({ date: '2025-10-13' });
const afterTwoDayEvent = makeEvent({ date: '2025-10-14' });
const afterTwoWeekEvent = makeEvent({ date: '2025-10-26' });
const afterTwoMonthEvent = makeEvent({ date: '2025-12-12' });
...
expect(result.current.filteredEvents).toHaveLength(2);
expect(result.current.filteredEvents).toMatchObject([
{ date: '2025-10-13' },
{ date: '2025-10-14' },
]);
});
이 때 주의한 것이 특정 테스트에서 잡아내야하는 기능이 다른 테스트의 실패를 유발하지 않게끔 하는 것입니다.
구체적으로는 `제목, 설명, 위치 중 하나가 검색어 필터링이 되지 않는 경우'가 있다고 가정합니다. 첫번째 테스트에서 제목, 설명, 위치를 모두 필터링하는 경우까지 커버리지를 두지 않고 제목만을 정의해서 필터링해내는지 테스트했습니다.
제목, 설명, 위치 검색을 모두 포함하여 테스트를 돌릴수도 있습니다. 그 상황에서 테스트가 실패한다면 이 테스트가 검색어 필터링을 못하는 것인지, 3가지 조건의 or의 논리가 적용이 안된건지 판단할 수 없기 때문입니다.
하지만 이렇게 단순 검색 테스트에서는 하나의 조건만으로 호출 해두면, 실패했을 시 검색 기능에 문제가 있을 가능성이 높습니다. 그리고 첫번째 테스트를 통과하고 두번째 테스트에서 실패한다면 3가지 조건을 동시에 따지지 못한다고 판단할 수 있습니다.
이에 맞게 팩토리 함수를 이용해서 최소한의 속성 데이터만을 변수로 사용했습니다.
문서로 잘 읽히는 코드
지난 주차 클린코드를 통해 가독성이 좋고 명시성이 좋은 코드에 대해서 이미 많이 강조가 되었습니다. 테스트 코드는 하나의 문서로서 그 코드를 읽고 이해하는 것이 상대적으로 중요한 코드입니다. 그렇기에 더욱 더 이 특성이 강조됩니다.
- 구체적인 변수명
- 필요없는 중간 선언 최소화
- 반대로 테스트에서 타게팅 되는 값은 중간선언하여 변수명으로 강조
it('2025-07-01에 대하여 주간 뷰에서 7월 첫째주의 이벤트만 반환한다', () => {
...
const searchView = 'week';
const filteredEvents = getFilteredEvents([...weekDay, ...notWeekDay], '', date, searchView);
...
});
it('검색어가 없을 때 뷰의 기간에 해당하는 모든 이벤트를 반환한다', () => {
...
const filteredEvents = getFilteredEvents(events, '', currentDate, 'week');
...
});
위처럼 getFilteredEvents의 view인자가 중요한 경우에 const searchView = 'week'로 중간 선언을 해준 반면
view가 week인지 month인지 여부가 강조되지 않는 테스트에서는 'week'를 직접적으로 입력했습니다.
it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {
const currentDate = new Date(2025, 9, 12); // 2025-10-12
const uncontainedEvent = makeEvent({ title: '월간 회의', date: '2025-10-15' });
const containedTitleEvent = makeEvent({ title: '주간 회의', date: '2025-10-15' });
const containedDescriptionEvent = makeEvent({ description: '주간 스터디', date: '2025-10-15' });
const containedLocationEvent = makeEvent({ location: '주간 회의장', date: '2025-10-15' });
const initialEvents = [
containedTitleEvent,
containedDescriptionEvent,
containedLocationEvent,
uncontainedEvent,
];
const { result } = renderHook(() => useSearch(initialEvents, currentDate, 'month'));
expect(result.current.filteredEvents).toHaveLength(4);
act(() => {
result.current.setSearchTerm('주간');
});
expect(result.current.filteredEvents).toHaveLength(3);
expect(result.current.filteredEvents).toMatchObject([
{ title: '주간 회의' },
{ description: '주간 스터디' },
{ location: '주간 회의장' },
]);
});
구체적인 변수명을 통해 테스트 코드를 읽을 때 직관적으로 이해할 수 있도록 했습니다.
날짜 생성하는 방식
관련해서 고민했던 부분이 날짜 생성을 어떻게 할 것인지 였습니다.
옵션은 다음과 같습니다.
new Date('2025-08-22')
new Date(2025, 07, 22)
toDate('2025-08-22)
단순하게 보면 toDate를 사용하는 것이 안정적이었지만 직접 작성한 유틸함수의 경우 배제하고자 했습니다.
new Date(2025, 07, 22) 형식을 쓰고 싶었지만 month값이 실제보다 1이 작아 인지에 부하를 준다고 느꼈습니다.
new Date(2025, 07, 22) // 2025-08-22
결론적으로 이와같이 안전한 인자 + 주석으로 가독성을 보장하고자 했습니다.
데이터 관리
앞서 언급한 기준들과 모두 연관이 되어있는 부분이 입력 데이터를 비교하는 것이었습니다.
테스트에 입력될 데이터를 각 테스트 환경 별로 선언해야 하며 이는 테스트 코드 안에 정의되어있어야 바람직하다 생각합니다. 동시에 모든 데이터들을 테스트마다 선언하면 가독성이 떨어지고 복잡도가 높아집니다.
이 부분은 팀원들이 같이 고민하던 부분이고, 멘토링 및 qna 시간을 통해 대안을 얻어낼 수 있었습니다.
독립적인 테스트를 위해 동적으로 바꿔야 하는 데이터 속성 중 일부만을 노출하는 것이 테스트 코드에 적합합니다. Event라는 인터페이스에서 startTime에 대한 테스트를 할 때 굳이 title, description을 전부 선언하는 것은 여러 문제점을 가집니다
- 필요없는 코드를 작성해야 하기에 생산성이 떨어집니다.
- 해당 영역에서의 잘못된 입력 혹은 동작으로 본 테스트에서 원하지 않은 에러가 발생할 수 있습니다.
- 읽을 때 필요없는 코드로 가독성이 하락합니다.
이를 해결하기 위해 필요한 속성만을 노출하여 테스트를 할 수 있는 수단은 다음과 같습니다.
- 팩토리 함수를 통한 완성된 데이터 구성
- 데이터의 일부분만을 선언한 후 타입 체킹을 이용하기
먼저 보다 완성된 객체가 필요한 경우에는 팩토리 함수를 이용했습니다. 객체의 일부분만을 인자로 입력하면 다른 값들은 변수를 최소화한 기본값을 넣어주고 입력한 값만 변동된 객체를 반환합니다.
const event = makeEvent({ date: '2025-10-15', startTime: '09:00', notificationTime: 10 });
const initialEvents = makeEvents(1);
이로써 이 테스트에서 테스트하고자 하는 속성 데이터가 어떤건지 명시할 수 있습니다. 또한 코드의 길이를 줄일 수 있습니다.
보다 간단한 방식은 속성의 일부만을 정의한 후 타입 단언을 통해 자동으로 다른 속성에 대해서는 생략할 수 있는 방식입니다.
it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {
const newEvent = {
date: '2025-08-22',
startTime: '10:00',
endTime: '16:00',
} as Event;
const notOverlappedEvents = [
{ id: 'firstNotOverlapped', date: '2025-08-22', startTime: '09:00', endTime: '09:30' },
{ id: 'secondNotOverlapped', date: '2025-08-22', startTime: '17:00', endTime: '18:00' },
] as Event[];
const overlappedEvents = [
{ id: 'firstOverlapped', date: '2025-08-22', startTime: '14:00', endTime: '18:00' },
{ id: 'secondOverlapped', date: '2025-08-22', startTime: '06:00', endTime: '12:00' },
] as Event[];
expect(
findOverlappingEvents(newEvent, [...notOverlappedEvents, ...overlappedEvents])
).toMatchObject([{ id: 'firstOverlapped' }, { id: 'secondOverlapped' }]);
});
필요한 속성만 정의하고 as Event 혹은 as Event[]를 이용합니다.
마찬가지로 필요한 속성만을 사용할 수 있으며 무엇보다 간단합니다.
다른 속성이 정의되지 않은(undefined)된 속성으로 처리됩니다. 이 때문에 완성된 객체를 요구하는 상황에서 실패 지점을 찾지 못하는 문제가 발생할 수 있습니다. 다른 속성을 체크하지 않는 것이 보장된 상황에서 사용하며 그렇지 않은 경우에는 팩토리 함수를 이용합니다.
특히 특정 속성과 비교하는 toMatchObject와 같이 쓸때 효율적으로 사용할 수 있었습니다.
타이머와 렌더링
날짜 데이터를 사용하고 디지털 시간을 이용한 기능들을 구현한 경우 faketimer를 사용해야 합니다. 타이머에 대한 이해도를 높여서, 적절한 시기에 faketimer와 realtimer를 교체하고 시간을 진행시켜야 합니다.
날짜와 관련된 ui 렌더링이 있을 경우 그 중 알맞는 시점에 데이터를 렌더링해야합니다.
it('notificationTime=10이면 10분 전 실제 알림 토스트가 뜬다', async () => {
server.use(
...setupMockHandlerCreation([
makeEvent({
id: 'n1',
title: '알림 이벤트',
date: '2025-09-01',
startTime: '09:00',
endTime: '10:00',
notificationTime: 10,
}),
])
);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date('2025-09-01T08:49:00'));
render(
<ThemeProvider theme={createTheme()}>
<SnackbarProvider>
<App />
</SnackbarProvider>
</ThemeProvider>
);
const list = within(screen.getByTestId('event-list'));
await list.findByText('알림 이벤트');
await act(async () => {
vi.advanceTimersByTime(60000);
});
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('10분 후 알림 이벤트 일정이 시작됩니다.');
expect(list.getByText('알림: 10분 전')).toBeInTheDocument();
vi.clearAllTimers();
vi.useRealTimers();
});
MUI의 select를 찾는 이슈
중간에 select를 테스트 환경에서 get/find하는 과정에서 어려움이 있었습니다.
const categoryContainer = await screen.findByLabelText('카테고리');
const categorySelect = await within(categoryContainer).findByRole('combobox');
await user.click(categorySelect);
const workingSelectItem = screen.getByRole('option', { name: '개인-option' });
await user.click(workingSelectItem);
const notifyLabel = await screen.findByText('알림 설정', { selector: 'label' });
const notifySelect = within(notifyLabel.parentElement!).getByRole('combobox');
await user.click(notifySelect);
const notiListbox = await screen.findByRole('listbox');
await user.click(within(notiListbox).getByRole('option', { name: '1시간 전' }));
expect(notifySelect).toHaveTextContent('1시간 전');
MUI는 포탈을 통해 전역으로 select 아이템을 생성합니다. 그래서 기존의 ui 요소를 찾는 방식으로 findByRole을 했을때 ui를 찾지 못했습니다.
컴포넌트 측에서 labelId–aria-labelledby–id를 엄격히 연결해두면 테스트가 더 단단해지고, within(listbox) 패턴의 안정성이 올라가지만 테스트 코드 내에서 해결하고자 했습니다.
그 대안으로 label을 추적해서 Select 옵션을 생성시킨 후에 전역에서 찾는 방식으로 탐색했습니다. 해당 방식에 대해서는 실제 mui 구현과 비교해봤을 때 들어맞지 않는 부분도 있어서 더 찾아보고자 합니다.
학습 효과 분석
vitest에서 제공하는 다양하고 유연한 메소드들을 많이 사용하지 못했습니다. 쓰던 것만 사용한 느낌이라 더 알고 사용하고자 합니다.
리팩토링과 리팩토링한 부분에 대해 추가 테스트 코드를 작성하는 부분은 시간이 촉박하여 많은 부분 llm을 통해 생성했습니다. 다른 관점으로는 llm으로 생성한 코드를 테스트 코드를 통해 검증하는 앞으로 자주 하게될 작업을 했다고 볼 수 있지만, 직접 해보는 경험도 필요하다 생각해 다른 기회가 된다면 직접 해보고 싶습니다.
리뷰 받고 싶은 내용
- 중간 선언의 정도 전체적인 테스트의 맥락을 담고 싶은 방향과 필요하지 않은 내용을 최소화하고 싶은 방향이 충돌하는 부분이라고 생각합니다. 테스트에서 주가 되는 부분이 아니라면 중간 선언을 최소화하고 한줄에 많은 동작이 이뤄나게 하고 싶습니다. 동시에 드는 우려는, 그럴 경우 테스트를 읽어나갈 때 흐름을 잃을 수 있지 않을까 하는 것입니다.
관련해서 어떻게 생각하시는지 궁금합니다. 또한 이 과제 코드에서 해당 관점에서의 피드백을 주신다면 감사하겠습니다.
과제 피드백
Q. 중간 선언의 정도 전체적인 테스트의 맥락을 담고 싶은 방향과 필요하지 않은 내용을 최소화하고 싶은 방향이 충돌하는 부분이라고 생각합니다. 테스트에서 주가 되는 부분이 아니라면 중간 선언을 최소화하고 한줄에 많은 동작이 이뤄나게 하고 싶습니다. 동시에 드는 우려는, 그럴 경우 테스트를 읽어나갈 때 흐름을 잃을 수 있지 않을까 하는 것입니다.
A. 두현님 의견이 동의합니다~ 사실 저는 해당테스트에서 중요하거나 생략이 가능한 부분은 실제 코드와 다르더라도 생략을 하는 경우가 많습니다. 예를들어 어떤 함수 5개의 프로퍼티를 인자로 받아서 어떤 결과를 만드는 함수라고 하고, 이번 테스트에서는 프로퍼티중 2개만 필요한 상황이라면 저는 과감하게 필요한 프로퍼티만 넘기고 as 로 캐스팅해버립니다. 중간선언은 아니지만 비슷한 맥락이라고 생각해요. 테스트의 중요한 부분이 더 부각 되서 리더빌리티를 높일 수 있다면 그것이 제일 좋은 것 같습니다. expect(sum(1,2)).toEqual(3) 과 같이 좀더 실제 테스트 코드가 대상 모듈(함수)의 기능과 역할을 최대한 좁은 영역에서 보여주는게 테스트의 의도를 더 잘 표현한다고 생각합니다. 이 부분이 중요한 것이니까요.
그만큼 리더빌리티는 테스트 대상 모듈 못지 않게 테스트코드에서도 중요합니다. 그래서 반대로 목데이터가 큰 경우 그것을 이번 테스트에서 의미있는 변수명에 담아 씀으로 리더빌리티를 높일 수도 있을 것 같아요.
리더빌리티의 관점에서 그때 그때마다 옳은 것을 선택하면 좋을 것 같습니다~