프론트엔드 통합 테스트로 더 안전한 웹 서비스 개발하기

Oct.15.2024 이호빈

QA/Testing WEB Web Frontend

여러분의 테스트코드를 얼마나 신뢰하시나요?

오늘날 많은 웹 서비스가 성장하면서 UI/UX의 중요성이 더욱 강조되고, 프론트엔드에서 처리되는 비즈니스 로직도 증가하고 있습니다. 이에 따라 프론트엔드 테스트의 중요성도 자연스럽게 커지고 있습니다. 개발자들은 다양한 테스트 방법을 적용하여 코드의 안정성과 신뢰성을 확보하고 있습니다. 그 중에서도 일반적으로 작성되는 테스트 방식은 단위 테스트(Unit Test)입니다.

아래는 필수 약관의 동의 여부를 반환하는 함수와 약관 체크박스의 동작을 검증하는 테스트 코드 예시입니다.

// checkAllRequiredTerms.test.ts
describe("checkAllRequiredTerms()", () => {
  it("필수 약관이 모두 체크되어있어야 true를 반환한다", () => {
    const terms: Term[] = [
      { required: true, term: "약관 1", isChecked: true },
      { required: true, term: "약관 2", isChecked: true },
      { required: false, term: "약관 3", isChecked: false },
    ];

    expect(checkAllRequiredTerms(terms)).toEqual(true);
  });
  it("필수 약관 중 하나라도 체크되어있지 않으면 false를 반환한다", () => { /* ... */ });
  it("필수 약관이 없으면 true를 반환한다", () => { /* ... */ });
});

// TermCheckBox.test.tsx
describe("<TermCheckBox />", () => {
  it("약관 체크박스를 클릭하면 onChange가 호출되고, 체크 상태가 바뀐다", async () => {
    const user = userEvent.setup();
    const onChange = jest.fn((isChecked: boolean) => {});
    render(<TermCheckBox required={true} isChecked={false} onChange={onChange} term="약관 4" />);

    const checkBox = screen.getByRole("checkbox");

    expect(screen.getByText("(필수) 약관 4")).toBeInTheDocument();
    expect(checkBox).not.toBeChecked();

    // 체크박스 클릭
    await user.click(checkBox);
    expect(onChange).toHaveBeenCalledWith(true);
    expect(checkBox).toBeChecked();
  });
});

하지만 현실적으로 단위 테스트만으로 전체 애플리케이션의 동작을 검증하는 것은 매우 어렵습니다. 웹 애플리케이션은 수십 개의 컴포넌트와 수백 개의 함수가 맞물려서 동작합니다. 때문에 함수와 단일 컴포넌트들의 무결성이 보장된다 하더라도 각 단위들이 합쳐져서 함께 동작할 때 의도한 동작을 문제없이 수행하는지 예측하기는 어렵습니다.

다음 코드는 서버를 통해 내려받은 필수 약관이 모두 체크되면 "주문하기" 버튼이 활성화되는 컴포넌트입니다. 앞서 작성된 테스트만으로는 OrderButtonSection 컴포넌트가 정상적으로 동작하는지 확신하기 어렵습니다.

// 정책: 필수 약관을 모두 선택하면 주문하기 버튼이 활성화 된다

// 전체 코드가 정책대로 잘 동작하는가?
export const OrderButtonSection = () => {
  // API 조회 후 받은 응답값을 매핑 후 반환
  const { terms, updateTerms } = useFetchTermQuery();
  const isCheckedAllRequiredTerms = checkAllRequiredTerms(terms); 

  return (
    <footer>
      <TermList terms={terms} updateTerms={updateTerms} />
      <Button disabled={!isCheckedAllRequiredTerms}>주문하기</Button>
    </footer>
  );
};

코어웹프론트개발팀에서는 테스트가 체계적으로 자리 잡기 전까지 주로 단위 테스트나 개별 컴포넌트의 동작 검증에 집중했습니다. 개별 동작의 검증은 코드 수정에 대한 영향 범위를 제한적인 범위에서만 파악할 수 있었습니다. 컴포넌트와 모듈 연동에 대한 검증은 QA를 통해 진행되었고, 리소스 확보가 어려운 경우 영향 범위가 큰 코드 개선 작업은 미뤄지기도 했습니다. 이에 코어웹프론트개발팀은 단순히 개별 컴포넌트의 동작을 넘어 애플리케이션의 흐름에 더 가까운 통합 테스트 작성을 통해 자동화된 테스트의 신뢰도를 조금 더 높이고자 했습니다.

통합 테스트가 무엇인가요?

통합 테스트(Integration Test)는 애플리케이션의 여러 모듈이나 컴포넌트가 함께 작동하는 방식을 검증하는 테스트입니다. 단위 테스트(Unit Test)가 함수나 모듈의 정확성과 동작을 세밀하게 확인하여 코드의 기본적인 안정성을 검증한다면, 통합 테스트는 다양한 구성 요소가 협력하여 전체 또는 일부 시스템이 예상대로 동작하는지를 확인하는 데 중점을 둡니다.

아래 코드는 OrderButtonSection 컴포넌트의 동작을 테스트하는 통합 테스트입니다. 다음 테스트를 통해 서버로부터 내려받은 필수 약관이 모두 체크되었을 때 "주문하기" 버튼이 정상적으로 활성화되는지 검증할 수 있습니다.

// OrderButtonSection.test.tsx
describe("<OrderButtonSection />", () => {
  it("필수 약관이 모두 체크되면 주문버튼이 활성화된다", async () => {
    const user = userEvent.setup();
    // 약관 조회 API 모킹
    mockFetchTermsAPI([
      { required: true, term: "약관 1" },
      { required: false, term: "약관 2" },
      { required: true, term: "약관 3" },
    ]);
    render(<OrderButtonSection />);

    // 서버 API를 불러올 때까지 대기
    const loader = screen.getByTestId("loader");
    await waitFor(() => expect(loader).not.toBeInTheDocument());

    const orderButton = screen.getByText("주문하기");
    expect(orderButton).toBeDisabled();

    // 필수 체크박스만 클릭
    const requiredCheckboxs = screen
      .getAllByRole("checkbox")
      .filter((checkBox) => checkBox.hasAttribute("required"));

    for (const checkbox of requiredCheckboxs) {
      await user.click(checkbox);
    }

    expect(orderButton).not.toBeDisabled();
  });
});

통합 테스트(Integration Test)단위 테스트(Unit Test)E2E(End-to-End Test) 테스트 사이에 위치합니다. 통합 테스트는 여러 구성 요소 간의 상호작용을 검증하는 데 초점을 맞추기 때문에 실행 시간이 단위 테스트보다 길어질 수 있지만 더 광범위한 상호작용을 검증할 수 있습니다. 통합 테스트는 실제 시나리오를 모두 포괄하지는 않기 때문에 E2E 테스트에 비해 신뢰도가 다소 떨어질 수 있지만 훨씬 빠른 검증으로 개발 초기 단계에서 보다 빠른 피드백을 제공하고, 모듈 간의 문제를 조기에 발견하는 데 큰 도움을 줍니다.

* 단위 테스트(Unit Test) – 개별 함수나 모듈의 동작을 독립적으로 검증하는 테스트

* E2E 테스트(End to End Test) – 페이지 진입부터 이탈 시점까지 전체적인 흐름이 정상적으로 작동하는지 검증하는 테스트

테스트 수준별 특징

통합 테스트를 잘 작성하면 애플리케이션의 전반적인 핵심 동작들을 효과적으로 검증할 수 있으며, 실제 사용자 경험과 가까운 테스트 결과를 얻을 수 있습니다. 이를 통해 애플리케이션의 신뢰성을 높이고, 사용자에게 안정적인 서비스를 제공할 수 있습니다.

코어웹프론트개발팀에서는 다음과 같은 세 가지 주요 목적을 위해 통합 테스트를 진행합니다.

  • 함수, 컴포넌트 연동 테스트
    • 각 함수와 컴포넌트, 훅이 서로 잘 연동되어 의도한 순서대로 동작하는지 확인합니다. 이 테스트를 통해 개별 구성 요소 간의 상호작용이 제대로 이루어지고 있는지 검증할 수 있습니다.
  • 서버 연동 테스트
    • 서버가 내려주는 값이 애플리케이션에 잘 반영되는지, 다양한 서버 응답 케이스(예: 성공, 에러, 업데이트 등)에 따라 애플리케이션이 올바르게 동작하는지 확인합니다.
  • 유저 인터랙션 테스트
    • 유저가 클릭, 입력 등의 행동을 취할 때, 애플리케이션이 올바르게 동작하는지를 검증합니다. 유저에 인터렉션이 일어난 컴포넌트의 변화뿐만 아니라, 그 이후 일어나는 전체적인 시나리오를 테스트하여 실제 사용 환경과 가까운 동작을 검증합니다.

통합 테스트 작성하기

예제와 함께 통합 테스트를 작성하는 방법을 소개하겠습니다.

포인트 시스템을 담당하는 PointSection 컴포넌트가 있습니다. PointSection 컴포넌트는 전역 상태와 서버 응답 값을 활용해 사용 가능한 포인트(getAvailablePoint)를 계산합니다.

// PointSection.tsx
export const PointSection = () => {
  const { orderAmount } = useOrderContext();
  const { pointDiscountAmount, setPointDiscountAmount } = usePointContext();
  const { couponDiscountAmount } = useCouponContext();

  // 포인트 잔액 조회 - 주문금액이 있을 때에만 포인트 조회
  const { data, error, isLoading } = useQuery({
    queryKey: ["point"],
    queryFn: fetchPointBalance,
    enabled: orderAmount > 0
  });

  if (orderAmount === 0) {
    return null;
  }

  if (error) {
    return <div>포인트 정보를 불러오지 못했습니다</div>;
  }

  if (isLoading || !data) {
    return <Loader />;
  }

  const availablePoint = getAvailablePoint(
    orderAmount,
    couponDiscountAmount,
    data.pointBalance
  );

  return (
    <div>
      <h3>포인트</h3>
      <span>적용 포인트: {pointDiscountAmount.toLocaleString()}원</span>
      <span>사용 가능 포인트: {availablePoint.toLocaleString()}원</span>
      <PointInput
        availablePoint={availablePoint}
        onChangePoint={setPointDiscountAmount}
      />
    </div>
  );
};

PointSection을 구성하는 컴포넌트와 함수들은 단위 테스트를 통해 쉽게 검증할 수 있습니다. 하지만 PointSection 컴포넌트는 렌더링하기 위해 ‘결제 금액포인트 조회사용 가능 포인트 계산컴포넌트 노출’의 순서를 거치고 있어, 구성 요소들의 단위 테스트만으로는 실제 애플리케이션에서 적용 가능 포인트가 상황에 맞게 적절하게 동작하는지 예측하기 어렵습니다.

// getAvailablePoint.test.ts
describe("getAvailablePoint()", () => {
  it("보유 포인트가 충분한 경우: 사용 가능한 포인트 금액 = 주문금액 - 쿠폰사용금액", () => {
    const paymentAmount = 10_000;
    const couponDiscountAmount = 3_000;
    const pointBalance = 50_000;
    const availablePoint = 7_000;

    expect(
      getAvailablePoint(paymentAmount, couponDiscountAmount, pointBalance)
    ).toEqual(availablePoint);
  });
  it("보유 포인트가 부족한 경우: 사용 가능한 포인트 금액 = 보유 포인트 금액 ", () => { /* ... */ });
});

// PointInput.test.tsx
describe("<PointInput />", () => {
  it("사용가능한 포인트가 없으면 비활성화 된다", async () => {
    render(<PointInput availablePoint={0} />);
    expect(screen.getByRole("input")).toBeDisabled();
  });
  it("숫자만 입력이 가능하다", () => { /* ... */ });
  it("초기화 버튼을 누르면 입력된 값들이 모두 제거된다", () => { /* ... */ });
  it("사용가능한 금액보다 큰 값을 입력할 수 없다", () => { /* ... */ });
});

PointSection컴포넌트 테스트를 통해 프로퍼티와 서버응답값에 따른 다양한 시나리오를 검증해보겠습니다.

  1. 전역 상태를 잘 가져오는가
  2. 서버로부터 받아온 잔여 포인트가 잘 적용되는가
  3. 결제 금액과 서버 응답에 따라 적절한 UI를 노출하는가
  4. 금액을 입력했을 때 사용 포인트 할인 금액이(pointDiscountAmount) 업데이트되는가

제일 먼저 전역 상태를 잘 가져오는지 테스트하기 위해 결제금액이 0원인 경우를 먼저 테스트하겠습니다. 아직 서버 응답을 모킹하지 않았기 때문에 서버 API 요청 함수인 fetchPointBalance를 모킹해줍니다. jest.mock 을 사용해 내가 작성한 모듈이나 외부 패키지에 작성된 함수들을 가상 함수로 덮어씌울 수 있습니다. 테스트 안에서 모킹된 함수가 실행될 수 있도록 테스트 파일 최상단에 위치시켜줍니다.

// PointSection.test.tsx
jest.mock("./apis", () => ({
  ...jest.requireActual("./apis"),
  fetchPointBalance: async () => ({ pointBalance: 0 }),
}));

/* imports */
describe.only("<PointSection />", () => {
  it("결제금액이 0원일 때 아무 컴포넌트도 노출되지 않는다", async () => {
    const { container } = render(<PointSection />, {
      wrapper: ({ children }) => (
        <QueryClientProvider client={new QueryClient()}>
          <MyContextProviders orderAmount={0}>{children}</MyContextProviders>
        </QueryClientProvider>
      ),
    });

    // 컴포넌트가 렌더링되지 않았는지 확인
    expect(container.firstChild).toBeNull();
  });
});

PointSection와 같이 전역 상태나 서버 상태에 접근하는 컴포넌트는 Provider 같은 부모 컴포넌트가 필요할 수 있습니다. render의 두 번째 파라미터에 wrapper라는 옵션으로 렌더링 할 컴포넌트 겉에 씌워줄 부모 컴포넌트를 작성할 수 있습니다.

테스트마다 wrapper 옵션을 넣어주지 않아도 되도록 customRender 함수를 만들어줍니다. (Testing Library 공식 문서 | customRender) 이 프로젝트에는 react-query를 사용하고 있기 때문에 customRender에 QueryClientProvider도 함께 적용합니다.

// 예제: testUtils.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { MyContextProviders } from "./MyContextProviders";

type RenderOptions = Parameters<typeof render>[1];

interface CustomOptions {
  orderAmount: number;
}

export const customRender = (
  ui: React.ReactElement,
  { orderAmount, ...options }: CustomOptions & RenderOptions
) =>
  render(ui, {
    wrapper: ({ children }) => (
      <QueryClientProvider client={new QueryClient({ ...queryOptions })}>
        <MyContextProviders orderAmount={orderAmount}>
          {children}
        </MyContextProviders>
      </QueryClientProvider>
    ),
    ...options,
  });

다음은 react-query의 동작과 서버 응답에 따른 결과가 잘 이루어지는지 확인하겠습니다. 통합 테스트의 주요 목적은 웹 API 서버를 검증하는 것이 아니라, API를 통해 전달된 데이터를 처리하는 애플리케이션의 동작을 검증하는 것입니다. 실제 서버가 테스트 환경에서 필요하지 않기 때문에, 서버 응답 데이터를 모킹하여 다양한 서버 응답 결과에 따른 처리 과정을 테스트할 수 있습니다. MSW (Mock Service Worker)를 활용하면 Node 환경에서도 API 응답 값을 쉽게 모킹할 수 있습니다. 공식 문서에서 제공하는 가이드에 따라 설정해 줍니다.

// PointSection.test.tsx

// 기본 API 응답값 - 보유 포인트 1,000원
const server = setupServer(
  http.get("/api/point", () => {
    return HttpResponse.json({ pointBalance: 1_000 });
  })
);

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

describe("주문금액 0원 케이스", () => {
  it("주문금액이 0원일 때 PointSection은 노출되지 않는다", async () => {
    const { container } = customRender(<PointSection />, { orderAmount: 0 });

    // 컴포넌트가 렌더링되지 않았는지 확인
    expect(container.firstChild).toBeNull();
  });
});

describe("API 응답 테스트", () => {
  it("포인트 조회에 성공하면 포인트 정보가 렌더링된다", async () => {
    // 주문금액: 10,000원
    customRender(<PointSection />, {
      orderAmount: 10_000,
    });

    // API 응답 대기 상태에 로더가 렌더링되는지 확인
    expect(screen.getByTestId("loader")).toBeInTheDocument();

    // API 조회가 완료되면 포인트 정보가 렌더링되는지 확인
    await screen.findByText("사용 가능 포인트: 1,000원");
  });
});

server.use를 사용하면 기존 API 응답 값을 덮어씌워 다양한 응답 케이스에 대한 테스트도 가능합니다. API 요청에 실패한 시나리오에 대한 테스트 코드도 추가합니다.

it("포인트 조회에 실패하면 안내문구가 나온다", async () => {
  // 포인트 조회 API 덮어쓰기 - 포인트 잔액 조회 실패 [500]
  server.use(
    http.get("/api/point", () =>
      HttpResponse.json({ error: "Internal Server Error" }, { status: 500 })
    )
  );
  customRender(<PointSection />, { orderAmount: 10_000 });

  // API 응답 대기 상태에 로더가 렌더링되는지 확인
  expect(screen.getByTestId("loader")).toBeInTheDocument();

  // API 조회가 실패하면 안내문구가 나오는지 확인
  await screen.findByText("포인트 정보를 불러오지 못했습니다");
});

마지막으로 인풋에 사용 포인트를 입력했을 때 컴포넌트가 올바르게 반응하는지 검증하는 테스트를 작성하겠습니다. userEventfireEvent를 활용해 포인트 사용 시나리오 테스트를 작성합니다. 코어웹프론트개발팀에서는 사용자가 실제로 수행하는 동작을 검증하기 위해 userEvent를 활용해 주로 검증합니다.

it("배민포인트 사용 시나리오 - 인풋에 포인트 금액을 입력하면 사용가능금액이 업데이트된다", async () => {
  const user = userEvent.setup();
  // 포인트 조회 API 모킹 - 포인트잔액 1,000원
  setupServer(
    http.get("/api/point", () => HttpResponse.json({ pointBalance: 1_000 }))
  );
  customRender(<PointSection />, { orderAmount: 10_000 });

  // 데이터를 정상적으로 받아와서 배민포인트가 렌더링될 때까지 대기
  await screen.findByText("사용 가능 포인트: 1,000원");
  expect(screen.getByText("적용 포인트: 0원")).toBeInTheDocument();

  const input = screen.getByRole("input");
  await user.type(input, "500"); // 사용가능금액 입력

  expect(screen.getByText("적용 포인트: 500원")).toBeInTheDocument();
});
it('fireEvent로 입력하면 비활성화된 인풋도 onChangeHandler가 실행된다', () => {
  render(<input disabled onChange={onChangeHandler} placeholder="Enter text" />);

  fireEvent.change(screen.getByPlaceholderText('Enter text'), { target: { value: 'Hello' } });
  expect(onChangeHandler).toBecalled(); // onChange가 호출됨
});

it('userEvent로 입력하면 비활성화된 인풋은 onChangeHandler가 실행되지 않는다', async () => {
  const user = userEvent.setup();
  render(<input disabled onChange={onChangeHandler} placeholder="Enter text" />);

  await user.type(screen.getByPlaceholderText('Enter text'), 'Hello');
  expect(onChangeHandler).not.toBecalled(); // onChange가 호출되지 않음
});

앞서 설명한 테스트 기법들을 모두 활용하면 보다 복잡한 시나리오도 효과적으로 검증할 수 있습니다. 이렇게 실제 사용 환경에 가까운 다양한 시나리오를 검증할수록 애플리케이션의 신뢰도를 효과적으로 높일 수 있습니다.

// DiscountMethods.test.tsx
describe('포인트 + 쿠폰 사용 시나리오', () => {
  it('쿠폰을 사용하면 이미 적용되어있던 적용 포인트의 금액이 변경될 수 있다.', async () => {
    const user = userEvent.setup();

    // 쿠폰 조회 API 모킹 - 사용가능쿠폰 10,000원
    // 포인트 조회 API 모킹 - 사용가능포인트 10,000원
    // 주문 금액: 15,000원
    setupServer(
      http.get('/api/coupon', () => HttpResponse.json({ coupons: [{ couponName: "1만원 할인쿠폰", discountAmount: 10_000 }] })),
      http.get('/api/point', () => HttpResponse.json({ availablePoint: 10_000 }))
    );

    customRender(<DiscountMethods />, { orderAmount: 15_000 });

    // 서버 API를 모두 불러올 때까지 대기
    await screen.findByText("사용 가능 포인트: 10,000원");
    await screen.findByText("사용 가능한 쿠폰: 1개");

    // 포인트 적용
    const input = screen.getByPlaceholderText('사용할 포인트 금액을 입력해주세요');
    await user.type(input, '10000');
    await screen.findByText("적용 포인트: 10,000원");

    // 쿠폰 적용
    const coupon = screen.getByText("1만원 할인쿠폰");
    await user.click(coupon);
    await screen.findByText("쿠폰 할인 금액: 10,000원");

    // 팝업 및 적용 포인트 금액 변경 확인
    await screen.findByText("포인트 사용금액이 변경되었어요");
    await screen.findByText("적용 포인트: 5,000원");
  });

  it('사용 가능 포인트 금액의 변동이 있어도, 적용 포인트가 더 적다면 적용 포인트의 금액은 변하지 않는다', async () => { /* ... */ });
});

// OrderPage.test.tsx
describe('결제수단 변경 시 쿠폰 해제 시나리오', () => { /* ... */ });

테스트 분리의 필요성

실제 사용자 경험과 가까운 시나리오를 테스트 할수록 검증에 더 높은 신뢰도를 가져다주는 것은 맞지만, 모든 동작을 고수준 테스트로 검증하는 것은 비효율적이고 운영하기 어려울 수 있어 적절한 수준의 테스트 분리가 필요합니다.

예제에서 볼 수 있듯 PointSection보다 DiscountMethods를 테스트하기 위해 필요한 모킹이 더 많습니다. 주문서 페이지 전체(OrderPage)를 렌더링해서 테스트할 때에는 그보다 훨씬 많은 서버 응답 값과 외부 모듈의 모킹이 필요하다는 것을 쉽게 예상할 수 있습니다. 이처럼 테스트할 컴포넌트가 클수록 외부 라이브러리나 외부 시스템에 더 많이 의존하기 때문에, 테스트 코드는 더 복잡해지고 실행 시간도 훨씬 더 길어집니다.

// DON'T - 고수준의 테스트로 모든 시나리오 검증하기

describe("사용 가능 포인트 금액 검증", () => {
  it("보유 포인트가 충분한 경우: 사용 가능한 포인트 금액 = 주문금액 - 쿠폰사용금액", () => {
    const user = userEvent.setup();

    // 주문 금액: 8,000원
    // 쿠폰 조회 API 모킹 - 사용가능쿠폰 5,000원
    // 포인트 조회 API 모킹 - 사용가능포인트 4,000원
    setupServer(
      http.get('/api/coupon', () => HttpResponse.json({ coupons: [{ couponName: "5천원 할인쿠폰", discountAmount: 5_000 }] })),
      http.get('/api/point', () => HttpResponse.json({ availablePoint: 4_000 })),
    );

    customRender(<DiscountMethods />, { orderAmount: 8_000 });

    // 쿠폰 조회 대기 후 쿠폰 적용
    const coupon = await screen.findByText("5천원 할인쿠폰");
    await user.click(coupon);

    // 포인트 적용
    await screen.findByText("사용 가능 포인트: 3,000원");
  });

  it("보유 포인트가 부족한 경우: 사용 가능한 포인트 금액 = 보유 포인트 금액", () => {
    const user = userEvent.setup();

    // 주문 금액: 8,000원
    // 쿠폰 조회 API 모킹 - 사용가능쿠폰 5,000원
    // 포인트 조회 API 모킹 - 사용가능포인트 1,000원
    setupServer(
      http.get('/api/coupon', () => HttpResponse.json({ coupons: [{ couponName: "5천원 할인쿠폰", discountAmount: 5_000 }] })),
      http.get('/api/point', () => HttpResponse.json({ availablePoint: 1_000 })),
    );

    customRender(<DiscountMethods />, { orderAmount: 8_000 });

    // 쿠폰 조회 대기 후 쿠폰 적용
    const coupon = await screen.findByText("5천원 할인쿠폰");
    await user.click(coupon);

    // 포인트 적용
    await screen.findByText("사용 가능 포인트: 1,000원");});
  });
});

때문에 중복 테스트가 존재한다면 제거할 수 있는 테스트는 과감히 제거하고 핵심 동선을 제외한 세부 케이스는 적절한 단위 테스트로 분리하는 것이 테스트 코드를 운영하기에 훨씬 좋습니다. 적절한 테스트 코드 수준의 제거와 분리는 단위 테스트의 효율성과 통합 테스트의 신뢰성이라는 두 가지 장점을 극대화하는 데 많은 도움이 됩니다.

// DO - 적절한 수준의 테스트들로 분리하기

describe("<CouponSection />", () => {
  it("쿠폰 사용 시, 쿠폰 사용 금액이 정상적으로 반영되는지 확인", () => { /* ... */ });
});

describe("<PointSection />", () => {
  it("조회된 보유 포인트와 쿠폰 사용 금액, 주문금액으로 사용 가능한 포인트 금액을 잘 계산해서 보여주는지 확인", () => { /* ... */ });
});

describe("getAvailablePoint()", () => {
  it("보유 포인트가 충분한 경우: 사용 가능한 포인트 금액 = 주문금액 - 쿠폰사용금액", () => { /* ... */ });
  it("보유 포인트가 부족한 경우: 사용 가능한 포인트 금액 = 보유 포인트 금액 ", () => { /* ... */ });
});

통합 테스트를 작성하면 코드 수정으로 발생할 수 있는 다양한 시나리오들의 사이드이펙트도 쉽게 파악할 수 있습니다. 고수준의 테스트는 테스트와 연관된 많은 모듈 중 하나라도 결함이 발생하면 예상하는 결과를 만들어내지 못할 수 있기 때문입니다. 예를 들어 적용 가능한 포인트 금액을 계산하는 getAvailablePoint 함수를 수정 중에 결함이 생긴다면, 연관된 수많은 테스트가 실패할 수 있습니다.

describe("<PointSection />", () => {
  // FAILED
  it("조회된 보유 포인트와 쿠폰 사용 금액, 주문금액으로 사용 가능한 포인트 금액을 잘 계산해서 보여주는지 확인", () => { /* ... */ });
});

describe('포인트 + 쿠폰 사용 시나리오', () => {
  // FAILED
  it('쿠폰을 사용하면 이미 적용되어있던 적용 포인트의 금액이 변경될 수 있다.', async () => { /* ... */ });

  // FAILED
  it('사용 가능 포인트 금액의 변동이 있어도, 적용 포인트가 더 적다면 적용 포인트의 금액은 변하지 않는다', async () => { /* ... */ });
});

테스트 분리가 잘 되어 있지 않다면 통합 테스트가 실패했을 때 정확히 어떤 시스템이 사이드 이펙트를 발생시켰는지 예측하기 어렵습니다. 이처럼 적절한 수준의 테스트 작성은 테스트 실패의 원인을 빠르게 발견하는 데에도 많은 도움을 줍니다. 다양한 수준의 테스트를 적절하게 나눠서 작성한다면 코드 수정에 따른 사이드 이펙트도 쉽게 발견하고, 실패한 테스트 케이스의 원인도 보다 효율적으로 추적할 수 있습니다.

적절한 수준으로 분리되지 않은 테스트
적절한 수준으로 분리된 테스트

테스트의 목적에 충실하기

통합 테스트는 시스템의 여러 구성 요소가 함께 제대로 작동하는지를 검증하는 아주 유용한 테스트입니다. 이 검증을 통해 작은 수정이 발생시킬 수 있는 다양한 사이드 이펙트를 조기에 발견할 수 있어, 코드 수정에 대한 자신감을 높일 수 있습니다. 또한, 자동화된 통합 테스트는 QA에서 더 중요한 검증에 집중할 수 있도록 도와줍니다.

하지만 애플리케이션을 검증할 때 한 종류의 테스트만 사용하는 것은 충분하지 않을 수 있습니다. 하나의 프로젝트 내에서도 각 모듈이나 레이어의 특성에 맞는 최적의 테스트 전략을 선택하는 것이 중요합니다. 이를 위해 코드 작성 시 테스트 용이성을 고려하고, 단위 테스트나 E2E 테스트 등 다양한 수준의 테스트를 시도해 보는 것이 좋습니다. 테스트의 궁극적인 목적은 애플리케이션의 품질을 보장하는 것이므로, 특정 방법론이나 분류에 매몰되기보다는 상황과 목적에 맞는 적절한 수준의 테스트를 작성해보세요.

참고 자료