이번 과제에서는 테스트 주도 개발(TDD)을 요구사항으로 제시받았다.
테스트를 먼저 작성하고, 실패하는 것을 확인한 뒤에 로직을 구현하는 방식이었다.
생각보다 훨씬 낯설고 불편한 흐름이었다.
아직 머릿속에 기능의 동작 구조가 잡히지도 않았는데, 먼저 테스트를 짜야 한다는 게 심리적으로도 꽤 큰 부담이었다.
“이걸 테스트로 먼저 어떻게 나누지?”부터 시작해서, “아예 함수도 없는데 타입은 어떻게 지정하지?” 같은 질문이 계속 맴돌았다.
특히 테스트 단위를 어디서 나눠야 할지 기준이 잘 서지 않았다.
단순해 보이는 한 줄짜리 요구사항도 막상 테스트로 옮기려 하면 하나의 테스트로 처리할지, 조건을 쪼개서 나눠야 할지 헷갈리는 경우가 많았다.
먼저 요구사항을 살펴보며 유닛 테스트로 다룰 수 있는 항목부터 분류했다.
반복 일정 생성 로직은 대부분 날짜 계산에 집중되어 있었고, 같은 입력에 대해 항상 같은 출력이 나와야 하는 구조였다.
그래서 generateRepeatEvents
라는 유틸 함수를 따로 만들어 반복 간격이나 종료 조건, 말일과 윤년 등의 예외 케이스들을 유닛 테스트로 먼저 정의했다.
// 최초 정의한 generateRepeatEvents 함수
export const generateRepeatEvents = (baseEvent: EventForm) => {
const repeatEvents = [];
return repeatEvents;
};
함수 로직을 구현하기 전에 테스트부터 먼저 작성하면서, 결과적으로 어떤 값이 나와야 하는지를 기준으로 함수 구조를 설계해나갔다.
// 테스트 작성 후 완성한 generateRepeatEvents 함수
export function generateRepeatEvents(event: Event | EventForm): Event[] {
const { repeat, date } = event;
if (repeat.type === 'none') return [event as Event];
const startDate = parseISO(date);
const endDate = parseISO(repeat.endDate ?? DEFAULT_END_DATE);
const interval = repeat.interval || 1;
const baseDay = startDate.getDate();
let current = new Date(startDate);
const results: Event[] = [];
while (!isAfter(current, endDate)) {
const year = current.getFullYear();
const month = current.getMonth();
let newDate: Date;
if (repeat.type === 'monthly') {
const last = lastDayOfMonth(new Date(year, month)).getDate();
const correctedDay = Math.min(baseDay, last);
newDate = new Date(year, month, correctedDay);
} else if (repeat.type === 'yearly') {
if (month === 1 && baseDay === 29 && !isLeapYear(current)) {
newDate = new Date(year, month, 28);
} else {
const last = lastDayOfMonth(new Date(year, month)).getDate();
const correctedDay = Math.min(baseDay, last);
newDate = new Date(year, month, correctedDay);
}
} else {
newDate = new Date(current);
}
results.push({
...(event as Event),
date: format(newDate, 'yyyy-MM-dd'),
});
current = getNextDate(current, repeat.type, interval);
}
return results;
}
function getNextDate(date: Date, type: string, interval: number): Date {
switch (type) {
case 'daily':
return addDays(date, interval);
case 'weekly':
return addWeeks(date, interval);
case 'monthly':
return addMonths(date, interval);
case 'yearly':
return addYears(date, interval);
default:
return date;
}
}
그 이후부터는 유틸 함수로 처리할 수 없는 영역, 즉 반복 일정이 실제로 렌더링되는 방식이나 UI 상의 아이콘 표시 여부, 수정/삭제 시의 동작 등은 통합 테스트로 설계했다.
예를 들어 '반복 회의'가 여러 날짜에 표시되는지, 반복 아이콘이 달리는지, 반복 일정 중 하나를 수정했을 때 단일 일정으로 전환되는지 등은 모두 사용자 흐름을 기준으로 테스트를 작성했고, 이를 기준으로 훅 내부 로직이나 컴포넌트 구조도 조금씩 수정해 나갔다.
전체적으로는 "이건 유틸로 처리할 수 있겠다"는 판단이 선 부분은 유닛 테스트로 먼저 잡고, 나머지 사용자 동선이나 API 요청, 상태 변화가 얽힌 흐름은 통합 테스트로 풀어나가는 방식이었다.
기능보다 테스트가 먼저 기준을 제시해주는 흐름이 낯설긴 했지만, 덕분에 설계 기준을 세우는 데 도움이 되었다.
앞 문단에서는 테스트를 유닛과 통합으로 나누고 그에 따라 구조를 잡아갔다고 적었지만, 사실 그 기준을 정하는 과정이 가장 어려웠다.
테스트 코드 하나를 작성하기 전에 늘 이런 질문이 머리를 떠나지 않았다.
“이건 어디서부터 테스트를 시작해야 하지?” “어디까지를 확인해야 충분하다고 할 수 있지?”
단순히 로직을 검증한다고 해도 그게 하나의 함수로 끝나는 계산인지, 아니면 여러 상태와 UI가 얽힌 흐름까지 포함해야 하는지 경계를 정하기가 쉽지 않았다.
특히 반복 일정처럼 여러 컴포넌트와 훅, 상태 흐름이 겹치는 기능의 경우 “이건 유틸 테스트로 충분한가?”, “통합 테스트로 확인해야 하나?”, “기능을 나눠야 테스트가 가능한 건가?” 같은 질문들이 매 순간 따라붙었다.
그래서 이번 과제의 테스트 구조는 처음부터 명확한 기준 아래 정리된 것이 아니라, 그런 고민들을 수없이 반복하면서 만들어낸 결과였다.
계산 중심의 기능은 유틸로 분리해 유닛 테스트를 붙이고, 사용자 상호작용과 렌더링까지 포함하는 흐름은 통합 테스트로 구성했다.
중복 없이 테스트가 각각의 책임을 명확하게 커버하도록 나누는 것도 중요했고, 가능하다면 하나의 테스트가 다른 흐름에 영향을 주지 않도록 테스트 간의 경계도 조심스럽게 정리했다.
지금 돌이켜보면 테스트를 잘 나눈다는 건 결국 구현을 어떻게 구조화할 것인지, 각 기능이 어떤 책임을 갖고 있는지를 먼저 결정하는 일에 가깝다.
테스트는 그 판단을 반영하는 결과물이었고, 그 판단을 내리는 과정 자체가 가장 어렵고 많은 시간을 요구하는 작업이었다.
반복 일정 기능처럼 로직이 복잡하고 사용자 흐름이 길어질수록 어떤 테스트 방식이 적절한지에 대한 고민도 깊어졌다.
유닛 테스트 하나로는 불충분하고, 통합 테스트만으로는 놓치는 부분이 생길 수 있었기 때문에 사용자 시나리오 전체를 아우르는 테스트 방식이 필요하다고 느꼈다.
그래서 우리 팀은 테스팅 트로피(Testing Trophy) 모델을 참고해 테스트 비중을 정리했다.
예측 가능한 순수 로직은 유닛 테스트로, 화면 단위의 흐름은 통합 테스트로, 그리고 실제 API 연동과 사용자 인터랙션까지 포함된 플로우는 E2E 테스트로 나눴다.
특히 반복 일정처럼 렌더링과 상태 갱신이 여러 단계를 거치는 경우에는 통합 테스트만으로는 부족하다고 판단해 실제 API 응답과 UI 반영까지 확인하는 E2E 테스트를 따로 작성했다.
E2E 테스트에서는 폼을 입력하고 저장 버튼을 누른 뒤 서버에 실제로 데이터가 저장되고, 이 데이터가 다시 리스트나 달력에 제대로 렌더링되는지를 확인했다.
여기에 더해, 일정 추가/삭제 시 사용자에게 보여지는 토스트 메시지까지 검증함으로써 단순히 ‘동작한다’는 확인을 넘어 사용자 입장에서 ‘제대로 작동하는가’를 확인하는 테스트를 목표로 삼았다.
이러한 방식은 실제 사용자의 흐름을 가장 가깝게 재현할 수 있다는 점에서 안정성을 제공해주었다.
뿐만 아니라 중요한 플로우가 깨지지 않았다는 확신을 테스트를 통해 얻게 되었다.
하지만 그만큼 작성과 유지 비용도 높았고, CI/CD 환경에서 실행 시간이 과도하게 늘어나는 문제도 있었다.
테스트 시나리오 하나가 커질수록 로컬에서도 디버깅이 어렵고, 변경 사항이 생기면 테스트 수정이 번거롭게 느껴지는 순간도 있었다.
결국 테스트는 비용과 신뢰도의 균형을 조율하는 작업이라는 점을 체감하게 되었고, 모든 흐름을 E2E로 커버하기보다는 꼭 필요한 핵심 시나리오에만 적용하는 것이 현실적인 선택이라는 결론에 다다르게 되었다.
이번 과제를 통해 테스트는 단순히 "잘 돌아가는지 확인하는 도구"를 넘어, 설계 방향에까지 영향을 미치는 요소라는 걸 느꼈다.
기능을 구현하기 전에 테스트를 먼저 작성하는 흐름 덕분에 자연스럽게 책임을 나누고 구조를 쪼개는 기준을 고민하게 되었고, 그 과정 속에서 테스트는 단순한 검증을 넘어 설계의 출발점이 되기도 했다.
하지만 그렇다고 해서 테스트 주도 개발(TDD)이 무조건 좋았다고는 말하기 어렵다.
기능의 윤곽이 잡히지 않은 상태에서 테스트부터 작성하는 흐름은 여전히 낯설고 불편했고, "이건 테스트를 먼저 짜는 게 맞나?", "차라리 구현하고 테스트하는 게 더 빠르지 않나?" 같은 의문도 계속 따라붙었다.
특히 복잡한 흐름을 다룰수록 테스트를 어디까지 작성해야 할지, 어떤 단위로 나눠야 할지 판단이 애매해졌고,
결국 테스트보다는 테스트의 경계를 정하는 일에 더 많은 시간이 들기도 했다.
TDD가 이상적으로 보일 수는 있어도 그 흐름이 모든 상황에서 실질적인 도움이 되는지는 여전히 판단이 서지 않는다.
앞으로도 테스트를 무조건 앞세우기보다는, 기능의 복잡도와 맥락에 따라 어느 수준까지 필요하고 효과적인지를 따져가며 적용하는 그런 현실적인 균형 감각이 더 중요하지 않을까 싶다.