항해플러스 프론트엔드 5기 후기(7주차) - 테스트 코드

유한별·2025년 5월 18일
0
post-thumbnail

사실 나는 테스트의 효용성에 대해 회의적인 시선을 가지고 있었다.
단순히 돌아가는 코드에 굳이 테스트까지 붙이는 게 과연 얼마나 가치가 있을까 싶었고, 테스트가 개발 리소스를 과도하게 소모한다는 인식도 강했다.
이번 과제를 시작할 때도 마찬가지였다.
테스트를 직접 짜보면서도 계속 생각했다.
“이걸 굳이 해야 하나?” “테스트가 실제로 어떤 가치를 주고 있는가?”

물론 테스트를 작성하는 과정에서 “있으면 좋겠다” 싶은 순간들도 분명 있었다.
리팩토링을 할 때 기능이 깨지지 않았다는 걸 빠르게 확인할 수 있었고, 테스트가 일종의 신뢰망처럼 작동하긴 했다.
하지만 한편으로는 테스트가 구조에 지나치게 의존하고 있어, 작은 구조 변경에도 테스트 코드를 함께 수정해야 한다는 점에서 부담이 생겼다.

게다가 어디까지 테스트를 해야 할지, 어떤 흐름을 어떤 방식으로 검증해야 할지 기준이 모호했다.
테스트를 작성하면 할수록 오히려 “무엇을 검증해야 하지?”, “이건 테스트해야 할 정도의 로직인가?”라는 고민이 커졌다.
테스트가 설계와 구조를 고민하게 만든 건 분명한데, 그게 꼭 긍정적인 방향으로만 작용한 건 아니었다.

🧠 흐름 기반 테스트 구조

이번 과제에서는 테스트가 병렬로 실행될 경우에도 안전하게 동작할 수 있도록 설계해보라는 요구가 있었다.
평소 테스트를 직렬로만 실행해왔기 때문에, 처음에는 병렬성이 왜 문제인지조차도 직관적으로 다가오지 않았다.
테스트마다 상태를 초기화해 사용하면 되는 거 아닌가? 실제로 내가 작성한 테스트 구조도 그랬다.
전역 eventStore를 사용하되, 테스트마다 setupMockHandlerCreation()을 호출해 새로운 인스턴스를 할당했다.

class EventStore {
  ...
}

let eventStore: EventStore;

export const setupMockHandlerCreation = (initEvents: Event[] = []) => {
  eventStore = new EventStore(initEvents);
};
const MOCK_EVENTS = [...];
setupMockHandlerCreation(MOCK_EVENTS);

이 방식은 병렬성까지 보장하지는 않지만, 테스트 간 상태가 충돌하지 않도록 각 테스트 내에서 상태를 명시적으로 분리하는 구조였다.
덕분에 테스트 흐름 안에서 어떤 데이터를 기반으로 어떤 기능을 확인하는지 명확하게 드러났고, 이 흐름이 어긋나지 않는 한 테스트는 충분히 안정적으로 동작했다.

과제를 통해 병렬 테스트에 대한 고민이 생기면서, 그에 대한 대응 방안도 고민해보긴 했다.
uuid를 부여해 요청마다 고유한 testId를 설정하고, 핸들러에서 이를 기준으로 상태를 분기 관리하는 방식을 고민했었다.
이 방법은 병렬성 문제를 해결해줄 수 있었지만, 테스트 코드 외부에서 상태를 관리하게 되면서 테스트가 어떤 상태에서 시작되는지 흐름 상 드러나지 않게 된다는 문제가 생겼다. 나로서는 이게 더 큰 단점이었다.

병렬성은 분명 중요한 주제지만, 내가 이 과제에서 더 우선시한 건 테스트 흐름 안에서 상태를 명시적으로 다루는 구조였다.
테스트는 코드가 어떤 조건에서 어떤 결과를 만드는지 드러내야 한다고 생각했고, 흐름을 숨기는 방식보다는 드러내는 방식을 선택했다. 병렬성은 이후 다른 조건이 생겼을 때 다시 고려하면 되는 문제였다.

👣 사용자 흐름 중심의 테스트 설계

테스트를 작성할 때 가장 고려한 건, 사용자의 실제 사용 흐름을 어떻게 테스트에 자연스럽게 녹여낼 수 있을까였다.
일정 입력부터 저장, 상태 갱신을 거쳐 리스트나 달력에 반영되는 것까지 하나의 흐름으로 테스트를 구성했다.
입력저장fetch렌더링 확인이라는 절차가 자연스럽게 이어져야만, 사용자가 마주할 수 있는 실제 시나리오를 충분히 검증할 수 있다고 생각했기 때문이다.

당시엔 빠르게 구조를 잡는 데 집중한 탓에 userEvent 대신 fireEvent를 주로 사용했다. 지금 생각하면 사용자 인터랙션을 더 정확하게 시뮬레이션할 수 있는 방법을 선택했어야 했다.
예를 들어 텍스트 입력이나 셀렉트 박스 선택처럼 디테일한 상호작용은 userEvent에서 제공하는 유틸리티로 테스트해야 실제 동작과 더 가깝다.
테스트의 목적이 단순 렌더링 확인이 아니라 흐름 자체의 타당성을 검증하는 것이라면, 이벤트 트리거 방식도 실제에 가깝게 따라가야 했다고 느꼈다.

그럼에도 불구하고, 이 흐름 중심의 테스트 방식 덕분에 기능 간 연결 구조를 빠르게 점검하고 예상치 못한 상태 누락이나 반영 오류를 조기에 발견할 수 있었다.

🧱 리팩토링의 부담

테스트 코드를 작성하면서 가장 크게 느낀 또 하나의 지점은 테스트가 구조에 강하게 의존하게 될 경우 오히려 리팩토링의 걸림돌이 될 수 있다는 점이었다.
이번 과제를 진행하며 꽤 많은 테스트 코드를 작성했고, 처음엔 이게 큰 안정감을 줬다.
내부 구조를 고치더라도 테스트가 통과하면 '기능은 여전히 잘 작동하고 있다'는 신호가 되었기 때문이다.

하지만 그 테스트들이 내부 구현과 지나치게 결합되어 있을 경우, 구조를 개선하려는 시도 자체가 부담으로 다가왔다.
예를 들어 일정 CRUD 기능을 담당하는 useEventOperations 훅은 상태 관리와 API 호출이 섞여 있는 복잡한 흐름을 갖고 있었지만, 이미 테스트가 조밀하게 작성되어 있었기 때문에 내부 로직을 나누거나 책임을 분리하는 작업을 망설이게 됐다.
결국 이 훅은 손대지 않고, 비교적 독립적인 다른 기능의 훅을 새로 작성해 리팩토링 방향을 전환하게 됐다.

테스트는 기능을 지키는 안전장치이기도 하지만, 구조와의 결합도가 높아지면 오히려 코드 개선의 기회를 차단할 수도 있다.
특히 처음부터 구현 디테일이 아닌 동작 결과 중심으로 테스트를 설계하지 않으면 구조 변경 시 테스트 자체가 리스크가 되어버린다는 사실을 이번에 뼈저리게 느꼈다.

🎯 통합 테스트의 범위에 대한 고민

앞서 작성한 유닛 테스트들은 특정 훅이나 컴포넌트 단위의 기능만을 검증하면 되었기 때문에, 어떤 동작을 테스트할지 결정하는 데 큰 어려움이 없었다. 그러나 통합 테스트에서는 범위가 훨씬 넓어졌다. 사용자의 흐름 전체를 테스트해야 한다는 막연한 기준 아래, 어디서 시작해서 어디서 끝내야 하는지를 정하는 게 애매하게 느껴졌다.

실제로 작성한 테스트는 일정 정보를 수정하고, 그 결과가 리스트와 달력 뷰에 반영되는지를 확인하는 흐름이었다. 테스트 자체는 잘 작동했지만, ‘이 흐름이 왜 중요하며, 꼭 이렇게까지 테스트해야 하나?’라는 물음은 남았다. 단순히 동작을 검증하는 수준을 넘어서, 그 흐름을 테스트에 담는 일이 생각보다 훨씬 더 많은 리소스를 요구했기 때문이다.

  it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {
    setupMockHandlerCreation(MOCK_EVENTS as Event[]);

    render(
      <ChakraProvider>
        <App />
      </ChakraProvider>
    );

    const eventList = await screen.findByTestId('event-list');
    const eventItems = within(eventList).getAllByTestId('event-item');

    const eventItem = eventItems[0];

    const editButton = within(eventItem).getByTestId('edit-button');
    await userEvent.click(editButton);

    const titleInput = screen.getByLabelText('제목');
    const dateInput = screen.getByLabelText('날짜');
    const startTimeInput = screen.getByLabelText('시작 시간');
    const endTimeInput = screen.getByLabelText('종료 시간');
    const descriptionInput = screen.getByLabelText('설명');
    const locationInput = screen.getByLabelText('위치');
    const categoryInput = screen.getByLabelText('카테고리');

    await Promise.all([
      userEvent.clear(titleInput),
      userEvent.clear(dateInput),
      userEvent.clear(startTimeInput),
      userEvent.clear(endTimeInput),
      userEvent.clear(descriptionInput),
      userEvent.clear(locationInput),
    ]);

    const editedEvent = {
      title: '이벤트 수정',
      date: '2025-05-17',
      startTime: '10:00',
      endTime: '11:00',
      description: '이벤트 수정 설명',
      location: '회의실 수정',
      category: '기타',
    };

    // 일정 수정 정보 입력
    await userEvent.type(titleInput, editedEvent.title);
    await userEvent.type(dateInput, editedEvent.date);
    await userEvent.type(startTimeInput, editedEvent.startTime);
    await userEvent.type(endTimeInput, editedEvent.endTime);
    await userEvent.type(descriptionInput, editedEvent.description);
    await userEvent.type(locationInput, editedEvent.location);
    await userEvent.selectOptions(categoryInput, editedEvent.category);

    // 수정 정보 저장
    const saveButton = screen.getByTestId('event-submit-button');
    await userEvent.click(saveButton);

    // 수정된 일정이 리스트에 표시되는지 확인
    const newEventList = await screen.findByTestId('event-list');
    const newEventItems = within(newEventList).getAllByTestId('event-item');

    const updatedEvent = newEventItems[0];

    expect(updatedEvent).toHaveTextContent(editedEvent.title);
    expect(updatedEvent).toHaveTextContent(editedEvent.date);
    expect(updatedEvent).toHaveTextContent(editedEvent.startTime);
    expect(updatedEvent).toHaveTextContent(editedEvent.endTime);
    expect(updatedEvent).toHaveTextContent(editedEvent.description);
    expect(updatedEvent).toHaveTextContent(editedEvent.location);
    expect(updatedEvent).toHaveTextContent(editedEvent.category);

    // 월별 뷰에 수정된 일정이 정확히 표시되는지 확인
    const monthView = screen.getByTestId('month-view');
    const monthViewEvent = within(monthView).getByText(editedEvent.title);
    expect(monthViewEvent).toBeInTheDocument();
  });

결국 이 테스트를 작성하면서 가장 크게 부딪혔던 건 ‘어디까지 테스트해야 하는가?’라는 질문이었다.
과제에는 요구사항 정의서나 기능 명세가 따로 주어지지 않았고, 그로 인해 테스트 시나리오를 어떤 기준으로 설계할지조차 막막한 순간이 많았다.
그때그때 필요하다고 생각되는 흐름을 기준으로 테스트를 작성했지만, 어디까지 확인해야 충분한지는 끝내 감이 잡히지 않았다.

✍️ 회고

이번 과제를 통해 테스트에 대한 시선을 다시 한 번 점검하게 되었다.
이전까지는 테스트의 효용성에 회의적인 입장이었고, 과제를 진행하면서도 '이게 과연 얼마나 도움이 될까?'라는 생각을 떨치기 어려웠다.

실제로 테스트가 있으면 기능 변경 시 큰 안정감을 주긴 했다. 구조를 고치거나 새로운 기능을 추가할 때도 테스트가 통과된다는 건 일종의 신뢰를 만들어주었고, 덕분에 자신 있게 리팩토링을 진행할 수 있는 구간도 있었다.

하지만 이 안정감이 과연 ‘설계가 옳다’는 보장을 해주는지는 여전히 의문이다.
테스트는 기능이 돌아간다는 걸 보여줄 뿐, 구조가 좋은지를 말해주지는 않는다.
결국 지금 구조를 그대로 유지한 채 '돌아가고 있다'는 착각에 빠지는 순간도 있었고, 이건 테스트가 주는 안정감의 이면이라고 생각한다.

기술적으로도 아쉬운 부분은 많았다.
초기에는 빠르게 테스트를 작성하는 데 집중한 나머지, userEvent가 아닌 fireEvent 중심으로 흐름을 구성했다.
사용자 동선을 따른다고는 했지만, 실제 사용자와 유사한 상호작용을 충분히 모사했다고 보긴 어렵다.
그리고 테스트 코드가 구현 구조에 너무 밀접하게 얽혀 있다 보니, 훅을 리팩토링하려 할 때 테스트를 같이 바꿔야 하는 부담도 컸다.
이 때문에 구조 개선이 필요한 훅을 끝내 손대지 못하고 넘어간 순간도 있었다.

이번 과제를 통해 테스트는 ‘어디까지나 개발자의 선택을 도와주는 도구’일 뿐이라는 점을 다시 한 번 느꼈다.
정답을 보장해주는 것도 아니고, 설계를 대신해주는 것도 아니다.
결국 중요한 건 테스트가 만들어내는 안정감을 어떻게 해석할 것인가, 그리고 그 안정감을 구조적인 개선과 함께 가져갈 수 있도록 ‘거리를 조절하는 법’을 익히는 것이라는 생각이 들었다.

과제 결과 및 코드

  • 테스트 싫어 인간, 과제 통과만으로도 감지덕지...

profile
세상에 못할 일은 없어!

2개의 댓글

comment-user-thumbnail
2025년 5월 19일

점점 더 멋있어지시네여

1개의 답글