본문 바로가기
Resource/웹 프론트엔드

[React] 프론트엔드 테스트와 의존성 분리

by 우창욱 2023. 11. 29.

프론트엔드와 테스트

대부분 웹 프론트엔드 개발자들은 테스트 코드의 중요성에 대해서 어렴풋이 알고는 있다고 생각합니다. 다만, 아래와 같은 이야기들로 테스트 코드 관련 담론을 무마하는 경향이 있는 것 같습니다. 

 

  1. 프론트엔드는 코드 변경이 빈번하다던데, 웹 프론트엔드에서 굳이 테스트 코드를 짜야하나?
    1. (반박) 테스트 코드를 작성하면 변경사항이 기존 기능에 영향을 미치는지 빠르게 확인할 수 있다.
    2. (반박) 코드 변경에 따른 예상치 못한 버그를 사전에 방지할 수 있다.
  2. 프론트엔드 개발자가 직접 UI를 확인하면서 개발할텐데 테스트 코드를 굳이 작성할 필요는 없지 않나?
    1. (반박) 프론트엔드 개발자의 수동 테스트도 중요하지만, 테스트 코드는 자동화된 테스트를 통해 일관된 품질을 유지하는 데 도움이 된다.
    2. (반박) 수동 테스트로는 모든 시나리오를 커버하기 매우 어렵다.
  3. 프론트엔드 팀원끼리 코드 리뷰를 열심히 하면 되지 않나?
    1. (반박) 테스트 코드로 검증되지 않은 코드를 리뷰하는 것보다 테스트 코드로 검증한 코드를 리뷰하는 게 더 효율적이다.
    2. (반박) 코드 리뷰만으로 모든 에러를 체크할 수 없다.
    3. (반박) 프론트엔드 개발자가 혼자인 곳에서는 코드 리뷰가 불가능하다.

위 담론들 모두 웹 프론트엔드 분야에서 테스트 코드를 작성할 필요가 없다는 대답으로는 그 근거가 부족합니다. 또한 단순한 팀 프로젝트 정도의 어플리케이션이 아니라, 안정성이 높은 프로덕션 레벨의 어플리케이션을 개발하기 테스트 코드가 반드시 필요하다고 생각합니다. 그러지 않으면 언제 코드가 터질지 모르는 불안감을 항상 지닌채로 개발을 진행해야 할 것입니다.

 

웹 프론트엔드 개발과 테스트

웹 프론트엔드 분야는 대부분 React 생태계 기반으로 이루어져 있습니다. React 16버전부터는 컴포넌트의 상태는 React에서 제공하는 hook 또는 커스텀 hook을 사용하여 관리하고, 이 hook들로부터 처리된 데이터들을 렌더링하여 UI를 보여주는 방식으로 프론트엔드 개발이 진행되고 있습니다.

Leaflet라이브러리를 사용해서 지도를 렌더링하는 Map 컴포넌트가 있다고 가정해보겠습니다. Map 컴포넌트는 아래처럼 구성될 것입니다.

 

 
 

그리고, 전체 코드를 개략적으로 살펴보면 아래와 같습니다. useRef, useGlobalStore, useDrawROIStore hook들을 사용하여 컴포넌트에서 사용할 데이터들을 가져오고, useEffect 훅을 사용하여 해당 데이터들을 조작하거나 처리하는 로직들을 수행합니다.

/**
* import 관련 코드
*/ 

const Map = () => {
  const mapRef = useRef<L.Map | null>(null);
  const tileLayerRef: React.MutableRefObject<L.TileLayer | null> = useRef(null);
  const currentTileLayer = useGlobalStore((state) => state.currentTileLayer);
  const isROIEnabled = useDrawROIStore((state) => state.isROIEnabled);
  const setROIEnable = useDrawROIStore((state) => state.setROIEnable);
  
  useEffect(() => {
    mapRef.current = L.map("map", mapParams);
    ...
    // 지도 레이어 렌더링 로직
  })
  
  useEffect(() => {
    if (tileLayerRef.current) {
      ...
    // 타일 레이어 변경 로직
  })
  
  return (
    <>
      ...
    // UI 렌더링 로직
  )
}

export default Map;

이 과정을 거치면, 브라우저에 렌더링 하는 것에는 전혀 문제가 없습니다. 문제는 서비스의 규모가 커지면서 기능을 확장할 때 발생하게 됩니다. 확장성 있는 기능 개발을 위해서는 리팩토링이 필수적이고, 리팩토링을 위해서는 테스트 코드가 필수적입니다.


 

Map 컴포넌트의 UI를 테스트한다고 가정해보겠습니다.

단순하게 생각해보면, Map 컴포넌트를 대상으로 하는 하나의 테스트 코드를 작성할 수 있을 것입니다. 그러나 이런 방식은 UI 단위 테스트를 수행하기 어렵게 만듭니다. 왜냐하면 단위 테스트는 하나의 실패 조건만을 가져야 하기 때문입니다.

 

단위 테스트를 수행하게 되면, 개발자들은 단위 기능이 정상적으로 동작하는지에 대한 판단 기준을 갖게 됩니다.

// Map.test.tsx
/**
* import 관련 코드
*/ 

test('Map renders correctly', () => {
  const { getByTestId } = render(<Map />)
  const mapElement = getByTestId('map')
  
  const loginButtonElement = getByRole('login')

  expect(loginButtonElement).toBeInTheDocument()
})

만약 컴포넌트 내부의 버튼이 정상적으로 동작하는지를 검증하는 단위 테스트를 작성한다고 하면, 컴포넌트 전체를 렌더링하고 (1), 컴포넌트에서 사용되는 라이브러리도 가져오고(2), 그 후에 버튼이 동작하는지를 체크해야 합니다.

이와 같이, 테스트하기 이전에 의존해야하는 것들이 많아져서 테스트의 성공여부가 다른 것들에 영향을 받게 되며 이는 단위 테스트를 방해합니다.

이런 경우, 컴포넌트의 UI별로 단위 테스트를 작성하기 위해 테스트 러너(ex. jest, vitest, mocha, …etc) 라이브러리에서 제공하는 모킹 함수를 사용하여 의존성을 제거해주어야 합니다.


프론트엔드와 의존성 분리

테스트 러너 라이브러리에서 제공하는 모킹 함수를 활용하면 단위 테스트를 수행할 수 있습니다. 하지만 컴포넌트를 UI 렌더링을 담당하는 Presentational Component와 로직을 담당하는 Container Component로 분리한다면, UI 단위 테스트와 로직 통합 테스트를 보다 용이하게 진행할 수 있습니다. 이는 컴포넌트 내부의 의존성을 분리하고, 로직 컴포넌트에서 UI 컴포넌트로의 의존성을 주입하는 개념이라고 할 수 있습니다. 

 

아래는 예시로 작성한 코드입니다.

 

Container Component

import * as L from "leaflet";
import { MAP_TILES } from "@/constants/MapTiles";
import { MutableRefObject, useEffect, useRef } from "react";
import useDrawROIStore from "@/store/DrawROIStore";
import Map from "@/components/templates/Map";
import useTileLayerStore from "@/store/TileLayerStore";
import useGlobalStore from "@/store/GlobalStore";

type BoundingBox = { maxx: number; maxy: number; minx: number; miny: number };

type OverlayPNGToMap = {
  imageUrl: string;
  boundingBox: BoundingBox;
};

const MapContainer = () => {
  const mapRef = useRef<L.Map | null>(null);
  const tileLayerRef: MutableRefObject<L.TileLayer | null> = useRef(null);
  const currentTileLayer = useTileLayerStore((state) => state.currentTileLayer);
  const isROIEnabled = useDrawROIStore((state) => state.isROIEnabled);
  const setROIEnable = useDrawROIStore((state) => state.setROIEnable);
  const currentDataCard = useGlobalStore((state) => state.currentDataCard);
  const dataCards = useGlobalStore((state) => state.dataCards);
  const setCurrentMap = useGlobalStore((state) => state.setCurrentMap);

  /**
  * ...
  * 컴포넌트 로직 관련 코드
  * ...
  */

  // Container 컴포넌트에서 Presentational 컴포넌트를 렌더링한다.
  // Container 컴포넌트는 Presentational 컴포넌트에 의존성 주입을 한다.
  return (
    <Map
      currentMap={mapRef.current}
      isROIEnabled={isROIEnabled}
      setROIEnable={setROIEnable}
    />
  );
};

export default MapContainer;

 

Presentational Component

import { CSSProperties } from "react";
import ROICanvasContainer from "@/containers/ROICanvasContainer";
import MapButtons from "./MapButtons";

const mapStyles: CSSProperties = {
  overflow: "hidden",
  width: "100%",
  height: "100%",
};

type Props = {
  currentMap: L.Map | null;
  isROIEnabled: boolean;
  setROIEnable: (currentROI: boolean) => void;
};

const Map = ({ currentMap, isROIEnabled, setROIEnable }: Props) => {
  return (
    <>
      <div
        id="map"
        style={{
          ...mapStyles,
          position: "relative",
          top: 0,
          zIndex: 0,
        }}
      />
      <ROICanvasContainer currentMap={currentMap} />
      <MapButtons
        currentMap={currentMap}
        isROIEnabled={isROIEnabled}
        setROIEnable={setROIEnable}
      />
    </>
  );
};

export default Map;

 

이렇게 컴포넌트를 분리하게 된다면 UI 단위 테스트를 수행하기 용이해집니다. UI 단위 테스트만을 수행하고 싶은 경우, 라이브러리라는 의존성을 가지는 로직을 주입해주어야 할 필요가 없기 때문에 UI 단위 테스트를 수행할 때는 UI 렌더링 컴포넌트만을 렌더링하면 됩니다.

 

그러나 만약 로그인 제출이 정상적으로 동작하는지, 사이드바가 잘 열리는지, 등의 컴포넌트의 로직이 정상적으로 동작하는지를 확인하고 싶다면, 이 경우에는 Presentation 컴포넌트도 렌더링을 해야합니다. 프론트엔드의 로직만을 테스트할 수 없기 때문에 단위 테스트라고 명명하기에는 과한 감이 있어서 통합 테스트라고 부르게 됩니다. 그러나 백엔드나 다른 개발 분야에서 이야기하는 통합 테스트와는 그 무게나 비중이 좀 더 낮다고 할 수 있습니다.

UI 단위 테스트

// Login.unit.test.tsx
/**
* import 관련 코드
*/ 

describe("Login UI 단위 테스트를 수행한다.", () => {
  test("Login 버튼이 정상적으로 렌더링되고, Login 버튼을 클릭하면 loginPage:login-button:click argument를 넘겨준다.", () => {
    // given
    const track = vi.fn();
    const rendered = render(<Login track={track} />);

    // when
    rendered.getByText("로그인").click();

    // then
    expect(track).toHaveBeenNthCalledWith(1, "loginPage:login-button:click");
  });
});
// TileLayers.ui.unit.test.tsx
/**
* import 관련 코드
*/ 

describe("TileLayers UI 단위 테스트를 수행한다.", () => {
  test("TileLayer 버튼을 클릭하면, tileLayers:tilelayer-button:click이 argument로 전달된다.", async () => {
    // given
    const mockSetCurrentTileLayer = vi.fn();
    const track = vi.fn();
    const user = userEvent.setup();
    render(
      <TileLayers
        setCurrentTileLayer={mockSetCurrentTileLayer}
        layers={["google_satellite", "leaflet_dark", "leaflet_osm"]}
        track={track}
        handleMouseEnter={() => {}}
        handleMouseLeave={() => {}}
      />
    );

    // when
    const tileLayerButton = screen.getByRole("google_satellite");
    await user.click(tileLayerButton);

    // then
    expect(track).toHaveBeenNthCalledWith(
      1,
      "tileLayers:tilelayer-button:click"
    );

    await user.click(tileLayerButton);
  });
});

로직 통합 테스트

// SideBarItemContainer.unit.logic.test.tsx
/**
* import 관련 코드
*/ 

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe("SideBarItemContainer 로직 단위 테스트를 수행한다.", () => {
  test("SideBarItemContainer를 렌더링 한다.", async () => {
    // given
    const queryClient = new QueryClient();

    // when
    render(
      <QueryClientProvider client={queryClient}>
        <SideBarItemContainer
          text={"BookMark"}
          icon={<BsBookmarks size={20} role="BookMark" />}
        />
      </QueryClientProvider>
    );

    // then
    const sideBarItemContainer = await screen.getByRole("BookMark");
    expect(sideBarItemContainer).toBeInTheDocument();
  });
}

웹 프론트엔드 분야에서도 위와 같이 컴포넌트를 분리함으로써 테스트에 대한 부담감을 덜고 안정성과 확장성 있는 개발을 할 수 있을 것으로 생각합니다.