
저번 회고에서는 테스트 코드 개념 위주로 정리를 했다면, 이번 회고에서는 내가 과제 요구사항을 어떻게 구현했고 그 요구사항에 대한 테스트 코드를 어떻게 작성했는지에 대해 적어보려고 한다!!

- (필수) 반복 유형 선택
- 일정 생성 또는 수정 시 반복 유형을 선택할 수 있다.
- 반복 유형은 다음과 같다: 매일, 매주, 매월, 매년
- 31일에 매월을 선택한다면 → 매월 마지막이 아닌, 31일에만 생성하세요.
- 윤년 29일에 매년을 선택한다면 → 29일에만 생성하세요!
- (필수) 반복 일정 표시
- 캘린더 뷰에서 반복 일정을 아이콘을 넣어 구분하여 표시한다.
- (필수) 반복 종료
- 반복 종료 조건을 지정할 수 있다.
- 옵션: 특정 날짜까지
- 예제 특성상, 2025-10-30까지 최대 일자를 만들어 주세요.
- (필수) 반복 일정 단일 수정
- 반복일정을 수정하면 단일 일정으로 변경됩니다.
- 반복일정 아이콘도 사라집니다.
- (필수) 반복 일정 단일 삭제
- 반복일정을 삭제하면 해당 일정만 삭제합니다.
필수 요구사항은 위와 같다. 그리고 TDD (테스트 주도 개발) 방식으로 구현하는 것이 포인트였다.
테스트를 먼저 작성한 후 기능을 구현해야 했기에... 테스트 쪽으로는 경험이 적다보니 테스트 자체에 오류가 있어 결국 테스트 코드를 다시 건드리게 되는 경우가 다수였다. 🥹
// src/utils/repeatUtils.ts
// 반복 일정을 단일 일정으로 변환
export const toSingleEvent = (event: Event | EventForm): Event | EventForm => ({
...event,
repeat: { type: 'none', interval: 0 },
});
// 반복 시작 날짜가 반복 종료 날짜보다 앞인지 확인
export const checkEndDateValid = (start: Date, end: Date): boolean => {
return start <= end;
};
// ...
export const getRepeatEventList = (event: EventForm): EventForm[] => {
if (event.repeat.type === 'none') return [event];
switch (event.repeat.type) {
case 'daily':
return getDailyRepeatEvents(event);
case 'weekly':
return getWeeklyRepeatEvents(event);
case 'monthly':
return getMonthlyRepeatEvents(event);
case 'yearly':
return getYearlyRepeatEvents(event);
default:
return [event];
}
};
우선 요구사항 중 반복 생성된 일정을 수정할 시 단일 일정으로 수정되어야 하기 때문에 인자로 받은 일정을 단일 일정으로 수정하는 유틸 함수를 작성해주었다. 그리고 반복 유형에 따라 매일, 매주, 매월, 매년 반복 일정을 생성하는 함수를 각각 작성한 후, 각 케이스에 맞게 호출되도록 하여 가독성을 높였다! 👍
추가로 반복 종료일을 지정하지 않은 상태에서 반복 일정을 (과제 특성 상) 2025-10-30 이후부터 생성할 경우, 해당 일정을 하나만 생성되도록 하기 위해 반복 시작 날짜가 반복 종료 날짜보다 앞인지 확인하는 검증 함수를 추가했다.
// 반복 설정 컴포넌트
{isRepeating && (
<Stack spacing={2}>
<FormControl fullWidth>
<FormLabel>반복 유형</FormLabel>
<Select
size="small"
aria-label="반복 유형 선택"
value={repeatType}
onChange={(e) => setRepeatType(e.target.value as RepeatType)}
>
<MenuItem value="daily" aria-label="daily-option">
매일
</MenuItem>
<MenuItem value="weekly" aria-label="weekly-option">
매주
</MenuItem>
<MenuItem value="monthly" aria-label="monthly-option">
매월
</MenuItem>
<MenuItem value="yearly" aria-label="yearly-option">
매년
</MenuItem>
</Select>
</FormControl>
<Stack direction="row" spacing={2}>
<FormControl fullWidth>
<FormLabel htmlFor="repeat-interval">반복 간격</FormLabel>
<TextField
id="repeat-interval"
size="small"
type="number"
aria-label="반복 간격 선택"
value={repeatInterval}
onChange={(e) => setRepeatInterval(Number(e.target.value))}
slotProps={{ htmlInput: { min: 1 } }}
/>
</FormControl>
<FormControl fullWidth>
<FormLabel htmlFor="repeat-end">반복 종료일</FormLabel>
<TextField
id="repeat-end"
size="small"
type="date"
aria-label="반복 종료일 선택"
value={repeatEndDate}
onChange={(e) => setRepeatEndDate(e.target.value)}
/>
</FormControl>
</Stack>
</Stack>
)}
// 반복 일정 생성
it('생성한 반복 일정이 반복 설정에 맞게 표시된다', async () => {
setupMockHandlerRepeatCreation();
const { user } = setup(<App />);
await user.click(screen.getAllByText('일정 추가')[0]);
await user.type(screen.getByLabelText('제목'), '정기 회의');
await user.type(screen.getByLabelText('날짜'), '2025-10-01');
await user.type(screen.getByLabelText('시작 시간'), '11:00');
await user.type(screen.getByLabelText('종료 시간'), '12:00');
await user.type(screen.getByLabelText('설명'), '정기 팀 미팅');
await user.type(screen.getByLabelText('위치'), '회의실 A');
await user.click(screen.getByLabelText('카테고리'));
await user.click(within(screen.getByLabelText('카테고리')).getByRole('combobox'));
await user.click(screen.getByRole('option', { name: `업무-option` }));
const repeatCheckbox = screen.getByLabelText('반복 일정');
await user.click(repeatCheckbox);
// 반복 유형 선택 - 매주
const repeatSelect = within(screen.getByLabelText('반복 유형 선택')).getByRole('combobox');
await user.click(repeatSelect);
await user.click(screen.getByRole('option', { name: 'weekly-option' }));
// 반복 간격 선택 - 2주 간격
await user.clear(screen.getByLabelText('반복 간격'));
await user.type(screen.getByLabelText('반복 간격'), '2');
// 반복 종료일 선택
await user.type(screen.getByLabelText('반복 종료일'), '2025-10-30');
// 일정 추가
await user.click(screen.getByTestId('event-submit-button'));
const eventList = within(screen.getByTestId('event-list'));
expect(eventList.getAllByText('정기 회의')).toHaveLength(3);
// 2025-10-01, 2025-10-15, 2025-10-29
});
테스트 라이브러리는 aria-label="반복 유형 선택"의 label 값을 통해 select를 찾을 수 있게 된다. 그리고 select는 시맨틱 상 combobox 역할을 가지므로, getByRole('combobox')를 사용하여 더욱 정확히 select를 가져올 수 있다.
내부 항목들은 role="option"이면서 weekly-option'와 같은 이름으로 노출되므로 getByRole('option', { name: 'weekly-option' })로 가져와 선택할 수 있게 된다.
반복 간격과 종료일의 경우 FormLabel 내의 텍스트로 컴포넌트를 찾은 뒤, 해당 컴포넌트의 htmlFor 속성과 id 값의 매칭을 통해 각 label에 맞는 TextField를 가져온다. 추가로 반복 간격의 경우엔 input 필드에 이미 들어있는 값을 지우고 새로운 값을 확실히 입력하기 위해 userEvent.clear을 먼저 실행해주었다 😊
// 단일 일정 컴포넌트
<Stack direction="row" spacing={1} alignItems="center">
{isNotified && <Notifications fontSize="small" />}
<Typography
variant="caption"
noWrap
sx={{ fontSize: '0.75rem', lineHeight: 1.2 }}
>
{event.repeat.type !== 'none' && <span>*</span>}
{event.title}
</Typography>
</Stack>
// 반복 일정은 제목 앞에 * 표시
it('캘린더 뷰에서 반복 일정은 아이콘으로 표시된다', async () => {
setupMockHandlerRepeatCreation();
const { user } = setup(<App />);
await user.click(screen.getAllByText('일정 추가')[0]);
await user.type(screen.getByLabelText('제목'), '정기 회의');
await user.type(screen.getByLabelText('날짜'), '2025-10-01');
await user.type(screen.getByLabelText('시작 시간'), '11:00');
await user.type(screen.getByLabelText('종료 시간'), '12:00');
await user.type(screen.getByLabelText('설명'), '정기 팀 미팅');
await user.type(screen.getByLabelText('위치'), '회의실 A');
await user.click(screen.getByLabelText('카테고리'));
await user.click(within(screen.getByLabelText('카테고리')).getByRole('combobox'));
await user.click(screen.getByRole('option', { name: `업무-option` }));
const repeatCheckbox = screen.getByLabelText('반복 일정');
await user.click(repeatCheckbox);
// 반복 유형 선택 - 매일
await user.click(within(screen.getByLabelText('반복 유형 선택')).getByRole('combobox'));
await user.click(screen.getByRole('option', { name: 'daily-option' }));
// 반복 종료일 선택
await user.type(screen.getByLabelText('반복 종료일'), '2025-10-03');
// 일정 추가
await user.click(screen.getByTestId('event-submit-button'));
const monthView = within(screen.getByTestId('month-view'));
expect(monthView.getAllByText('*')).toHaveLength(3);
});
필수 요구사항에 따르면 반복 생성된 일정은 아이콘을 넣어 구분 표시를 해야 했기 때문에, 난 간단하게 * 문자열 하나만 추가했다...ㅎㅎ 이때 event.title과 * 을 문자열 하나로 합치지 않고, span 태그로 감싸줬다.
getAllByText는 DOM 내 텍스트 노드 단위로 요소를 찾기 때문에 span과 같은 태그로 감싸줬을 경우엔 테스트가 동작하지만, title 값과 합쳤을 경우엔 그 자체가 하나의 문자열 노드가 되기 때문에 테스트에서 인식되지 않는다!!

테스트 피라미드 전략

테스트 트로피 전략
+) 팀원들과의 회의 후 테스트 트로피 전략을 선택한 후 통합 테스트와 훅 테스트를 추가했으나, e2e나 시각적 회귀 테스트를 작성하지 않아서 심화 과제는 실패했다.. 🥹 앞으론 과제 통과 기준을 확실히 알자...
잘하고있다.. !
TDD 방식의 구현은 처음이라 시작할 때 좀 막막했던 것 같고, 위에 적은 말이지만 테스트 코드 자체에 오류가 있어 시간을 많이 쓴 것 같다. 그래도 이번 과제를 통해 테스트에서 요소를 정확히 찾아 이벤트를 트리거하는 과정을 거치면서 DOM 조작과 이벤트 시뮬레이션에 대한 이해가 향상된 것 같다!
사실 아직 어떤 테스트가 불필요한 테스트인지 명확히 구분하는 게 어려운 것 같다. 다다익선이 아니라니...
공부 필요...