좋은 테스트란 무엇일까요?
테스트 코드는 소프트웨어의 기능과 동작을 테스트해 결함을 찾아내고 수정할 수 있도록 돕는 역할을 합니다. 개발자는 테스트 코드를 작성한 뒤 실행해 예상된 결과가 나오는지를 확인할 수 있습니다.
요구 사항의 문서화: TDD를 통해 테스트 코드를 먼저 작성하게 되면, 요구 사항을 정리하고 모든 케이스를 만족시키도록 개발을 할 수 있어 문서화의 역할을 합니다.
리팩토링 진행 시 사이드 이펙트 감소: 리팩토링을 진행할 때 작성해 두었던 테스트 코드를 실행해 예상치 못한 사이드 이펙트나 버그를 줄일 수 있습니다.
결합도와 의존성이 낮은 코드를 지향할 수 있음: 코드의 의존성이 낮을 수록 테스트 코드를 작성하기 용이하기 때문에, 코드의 품질을 향상 시킬 수 있습니다.
개발 시간 증가: 하나의 기능을 구현하더라도 테스트 코드를 함께 작성해야 하기 때문에 초기 개발 비용이 증가할 수 있습니다.
불완전한 테스트: 테스트 코드를 작성한다 하더라도 완벽히 모든 케이스를 고려할 수는 없기 때문에, 테스트 코드만으로 버그를 모두 방지할 수는 없습니다. 따라서 테스트 코드 작성에 대한 트레이드 오프를 고려해야 합니다.
오버 엔지니어링: 테스트 코드에 너무 많은 리소스를 투자할 경우 개발 속도 저하와 비효율적인 리소스 사용의 원인이 될 수 있습니다.
유지 보수 비용: 소프트웨어의 기능이 변경될 때 테스트 코드 또한 수정되어야 하기 때문에 추가적인 유지 보수 비용이 발생합니다
테스트 코드의 필요성에 대한 공감대는 개발 직군 전반에 걸쳐 넓게 형성되어 있다고 느낍니다. 다만 이러한 장점과 더불어 고려해야 하는 단점이 있는 만큼, 테스트 코드를 잘 활용하기 위해서는 ‘좋은 테스트 코드’를 작성할 줄 알아야 한다고 생각합니다.
테스트 코드를 공부하다보면 어떻게 좋은 테스트 코드를 잘 작성할 수 있는가에 대한 내용을 접할 수 있습니다만, 단순히 많은 것을 테스트하는 게 베스트 케이스가 아닌 만큼 이보다 선행되어야 하는 고민이 어떤 것을 테스트해야 좋을까? 라고 생각합니다.
개발하면서 테스트 코드에 대한 필요성을 많이 느꼈고, 하나씩 도입해 나가던 중이었습니다. 당시 근무하던 회사는 테스트 코드의 불모지였고, 가장 필요하다고 생각하는 부분부터 도입해나가기 시작했습니다.
결국, 실무를 진행할 때 어떤 부분이 반드시 테스트가 필요한가?에 대한 질문은, 테스트 코드의 품질에 앞서 던져야 한다고 생각했기 때문에 테스트 코드에 대한 강의나 책을 내려놓고 다른 기업의 기술블로그들을 참고하기 시작했습니다.
오늘은 그 중 이벤트 로깅 테스트에 대해 이야기 해보려고 합니다.
실무에서 사용자 행동 추적을 위한 Amplitude 및 Google Analytics, Facebook Pixel과 같은 툴의 트래킹 코드를 심는 업무들을 한 경험이 있습니다. 이 때마다 고민이 되었던 것은,
가독성 및 유지보수에서 많이 불리해지는 것을 느낄 수 있었습니다. 리팩토링을 진행하면서 코드의 구조가 바뀌면 이 트래킹 코드가 어느 시점에 어느 코드에 위치해야 하는지도 알기 어려웠고, 관리하기가 어려웠습니다. 비슷한 시점에 여러 유사한 트래킹 코드가 들어가는 경우도 있었습니다. 다른 툴을 또 트래킹 코드를 추가해야 한다면? 악순환이 반복됩니다.
테스트를 하는 것 또한 어려웠습니다. 데이터를 수집하는 것은 잘못되었다 해도 눈으로 확인하기 어려울 뿐더러 한번 잘못 수집된 데이터는 사용할 수 없기 때문에 신중해야 했습니다. 당시에는 해당 툴의 test 기능을 사용했는데, 직접 트래킹 코드를 심은 기능을 실행해 해당 툴에 데이터가 들어오는지 확인하는 방법이었기 때문에 한계가 분명했습니다.
테스트 코드를 작성한다고 했을 때, 마찬가지로 직접 데이터 로그를 툴에 쌓도록 코드를 실행하게 되었습니다. 이는 비즈니스 로직에 트래킹 코드가 강하게 결합되어 있었기 때문입니다. 개발 환경에서 로깅을 통해 원하는 형태로 해당 로직이 실행되는지 알아야 하는데 코드 간 결합도가 너무 높았던 것이 문제였습니다.
그래서 그 당시 저는 어떻게 테스트를 했느냐…
정말 부끄럽지만, 개발자로서 커리어를 시작한 입사 초기에 해당 업무를 맡았고
저는 그냥 단순히 코드를 심는 업무라 생각해 각각의 위치를 파악해 데이터 수집에 집중했습니다.
테스트는 해당 툴의 미리보기 기능을 사용해 직접 코드를 작성한 기능을 실행시키고, 데이터가 들어오는지 확인했죠. ㅠㅠ
또한 이벤트 로깅 코드 자체에 대한 중요성을 그리 크게 체감하지도 못했던 것 같습니다.
다른 업무에 비해 상대적으로 중요성이 떨어져보였고, 개발관점에서 고민해야 할 부분이 크게 없다고 생각했기 때문에 기존에 작성되어 있던 방식 그대로 데이터를 수집하는 코드를 작성해 넣었습니다.
하지만 성숙한 조직일 수록, 또는 조직이 성숙하기 위해서는 소프트웨어의 발전과 더불어 중요한 것이 데이터이기 때문에,
에 대한 고민을 여전히 안고 있었습니다.
생각보다 이벤트 로깅은 개발자에게도 중요하게 고려해야 할 점들이 많았고, 이 또한 반드시 테스트해야 하는 부분이었고, 좋은 방법들이 존재했습니다. 오늘은 어떻게 해당 로직을 개선했는지 예시 코드를 들어 정리해보겠습니다.
프로젝트에 이를 적용해보도록 하겠습니다. 먼저, 단순히 데이터 로깅 코드를 필요한 곳에 심어 실행시키는 방식으로 코드를 작성해보겠습니다.
다음과 같은 플로우로 데이터를 추적하려고 합니다. 여기에서는 데이터 수집에 대한 설계는 고려하지 않고 잘 설계된 데이터 수집 구조를 그대로 따라간다고 가정하겠습니다.
각각의 코드는 다음과 같이 비즈니스 로직에 포함됩니다.
const handleLoginComplete = (token: string) => {
localStorage.setItem(STORAGE_KEY.TOKEN, token);
navigate("/board");
toast.success("Welcome!");
amplitude.getInstance().logEvent("login");
};
const handleAddCard = ({ description }: { description: string }) => {
onAddCard(
{
description,
listId,
},
{
onSuccess() {
reset();
},
},
);
amplitude.getInstance().logEvent("add a card");
};
이러한 방식으로 작성될 경우 다음과 같은 문제가 있었습니다.
로그인 페이지를 예로 들어보겠습니다.
간단히 아이디와 패스워드를 입력 후, Login 버튼을 클릭하면 로그인 처리가 됩니다.
우리는 이 때 구글 애널리틱스에 login event를 발송해야 합니다.
dataLayer를 사용해 login event를 발송하는 코드는 이런 방식으로 작성할 수 있습니다.
단순하게 로그인 버튼을 클릭해 로그인이 완료될 때, 해당 함수에 로직을 포함시켰습니다.
const LoginPage = () => {
const handleSubmitClick = async () => {
//...
window.dataLayer.push({ event: "login" });
};
const handleSubmitForm = handleSubmit(handleSubmitClick);
return (
<S.Container>
<Form
onSubmit={HandleSubmitForm}
>
<Form.Item label="Id" name="id">
<S.Input {...register("id")} name="id" />
</Form.Item>
<Form.Item label="Password" name="password">
<S.InputPassword {...register("password")} />
</Form.Item>
<Form.Item >
<Button type="submit">
Login
</Button>
</Form.Item>
</Form>
</S.Container>
);
};
export default LoginPage;
이렇게 되면
해당 부분을 개선하기 위해 코드의 결합도를 낮추어보겠습니다.
먼저, LoginPage 컴포넌트를 presentational/ container component로 분리해주겠습니다.
이렇게 하는 이유는 테스트를 용이하게 할 수 있도록 하기 위함입니다.
container에서는 세부 로직을 담당하고,
presentational Comonent는 UI를 담당하는 순수한 컴포넌트이므로 보다 수월하게 테스트를 할 수 있습니다.
해당 컴포넌트로 이벤트 로깅관련 함수를 track이라는 이름의 prop으로 넘겨주겠습니다.
그러면 track 함수는 테스트를 위해 다른 로직으로 대체할 수 있게 됩니다.
const LoginPage = () => {
const handleSubmitClick = async () => {
//...
};
const track = (event: UserEvent) => {
window.dataLayer.push({ event: "login" });
};
const handleSubmitForm = handleSubmit(handleSubmitClick);
return <LoginPageView track={track} register={register} onSubmit={handleSubmitForm} />;
};
export default LoginPage;
이벤트 로깅을 테스트해야 하는 항목은 2가지입니다.
우리는 앞서 진행한 작업으로 이 항목들을 테스트할 수 있게 되었습니다.
interface Props {
track: UserTracker["track"];
register: any;
onSubmit: any;
}
const LoginPageView = ({ track, register, onSubmit }: Props) => {
return (
<S.Container>
<S.Title>Login</S.Title>
<Form
onSubmit={() => {
onSubmit();
track("login");
}}
>
<Form.Item label="Id" name="id">
<S.Input {...register("id")} name="id" />
</Form.Item>
<Form.Item label="Password" name="password">
<S.InputPassword {...register("password")} />
</Form.Item>
<Form.Item>
<Button type="submit">
Login
</Button>
</Form.Item>
</Form>
</S.Container>
);
};
track함수가 무엇일지는 몰라도, 우리는 “login”이라는 이벤트 인자값을 전달해주고 있습니다.
실제로는 track에 로깅 로직이 들어가니 login 이라는 이벤트가 들어갑니다.
우리는 track에 테스트용 로직을 넣어 login이라는 이벤트가 정확한 횟수만큼 호출되는지 확인할 수 있습니다.
describe("LoginPage tests", () => {
const track = vi.fn();
const register = vi.fn();
const onSubmit = vi.fn();
const setup = () => {
const utils = customRender(<LoginPageView track={track} register={register} onSubmit={onSubmit} />);
return {
...utils,
submitButton: utils.getByRole("button", { name: /submit/i }),
};
};
it("logs the login event when the submit button is clicked", async () => {
const { submitButton } = setup();
await act(() => fireEvent.submit(submitButton));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(track).toHaveBeenNthCalledWith(1, "login");
});
});
track을 mocking한 다음, 해당 함수에 “login”이라는 이벤트 파라미터가 전달된 채로 1회 호출되는지를 테스트하는 코드입니다.
만약 실수로 다른 이벤트명을 넣었다면? 의도와 달리 해당 함수가 2번이상 호출된다면?
테스트를 통해 명시된 요구사항에 맞게 구현했음을 보장할 수 있습니다.
예를 들어볼게요.
여기까지 진행한 이후 기획자에게 추가 요청사항이 왔습니다.
로그인 화면에 회원가입 버튼과 무료 체험 버튼을 추가해 각각에 맞는 signup, free_trial 이벤트를 클릭 시 발송해달라고 합니다.
추가 테스트부터 작성합니다.
it("회원가입 버튼 클릭 시 signup event 발송", async () => {
const { signupButton } = setup();
await act(() => fireEvent.submit(signupButton));
expect(onClick).toHaveBeenCalledTimes(1);
expect(track).toHaveBeenNthCalledWith(1, "signup");
});
it("무료체험 버튼 클릭 시 free_trial event 발송", async () => {
const { freeTrialButton } = setup();
await act(() => fireEvent.submit(freeTrialButton));
expect(onClick).toHaveBeenCalledTimes(1);
expect(track).toHaveBeenNthCalledWith(1, "free_trial");
});
테스트에 맞게 기능을 구현합니다.
return (
<S.Container>
<Form
onSubmit={HandleSubmitForm}
>
<Form.Item label="Id" name="id">
<S.Input {...register("id")} name="id" />
</Form.Item>
<Form.Item label="Password" name="password">
<S.InputPassword {...register("password")} />
</Form.Item>
<Form.Item >
<Button type="submit">
Login
</Button>
</Form.Item>
</Form>
//버튼 추가
<Button onClick={() => track("signup")}>Signup</Button>
<Button onClick={() => track("signup")}>Free trial</Button>
</S.Container>
);
};
export default LoginPage;
다시 테스트를 실행합니다.
어라, 테스트가 실패했습니다.
…
아하, 급한마음에 버튼을 복붙하다가 이벤트를 잘못 지정해두었습니다.
<Button onClick={() => track("signup")}>Signup</Button>
<Button onClick={() => track("signup")}>Free trial</Button>
이벤트 내용을 고치면 테스트를 모두 통과합니다.
<Button onClick={() => track("signup")}>Signup</Button>
<Button onClick={() => track("free_trial")}>Free trial</Button>
그럼 어떻게 이벤트 로깅 로직을 테스트할 수 있는 지에 대해서 알았습니다.
이제 이 로직을 어떻게 유지보수 관점에서 더 유리하게 만들 수 있을지를 고민해볼 단계입니다.
다음과 같은 부분에 대해 생각해볼 수 있습니다.
export interface UserTracker {
track: (event: UserEvent, prpoerties?: Record<string, unknown>) => void;
}
그리고 google analytics를 위한 구현체를 만듭니다.
export const UserTrackerGoogleAnalyticsImpl = {
track: (event: UserEvent, prpoerties?: Record<string, unknown>) => {
window.dataLayer.push({ event });
},
};
이제 기존 로직을 이 구현체로 대체하겠습니다.
이제 LoginPage 컴포넌트에서는 google analytics 이벤트 로깅에 대한 로직이라는 것을 알 수 있지만, 해당 로직에 대한 세부사항을 알지 않아도 됩니다.
//변경
// ga 이벤트 발송관련 로직이라는 것을 알 수 있습니다.
// 관련 세부사항은 보이지 않습니다.
const track = (event: UserEvent) => {
UserTrackerGoogleAnalyticsImpl.track(event);
};
//기존
// 코드만 봐서는 어떤 로직인지 정확히 알 수 없습니다.
// ga 이벤트 발송관련 로직에 대해 해당 컴포넌트에서 알 필요 없는 세부사항이 작성되어 있습니다.
const track = (event: UserEvent) => {
window.dataLayer.push(event);
};
뿐만 아니라, 유지보수 측면에서도 유용합니다.
ga에 대한 로깅 코드를 모두 작성했는데, 새로운 요청사항이 들어왔다고 가정해보겠습니다.
이제는 동일한 이벤트를 앰플리튜드로 발송해야 합니다.
기존대로 코드를 작성했다면 다음과 같이 코드를 작성해야 합니다.
const track = (event: UserEvent) => {
window.dataLayer.push(event)
amplitude.getInstance().logEvent(event)
};
이 외에도 Init, setDeviceId와 같은 메서드들도 적절히 핸들링해야 합니다.
변경된 로직에서 추가해보겠습니다.
세부사항에 의존하지 않고도 손쉽게 추가할 수 있습니다.
const track = useCallback<UserTracker["track"]>(
(...args) => {
[UserTrackerAmplitudeImpl.track, UserTrackerGoogleAnalyticsImpl.track].forEach((track) => track(...args));
},
[UserTrackerAmplitudeImpl, UserTrackerGoogleAnalyticsImpl],
);
앰플리튜드에 맞는 인터페이스와 구현체를 작성합니다.
export interface UserTracker {
track: (event: UserEvent, prpoerties?: Record<string, unknown>) => void;
}
export interface UserTrackerAmplitude extends UserTracker {
init: () => void;
setDeviceId: (deviceID: string) => void;
}
export const UserTrackerAmplitudeImpl: UserTrackerAmplitude = {
init: () => {
amplitude.getInstance().init(import.meta.env.VITE_AMPLITUDE_API_KEY);
},
setDeviceId: (deviceId: string) => {
amplitude.getInstance().setDeviceId(deviceId);
},
track: (event: UserEvent, prpoerties?: Record<string, unknown>) => {
amplitude.getInstance().logEvent(event);
},
};
당시에는 이벤트 로깅에 대해 업무의 중요도에 비해 테스트가 어려워 썩 달갑지 않게 생각했던 기억이 납니다. 지금은 생각이 다릅니다. 비즈니스 로직 뿐 아니라 사용자의 행동로그를 추적하기 위한 데이터의 정확한 수집 역시 중요한 부분입니다. 테스트와 유지보수성을 염두에 두고 개발하는 것이 당시에는 부족했고, 이러한 방식으로 작업했다면 테스트에 들이는 시간을 줄이되 정확도는 높이고, 유지보수 또한 효율적으로 할 수 있었을 겁니다.
처음부터 완벽할 수는 없죠! 다만 부족한 점을 늘 짚어보고 고민하며 공부하고 개선해나가다보면 비슷한 상황이 올 때 훨씬 성장한 실력으로 마주할 수 있을 거라 생각합니다.