디어코퍼레이션 기술 블로그에 기고한 글입니다: https://blog.deering.co/frontend-unit-test-you-must-write/
안녕하세요, 디어코퍼레이션의 김명재입니다.
모든 스타트업에서 절대 양보해서는 안 되는 테스트 코드라는 게 있을까요? 언제 폐기될지도 모르는 최소 기능 제품(Minimum Viable Product, MVP)을 빠르게 만들고 고객의 피드백을 받아 짧은 시간에 많은 이터레이션을 수행해서 유효한 학습을 하기에도 빠듯한데 엔지니어가 한가롭게(?) 테스트 코드를 작성할 수 있을까요. 모 책에서 나온 '리팩토링은 지옥에서나 하라'는 말처럼, 누군가는 테스트 코드는 지옥에서나 짜라고 말하고 싶을 수도 있을 것 같습니다.
하지만 테스트 코드가 유효한 학습에 도움이 된다면 어떨까요? 그 누구도 그런 테스트 코드를 작성하는데 반대할 수 없겠지만, 그런 테스트코드가 존재할까요?
단위테스트로 반드시 100% 검증되어야 하는 스펙이 딱 하나 있다면 바로 로깅 입니다. 로깅은 육안으로 테스트 할 수 없다보니, 의식적으로 이를 따로 테스트하려고 작정하지 않는 한, 문제가 생겨도 이를 인지하는데 시간이 오래걸립니다. 로깅과 같이 “평소에 눈에 보이지 않는 스펙”이야말로, 가장 먼저 테스트해야 합니다.
유효한 학습은 여러 가지 방식으로 할 수 있지만, 사용자가 제품을 어떻게 사용하는지를 분석해서 유효한 학습을 하겠다고 결정했다면 사용자의 행동을 추적할 수 있는 코드를 앱에 심어야 합니다. 사용자의 행동을 데이터로 변환해야 하는데, 데이터 자체를 올바르게 쌓지 않는다면 이는 당연히 좋지 않은 결과로 이어질 수 있습니다.
어떻게 데이터를 잘 쌓고 있는지 확신할 수 있을까요? 이전에 백엔드 개발자로 일했던 경험을 떠올려보면 동료 앱 개발자분들이 앱에 로그를 제대로 심었는지 확인하기 위해 QA 분들이 많은 시간을 소비했었습니다. 로그를 남기는 코드는 빠뜨리기도 쉽고 실수하기도 쉬워서 스프린트 막바지에 로그 관련 수정사항이 발생하는 경우가 많았습니다.
그래서 인용한 류성두님의 말을 저도 완전히 동의합니다. 데이터 기반 의사결정을 하기 위해 로그를 남기기로 했다면, 최소한 로그 관련 부분만은 100% 테스트 코드를 작성해야 합니다. 여러 요인으로 회사가 망할 수 있겠지만 로그 잘못 남겨서 회사가 망해버리면 안 되잖아요.
이 글에서는 사용자 행동 추적을 위한 로그를 남기는 기능을 의존성 주입 방식으로 구현하고 테스트 코드까지 작성해보겠습니다. 테스트 프레임워크는 vitest를 사용하겠습니다. 예시 코드는 여기, 프로젝트 데모는 여기에서 확인할 수 있습니다.
소프트웨어의 복잡도를 통제하기 위해서는 컴포넌트의 응집도는 높게, 결합도는 낮게 만들어야 합니다. 코드들의 결합도가 높다면 테스트를 수행할 때 결합해 있는 모든 부분을 테스트해야 합니다. 테스팅 라이브러리에서 결합을 강제로 끊고 일부분만 테스트할 수도 있겠지만, 테스트마다 코드들의 결합을 강제로 끊는 코드를 작성한다면 여간 피곤한 일이 아닙니다.
아래는 jest 공식 문서의 예제입니다. SoundPlayerConsumer
와 SoundPlayer
의 정적인 결합을 강제로 끊기 위해 jest.mock
함수를 호출합니다.
// https://jestjs.io/docs/es6-class-mocks#complete-example
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
// 정적으로 의존하고 있는 SoundPlayer를 mock으로 대체하기 위해 아래와 같은 코드를 작성해야 한다.
const mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
});
beforeEach(() => {
SoundPlayer.mockClear();
mockPlaySoundFile.mockClear();
});
it('The consumer should be able to call new() on SoundPlayer', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
// Ensure constructor created the object:
expect(soundPlayerConsumer).toBeTruthy();
});
it('We can check if the consumer called the class constructor', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
expect(SoundPlayer).toHaveBeenCalledTimes(1);
});
it('We can check if the consumer called a method on the class instance', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
const coolSoundFileName = 'song.mp3';
soundPlayerConsumer.playSomethingCool();
expect(mockPlaySoundFile.mock.calls[0][0]).toBe(coolSoundFileName);
});
Node.js 생태계에는 jest라는 강력하고 훌륭한 테스팅 라이브러리가 있어서 정적으로 결합한 코드들을 테스트할 때도 그리 어렵지 않게 결합을 끊고 테스트를 할 수 있긴 합니다. 하지만 테스트를 한 두 개만 하는 게 아닌데 모든 테스트에 이렇게 의존성을 끊는 코드가 달려있다면 그 양이 적지 않을뿐더러, 만약 jest가 아니라 다른 테스팅 라이브러리를 사용하기로 결정한다면(그럴 일이 없을 거라고 누가 확신할 수 있을까요) 피곤한 일이 잔뜩 발생합니다.
처음부터 복잡도 관리의 금과옥조를 지키며 응집도는 높고 결합도는 낮은 컴포넌트를 작성하는 게 상책입니다. 정적인 결합이 적도록 컴포넌트를 작성하면 유지보수는 물론 테스트를 할 때도 유리합니다. 응집도는 높고 결합도는 낮은 컴포넌트를 작성한다면 결국 동적으로(=런타임에) 이 컴포넌트들을 엮어주는 순간이 발생합니다. 런타임에 컴포넌트들을 엮어주려면 어떤 컴포넌트의 props
(혹은 생성자)에 다른 컴포넌트를 매개변수로 넣어야 합니다. 이것이 의존성 주입입니다.
사용자 행동 추적은 Amplitude, Google Analytics, Adobe Analytics 등 여러 도구로 할 수 있습니다. 하지만 어떤 특정한 도구를 사용하는지는 비즈니스 관점에서 사소한 세부 사항입니다. 보고서를 받는 사장님 입장에서 Amplitude를 쓰든 Google Analytics를 쓰든 무슨 큰 차이가 있을까요? 사용자의 행동을 분석한다는 것이 목표이지, 도구의 사용을 추구하는 것이 목적이 아니기 때문에 코드에도 이를 반영합니다.
// src/domain/model/UserTracker.ts
export type UserEvent = 'home:product-tour-button:click' | 'home:apply-button:click'
export type UserTracker = {
track(event: UserEvent, properties?: Record<string, unknown>): void;
}
현재 제품의 홈 화면에는 제품 체험 버튼과 사용 신청 버튼만 있다고 가정합시다. 우리는 사용자가 홈 화면에서 어떤 버튼을 클릭하는지 알고 싶습니다. 각각의 이벤트를 UserEvent
타입으로 정의했습니다. UserTracker
는 track
이라는 함수 하나를 가진 객체에 대한 타입입니다.
Home
컴포넌트는 props
로 track
함수를 입력받습니다. Home
컴포넌트는 이벤트를 남기는 track
함수만 알 뿐 이 타입의 함수를 호출했을 때 로그가 어떤 도구로 전송되는지는 알지 못합니다.
// src/components/templates/Home.tsx
import Button from "@/components/molecules/Button";
import { UserTracker } from "@/domain/model/UserTracker";
import GentlyShakingInteraction from "@/components/atoms/GentlyShakingInteraction";
type Props = {
track: UserTracker["track"];
};
export default function Home({ track }: Props) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-24">
<div className={"flex space-x-2"}>
<div className={"flex items-center"}>
<GentlyShakingInteraction>
<Button
onClick={() => track("home:product-tour-button:click")}
variant={"CTA"}
>
무료 체험 시작하기
</Button>
</GentlyShakingInteraction>
</div>
<Button onClick={() => track("home:apply-button:click")}>
사용 신청하기
</Button>
</div>
</div>
);
}
Home
컴포넌트는 UI만 담당할 뿐 세부 사항(Amplitude인지 Google Analytics인지)에 의존하지 않습니다. 어딘가에서는 이 세부 사항을 담당해야 하는데, 저는 보통 Container
라고 이름붙인 컴포넌트에서 세부 사항을 처리합니다.
// src/containers/HomeContainer.tsx
import React from "react";
import Home from "@/components/templates/Home";
import { UserTrackerAmplitude } from "@/adapters/outgoing/UserTrackerAmplitude";
export default function HomeContainer() {
const [userTracker] = React.useState<UserTracker>(UserTrackerAmplitude);
return <Home track={userTracker.track} />;
}
리액트 세상에는 댄 아브라모프가 제안한 Presentational and Container Component 패턴이 유명합니다. 원문을 읽어보시면 이 패턴이 하도 유명해진 나머지 모든 상황에서 맹목적으로 Presentational Component와 Container Component를 나눠서 작성하는 상황에 대해서 댄 아브라모프가 한탄하고 있음을 알 수 있습니다(...). 리액트가 Hooks를 도입하면서 댄 아브라모프는 Presentational and Container Component가 달성하려고 했던 'UI와 복잡한 비즈니스로직의 분리'를 이 패턴의 도움을 받지 않고도 달성할 수 있다고 판단한 것 같습니다.
그렇다고 하더라도 저는 이 패턴이 쓸모가 없어졌다고 생각하지 않습니다. 관심사를 분리하는 것은 어느 프로그래밍 패러다임을 막론하고 권장하는 일이기 때문입니다. 2021년 네이버 D2에서 발표한 'React VAC(Value Asset Component)'라는 패턴(https://tv.naver.com/v/23162062, https://wit.nts-corp.com/2021/08/11/6461)은 Presentational and Container Component 패턴이 달성하려는 'UI와 비즈니스 로직의 분리'를 극단으로 추구했습니다. UI를 그리는 컴포넌트는 모든 행동(함수)과 상태(데이터)를 주입받습니다. 오직 UI만을 위한 리액트 컴포넌트는 순수함수와 유사한 특성을 가집니다. UI 컴포넌트가 직접 행동이나 상태를 정의하지 않고 외부효과를 직접 일으키지 않기 때문에 테스트 상황에서는 UI 컴포넌트가 잘 작동하는지 확인할 수 있는 행동과 상태를, 실제 상황에서는 비즈니스 로직과 관련된 행동과 상태를 주입할 수 있습니다.
저는 Container 컴포넌트를 외부 의존성과 상태를 Presentational 컴포넌트에 주입하기 위한 용도로 사용합니다. Presentational 컴포넌트는 순수('순수함수'의 그 순수, 상태 변화가 없음)하게 유지하고, Container 컴포넌트에서 온갖 더러운(...) 일을 처리합니다. Container 컴포넌트는 세부 사항과 도메인 로직을 이어주는 일종의 adapter입니다. Container 컴포넌트에서는 UserTrackerAmplitude
라는 세부 사항을 명시적으로 의존했습니다. 여기서도 세부 사항을 완전히 숨기고 싶다면 제어의 역전(Inversion of Control, IOC) 컨테이너를 사용하는 걸 고려해볼 수 있습니다.
UserTrackerAmplitude
는 UserTracker
를 확장합니다. track
함수만 필요한 곳에서는 UserTrackerAmplitude
타입의 객체를 UserTracker
로 받아서 필요한 함수만 뽑아내 사용하면 됩니다.
// src/adapters/outgoing/UserTrackerAmplitude.ts
import { UserTracker } from "@/domain/model/UserTracker";
export type UserTrackerAmplitude = UserTracker & {
init(apiKey: string): void;
setDeviceId(deviceId: string): void;
};
예제에서는 실제 Amplitude API 대신 console.log
를 사용하겠습니다.
// src/adapters/outgoing/userTrackerAmplitudeImpl.ts
import { UserEvent } from "@/domain/model/UserEvent";
import { UserTrackerAmplitude } from "@/adapters/outgoing/UserTrackerAmplitude";
export const UserTrackerAmplitudeImpl = (): UserTrackerAmplitude => ({
init: (apiKey: string) => {
console.log(`UserTrackerAmplitude.init(${apiKey})`);
},
setDeviceId: (deviceId: string) => {
console.log(`UserTrackerAmplitude.setDeviceId(${deviceId})`);
},
track: (event: UserEvent, properties?: Record<string, unknown>) => {
console.log(
"UserTrackerAmplitude.track(event, properties)",
event,
properties
);
},
});
여기까지만 작업하고 끝낼 수는 있겠지만 아무래도 영 불안합니다. 어떤 변경 사항이 생겼을 때 실수로 위 로그를 남기지 않게 될 가능성이 있기 때문입니다. 몇 년 일을 해보니 제품 개발하면서 '설마 그러겠어?'라는 생각이 들었다면 제 예상보다 훨씬 빠르게 그 일이 벌어지는 경우가 대부분이었기 때문에... 실수로라도 로그를 누락하지 않도록 조치해야 마음이 편할 것 같습니다. 그리고 우리는 테스트 코드를 작성할 수 있습니다.
예제 코드에서는 React Testing Library와 vitest를 사용합니다. vitest는 jest보다 속도도 빠르고 TypeScript를 사용하는 상황에서는 jest보다 설정도 간단합니다. 테스팅 환경설정은 이 커밋을 참고하시면 됩니다.
로그 심는 작업을 TDD로 수행하기 위한 모든 준비가 완료되었습니다. 이제 최소한 로그를 심는 작업을 할 때는 실패하는 테스트를 먼저 만들고, 테스트에 성공하기 위해 제품을 변경하는 방식으로 개발하도록 팀 문화를 만들어가면 되겠군요! 데이터 기반 의사결정의 핵심이라고 볼 수 있는, 아주 중요한 지표 작업을 절대 실수하지 않는 엔지니어가 되어서 QA 분들의 걱정과 부담을 줄여주는 훌륭한 동료가 될 수 있을 것 같습니다.
이미 테스트할 제품 코드를 작성했기 때문에 아직은 TDD라고 할 수는 없습니다. 일단 어떻게든 테스트를 작성하고, 이후의 작업부터는 테스트를 먼저 작성하면 될 것 같습니다. 우리가 테스트하고 싶은 내용은 다음과 같습니다.
- '무료 체험 시작하기' 버튼을 눌렀을 때
track
함수에"home:product-tour-button:click"
을 매개변수로 넣어서 호출한다. - '사용 신청하기' 버튼을 눌렀을 때
track
함수에"home:apply-button:click"
을 매개변수로 넣어서 호출한다.
// src/components/templates/Home.tsx
import Button from "@/components/molecules/Button";
import { UserTracker } from "@/domain/model/UserTracker";
import GentlyShakingInteraction from "@/components/atoms/GentlyShakingInteraction";
type Props = {
track: UserTracker["track"];
};
export default function Home({ track }: Props) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-24">
<div className={"flex space-x-2"}>
<div className={"flex items-center"}>
<GentlyShakingInteraction>
<Button
onClick={() => track("home:product-tour-button:click")}
variant={"CTA"}
>
무료 체험 시작하기
</Button>
</GentlyShakingInteraction>
</div>
<Button onClick={() => track("home:apply-button:click")}>
사용 신청하기
</Button>
</div>
</div>
);
}
다행히 Home
컴포넌트는 track
함수를 정적으로 의존하지 않고 props
를 통해 의존하고 있으므로, 테스트할 때 Home
컴포넌트를 렌더링하면서 테스트에 사용할 track
함수를 작성하면 됩니다.
테스트용 track
함수는 호출했을 때 매개변수를 어딘가에 기록할 수 있기만 하면 됩니다. 아래처럼 간단하게 테스트용 Spy를 구현하면 됩니다.
// src/components/templates/Home.unit.test.tsx
import { beforeEach, describe } from "vitest";
import React from "react";
import Home from "@/components/templates/Home";
import { UserTracker } from "@/domain/model/UserTracker";
describe("Home", () => {
// 테스트용 가짜 track함수를 위한 trackSpy 객체를 선언한다.
let trackSpy: {
track: UserTracker["track"];
event?: Parameters<UserTracker["track"]>[0];
};
// 매 테스트를 실행하기 전에 trackSpy 객체를 초기화한다.
// track함수는 아무 일도 하지 않고 trackSpy.event에 매개변수를 넣는다.
beforeEach(() => {
trackSpy = {
track: (event) => {
trackSpy.event = event;
},
};
});
});
Before we exercise the system under test (SUT), we install a Test Spy as a stand-in for depended-on component (DOC) used by the SUT. The Test Spy is designed to act as an observation point by recording the method calls made to it by the SUT as it is exercised. During the result verification phase, the test compares the actual values passed to the Test Spy by the SUT with the values expected by the test.
테스트를 수행하기 전에, 우리는 Test Spy를 DOC(의존하는 컴포넌트)대신 설치해서 시스템이 테스트 중인 시스템이 사용하도록 할 수 있습니다. Test Spy는 '관측 지점'으로서 테스트 중에 시스템이 수행한 메서드 호출을 기록합니다. 결과 확인 단계에서, 테스트는 시스템이 Test Spy에 기록한 실제 값과 테스트가 예상하는 값을 비교합니다.
Home
컴포넌트를 렌더링할 때 trackSpy.track
함수를 넣어주고, '무료 체험 시작하기'
버튼을 클릭했을 때 trackSpy.event
에 올바른 값이 저장되는지 확인하는 테스트를 작성하겠습니다.
// src/components/templates/Home.unit.test.tsx
import { describe, it, vi, expect, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import React from "react";
import Home from "@/components/templates/Home";
import { UserTracker } from "@/domain/model/UserTracker";
describe("Home", () => {
let trackSpy: {
track: UserTracker["track"];
event?: Parameters<UserTracker["track"]>[0]; // track함수의 첫 번째 매개변수의 타입
};
beforeEach(() => {
trackSpy = {
track: (event) => {
trackSpy.event = event;
},
};
});
it("should call track function with 'home:product-tour-button:click' argument when '무료 체험 시작하기' button is clicked.", () => {
// given
const rendered = render(<Home track={trackSpy.track} />);
// when
rendered.getByText("무료 체험 시작하기").click();
//then
expect(trackSpy.event).toBe("home:product-tour-button:click");
});
});
테스트를 잘 통과하네요!
trackSpy
객체는 왠지 범용적으로 사용할 수 있게 추상화할 수도 있을 것 같습니다.
// Spy 타입을 선언하는데, 타입 변수 F는 모든 함수를 받을 수 있도록 선언한다.
type Spy<F extends (...args: any) => any> = {
f: F;
// 타입스크립트의 utility type중 하나인 Parameters를 사용해 매개변수를 저장할 property를 선언한다.
args?: Parameters<F>;
};
// spy함수는 Spy 타입의 객체를 생성한다.
const spy = <F extends (...args: any) => any>(): Spy<F> => {
const s: Spy<F> = {
f: ((...args: any): any => {
s.args = args;
}) as F,
};
return s;
};
이제 어떤 함수라도 대체할 수 있는 Spy
객체를 사용할 수 있게 되었습니다. 잘 작동하는지 테스트해보겠습니다.
describe("Home", () => {
let trackSpy: Spy<UserTracker["track"]>;
beforeEach(() => {
trackSpy = spy();
});
it("should call track function with 'home:product-tour-button:click' argument when '무료 체험 시작하기' button is clicked.", () => {
// given
const rendered = render(<Home track={trackSpy.f} />);
// when
rendered.getByText("무료 체험 시작하기").click();
//then
expect(trackSpy.args?.[0]).toBe("home:product-tour-button:click");
});
});
코드가 깔끔해졌습니다. 역시 잘 작동합니다.
모든 타입에 사용할 수 있는 간단한 Spy
구현체를 만들어봤습니다. 생각해보면 몇 가지 추가할 기능이 있는데요, 예를 들어서 Spy
의 함수가 몇 번 호출되었는지, 그리고 호출될 때마다 어떤 매개변수를 받았는지 기록하게 할 수도 있을 것 같습니다.
하지만 이미 vitest
에서 vi.fn()
이라는 함수를 동일한 목적으로 구현해놨습니다(jest는 jest.fn()
). '사용 신청하기'
버튼을 누르는 경우의 테스트는 vi.fn()
으로 작성해보겠습니다.
it("should call track function with 'home:apply-button:click' argument when '사용 신청하기' button is clicked.", () => {
// given
const track = vi.fn();
const rendered = render(<Home track={track} />);
// when
rendered.getByText("사용 신청하기").click();
//then
expect(track).toHaveBeenNthCalledWith(1, "home:apply-button:click");
});
테스트를 통과했습니다. vi.fn()
에서는 함수가 몇 번 불렸고 어떤 매개변수를 받았는지까지 확인할 수 있습니다.
Presentational 컴포넌트와 Container 컴포넌트 패턴을 적극적으로 활용하면 지금 보시는 것처럼 테스트 코드를 작성하기가 쉬워집니다. 의존성은 Container 컴포넌트로 몰고 Presentational 컴포넌트는 순수함수로 유지한다면 vi.fn()
만으로도 거의 모든 경우의 테스트를 작성할 수 있습니다. 테스트 프레임워크에 과도하게 의존하는 것도 좋지 않지만, 의존성 관리를 철저하게 한다면 테스트 프레임워크의 복잡한 고급 기능을 사용할 일 자체가 없습니다.
요구사항이 추가되었습니다! 홈 화면에 '회원 가입하기'
버튼을 추가하고 고객들이 이 버튼을 얼마나 누르는지 보고 싶습니다. 먼저 실패하는 테스트를 만듭니다. 이벤트 이름은 "home:signup:click"
입니다.
it("should call track function with 'home:signup:click' argument when '회원 가입하기' button is clicked.", () => {
// given
const track = vi.fn();
const rendered = render(<Home track={track} />);
// when
rendered.getByText("회원 가입하기").click();
//then
expect(track).toHaveBeenNthCalledWith(1, "home:signup:click");
});
실패하는 테스트를 확인합니다. '회원 가입하기'
버튼이 없군요.
아래처럼 버튼을 추가하고 다시 테스트를 실행합니다.
// src/components/templates/Home.tsx
import Button from "@/components/molecules/Button";
import { UserTracker } from "@/domain/model/UserTracker";
import GentlyShakingInteraction from "@/components/atoms/GentlyShakingInteraction";
type Props = {
track: UserTracker["track"];
};
export default function Home({ track }: Props) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-20">
<div className={"flex space-x-2"}>
<div className={"flex items-center"}>
<GentlyShakingInteraction>
<Button
onClick={() => track("home:product-tour-button:click")}
variant={"CTA"}
>
무료 체험 시작하기
</Button>
</GentlyShakingInteraction>
</div>
<Button onClick={() => track("home:apply-button:click")}>
사용 신청하기
</Button>
<Button onClick={() => track("home:apply-button:click")}>
회원 가입하기
</Button>
</div>
</div>
);
}
테스트가 실패합니다. 빠르게 만들다가 실수를 해버렸습니다. '회원 가입하기'
버튼에 "home:signup:click"
이 아니라 "home:apply-button:click"
이벤트를 기록하고 있습니다. QA 분에게 넘기기 전에 발견해서 다행입니다. 이벤트 이름을 변경하고 다시 테스트를 실행하겠습니다.
// src/components/templates/Home.tsx
import Button from "@/components/molecules/Button";
import { UserTracker } from "@/domain/model/UserTracker";
import GentlyShakingInteraction from "@/components/atoms/GentlyShakingInteraction";
type Props = {
track: UserTracker["track"];
};
export default function Home({ track }: Props) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-20">
<div className={"flex space-x-2"}>
<div className={"flex items-center"}>
<GentlyShakingInteraction>
<Button
onClick={() => track("home:product-tour-button:click")}
variant={"CTA"}
>
무료 체험 시작하기
</Button>
</GentlyShakingInteraction>
</div>
<Button onClick={() => track("home:apply-button:click")}>
사용 신청하기
</Button>
<Button onClick={() => track("home:signup:click")}>{/* 이벤트 변경 */}
회원 가입하기
</Button>
</div>
</div>
);
}
모든 테스트가 통과합니다.
꼼꼼하신 분들은 이런 실수를 하지 않겠지만 저는 저 자신을 믿지 못합니다... 앞으로 지표가 추가될 때마다 테스트 먼저 작성한다면 걱정을 한시름 놓을 수 있을 것 같습니다.
Next.js에서는 페이지를 이동할 때 a
tag 대신 Link
컴포넌트를 사용해야 사용자에게 브라우저 로딩을 보여주지 않는 매끄러운 UX를 제공할 수 있습니다. 위 3개의 버튼을 눌렀을 때 각각에 해당하는 페이지로 이동하는 기능을 추가해야 하는데, 이 때는 테스트를 어떻게 작성할 수 있을까요?
일단 아래처럼 Next.js의 Link
컴포넌트를 Home
컴포넌트에서 바로 의존하도록 작성할 수 있을 것 같습니다.
// src/components/templates/Home.tsx
import Button from "@/components/molecules/Button";
import { UserTracker } from "@/domain/model/UserTracker";
import GentlyShakingInteraction from "@/components/atoms/GentlyShakingInteraction";
import Link from "next/link";
type Props = {
track: UserTracker["track"];
};
export default function Home({ track }: Props) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-20">
<div className={"flex space-x-2"}>
<div className={"flex items-center"}>
<GentlyShakingInteraction>
<Link href={"/product-tour"}>
<Button
onClick={() => track("home:product-tour-button:click")}
variant={"CTA"}
>
무료 체험 시작하기
</Button>
</Link>
</GentlyShakingInteraction>
</div>
<Link href={"/request-to-use"}>
<Button onClick={() => track("home:apply-button:click")}>
사용 신청하기
</Button>
</Link>
<Link href={"/signup"}>
<Button onClick={() => track("home:signup:click")}>
회원 가입하기
</Button>
</Link>
</div>
</div>
);
}
작동은 잘 됩니다. 하지만 순수했던 Home
컴포넌트가 Next.js 프레임워크에 오염되어버렸습니다. 이전에 작성한 테스트들도 Next.js의 Link 컴포넌트의 구현 방식에 따라서 실패할 가능성이 생겼습니다. Next.js가 아니라 다른 프레임워크를 사용하게 된다면(앞날은 아무도 모릅니다. remix도 있고, zustand와 jotai의 개발자인 Daishi Kato가 만들고 있는 wakuwork도 있고...) Home
컴포넌트도 같이 변경해줘야 합니다.
프레임워크라는 세부 사항이 도메인에 영향을 주고 있으므로 도메인 주도 개발의 관점에서도 좋은 상황이 아닙니다. 어떻게 하면 좋을까요? 컴포넌트 자체를 props
로 주입받을 수 있을까요? Amplitude 라이브러리를 직접 의존하지 않고 UserTracker
라는 타입을 선언한 것과 정확히 동일한 원칙에 따라 Next.js의 Link
컴포넌트를 대체할 수 있는 우리만의 Link
타입을 선언해보겠습니다.
// src/domain/model/Link.ts
import React from "react";
import { UrlObject } from "url";
export type Link = React.ElementType<
React.PropsWithChildren<{
href: string | UrlObject;
className?: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
}>
>;
// src/components/templates/Home.tsx
import Button from "@/components/molecules/Button";
import { UserTracker } from "@/domain/model/UserTracker";
import GentlyShakingInteraction from "@/components/atoms/GentlyShakingInteraction";
import { Link } from "@/domain/model/Link"; // Next.js의 Link대신 우리가 선언한 Link타입 사용
// Props에 Link 컴포넌트 추가
type Props = {
Link: Link;
track: UserTracker["track"];
};
export default function Home({ Link, track }: Props) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-20">
<div className={"flex space-x-2"}>
<div className={"flex items-center"}>
<GentlyShakingInteraction>
<Link href={"/product-tour"}>
<Button
onClick={() => track("home:product-tour-button:click")}
variant={"CTA"}
>
무료 체험 시작하기
</Button>
</Link>
</GentlyShakingInteraction>
</div>
<Link href={"/request-to-use"}>
<Button onClick={() => track("home:apply-button:click")}>
사용 신청하기
</Button>
</Link>
<Link href={"/signup"}>
<Button onClick={() => track("home:signup:click")}>
회원 가입하기
</Button>
</Link>
</div>
</div>
);
}
import
문과 Props
선언을 변경했습니다. 이제 HomeContainer
에서 Next.js의 Link 컴포넌트를 주입합니다.
// src/containers/HomeContainer.tsx
import React from "react";
import Home from "@/components/templates/Home";
import { UserTrackerAmplitudeImpl } from "@/adapters/outgoing/userTrackerAmplitudeImpl";
import { UserTracker } from "@/domain/model/UserTracker";
import NextLink from "next/link";
export default function HomeContainer() {
const [userTracker] = React.useState<UserTracker>(UserTrackerAmplitudeImpl);
return <Home Link={NextLink} track={userTracker.track} />;
}
테스트 코드는 어떻게 변경하면 될까요? Link
타입의 LinkStub
컴포넌트를 선언해서 사용하겠습니다. Test Stub은 미리 지정된 동작을 수행할 뿐 Test Spy처럼 값을 기록하지는 않습니다.
Before exercising the system under test (SUT), we install the Test Stub so that the SUT uses it instead of the real implementation. When called by the SUT during test execution, the Test Stub returns the previously defined values. The test can then verify the expected outcome in the normal way.
테스트를 수행하기 전에, 우리는 Test Stub을 설치해서 시스템이 실제 구현체가 아니라 Test Stub을 사용하도록 합니다. 테스트 중인 시스템이 Test Stub을 호출하면, Test Stub은 미리 지정된 값을 return 합니다. 이후엔 테스트 코드에서 우리가 기대한 값이 도출됐는지 검증하면 됩니다.
// src/domain/model/LinkStub.tsx
import React from "react";
import { Link } from "@/domain/model/Link";
const LinkStub: Link = ({ children }): JSX.Element => {
return <>{children}</>;
};
export default LinkStub;
// src/components/templates/Home.unit.test.tsx
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import React from "react";
import Home from "@/components/templates/Home";
import LinkStub from "@/domain/model/LinkStub";
describe("Home", () => {
...
it("should call track function with 'home:signup:click' argument when '회원 가입하기' button is clicked.", () => {
// given
const track = vi.fn();
const rendered = render(<Home Link={LinkStub} track={track} />);
// when
rendered.getByText("회원 가입하기").click();
//then
expect(track).toHaveBeenNthCalledWith(1, "home:signup:click");
});
});
여전히 테스트가 성공합니다. 특정한 프론트엔드 프레임워크에 의존하지 않는 탄탄한 컴포넌트와 테스트를 작성한 것을 축하합시다 🎉
HomeContainer
에서 Link
컴포넌트를 Home
컴포넌트에 주입하고 있으므로 Home
컴포넌트와 Next.js의 Link
컴포넌트 사이에 HomeContainer
라는 공간이 생겼습니다. 왠지 모르겠지만 Link
를 이동할 때마다 사용자에게 어떤 path로 이동하는지 alert()
로 알려주는 요구사항이 새로 생겼다면, 우리는 Home
컴포넌트의 변경 없이 이 요구사항을 처리할 수 있습니다.
Next.js의 Link
컴포넌트를 그대로 Home
에 넣지 않고 뭔가 조작을 하면 될 것 같습니다. React의 Function Component가 등장하기 전, Class Component 시절에 많이 사용했던 고계 컴포넌트(Higher Order Component, HOC) 패턴이 필요하겠군요.
// src/components/templates/Home.tsx
import React from "react";
import Home from "@/components/templates/Home";
import { UserTrackerAmplitudeImpl } from "@/adapters/outgoing/userTrackerAmplitudeImpl";
import { UserTracker } from "@/domain/model/UserTracker";
import NextLink from "next/link";
import { Link } from "@/domain/model/Link";
// 페이지 이동시 alert() 기능을 추가한 Link 컴포넌트를 생성한다.
const withLinkWithAlert: (
createAlertMessage: (href: string) => string
) => Link = (createAlertMessage) =>
function LinkWithAlert({ href, onClick, ...props }) {
return (
<NextLink
href={href}
onClick={(...args) => {
href && alert(createAlertMessage(href.toString()));
onClick && onClick(...args);
}}
{...props}
/>
);
};
export default function HomeContainer() {
const [userTracker] = React.useState<UserTracker>(UserTrackerAmplitudeImpl);
// 컴포넌트를 렌더링 할 때마다 매번 새로 생성하지 않도록 useMemo hook을 활용한다.
const LinkWithAlert = React.useMemo(
() => withLinkWithAlert((href) => `'${href}'로 이동합니다.`),
[]
);
return <Home Link={LinkWithAlert} track={userTracker.track} />;
}
새로운 요구사항이 도착했습니다. Amplitude로 충분하지 않았는지 Google Analytics도 도입해야 한다고 합니다. 테스트를 위해 의존성을 잘 분리해놨는데 덕분에 이 요구사항도 복잡한 변경 없이 해결할 수 있을 것 같습니다.
UserTracker
를 확장해서 Google Analytics 구현체를 만들겠습니다. 역시 여기서도 console.log
로 Google Analytics API를 대체합니다.
// src/adapters/outgoing/userTrackerGoogleAnalyticsImpl.ts
import { UserTracker } from "@/domain/model/UserTracker";
import { UserEvent } from "@/domain/model/UserEvent";
import { UserTrackerGoogleAnalytics } from "@/adapters/outgoing/UserTrackerGoogleAnalytics";
export type UserTrackerGoogleAnalytics = UserTracker & {
init(apiKey: string): void;
setUserId(userId: string): void;
};
export const UserTrackerGoogleAnalyticsImpl =
(): UserTrackerGoogleAnalytics => ({
init: (apiKey: string) => {
console.log(`UserTrackerGoogleAnalytics.init(${apiKey})`);
},
setUserId: (userId: string) => {
console.log(`UserTrackerGoogleAnalytics.setUserId(${userId})`);
},
track: (event: UserEvent, properties?: Record<string, unknown>) => {
console.log(
"UserTrackerGoogleAnalytics.track(event, properties)",
event,
properties
);
},
});
이제 Home
컴포넌트에서 userTracker
를 2개를 받아서 사용하면 되겠군요! Home
컴포넌트를 변경해봅시다.
사실 거짓말이었습니다. 몇 개의 userTracker
를 사용하는지도 도메인 입장에서 보면 세부 사항일 뿐입니다. userTracker의 개수
도 세부 사항이므로 HomeContainer
컴포넌트에서 처리하겠습니다.
// src/containers/HomeContainer.tsx
...
export default function HomeContainer() {
const [userTrackerAmplitude] = React.useState<UserTracker>(
UserTrackerAmplitudeImpl
);
const [userTrackerGoogleAnalytics] = React.useState<UserTracker>(
UserTrackerGoogleAnalyticsImpl
);
const track = React.useCallback<UserTracker["track"]>(
(...args) => {
// 동일한 인터페이스의 함수를 배열로 만들어서 forEach로 호출한다.
[userTrackerAmplitude.track, userTrackerGoogleAnalytics.track].forEach(
(track) => track(...args)
);
},
[userTrackerAmplitude, userTrackerGoogleAnalytics]
);
const LinkWithAlert = React.useMemo(
() => withLinkWithAlert((href) => `'${href}'로 이동합니다.`),
[]
);
return <Home Link={LinkWithAlert} track={track} />;
}
Amplitude와 Google Analytics 로그가 모두 잘 찍히는 것을 확인할 수 있습니다. 만약 UI 컴포넌트에서 Amplitude 구현체를 직접 의존했다면, 그리고 UI 컴포넌트의 수가 많았다면 엄청나게 오래 걸릴 수 있는 작업이었지만 의존성을 깔끔하게 분리해놓았기 때문에 코드를 몇 줄만 수정해서 새로운 UserTracker
를 쉽게 추가할 수 있었습니다.
이렇게 동일한 인터페이스의 함수(혹은 객체)를 하나의 함수로 추상화하는 것을 Composite Pattern이라고 부릅니다.
Composite is a structural design pattern that lets you compose objects into tree structures and then work with these structures as if they were individual objects.
복합체는 객체를 트리 구조로 구성한 다음 이러한 구조를 개별 객체처럼 작업할 수 있게 해주는 구조적 디자인 패턴입니다....
The greatest benefit of this approach is that you don’t need to care about the concrete classes of objects that compose the tree. You don’t need to know whether an object is a simple product or a sophisticated box. You can treat them all the same via the common interface. When you call a method, the objects themselves pass the request down the tree.
이 방식의 가장 큰 이점은 트리를 구성하는 객체의 구체적인 클래스에 대해 신경 쓸 필요가 없다는 것입니다. 객체가 단순한 제품인지 복잡한 상자인지 알 필요가 없습니다. 공통 인터페이스를 통해 모두 동일하게 처리할 수 있습니다. 메서드를 호출할 때 객체 자체가 요청을 트리 아래로 전달합니다.
지금까지 응집도 높고 결합도 낮은 리액트 컴포넌트를 작성하고 테스트를 작성해봤습니다. 외부 의존성을 제거한 순수한 UI 컴포넌트와 외부 의존성만이 목적인 컴포넌트를 나누어 작성하면 좋은 테스트 코드를 쉽게 작성할 수 있습니다.
저는 '응집도 높고 결합도 낮은 컴포넌트'라는 말을 들으면 곧장 '의존성 주입'이라는 개념까지 연결이 됩니다. 시스템의 복잡도가 증가하는 속도를 낮추려면 백엔드든 프론트엔드든 상관 없이 동적으로 컴포넌트를 주입하는 방식을 고려해볼 수 있습니다.
“모르게 해라”, 이 다섯 글자가 소프트웨어 아키텍처를 관통하는 이치인 것 같습니다. 컴포넌트가 서로를 모르고 딱 한 가지 기능만 담당하도록, UI가 외부 의존성을 모르도록, 도메인 영역이 세부 사항을 모르도록. 누가 누구를 의존할지, 누가 누구를 알아야 할지 세심하게 고려해야 소프트웨어 복잡도라는 괴물을 조련하기에 그나마 용이합니다.
의존성 주입은 사실 소프트웨어 세상의 특별한 무언가가 아니라 공학에서 복잡도를 관리하기 위한 근본 원칙 중 하나입니다. 범용적인 표준 인터페이스를 정하고, 인터페이스에 맞춰서 부품을 만들고 조립하는 게 곧 의존성 주입과 마찬가지니까요.
덜 복잡한 세상을 위해서 같이 힘내요.
- https://github.com/deer-develop/frontend-test-with-di
- https://frontend-test-with-di.vercel.app
- 뱅크샐러드 iOS팀이 숨쉬듯이 테스트코드 짜는 방식 3편 - 스펙별 단위 테스트, 류성두
- https://jestjs.io/docs/es6-class-mocks#complete-example
- https://www.robinwieruch.de/vitest-react-testing-library/
- Test Spy, xUnit Patterns.com
- Test Stub, xUnit Patterns.com
- 스타트업 개발 생산성 높이기: (2) 숨어있는 비용을 찾아 없애자, 김명재
- 제어의 역전(Inversion of Control, IOC) 컨테이너, 김명재
- https://refactoring.guru/design-patterns/composite