tomatopickles404 님의 상세페이지[9팀 권지호]. Chapte,r 3.-.2.. 프론트엔드 테스트. 코드 🦍🧦

8주차 과제 체크포인트

기본 과제

필수

  • 반복 유형 선택
    • 일정 생성 또는 수정 시 반복 유형을 선택할 수 있다.
    • 반복 유형은 다음과 같다: 매일, 매주, 매월, 매년
      • 31일에 매월을 선택한다면 -> 매월 마지막이 아닌, 31일에만 생성하세요.
      • 윤년 29일에 매년을 선택한다면 -> 29일에만 생성하세요!
  • 반복 일정 표시
    • 캘린더 뷰에서 반복 일정을 시각적으로 구분하여 표시한다.
      • 아이콘을 넣든 태그를 넣든 자유롭게 해보세요!
  • 반복 종료
    • 반복 종료 조건을 지정할 수 있다.
    • 옵션: 특정 날짜까지, 특정 횟수만큼, 또는 종료 없음 (예제 특성상, 2025-06-30까지)
  • 반복 일정 단일 수정
    • 반복일정을 수정하면 단일 일정으로 변경됩니다.
    • 반복일정 아이콘도 사라집니다.
  • 반복 일정 단일 삭제
    • 반복일정을 삭제하면 해당 일정만 삭제합니다.

선택

  • 반복 간격 설정
    • 각 반복 유형에 대해 간격을 설정할 수 있다.
    • 예: 2일마다, 3주마다, 2개월마다 등
  • 예외 날짜 처리:
    • 반복 일정 중 특정 날짜를 제외할 수 있다.
    • 반복 일정 중 특정 날짜의 일정을 수정할 수 있다.
  • 요일 지정 (주간 반복의 경우):
    • 주간 반복 시 특정 요일을 선택할 수 있다.
  • 월간 반복 옵션:
    • 매월 특정 날짜에 반복되도록 설정할 수 있다.
    • 매월 특정 순서의 요일에 반복되도록 설정할 수 있다.
  • 반복 일정 전체 수정 및 삭제
    • 반복 일정의 모든 일정을 수정할 수 있다.
    • 반복 일정의 모든 일정을 삭제할 수 있다.

심화 과제

  • 이 앱에 적합한 테스트 전략을 만들었나요?

각 팀원들의 테스트 전략은?

저희팀은 팀원별로 테스트 전략에 대한 의견을 주고 받기 보다는 피그잼을 통해 함께 브레인스토밍하며 전략을 좁혀 나갔습니다.

따라서, 하나의 PR에 테스트 전략을 수립하여 모두 페어코딩을 진행했습니다. 결론적으로 9팀은 심화과제 PR부분과 코드 전략을 함께 작성하여 그 내용이 같습니다.

합의된 테스트 전략과 그 이유는 무엇인가요?

저희 9팀은 TDD 기반으로 반복 일정 기능을 구현하면서 윤년, 월말 날짜 등 다양한 엣지 케이스를 마주할 수 있다고 판단했습니다. 따라서 테스트 전략의 목표를 엣지 케이스를 최소화하는 것으로 잡고, 이를 검증하기 위한 방향을 고민했습니다.

이 과정에서 단순히 “어떻게 테스트할까” 라는 관점에 머무르지 않고, “어떻게 하면 코드를 테스트하기 좋은 구조로 만들까” 라는 질문에 집중했습니다. 코치님께서도 이 점을 강조해주셨고, 그 조언을 바탕으로 레이어를 먼저 추상화한 뒤, 테스트할 부분과 그렇지 않은 부분을 의도적으로 구분하여 선택적으로 검증하는 전략을 수립했습니다.

아키텍처 정의하기

[기존 구조]

  • 역할 기반 디렉토리 구조
  • 유닛 테스트 비중이 높음 (피라미드 구성)

뿐만 아니라 utils 디렉토리는 아래와 같은 서로 다른 역할과 관심사가 혼재하고 있었습니다.

  • repeatEventUtils : 반복 일정의 다음 날짜를 계산하는 순수 계산 로직
  • notificationUtils : 사용자에게 알림을 띄우는 UI 관련 사이드 이펙트
  • eventUpdateUtils: 이벤트 관련 도메인 레이어

이처럼 역할이 다른 코드들이 한 폴더에 섞여있어 반복 일정 계산 로직을 구별하기 어려웠습니다.


[레이어 도출] image

  • 반복 일정의 엣지 케이스를 판단하는 핵심 로직은 대부분 repeatEventUtils 같은 순수 함수 안에 있다고 판단했습니다.
  • 이 함수들을 사이드 이펙트가 있는 함수로부터 분리하여 레이어를 나눈다면 테스트가 쉬워질 것이라고 의견을 모았습니다. 이 부분을 도메인 유틸 레이어(repeat) 로 정의하고 기존에 작성한 유닛 테스트를 활용하기로 했습니다.
  • 전략적 선택과 집중
    • 집중: "도메인 레이어" 에 유닛 테스트를 집중하기로 했습니다.
    • 선택: 도메인 로직 테스트에 집중하는 대신, 변경이 잦고 테스트 비용이 비싼 UI 관련 테스트의 비중은 낮추기로 결정했습니다.
    • 가정: msw 핸들러와 같은 라이브러리나 Date 객체, Javascript 내장 함수 관련 로직은 자체의 동작을 신뢰하기로 가정했습니다. 대신 API 요청/응답에 따라 앱이 어떻게 반응하는지를 검증하는 통합 테스트를 추가하기로 했습니다.

[새로운 레이어 구조] 관심사의 분리와 테스트 용이성이라는 두 가지 키워드로 다음과 같이 레이어를 세분화하고자 했습니다.

1. utils

repeat이라는 기능(feature)단위로 디렉토리를 묶고, 그 안에서 역할을 세분화 했습니다.

📦repeat
 ┣ 📜actions.ts       # 상태를 변경하는 로직 (도메인)
 ┣ 📜constants.ts     # 기능 관련 상수
 ┣ 📜formats.ts       # 문자열 포맷팅 등 (도메인/UI 보조)
 ┗ 📜helpers.ts       # 핵심 계산 로직 (순수 함수, 도메인)

2. test

📦repeat
 ┣ 📂e2e
 ┣ 📂integration
 ┗ 📂unit
 ┃ ┣ 📜actions.spec.ts
 ┃ ┗ 📜helpers.spec.ts

레이어 별 테스트 전략 수립

아키텍처가 명확하게 정의되니 코치님 말씀대로 각 디렉토리의 역할에 맞는 테스트 전략을 구체화 할 수 있었습니다.

반복 일정 생성에서는 기준에 맞는 일정 생성 및 엣지 케이스 판단이 중요합니다. 반면 UI 구현의 비중은 적습니다. 이를 테스트 전략에도 반영했습니다. 일정 생성과 기준에 맞는 일정일지 판단하는 함수의 유닛 테스트의 비중을 높였습니다. UI 구현을 검증하는 통합 테스트와 e2e테스트는 최소한의 사용성을 보장하는 정도로 최소화했습니다.

저희는 '전략적 선택과 집중' 원칙에 따라 테스트의 종류와 범위를 다음과 같이 결정했습니다.

[Unit Test] - 도메인 로직 (Domain Logic)

  • 대상: utils/repeat/helpers.ts, utils/repeat/actions.ts
  • 전략: 테스트의 절반 이상을 집중합니다. helpers의 순수 계산 함수들을 테스트하며 엣지 케이스를 검증하고, actions의 상태 변경 로직이 의도대로 동작하는지 확인합니다.
  • 이유: 반복 일정을 생성함에 있어서 윤년, 월별 일수와 같은 예외적 케이스를 고려하는 것은 큰 비중을 차지합니다. 그에 따라 테스트의 비중도 가장 높습니다.

[Integration Test] - 계층 간 통합 (Layer Integration)

  • 대상: UI 컴포넌트, API 레이어, 도메인 로직의 통합 동작
  • 전략: MSW를 활용하여 API의 성공, 실패, 비정상 응답 등 다양한 시나리오에 따라 UI 상태와 도메인 로직이 올바르게 연동되어 동작하는지 검증합니다.
  • 이유: "API 요청/응답에 따라 앱이 어떻게 반응하는가" 를 확인하는 것이 목적입니다. 실제 서버나 fetch 라이브러리를 테스트 하는 것이 아니라, 각 레이어들이 올바르게 통합되어 데이터의 흐름을 잘 처리하는지 확인하는 목적입니다.

[E2E Test] - 사용자 시나리오 (User Scenario)

  • 대상: 반복 일정 폼 등록, 삭제
  • 전략: "사용자가 반복 일정을 생성하고 저장할 수 있다" 와 같은 중요한 시나리오 위주로 검증합니다.
  • 이유: UI는 변경이 잦고 테스트 유지보수 비용이 아이콘 및 이벤트 리스트를 추가하는 UI구현의 중요도가 적습니다. 따라서 사용자에게 최종적으로 전달되는 핵심 기능이 문제없이 동작하는지만을 보장하기로 "선택" 했습니다.

추가로 작성된 테스트 코드는 어떤 것들이 있나요?

유닛 테스트

1. actions.spec.ts

  • expandEventsToNextOccurrences
    • 다음 날짜의 반복 일정 반환 여부 확인
    • weekly의 경우 다음 요일, 윤년의 경우 다음 윤년 반환 여부 확인 ex) expandEventsToNextOccurrences 윤년 체크
describe('expandEventsToNextOccurrences - yearly', () => {
  it('yearly(2/29) 이벤트는 다음 윤년 2/29로 확장되고 id에 날짜가 결합된다', () => {
    const events = [
      makeEvent({
        id: 'e3',
        title: 'Yearly',
        date: '2024-02-29',
        repeat: { type: 'yearly', interval: 1 },
      }),
    ];

    const now = new Date('2025-02-28T00:00:00Z');
    const expanded = expandEventsToNextOccurrences(events, now);
    expect(expanded).toHaveLength(1);
    expect(expanded[0].date).toBe('2028-02-29');
    expect(String(expanded[0].id)).toBe('e3:2028-02-29');
  });
});
  • getNextDailyOccurrence, getNextWeeklyOccurrence, getNextYearlyOccurrence

    • 기준일에서 하루, 1주, 1년 이후의 날짜 반환 여부 확인
    • 윤년의 경우 다음 윤년 반환 여부 확인
  • generateInstances

    • 범위 내 반복 일정 반환 여부 확인
    • 같은 요일, 31일, 윤년 체크

2. helpers.spec.ts

  • getNextYearlyOccurrence: 2/29 시작, from=평년 → 다음 윤년 ex) getNextYearlyOccurrence가 다음 윤년을 정확히 반환하는지 확인
it('getNextYearlyOccurrence: 2/29 시작, from=평년 → 다음 윤년', () => {
    const base = dateStringToUtcDateOnly('2024-02-29');
    const from = dateStringToUtcDateOnly('2025-02-28');
    const next = getNextYearlyOccurrence(base, from, 1);
    expect(toDateStringUTC(next)).toBe('2028-02-29');
});
  • getNextMonthlyOccurrence: 1/31 시작, from=2월 → 3/31
  • getNextWeeklyOccurrence: 수요일 시작, from=다음주 월요일 → 다음 수요일
  • getNextDailyOccurrence: from이 더 뒤면 interval에 맞게 다음 날짜
  • clampEndDate는 더 이른 날짜를 반환해야 한다
  • daysInMonthUTC는 2월 윤년/평년과 30/31일을 정확히 반환해야 한다
  • addMonthsUntilHasDay는 1/31에서 2월을 건너뛰고 3/31을 반환해야 한다

통합 테스트

1. 반복 UI 및 표시

  • 반복 일정을 저장하면 캘린더 셀에 반복 아이콘(↻)이 표시된다 ex) 반복 일정 생성 후 캘린더에서 반복일정 아이콘 확인
it('반복 일정을 저장하면 캘린더 셀에 반복 아이콘(↻)이 표시된다', async () => {
    setupMockHandlerCreation();

    vi.setSystemTime(new Date('2025-10-15'));

    const { user } = setup(<App />);

    await user.click(screen.getAllByText('일정 추가')[0]);

    await user.type(screen.getByLabelText('제목'), '반복 회의');
    await user.type(screen.getByLabelText('날짜'), '2025-10-15');
    await user.type(screen.getByLabelText('시작 시간'), '09:00');
    await user.type(screen.getByLabelText('종료 시간'), '10:00');
    await user.type(screen.getByLabelText('설명'), '반복 테스트');
    await user.type(screen.getByLabelText('위치'), '회의실 C');

    await user.click(screen.getByLabelText('카테고리'));
    await user.click(within(screen.getByLabelText('카테고리')).getByRole('combobox'));
    await user.click(screen.getByRole('option', { name: '업무-option' }));

    const repeatToggle = await screen.findByLabelText('반복 일정');
    await user.click(repeatToggle);
    await user.click(await screen.findByRole('combobox', { name: '반복 유형' }));
    await user.click(screen.getByRole('option', { name: '매주' }));

    await user.click(screen.getByTestId('event-submit-button'));

    await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox'));
    await user.click(screen.getByRole('option', { name: 'week-option' }));
    const weekView = within(screen.getByTestId('week-view'));
    expect(weekView.getAllByLabelText('반복 일정 아이콘').length).toBeGreaterThan(0);
});
  • 반복 일정 체크 시 반복 유형/종료일 입력 UI가 노출된다
  • 반복 일정을 단일 수정으로 저장하면 반복 아이콘이 사라진다
  • 반복일정을 단일 삭제하면 해당 날짜 셀에서만 사라진다

e2e 테스트

1. 기본 + 반복 아이콘

  • 앱이 로드되고 기본 UI가 보인다
  • 반복 일정 저장 시 캘린더 셀에 아이콘이 보인다

2. 단일 일정 삭제

  • 단일 일정 삭제가 성공하고 화면에서 사라진다

ex) 일정 추가 버튼 -> 입력 -> 일정 겹침 경고 -> 반복 일정 등록 완료 -> 일정 삭제 -> 단일 일정 삭제 확인

test.describe('E2E - 단일 일정 삭제', () => {
  test('단일 일정 삭제가 성공하고 화면에서 사라진다', async ({ page }) => {
    await page.goto('/');

    const todayStr = (() => {
      const now = new Date();
      const yyyy = now.getFullYear();
      const mm = String(now.getMonth() + 1).padStart(2, '0');
      const dd = String(now.getDate()).padStart(2, '0');
      return `${yyyy}-${mm}-${dd}`;
    })();

    await page.locator('text=일정 추가').first().click();
    const uniqueTitle = `삭제 테스트 ${Math.random().toString(36).slice(2, 7)}`;
    await page.fill('input[id="title"]', uniqueTitle);
    await page.fill('input[id="date"]', todayStr);
    await page.fill('input[id="start-time"]', '09:00');
    await page.fill('input[id="end-time"]', '10:00');
    await page.locator('[aria-labelledby="category-label"]').click();
    await page
      .getByRole('option', { name: /개인|업무/ })
      .first()
      .click();
    await page.getByTestId('event-submit-button').click();

    const overlapDialog = page.getByRole('dialog', { name: '일정 겹침 경고' });
    if (await overlapDialog.isVisible().catch(() => false)) {
      await overlapDialog.getByRole('button', { name: '계속 진행' }).click();
    }
    await expect(page.getByText('일정이 추가되었습니다.')).toBeVisible();

    const beforeCount = await page.locator('button[aria-label^="Delete event"]').count();
    const beforeTitleCount = await page.getByText(uniqueTitle).count();

    const titleNode = page.getByTestId('event-list').getByText(uniqueTitle).first();
    const deleteBtn = titleNode
      .locator('xpath=ancestor::*[contains(@class, "MuiBox-root")][1]')
      .locator('xpath=.//button[starts-with(@aria-label, "Delete event")]');
    await deleteBtn.first().click();

    await expect(page.getByText('일정이 삭제되었습니다.')).toBeVisible();
    const afterTitleCount = await page.getByText(uniqueTitle).count();
    expect(afterTitleCount).toBeLessThan(beforeTitleCount);

    const afterCount = await page.locator('button[aria-label^="Delete event"]').count();
    expect(afterCount).toBe(beforeCount - 1);

    await expect(page.getByLabel('반복 일정 아이콘').first()).toBeVisible();
  });
});

과제 셀프회고

TDD를 처음으로 접하게 되는 경험이었습니다. 생각보다 기능의 범위를 미리 예측하고 코드를 작성하는 것이 추상적이게 느껴져서 지금까지의 과제 중에 가장 체감 난이도가 높게 느껴졌습니다. 작성하면서도 내가 작성한 테스트가 과연 테스트 커버리지가 유효한가? 촘촘하게 유닛테스트를 만들고 통합에서 작성한 테스트가 과연 시나리오의 신뢰도를 높여주고 있을까? 직접 테스팅 하기 전까지는 스스로 신뢰하지 못하는 과정이었습니다.


기술적 성장

AI 활용

이번 과제는 TDD의 Red-Green-Refactor 과정을 AI와 협업하여 진행했습니다. 주로 사용한 기술은 Cursor와 클로드입니다.

[진행 과정]

  1. Red (실패하는 테스트 작성)
  • AI에게 요구사항을 기반으로 구현하고자 하는 기능 설명
  • 실패하는 테스트 코드 작성 요청
  • 테스트 실행으로 Red 상태 확인 후 commit
  1. Green (테스트 통과하는 최소 코드)
  • 테스트를 통과시키기 위한 최소한의 구현 코드 요청
  • 기능 동작 확인 후 commit
  1. Refactor (코드 개선)
  • DX(Developer Experience) 관점에서 코드 개선점 문의
  • 단일책임과 같은 클린코드 리팩토링
  • 가독성, 유지보수성, 성능 측면에서의 리팩토링 진행

적용한 Cursor Rules

📋 테스트 코드 작성 철학 및 가이드라인 (클릭하여 펼치기)
## 핵심 철학
- **좋은 테스트코드 = 실행 가능한 명세서**: 기능의 의도와 기대 동작을 사람이 읽기 쉽게 작성하고, 리팩터링에도 안정적으로 동작해야 함
- **가독성과 안정성이 최우선**: DRY보다 DAMP(Descriptive And Meaningful Phrases) 원칙을 따름
- **행위 중심 검증**: 구현 세부사항이 아닌 관찰 가능한 결과를 검증

## 구체적 원칙

### 1. 의도 우선 가독성
- 테스트 이름은 문장형으로 작성 (무엇을/언제/어떻게)
- AAA(Arrange-Act-Assert) 또는 Given-When-Then 구조 사용
- 최소한의 제어 흐름(if/loop)만 사용
- 각 테스트가 자신이 무엇을 검증하는지 명확히 드러내야 함

### 2. 의미 있는 중복 허용 (DAMP 원칙)
- **허용되는 중복**: 각 테스트가 자신의 맥락과 기대값을 스스로 드러낼 때
- **금지되는 중복**: 의도를 흐리거나 유지보수 비용만 늘리는 경우
- 과도한 헬퍼/추상화는 자제 - 테스트 스펙이 헬퍼 뒤에 숨겨지면 안됨
- 기대값은 테스트 내에서 "그대로" 명시하여 의도를 드러냄

### 3. 구현 디테일 비의존
- 내부 함수 호출 횟수, 구체적인 알고리즘, 비공개 상태 검증 금지
- 결과/화면/외부 인터페이스와 같은 관찰 가능한 동작만 검증
- 코드가 리팩터링되어도 테스트가 깨지지 않아야 함

### 4. 결정론적이고 고립된 테스트
- 시간/랜덤/네트워크 등 외부 요인 제어로 Flaky 테스트 제거
- 테스트 간 상태 공유 금지
- 각 테스트는 독립적으로 실행 가능해야 함

### 5. 단일 실패 원칙
- 한 테스트는 한 행동 단위만 검증
- 여러 단언문은 동일 행동에 대한 다양한 측면을 검증할 때만 허용
- 하나의 명확한 이유로만 실패해야 함

### 6. 유지보수 용이성
- 필요 이상의 글로벌 훅/픽스처 지양
- "중복이 의미를 줄 때는 인라인", "노이즈일 때만 빌더/헬퍼 사용"
- 테스트가 변경되어야 하는 이유를 최소화

### 7. 적절한 테스트 조합
- 단위 테스트: 빠르고 촘촘하게
- 통합/E2E 테스트: 적절한 수준으로
- 팀과 도메인에 맞는 테스트 피라미드/트로피 구성

### 8. 견고한 단언
- 스냅샷 테스트 남용 금지, 꼭 필요한 형태만 사용
- 중요한 속성만 명시적으로 `toEqual`/커스텀 매처로 검증
- 의미 있는 에러 메시지 제공

### 9. 커버리지는 수단
- 숫자 자체가 목표가 아님
- 핵심 시나리오/경계값/회귀 케이스를 우선적으로 커버
- 의미 있는 테스트가 자연스럽게 높은 커버리지를 만들어냄

## 테스트 스멜 감지 및 리팩터링 대상

### 즉시 리팩터링해야 하는 스멜들
- `sleep`이나 임의의 대기 시간 사용
- 랜덤 값에 의존하는 테스트
- 과도한 목(Mock) 체이닝
- 마법 같은 픽스처나 전역 상태
- 지나친 DRY로 인한 가독성 저하
- 애매하거나 의미 없는 테스트 이름
- 구현 세부사항에 강하게 결합된 테스트

**기억할 것: 읽는 사람이 설계 의도를 오해 없이 파악하고, 코드가 바뀌어도 테스트가 합리적인 이유로 실패/성공하도록 작성하는 것이 목표**
⚡ 테스트 품질 관리 및 중복 방지 규칙 (클릭하여 펼치기)

Testing Code of Conduct

Core Principles

1. No Test Duplication Rule

  • MUST: Test each unique scenario only once
  • MUST NOT: Create duplicate tests for the same logic across different groups
  • EXAMPLE: If testing leap year February 29th, test it in only one location

2. Equivalence Partitioning Rule

  • MUST: Test one representative value from each valid/invalid class
  • MUST NOT: Test multiple similar values within the same equivalence class
  • EXAMPLE: Test one valid date format, not multiple valid formats

3. Boundary Value Analysis Priority

  • MUST: Prioritize testing boundary values and edge cases
  • MUST: Focus on scenarios where errors are most likely to occur
  • MUST NOT: Focus only on normal, happy path scenarios

4. Logical Test Grouping

  • MUST: Group related tests logically by functionality or domain
  • MUST: Ensure each group has a clear, single responsibility
  • MUST NOT: Scatter similar tests across multiple unrelated groups

5. Given-When-Then Structure

  • MUST: Write tests with clear Given-When-Then structure
  • MUST: Make test intent obvious from code alone
  • MUST NOT: Write tests that require comments to understand

Implementation Guidelines

Test Organization

describe('Feature Name', () => {
  describe('Basic Functionality', () => {
    // Test normal cases
  });
  
  describe('Edge Cases', () => {
    // Test boundary values
  });
  
  describe('Error Conditions', () => {
    // Test invalid inputs
  });
});

Test Naming

// ✅ GOOD: Clear, descriptive test names
it('should return empty array when end date is before start date')

// ❌ BAD: Vague, unclear test names
it('should work correctly')

Test Structure

it('should handle monthly repetition on 31st correctly', () => {
  // Given: Clear setup
  const event = { /* test data */ };
  
  // When: Single action
  const result = functionUnderTest(event);
  
  // Then: Specific assertion
  expect(result).toEqual(expectedValue);
});

Quality Standards

Coverage Requirements

  • MUST: Cover all equivalence classes (valid/invalid)
  • MUST: Include boundary value tests
  • MUST: Test error conditions and edge cases
  • MUST NOT: Achieve coverage through duplicate tests

Maintainability

  • MUST: Write tests that are easy to understand and modify
  • MUST: Use consistent patterns across all test files
  • MUST NOT: Create complex test helpers that obscure test intent

Performance

  • MUST: Keep individual tests fast (< 100ms)
  • MUST: Avoid unnecessary setup/teardown
  • MUST NOT: Create tests that depend on external systems

Code Review Checklist

Before merging any test code, verify:

  • No duplicate test scenarios exist
  • All equivalence classes are covered
  • Boundary values are tested
  • Tests follow Given-When-Then structure
  • Test names clearly describe the scenario
  • Tests are logically grouped
  • No test depends on implementation details
  • All tests pass consistently

Examples

✅ Good Test Structure

describe('User Authentication', () => {
  describe('Valid Credentials', () => {
    it('should authenticate user with correct email and password', () => {
      // Given: Valid credentials
      const credentials = { email: 'user@example.com', password: 'password123' };
      
      // When: Authentication attempt
      const result = authenticateUser(credentials);
      
      // Then: Success response
      expect(result.success).toBe(true);
    });
  });
  
  describe('Invalid Credentials', () => {
    it('should reject authentication with wrong password', () => {
      // Given: Invalid password
      const credentials = { email: 'user@example.com', password: 'wrongpass' };
      
      // When: Authentication attempt
      const result = authenticateUser(credentials);
      
      // Then: Failure response
      expect(result.success).toBe(false);
      expect(result.error).toBe('Invalid credentials');
    });
  });
});

❌ Bad Test Structure

describe('User Tests', () => {
  it('should work', () => {
    // Vague test with unclear intent
    expect(something).toBe(true);
  });
  
  it('should also work', () => {
    // Duplicate test logic
    expect(something).toBe(true);
  });
});

Enforcement

  • Code Review: All test code must pass this checklist
  • Automated Checks: Use linting rules to enforce structure
  • Team Training: Regular review of testing best practices
  • Continuous Improvement: Update guidelines based on team feedback

코드 품질

리팩토링이 필요한 부분

1. 상태 관리 복잡성

[문제점]

const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false);
const [overlappingEvents, setOverlappingEvents] = useState<Event[]>([]);
const { enqueueSnackbar } = useSnackbar();
// ... 10개 이상의 상태들

추상화를 진행하지 않아 App 컴포넌트가 거대하고, 상태가 너무 많이 존재하고 있습니다.

[개선 방안]

  • 컴포넌트 분리
src/
├── components/
│   ├── EventForm/
│   │   ├── EventForm.tsx          // 폼 관리 전담
│   │   ├── RepeatEventSettings.tsx // 반복 설정 전담
│   │   └── index.ts
│   ├── Calendar/
│   │   ├── CalendarView.tsx       // 캘린더 뷰 전담
│   │   ├── WeekView.tsx          // 주별 뷰 전담
│   │   ├── MonthView.tsx         // 월별 뷰 전담
│   │   └── index.ts
│   ├── Event/
│   │   ├── EventItem.tsx         // 개별 이벤트 전담
│   │   ├── EventList.tsx         // 이벤트 목록 전담
│   │   └── index.ts
│   └── Dialog/
│       ├── OverlapDialog.tsx     // 겹침 확인 다이얼로그
│       └── index.ts
  • 상태 모듈화 각 상태를 하나의 훅 별로 모듈화하여 단일 책임과 추상화 레이어를 유지합니다.
src/
├── hooks/
│   ├── useOverlapDialog.ts       // 겹침 다이얼로그 상태
│   ├── useCalendarState.ts       // 캘린더 상태 통합
│   └── useEventFormState.ts     
  • 유틸함수 정리
src/
├── utils/
│   ├── calendar/
│   │   ├── viewHelpers.ts        // 뷰별 헬퍼 함수
│   │   └── renderHelpers.ts      // 렌더링 헬퍼 함수
│   └── constants/
│       ├── categories.ts         // 카테고리 상수
│       ├── notificationOptions.ts // 알림 옵션 상수
│       └── weekDays.ts           // 요일 상수

2. 복잡한 이벤트 생성/수정 로직

[문제점]

const addOrUpdateEvent = async () => {
  // 1. 유효성 검사
  if (!title || !date || !startTime || !endTime) { ... }
  if (startTimeError || endTimeError) { ... }
  
  // 2. 이벤트 데이터 생성
  const eventData: Event | EventForm = { ... };
  
  // 3. 겹침 검사
  const overlapping = findOverlappingEvents(eventData, events);
  
  // 4. 상태 업데이트 및 저장
  if (overlapping.length > 0) {
    setOverlappingEvents(overlapping);
    setIsOverlapDialogOpen(true);
  } else {
    await saveEvent(eventData);
    resetForm();
  }
};
  • addOrUpdateEvent가 너무 많은 책임을 갖고 있습니다.

[개선]

const validateEventForm = () => {
  if (!title || !date || !startTime || !endTime) {
    throw new Error('필수 정보를 모두 입력해주세요.');
  }
  if (startTimeError || endTimeError) {
    throw new Error('시간 설정을 확인해주세요.');
  }
};

const createEventData = (): Event | EventForm => ({
  id: editingEvent?.id,
  title,
  date,
  startTime,
  endTime,
  description,
  location,
  category,
  repeat: {
    type: isRepeating ? repeatType : 'none',
    interval: repeatInterval,
    endDate: repeatEndDate || undefined,
  },
  notificationTime,
});

const handleEventOverlap = async (eventData: Event | EventForm) => {
  const overlapping = findOverlappingEvents(eventData, events);
  if (overlapping.length > 0) {
    setOverlappingEvents(overlapping);
    setIsOverlapDialogOpen(true);
    return false; // 겹침 발생
  }
  return true; // 겹침 없음
};

const addOrUpdateEvent = async () => {
  try {
    validateEventForm();
    const eventData = createEventData();
    
    const canProceed = await handleEventOverlap(eventData);
    if (canProceed) {
      await saveEvent(eventData);
      resetForm();
    }
  } catch (error) {
    enqueueSnackbar(error.message, { variant: 'error' });
  }
};
  • 단일 책임 원칙 적용
    • 기존: 하나의 함수가 유효성 검사, 데이터 생성, 저장 등 모든 책임을 담당합니다.
    • 개선: 각 함수가 명확한 하나의 책임만 수행하도록 분리합니다.
    • 효과: 코드 수정 시 영향 범위 최소화, 구체적인 버그 발생 지점 추적이 가능해집니다.
  • 테스트 용이성 개선
    • 기존: 하나의 큰 함수를 테스트하려면 모든 경우의 수를 다 고려
    • 개선: 각 기능별로 독립적인 단위테스트 작성 가능

학습 효과 분석

findBy vs queryBy + waitFor

it('반복일정을 단일 삭제하면 해당 날짜 셀에서만 사라진다') 에서 findBy 메서드와 waitFor + queryBy 사용에 대해 고민했습니다.

[기존 작성]

 // 16일은 사라지고 17일은 유지되는지 확인
      await waitFor(() => {
        expect(day16Cell.queryByText('반복 삭제 테스트')).not.toBeInTheDocument();
        expect(day17Cell.getByText('반복 삭제 테스트')).toBeInTheDocument();
      });

[개선 방향]

waitFor + queryBy 보다는 findByText를 통해 비동기로 요소를 찾는게 더 가독성에 좋지 않을까? 라는 생각에 리팩토링을 하려고 했습니다.

await expect(findByText('반복 삭제 테스트')).rejects.toThrow();

그러나 지금과 같은 테스트 조건에서는 waitFor + queryByText 을 더 권장한다는 것을 알게 되었습니다.

주요 차이점들

  1. 의도 표현
  • waitFor + queryByText: "요소가 사라질 때까지 기다린다"는 의도가 명확
  • findByText: "요소를 찾는다"는 의도 - 사라짐을 기다리는 용도로는 부자연스러움

2. 존재하지 않는 요소 처리

  • queryByText: 요소가 없으면 null 반환 (에러 없음)
  • findByText: 요소가 없으면 에러 발생 - 존재하지 않는 요소를 기다릴 수 없음

따라서 의미상으로도 queryByText가 조금 더 자연스럽습니다.

  1. 복합 검증의 명확성 지금과 같은 예시처럼 두 가지 상태를 동시에 검증할 때
await waitFor(() => {
  // 16일 요소는 사라져야 함
  expect(day16Cell.queryByText('반복 삭제 테스트')).not.toBeInTheDocument();
  // 17일 요소는 존재해야 함  
  expect(day17Cell.getByText('반복 삭제 테스트')).toBeInTheDocument();
});

이런 경우 findBy로는 "사라짐"을 자연스럽게 표현하기 어렵습니다.

[결론]

  • waitFor + queryByText는 요소의 부재를 확인하거나 복합적인 상태 변화를 검증할 때 더 직관적이고 안정적인 패턴입니다.
  • findBy는 요소의 출현을 기다릴 때 사용하는 것이 적절합니다.

과제 피드백

TDD를 처음 접하더라도 TDD와 Red-Green-Refactor 를 어렵지 않게 적용하며 이해해 볼 수 있는 과제였습니다.


리뷰 받고 싶은 내용

Q1. 팀 과제에 지난 멘토링 시간에 조언해주셨던 부분을 적극적으로 적용해보고자 했습니다. 저희가 적절하게 잘 적용한게 맞을까요? 아쉬운 부분이 있다면 어떤 부분일까요?

Q2. TDD에서 기능의 범위를 미리 예측하고 코드를 작성하는 것이 아직은 추상적이게 느껴집니다. 코치님이 일하고 계신 실무에서는 테스트 코드나 TDD를 어떤 방식으로 활용하고 계신지, 라이브러리나 컨벤션, 프로세스와 같이 실무에서의 실용적인 적용사례에 대해 궁금합니다!

과제 피드백

지호님 수고하셨습니다. ㅎㅎ 이번에도 논문 잘 읽었습니다 :)

Q1. 팀 과제에 지난 멘토링 시간에 조언해주셨던 부분을 적극적으로 적용해보고자 했습니다.
저희가 적절하게 잘 적용한게 맞을까요? 아쉬운 부분이 있다면 어떤 부분일까요?

A. 와. 솔직히 이렇게까지 잘 이해하고 전략을 만들어주실지는 몰랐어요. 흠 너무 잘하셨어요

특히

[“어떻게 하면 코드를 테스트하기 좋은 구조로 만들까” 라는 질문에 집중했습니다.]

위 부분에서는 감동을 받았어요.. ㅎㅎ

실제 실무였다면 여기에 각 레이어별로 테스트 코드를 일반화해서 주석코드와 함께 템플릿겪의 예제를 추가해달라고 요청했을 것 같아요.

모든 것을 일관성있게 할 수 없지만 조금 더 테스트의 일관성을 맞춰서 리더빌리티를 높일 수 있어요. 예를들어 testing-library를 활용하더라도 테스트 방식은 사람마다 다를 수 있거든요 ㅎㅎ 그리고 예제를 만들다보면 또 논의해야 할 것들이 생겨나기도하고요~

Q2. TDD에서 기능의 범위를 미리 예측하고 코드를 작성하는 것이 아직은 추상적이게 느껴집니다.
코치님이 일하고 계신 실무에서는 테스트 코드나 TDD를 어떤 방식으로 활용하고 계신지, 라이브러리나 컨벤션, 프로세스와 같이 실무에서의 실용적인 적용사례에 대해 궁금합니다

A. 사실 모듈의 기능을 미리 예측해서 테스트 코드를 작성하는 방법은 없습니다~

이미 요구사항은 코딩을 하기 전에 있어야하고 해당 요구사항을 구현하기 위해 추가되어야할 모듈의 기능들을 테스트케이스에 하나씩 넣어가면서 테스트를 만드는 것이죵.

개발자가 모르는 것은 테스트할 수 없어요. 개발자가 아는 것만 테스트할 수 있습니다.

그렇기 때문에 의도치 않은 버그들이 테스트만 작성한다고 발생하지 않는 것이 아니에요. 물론 얻어 걸리는 경우는 있지요. 리팩토링과정에서 깨지는 것은 지금의 수정이 모듈의 기존 스펙을 해치지 않는지를 확인하는 것입니다.

그래서 테스트는 어플리케이션이나 특정 과제의 구조를 “만들때는” 도움이 안되요. 미리 만들어진 구조를 테스트하기 좋게 만들기 위해 구조를 좀 더 유연한 구조로 개선될 수 있지만 말이죠. 테스트 퍼스트에서 자주 햇갈리는 부분입니다. 크던 작던 아무튼 아키텍처는 테스트 보다 선행되어야합니다.

그리고 테스트는 요구사항에 맞게 구조는 미리 설계한다음 테스트를 통해 해당 구조가 서로 원할하게 맞물리게 하기위해, 사용자 관점에서 인터페이스를 설계하기 좋게 해줍니다. :)