최근에 TDD에 관한 글을 작성한 적이 있었습니다. 이 글을 작성한 이유는 바로 현재 진행 중인 프로젝트에 TDD를 적용하고 싶었기 때문이었습니다. 왜 TDD를 적용하고 싶었을까요? 가장 큰 원인은 아마도 작년 여름에 읽었던 "실용주의 프로그래머"라는 책 때문일 것입니다. 제가 느끼기에 책의 저자는 TDD 개발을 신봉하지는 않지만 개발 방법으로써 TDD는 실제로 많은 도움이 된다고 생각하는 것 같았습니다. 실제 TDD를 하기보다 마치 TDD를 하듯 개발하는 것이 습관이 되면 코드의 질이 향상된다고 하였기 때문입니다. 이 책 외에도 인터넷의 많은 개발자들이 TDD가 효과적인 방법이라고 이야기하곤 했습니다. 저는 책의 내용이 인상깊었고 도대체 TDD의 어느면이 개발에 도움될지 궁금하였기에 이번 프로젝트에서 TDD를 직접 사용해보기로 하였습니다. 그리고 저는 책에서 읽은 내용에 깊은 공감을 하는 중입니다.

TDD 적용의 어려움

출처: Microsoft copilot으로 제작

사실 TDD를 처음 해보면서 개발은 오히려 더뎌졌습니다. jest로 아주 기본적인 기능, 외부에 대한 의존성이 없는 UI만을 위한 컴포넌트를 테스트하기는 아주 쉬웠습니다. 그러나 백엔드에 요청을 보내거나 외부 라이브러리를 사용하는 컴포넌트에서 jest를 사용하려고 할 때마다 오류를 마주했습니다. react navigation이나 Tanstack Query같이 Provider를 제공하는 외부 라이브러리를 사용하려고 하면 Povider를 만들어 Wrapping 해주어야 사용가능하다는 식의 오류 메세지를 보았죠. 그래서 wrapper를 만드는 함수를 작성하여 테스트를 할 때마다 사용하기로 하였습니다.

 

그런데 이 방법은 뭔가 이질적인 느낌이 들었습니다. Tanstack Query에서는 "그래, 그럴 수 있지"라고 생각하였으나, react navigation에서는 달랐습니다. 단순히 Provider를 만들어 테스트를 시작하면 navigation.navigate 함수는 항상 실패합니다. 컴포넌트에서 이동하려는 스크린(현재 진행 중인 프로젝트가 RN 프로젝트이므로 스크린이라는 용어를 사용하겠습니다.)이 새로 wrapping한 Provider에는 등록되어 있지 않기 때문입니다. 그렇다고 Provide를 만들면서 이동할 스크린을 등록해주는 것도 번거로운 일이었습니다. 그리고 해당 코드는 제가 테스트하려는 부분이 아니기도 합니다. 결국 오랜 시간을 들여 공식 문서를 비롯해 인터넷을 뒤진 결과 jest.mock이라는 함수를 이용해 외부 라이브러리를 모두 mocking할 수 있다는 것을 알게 되었습니다.

jest.Mock

단일 컴포넌트를 테스트하기 위해 해당 컴포넌트에서 사용하는 모든 모듈을 테스트할 필요는 없습니다. 이를 위해서 특정한 모듈은 테스트 시에 외부 모듈에 대한 의존성을 제거하고 싶었습니다. 그렇게 함으로써 테스트 실패의 원인이 테스트 하는 컴포넌트에만 있다고 확신할 수 있다고 생각했기 때문입니다. 다행히 jest가 제공하는 mocking을 통해 한 컴포넌트가 외부의 모든 의존성을 제거하여 테스트를 실행할 수 있었습니다. 다음은 react navigation과 Tanstack Query라는 외부 라이브러리를 사용하는 컴포넌트를 테스트하기 위해 navigation과 Tanstack Query를 mocking하는 테스트 코드를 작성한 것입니다.

import { fireEvent, render, waitFor } from "@testing-library/react-native";
import { useQuery } from "@tanstack/react-query";
import { useNavigation } from "@react-navigation/native";

import List from "./List";
import Card from "../Card";
import { someType } from "../types";

// 모듈 전체를 mocking(해당 모듈에서 가져오는 모든 것을 mocking합니다.)
jest.mock("@react-navigation/native");
jest.mock("@tanstack/react-query");

describe("List component", () => {
    it("렌더링 테스트", async () => {
        const mockData: someType = Array.from({ length: 5 }, (_, i) => ({
            id: i,
            // url은 테스트 대상이 아니므로 하드코딩
            url: `https://example.com/test.jpg`,
        }));

        // useQuery를 mocking, 반환값을 수동으로 지정
        (useQuery as jest.Mock).mockReturnValue({
            data: mockData,
            isError: false,
            error: null,
        });

        const { getByTestId, queryByText } = render(<List />);

        // 모든 컴포넌트가 렌더링 되기를 기다림
        await waitFor(() => {
            // 모든 데이터가 올바르게 렌더링됨
            mockData.forEach((item: someType) => {
                expect(getByTestId(`card-${item.id}`)).toBeDefined();
            });

            // 에러 메세지는 나오지 않아야 함, 에러 메세지가 없으면 "네트워크 에러"가 기본값
            expect(queryByText("네트워크 에러")).toBeNull();
        });
    });

    it("에러 발생 시 에러 표시", async () => {
        const mockError = new Error("네트워크 에러");
        // 에러 상황의 useQuery를 mocking
        (useQuery as jest.Mock).mockReturnValue({
            data: null,
            isError: true,
            error: mockError,
        });

        const { getByText } = render(<List />);

        // 에러 메세지가 올바르게 나옴
        await waitFor(() => {
            expect(getByText("네트워크 에러")).toBeDefined();
        });
    });
});

describe("Card component", () => {
    // ...(렌더링 테스트)

    it("Pressable을 누르면 navigate 함수 호출", () => {
        // 함수 호출을 확인하기 위해 navigation을 mocking
        const mockNavigation = {
            navigate: jest.fn(),
        };

        const testId = 1;

        (useNavigation as jest.Mock).mockReturnValue(mockNavigation);

        const { getByTestId } = render(
            <Card id={testId} url="http://test-url" />,
        );

        // pressable요소 가져오기
        const pressable = getByTestId(`card-${testId}`);

        // pressable 요소에 press 이벤트 발생
        fireEvent.press(pressable);

        // navigation.navigate가 "otherScreen"과 { id: testId }를 인자로 받으면서 호출
        expect(mockNavigation.navigate).toHaveBeenCalledWith("otherScreen", {
            id: testId,
        });
    });
});

 

제가 직접 작성한 코드를 조합하고 수정하여 더 알아보기 쉽게 만든 것인데요. 길고 복잡하다고 생각될 수 있지만 제가 작성한 주석과 함께 읽어본다면 조금 더 이해가 쉬울 것이라고 생각합니다. 실제로 복잡한 코드가 아니기도 하고요. 위와 같은 방식으로 컴포넌트가 가진 의존성을 삭제하여 테스트를 작성할 수 있습니다. jest의 함수 mocking에 대해 더 알고 싶다면 공식 문서의 Mock Functions를 참고하세요.

테스트를 먼저 작성해서 얻게 된것

출처: Microsoft copilot으로 제작


직접 경험해 보면서 느낀 TDD의 장점은 바로 "작성해야 될 코드를 명확히 하는 것"이라고 생각합니다. 컴포넌트의 동작이 어떻게 되어야하는지 확실히 정해놓고 시작하기 때문에 컴포넌트의 수행 동작이 명확해집니다. 결국 어떤 코드를 작성해야 될지 명확하게 정하는 것이죠. 물론 기획 단계에서 이를 명확히할 수 있지만 컴포넌트 하나하나의 모든 기능을 기획단계에서 정하기는 어렵습니다. TDD는 이런 기획의 구멍을 보완해 주기 때문에 개발의 속도 증가에 도움이 되는 것 같습니다.

 

또한, 테스트를 통과하는 코드가 목적이다 보니, 컴포넌트에 필요없는 기능을 넣는 것을 지양하게 되는 점도 있습니다. 앞의 장점과 연관되지만 꼭 짚고 넘어가고 싶습니다. 필요없는 기능을 넣으려는 생각이 자연스럽게 차단되는 것이 TDD의 장점이며 동시에 부족한 기능, 생각지 못했던 점들은 테스트를 작성하면서 한 번 더 고려하게 되어 요구사항에 더 잘 들어맞는 컴포넌트를 작성할 수 있게 되었습니다. 필요없는 기능을 작성하지 않게되니 속도가 빨라지는 것은 덤이죠.

 

물론, 개발 속도가 진정으로 빨라지기 위해서는 테스트 도구에 익숙할 필요가 있습니다. 저는 jest 혹은 React-testing-library를 처음 사용해보기 때문에 개발 시간은 오히려 느려지는 것같기도 합니다. 정확히 말하면 테스트 작성을 통해 얻는 시간 단축보다 테스트 작성에 드는 소요 시간이 더 큰 상황입니다. 하지만 도구에 익숙하다면 이런 단점은 금방 줄어들 것이고 잃는 것보다는 얻는 게 더 많은 것같습니다. 코드 작성 시간뿐만 아니라 스스로도 느껴질만큼 코드가 더 깔끔하고 알아보기 쉽게 되었다고 느껴질 정도이니 말이죠.

그럼 TDD를 계속 이용할 것인가?

개인적인 생각으로는 TDD에 익숙해진다면 오히려 테스트 코드 작성을 줄여도 되겠다는 생각이 듭니다. 지금은 실패할 가능성이 거의 없는 부분 조차도 테스트를 작성하고 있습니다. 공부의 목적도 있지만, 혹시 모를 실수를 방지하기 위한 목적도 있습니다. 하지만 TDD에 익숙해지고 실수가 줄어든다면 TDD의 방식은 유지하되 코드를 작성할 때 실제 테스트 코드가 없어도 있는 것처럼 생각하며 코드를 작성할 수도 있지 않을까라는 생각이 듭니다. 물론 리팩토링에서도 테스트의 장점을 얻으려면 실제 테스트 코드가 필요하지만 말이죠.

 

저같은 경우는 TDD도 처음이지만 자동화된 테스트를 하는 것도 처음입니다. 기존에는 코드가 원하는 대로 작동하는 지 확인하기 위해서는 직접 리액트 서버를 로컬호스트에서 실행시켜보았죠. 그러다 보니 테스트 라이브러리를 공부하는 것에도 시간이 들었고, 테스트 코드를 작성하기 위해서는 테스트할 기능을 만들 때 사용할 코드에 대한 설계가 있어야 하기 때문에 쉽지 않은 작업이었습니다. 후자의 문제는 단순히 힘든 점은 아니었습니다. 내가 어떤 코드를 작성할 지에 대한 명확한 계획을 가질 수 있게 되어 코드의 품질이 나아지고 코드의 첫 작성부터 (작성한 테스트에 한해)빼먹는 부분없이 코드를 작성하여 더 빠르게 완성할 수 있었습니다.

 

결론

결국 TDD는 방법론일 뿐이고 코드가 수행해야 될 동작을 명확히 하는 것이 목적입니다. 이 방법에 따른다면 코드 품질 향상, 코드 작성 시간 감소, 리팩토링의 편의성 증가, 문서화의 용이성이라는 수 많은 장점을 얻을 수 있습니다. 저는 TDD로 얻을 수 있는 이점은 테스트 코드로 작성하는 데 드는 비용보다 더 크다고 생각하기 때문에, 특히 명확한 코드 작성이 필요하고 코드의 품질을 신경써야 하는 곳은 TDD 방법론을 이용하는 것이 좋은 선택이 될 것입니다.

 

다만 직접 경험해 본 결과 처음 사용하는 라이브러리가 들어가는 경우 테스트 작성이 어려워진다는 점, 일부 테스트의 경우 요구사항의 변경에 따라 쓸모없어지거나 틀린 테스트가 될 수 있다는 점이 단점으로 다가오기도 했습니다. 다만 후자의 경우는 경험이 쌓이면서 구분하는 방법을 알게 되지 않을까 하는 생각도 듭니다.

+ Recent posts