본문으로 건너뛰기

외부 라이브러리 테스트코드 짜기

· 약 10분

애플리케이션을 개발할 때 모든 코드를 직접 개발하기는 어렵습니다. 잘 개발되어있고 관리되고 있는 오픈소스를 사용하는 경우가 대부분입니다.
개발하고 있는 프로젝트에서도 많은 라이브러리들을 사용하고 있는데(chartjs, kakaoMap ...) 이러한 외부 라이브러리를 사용해야하는 경우 어떻게해야 잘 사용하고, 테스트코드를 짤 수 있는지 이야기 해보겠습니다.

경계 정하기

컴포넌트에서 외부라이브러리를 임포트해서 사용하는 경우 어떤 문제가 있을까?
A Component와 B Component에서 외부라이브러리를 사용하고 있을 때 B 컴포넌트에서 외부라이브러리의 전역 설정을 한다면?
지도를 그리기 위해 카카오 맵을 임포트하여 여러 컴포넌트에서 사용하고 있는데 네이버 맵으로 변경해달라고 요청이 왔다면?

컴포넌트와 외부 라이브러리와 강한 의존성을 가지게 되면 여러 문제가 생기게 된다. 외부 라이브러리는 많은 개발자들이 사용하게 하기 위해 여러 환경과 상황에서도 사용할 수 있도록 매우 유연하게 개발된다. 하지만 이런 유연성이 실제 코드에서는 많은 문제를 가져오는데 여러명이 개발할 때 전역 설정을 건드린다던가 window 객체에 주입이 되는 라이브러리 객체를 조작할 수도 있다는 위험이 있기 때문이다. 따라서 애플리케이션의 요구사항에만 집중하는 인터페이스를 가지도록 해야한다.

카카오 맵

카카오 맵을 사용하는 리액트 컴포넌트를 만들어보자.

외부 라이브러리를 포함하고 있는 컴포넌트
import { useRef } from "react";

function Map() {
const mapRef = useRef(null);
const [map, setMap] = useState<kakao.maps.Map | null>(null);

useEffect(() => {
const mapElement = ref.current;

if (mapElement) {
const options = {
center: new kakao.maps.LatLng(33.450701, 126.570667),
level: 3,
};

const kakaoMap = new kakao.maps.Map(mapElement, mapOption);
setMap(kakaoMap);
}
}, []);

const zoomIn = () => {
if (map) {
map.setLevel(map.getLevel() - 1);
}
};
const zoomOut = () => {
if (map) {
map.setLevel(map.getLevel() + 1);
}
};

return (
<div>
<div ref={mapRef} />
<button type="button" onClick={zoomIn}>
+
</button>
<button type="button" onClick={zoomOut}>
-
</button>
</div>
);
}

카카오 맵의 center와 zoomLevel을 정하여 초기화한 후 zoomIn zoomOut을 할 수 있는 컴포넌트를 만들었다. 컴포넌트에 라이브러리의 상세 구현내용들이 포함되어 있어 코드가 뒤섞여 버렸다. Map 컴포넌트에서는 지도 생성과 줌만 되면 되는데 라이브러리의 수많은 초기 셋팅들과 컴포넌트가 굳이 알지 않아도 되는 코드들이 들어가 읽기 힘들어지고 테스트하기 어려워졌다. 컴포넌트는 view, event, state외의 다른 코드가 들어가면 안된다.

이제 customHooks로 지도 라이브러리의 코드를 분리해보자.

customHooks으로 뺀 외부 라이브러리
import { RefObject, useEffect, useState } from "react";

import { LatLng } from "@/models/map";

interface Params {
ref: RefObject<HTMLDivElement>;
center: LatLng;
zoomLevel: number;
}

interface UseMap {
zoomIn: () => void;
zoomOut: () => void;
}

function useMap({ ref, center, zoomLevel }: Params): UseMap {
const [map, setMap] = useState<kakao.maps.Map | null>(null);

useEffect(() => {
const mapElement = ref.current;

if (mapElement) {
const mapOption = {
center: new kakao.maps.LatLng(center.latitude, center.longitude),
level: zoomLevel,
};
const kakaoMap = new kakao.maps.Map(mapElement, mapOption);
setMap(kakaoMap);
}
}, []);

const zoomIn = () => {
if (map) {
map.setLevel(map.getLevel() - 1);
}
};
const zoomOut = () => {
if (map) {
map.setLevel(map.getLevel() + 1);
}
};

return {
zoomIn,
zoomOut,
};
}

export default useMap;
외부 라이브러리를 분리한 컴포넌트
import useMap from "@/hooks/useMap";

function Map(): ReactElement {
const mapRef = useRef<HTMLDivElement>(null);
const { zoomIn, zoomOut } = useMap({
ref: mapRef,
center: {
latitude: 33.450701,
longitude: 126.570667,
},
zoomLevel: 3,
});

return (
<div>
<div ref={mapRef} />
<button type="button" onClick={zoomIn}>
+
</button>
<button type="button" onClick={zoomOut}>
-
</button>
</div>
);
}

컴포넌트는 정확히 뷰만 담당하고 있다. 중심좌표와 초기 줌레벨만 훅스에 전달하여 어떻게 줌인이 되고 줌아웃이 되는지 전혀 알지 못한다. useMap이 리턴한 zoomIn과 zoomOut을 그냥 사용하기만 하면 된다. 전달한 중심좌표로 지도를 생성하고 줌인과 줌아웃을 구현하는건 Map 컴포넌트의 책임이 아니기 때문이다. 따라서 테스트 코드를 짤때도 줌인 줌아웃에 대해 테스트 코드를 컴포넌트 테스트코드에 짤 필요가 없어졌다.

만약 다른 컴포넌트에서 지도를 사용할 때도 인터페이스만 알면 지도를 생성하여 사용할 수 있다. 어떻게 구현되는지 library docs를 읽을 필요도 없고 다른 개발자가 다른 설정을 건드릴까 걱정을 하지 않아도 된다. 카카오 맵에서 네이버 맵으로 변경해달라는 요청이 오면 인터페이스는 그대로 두고 useMap의 구현만 변경하면 된다.

테스트코드 짜기

위에서 라이브러리와 컴포넌트를 분리하면 테스트하기가 쉬워진다고 거듭 말했기에 이번에는 테스트코드 짜기가 얼마나 쉬워졌는지 알아보자. 처음 컴포넌트에서 라이브러리를 그대로 가져와 사용한 경우에 어떻게 테스트코드를 짜야했을까?

외부 라이브러리를 포함하고 있는 컴포넌트의 테스트코드
describe("Map", () => {
const initZoomLevel = 3;
const setLevel = jest.fn();
const getLevel = jest.fn();
const LatLng = jest.fn();

beforeEach(() => {
jest.clearAllMocks();

const kakao = {
maps: {
Map: jest.fn(),
LatLng,
Size: jest.fn(),
},
};
(kakao.maps.Map as jest.Mock).mockReturnValue({
setLevel,
getLevel: getLevel.mockReturnValue(initZoomLevel),
});

global.kakao = kakao as any;
});

const Map = () => render(<Map />);

context("줌인 버튼을 누르면", () => {
it("현재 zoomLevel을 하나 줄여야 한다.", () => {
renderLandMap();

fireEvent.click(screen.getByText("+"));

expect(setLevel).toBeCalledWith(initZoomLevel - 1);
});
});

context("줌아웃 버튼을 누르면", () => {
it("현재 zoomLevel을 하나 올려야 한다.", () => {
renderLandMap();

fireEvent.click(screen.getByText("+"));

expect(setLevel).toBeCalledWith(initZoomLevel + 1);
});
});
});

먼저 전역객체에 등록된 카카오 맵에서 지도 중심을 셋팅하기 위한 kakao.maps.LatLng, kakao.maps.Map, kako.maps.Size와 줌을 하기위한 kakao.maps.Map.setLevel, kakao.maps.Map.getLevel을 mocking하고

줌아웃 줌인을 했을 때 라이브러리에 값이 제대로 전달되었는지, 라이브러리의 함수가 제대로 호출되었는지를 체크해야 한다. 올바른 행동으로 보이는가? 컴포넌트가 너무 많은 일을 하고있는건 아닌지? 몰라도되는 일을 하는건 아닌지 의문이 든다면 테스트코드를 짜보면 이렇게 알게되는 경우가 종종있다.

컴포넌트와 라이브러리를 분리하면 테스트코드가 어떻게 짜여질까?

외부 라이브러리를 포함하지 않는 컴포넌트의 테스트코드
jest.mock("@/hooks/useMap");

describe("Map", () => {
const zoomIn = jest.fn();
const zoomOut = jest.fn();

beforeEach(() => {
jest.clearAllMocks();

(useMap as jest.Mock).mockReturnValue({
zoomIn,
zoomOut,
});
});

const Map = () => render(<Map />);

context("줌인 버튼을 누르면", () => {
it("zoomIn 이 호출되어야 한다.", () => {
renderLandMap();

fireEvent.click(screen.getByText("+"));

expect(zoomIn).toBeCalled();
});
});

context("줌아웃 버튼을 누르면", () => {
it("zoomout 이 호출되어야 한다.", () => {
renderLandMap();

fireEvent.click(screen.getByText("-"));

expect(zoomOut).toBeCalled();
});
});
});

컴포넌트는 라이브러리가 어떻게 동작되는지 몰라도 되기 때문에 줌인, 줌아웃을 할 때 zoomIn, zoomOut을 호출하는지만 체크하면 된다. zoomIn, zoomOut이 제대로 동작하는지를 테스트하는건 useMap의 책임이기 때문이다.