Chapter 3-2. 프론트엔드 테스트 코드: 회고

한칙촉·2025년 9월 10일
post-thumbnail

3-2. 프론트엔드 테스트 코드

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

WIL

✅ 테스트 코드

  1. (필수) 반복 유형 선택
    • 일정 생성 또는 수정 시 반복 유형을 선택할 수 있다.
    • 반복 유형은 다음과 같다: 매일, 매주, 매월, 매년
      • 31일에 매월을 선택한다면 → 매월 마지막이 아닌, 31일에만 생성하세요.
      • 윤년 29일에 매년을 선택한다면 → 29일에만 생성하세요!
  2. (필수) 반복 일정 표시
    • 캘린더 뷰에서 반복 일정을 아이콘을 넣어 구분하여 표시한다.
  3. (필수) 반복 종료
    • 반복 종료 조건을 지정할 수 있다.
    • 옵션: 특정 날짜까지
      • 예제 특성상, 2025-10-30까지 최대 일자를 만들어 주세요.
  4. (필수) 반복 일정 단일 수정
    • 반복일정을 수정하면 단일 일정으로 변경됩니다.
    • 반복일정 아이콘도 사라집니다.
  5. (필수) 반복 일정 단일 삭제
    • 반복일정을 삭제하면 해당 일정만 삭제합니다.

필수 요구사항은 위와 같다. 그리고 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는 적고 느림 → 전체 흐름 검증, 비용 높음
  • 목표 : 빠르고 안정적인 테스트를 많이 확보하면서, 전체 기능 검증도 최소한으로 유지

테스트 트로피 전략

  • 통합 테스트가 중심 → UI와 컴포넌트 상호작용을 많이 검증
  • 단위 테스트는 로직만 검증, 피라미드보다 비율 줄임
  • UI 중심 앱에 적합 → React, Vue 같은 SPA에서 많이 사용

+) 팀원들과의 회의 후 테스트 트로피 전략을 선택한 후 통합 테스트와 훅 테스트를 추가했으나, e2e나 시각적 회귀 테스트를 작성하지 않아서 심화 과제는 실패했다.. 🥹 앞으론 과제 통과 기준을 확실히 알자...


KPT

Keep

잘하고있다.. !

Problem

TDD 방식의 구현은 처음이라 시작할 때 좀 막막했던 것 같고, 위에 적은 말이지만 테스트 코드 자체에 오류가 있어 시간을 많이 쓴 것 같다. 그래도 이번 과제를 통해 테스트에서 요소를 정확히 찾아 이벤트를 트리거하는 과정을 거치면서 DOM 조작과 이벤트 시뮬레이션에 대한 이해가 향상된 것 같다!

Try

사실 아직 어떤 테스트가 불필요한 테스트인지 명확히 구분하는 게 어려운 것 같다. 다다익선이 아니라니...
공부 필요...

profile
빙글빙글돌아가는..

0개의 댓글