TLDR
단위 테스트, 통합테스트, E2E 테스트 까지 시나리오를 적어서 구현해 보았습니다.
코드는 링크를 참고해주세요!
Background
저는 현업에서 테스트 코드를 경험한 적이 없습니다.
다른 회사들에서 사용하는 사례를 보기도 하고, React Native UI 라이브러리에 기여했을 때엔 Unit Test와 Snapshot Test를 구현해보기도 했는데요. 결론적으로 실제 업무에선 Testing 도입을 꺼려했습니다.
테스트 코드 도입이 망설여지는 이유
제가 막연하게 테스트 코드를 도입이 망설여졌던 이유는 아래와 같습니다.
- Test Code를 도입해도, 테스트 케이스를 또 짜야하니까, 다른 일을 만드는 느낌
- 프론트엔드 코드는 너무 쉽게 변하니까, 테스트를 재작성해야 하지 않을까?
- 업무는 계속 피처 개발 위주로 진행되는데, 스케쥴 상 새로운 문화를 도입하기엔 버겁다.
오늘 토스에서 진행하는 ‘모닥불’ 이라는 세션을 통해서, 나름 제가 갖고 있었던 테스트코드 도입이 꺼려졌던 이유가 많이 사라진 것 같습니다.
모닥불 같이보기
테스트의 장점을 요약하면
- 피드백 사이클이 빨라진다. ( 직접 코드의 반영을 화면으로 눌러 테스트 하는 주기는 느리다. )
- AB 테스트 시 체감 엄청 많이 된다. ( 환경변수 세팅을 직접하는 건 엄청나게 힘듦 )
- 비즈니스 로직에 집중할 수 있다. ( 외부 의존성으로 개발이 복잡해 질 때, 모킹 등으로 환경 세팅해서, 내가 원하는 범위만 테스트 하면 되니까 )
- 안정성이 올라간다. ( 기존 코드에 대한 사이드이펙트 테스팅을 편하게 진행할 수 있다 )
- 잘짜여진 코드는 스펙 문서 역할을 한다. ( 프론트엔드의 경우 주요 인터렉션을 모두 확인할 수 있다. )
- 테스트 코드를 통해서, 코드를 더 잘 짜게 되는 경우도 있다. (Unit Test 시 책임이 몰리면, 이를 분리해야한다는 생각도 듦)
- 통합 혹은 E2E 테스트 코드 작성시, 유저 입장에서 프로덕트를 바라보게 된다.
라는 점들입니다. 제가 생각하기에, 프론트엔드의 테스트 코드는 주로 디자인 시스템과 같은 작은 단위의 컴포넌트를 단위 테스트 하는 케이스만 떠올랐는데, 장점을 보니 통합, E2E 테스트와 관련된 키워드 들이 많았습니다.
실용적인 테스트를 정의한다면
- 퍼널 테스트, 의존성이 다양한 스펙은 테스트 코드화
- 피드백 사이클이 긴 경우에, 이를 좁히고, 나누어서 테스트하기 위해서 사용하고, 나눈 테스트 기반으로 TDD
- Unit Test는, 사용자에게 의미 없는 경우가 많다보니, 토스 팀에서는 Integration Test 위주로 작성
- 테스트가 너무 쉬워보이는 문제는 테스트 도입을 피한다
동영상에서 제시한 실용적인 테스트의 가이드 라인입니다.
요약한다면, 토스팀에서는 “생산성” 이라는 본질에 집중한 테스트 코드를 사용하다보니, 통합 테스트 위주가 실용적이었다고 생각한 것 같습니다.
최대한 비슷하게 테스트 코드 경험해보기
토스에서 소개한 생산성을 가진 테스트 케이스는 대표적으로 다음과 같습니다.
- A/B 테스트 처럼 복잡한 환경에 따라 테스트하기 어려운 환경
- 외부 의존성에 따라 개발이 복잡해져, 피드백 사이클이 긴 경우
이러한 케이스를 보다 구체적으로 정의해서 큰 틀의 시나리오를 만들어 봤습니다.
- 프로모션이 활성화 된 Payment 기능을 도입하려하려 합니다. 프로모션이 진행중이라, 대상자들은 10% 할인을 자동적으로 적용합니다. (프로모션 대상자 API 존재)
- 프로모션 대상자는 10%의 상품 할인을 받습니다.
- 프로모션의 대상자가 아닌 경우 상품을 30000원에 구매합니다.
- 프로모션 참여율을 높일 테스트를 진행합니다. A 케이스에서는 구매 페이지에서 프로모션 참여 배너를 보여줍니다.
- B 케이스에선, 구매 완료 페이지에서 프로모션 참여 배너를 보여줍니다.
- Payment 기능은 디자인 시스템에 있는 컴포넌트를 활용하여 개발합니다.
이를 단위 테스트부터, 통합 테스트와 E2E 테스트로 나누어서 개발해 보겠습니다.
단위 테스트
단위 테스트는 하나만 짧게 진행하려고 합니다. 디자인 시스템에 존재하는 컴포넌트를 개발한다 가정했습니다.
<RadioGroups/>
기본적인 단위테스트를 작성해보면 다음과 같습니다.
- 라디오 버튼은 눌렀을 때 선택된 값이 변해야 하고,
- 초깃값이 주어졌을 때 해당 라디오 버튼이 선택되어야 합니다.
import { fireEvent, render } from "@testing-library/react"; import RadioGroups from "./radio-groups"; describe("Radio Groups 컴포넌트", () => { it("다른 라디오 버튼을 누르면 선택된 라디오 버튼이 변경된다.", () => { const { getByRole } = render( <RadioGroups groups={["group1", "group2"]} groupName="test" /> ); const group1Button = getByRole("radio", { name: "group1" }); fireEvent.click(group1Button); expect(group1Button).toHaveProperty("checked", true); }); it("초기 선택된 라디오 버튼이 있으면 그 버튼이 선택된 상태로 렌더링된다.", () => { const { getByRole } = render( <RadioGroups groups={["group1", "group2"]} groupName="test" initialSelectedValue="group2" /> ); const group2Button = getByRole("radio", { name: "group2" }); expect(group2Button).toHaveProperty("checked", true); }); });
아직 컴포넌트 개발 전이라 모든 Test 결과는 Fail입니다. 따라서 테스트 코드를 충족할 수 있는 컴포넌트를 개발해보겠습니다!
interface Props { initialSelectedValue?: string; groups: string[]; groupName: string; } const RadioGroups = ({ initialSelectedValue, groups, groupName }: Props) => { return ( <div> {groups.map((group) => ( <div key={group}> <input aria-label={group} role="radio" name={groupName} type="radio" key={group} value={group} defaultChecked={initialSelectedValue === group} /> <label htmlFor={group}>{group}</label> </div> ))} </div> ); }; export default RadioGroups;
개발 된 컴포넌트를 테스팅 해보겠습니다. 모두 Pass 했습니다!
작은 컴포넌트 단에서 겪을 인터렉션을 정상적으로 확인한 셈입니다.

또한, 테스트 케이스를 먼저 작성하면서 해당 컴포넌트가 가지는 중요한 인터렉션을 정의할 수 있었고, 이를 신경써서 개발할 수 있었습니다. TDD를 한 것이죠.
이제 통합 테스트로 넘어가 보겠습니다!
통합 테스트
이제, 통합 테스트를 통해서 컴포넌트들 끼리 상호작용하는 케이스들을 테스트 해보려고 합니다.
여기선 결제 결제 모듈이 로딩되었을 때의 시나리오를 가정해서 테스트 해보겠습니다.
PaymentForm은 다음과 같은 유저 플로우를 거치고 있습니다.
프로모션 유저 여부 받아오기
→ 프로모션 할인가 계산
→ 결제 수단 별 결제 Request
컴포넌트에서 해당 인터렉션을 구현한 부분은 다음과 같습니다. ( 언급이 필요하지 않은 부분은 생략했습니다.)
... const PaymentForm = ({ paymentHandler, product }: Props) => { ... const { data, isLoading: isLoadingPromotionUserState } = useQuery({ queryKey: ["promotion-user-state"], queryFn: () => fetch("api/promotion-user-state").then((res) => res.json()), }); useEffect(() => { if (data?.promotionUserState) { setPrice(product.price * 0.9); } }, [data?.promotionUserState]); ... const onSubmitPaymentRequest = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const paymentMethod = ( formRef.current?.elements.namedItem("payment-methods") as HTMLInputElement ).value; paymentHandler(paymentMethod?.toString() ?? "", price.toString()); }; return ( <form ref={formRef} onSubmit={onSubmitPaymentRequest} role="form"> <div> ... <li role="list" aria-label="카테고리"> 상품 가격:{" "} <span role="listitem" aria-label="product-price"> {isLoadingPromotionUserState ? "loading..." : price} </span> </li> </div> <div> 상품 결제 수단을 입력하세요. <RadioGroups groupName="payment-methods" groups={["toss", "account", "card"]} initialSelectedValue={"toss"} /> </div> <button role="button" aria-label="payment-submit" name="결제하기" disabled={isLoadingPromotionUserState} type="submit" > 결제하기 </button> </form> ); }; export default PaymentForm;
개발된 컴포넌트에서는
ReactQuery
를 활용해서, API를 호출하고 있고, 이벤트 핸들러와 가격 계산 로직을 갖고 있습니다. 이를 Testing Library
와 msw
라이브러리를 활용해서 테스트 해보겠습니다.그 전에, 간단하게 두 라이브러리를 설명하고 넘어가겠습니다.
Testing Library
: React 컴포넌트 테스트에 특화되어, 인터렉션이나 코드 스냅샷 테스트에 사용합니다.
msw
: 테스트 환경에서 API나 Global State를 Mocking 할 수 있게 도와줍니다.
여기서, 외부 의존성을 띄는 2가지 작업 ( 프로모션 여부, 결제 Request )는 Mocking 하여 처리하겠습니다.
import { http, HttpResponse } from "msw"; export const handlers = [ http.get("https://api.example.com/promotion-user-state", () => { return HttpResponse.json({ promotionUserState: false }); }), http.get("https://api.example.com/promotion-user", () => { return HttpResponse.json({ promotionUserState: true }); }), ];
msw
라이브러리를 통해서 Mocking API를 처리해주었고, 다음과 같은 핸들러로 묶어놓았습니다.또한, 실제 코드는
React Query
로 개발할 예정이기 때문에, 다름과 같은 Test Util 함수를 정의하여,
테스트 코드에서 ReactQuery를 테스트 할 수 있도록 설정하였습니다.import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, RenderOptions } from "@testing-library/react"; export const renderWithQueryClient = ( ui: React.ReactElement, options: RenderOptions ) => render(ui, { wrapper: ({ children }) => { return ( <QueryClientProvider client={new QueryClient()}> {children} </QueryClientProvider> ); }, ...options, });
이제 테스트 케이스를 작성해 봅시다!
import { fireEvent, screen, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import PaymentForm from "./payment-form"; import { setupServer } from "msw/node"; import { handlers } from "../mocks/payment-handlers"; import { renderWithQueryClient } from "../modules/test-utils"; const server = setupServer(...handlers); const PRODUCT = { name: "테스트 상품", price: 30000, }; beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); describe("UI 상태 테스트", () => { ... it("계좌이체 버튼을 누르면, 계좌 이체 선택이 checked 상태여야 한다.", () => { const { getByRole } = renderWithQueryClient( <PaymentForm paymentHandler={() => {}} product={PRODUCT} /> ); // account button click const accountButton = getByRole("radio", { name: "account" }); fireEvent.click(accountButton); expect(accountButton).toHaveProperty("checked", true); }); it("결제 버튼을 누르면, 선택한 결제 수단과 결제액이 전달되어야 한다.", async () => { const mockPaymentHandler = jest.fn(); renderWithQueryClient( <PaymentForm paymentHandler={mockPaymentHandler} product={PRODUCT} /> ); // submit button click const submitButton = screen.getByRole("button", { name: "payment-submit" }); // 폼 제출 이벤트 발생 fireEvent.submit(submitButton); // mockPaymentHandler가 호출되었는지 확인 expect(mockPaymentHandler).toHaveBeenCalledWith("toss", "30000"); expect(mockPaymentHandler).toHaveBeenCalledTimes(1); }); }); describe("API 연동 테스트", () => { ... it("로딩 후, 프로모션 유저의 가격을 표시한다.", async () => { server.use(handlers[1]); const { getByRole } = renderWithQueryClient( <PaymentForm paymentHandler={() => {}} product={PRODUCT} /> ); const priceElement = getByRole("listitem", { name: "product-price", }); await waitFor(() => { expect(priceElement).toHaveTextContent("27000"); }); }); it("로딩 후, 일반 유저의 가격을 표시한다.", async () => { server.use(handlers[0]); const { getByRole } = renderWithQueryClient( <PaymentForm paymentHandler={() => {}} product={PRODUCT} /> ); const priceElement = getByRole("listitem", { name: "product-price", }); // 로딩 상태가 사라질 때까지 기다림 await waitFor(() => { expect(priceElement).not.toHaveTextContent("loading..."); }); // 가격이 정확한지 확인 expect(priceElement).toHaveTextContent("30000"); }); });
테스트 단위는 다음과 같이 쪼갰습니다.
- 인터렉션 테스트
- 컴포넌트 단에서 호출하는 API 테스트
테스트 코드를 디테일하게 하나하나 설명하고 싶지만, 생각보다 테스트 코드를 보니 어떠한 로직을 테스트 하는지 한눈에 보이는 것 같습니다. 이게 테스트 코드의 기능 중의 하나라고 생각합니다.
이제, 다양한 컴포넌트와 로직이 결합된 코드를 테스트 했습니다. 그렇다면, 해당 컴포넌트를 활용한 화면 단에서 유저의 경험을 테스팅 해보겠습니다.
E2E 테스팅
가장 처음에 언급했던 시나리오를 다시 돌아보도록 하겠습니다.
프로모션이 활성화 된 Payment 기능을 도입하려하려 합니다. 프로모션이 진행중이라, 대상자들은 10% 할인을 자동적으로 적용합니다. (프로모션 대상자 API 존재)✅ Integration Test
프로모션 대상자는 10%의 상품 할인을 받습니다.✅ Integration Test
프로모션의 대상자가 아닌 경우 상품을 30000원에 구매합니다.✅ Integration Test
- 프로모션 참여율을 높일 테스트를 진행합니다. A 케이스에서는 구매 페이지에서 프로모션 참여 배너를 보여줍니다.
- B 케이스에선, 구매 완료 페이지에서 프로모션 참여 배너를 보여줍니다.
Payment 기능은 디자인 시스템에 있는 컴포넌트를 활용하여 개발합니다. →✅ Unit Test
앞서 개발한 테스트 코드를 통해, 서비스의 시나리오를 이 정도로 검증했습니다.
이제 좀 더 기능 전체적인 시야에서의 테스트만 남아서, 이를 E2E 테스트로 검증해보려 합니다.
이를 위해선, 일단 다른 화면들과 로직들을 개발해 둬야겠죠. 모두 언급할 수 없기 때문에, 개발한 화면과 중요 인터렉션만 짚고 넘어가겠습니다. ( 화면은 그냥 정의만 해 두어서 코드 언급은 안 하겠습니다. )
<결제 완료 화면/>
<프로모션 참여 화면/>
- AB Test를 위한 React Context Provider : 서버에서 AB 테스트 타깃을 응답받기 위해서 사용하고, 결제 완료 페이지와 결제 페이지에서 Consume 합니다.
자세한 코드 구현은 을 참고해주세요!
E2E 테스트 구성은 다음과 같이 정의 해 보았습니다.
- 정상적인 결제 유저 플로우
- AB 테스트에서 A 유저 테스팅
- AB 테스트에서 B 유저 테스팅
import { test, expect } from "@playwright/test"; test.describe("결제 페이지 시나리오", () => { test.beforeEach(async ({ page }) => { await page.route("**/api/ab-test", async (route) => { await route.fulfill({ status: 200, body: JSON.stringify({ isACase: true }), }); }); await page.goto("/payment"); }); test("결제 성공", async ({ page }) => { await page.route("**/api/payment", async (route) => { await route.fulfill({ status: 200, }); }); // 결제 버튼 누르고 await page.getByRole("button", { name: "payment-submit" }).click(); // 기다려서 성공 페이지 나오는 것 확인 await expect(page).toHaveURL("/payment/success"); }); test("결제 실패", async ({ page }) => { await page.route("**/api/payment", async (route) => { await route.abort("aborted"); }); // 결제 버튼 누르고 await page.getByRole("button", { name: "payment-submit" }).click(); // 기다려서 실패 알림 나오는 것 확인 await expect( page.getByRole("alert", { name: "payment-error" }) ).toBeVisible(); }); test("배너 클릭 시 프로모션 페이지로 이동", async ({ page }) => { await page.getByRole("banner", { name: "promotion-banner" }).click(); await expect(page).toHaveURL("/promotion"); }); }); test.describe("결제 시나리오 중, AB 테스트 검증 -> A 케이스", () => { test.beforeEach(async ({ page }) => { await page.route("**/api/ab-test", async (route) => { await route.fulfill({ status: 200, body: JSON.stringify({ isACase: true }), }); }); await page.goto("/payment"); }); test("A 케이스 배너 확인", async ({ page }) => { await expect(page).toHaveURL("/payment"); await expect( page.getByRole("banner", { name: "promotion-banner" }) ).toBeVisible(); }); }); test.describe("결제 시나리오 중, AB 테스트 검증 -> B 케이스", () => { test.beforeEach(async ({ page }) => { await page.route("**/api/ab-test", async (route) => { await route.fulfill({ status: 200, body: JSON.stringify({ isACase: false }), }); }); await page.goto("/payment"); }); test("결제 이후, 결제 페이지에서 B 케이스 배너 확인", async ({ page }) => { await page.route("**/api/payment", async (route) => { await route.fulfill({ status: 200, }); }); // 결제 버튼 누르고 await page.getByRole("button", { name: "payment-submit" }).click(); // 기다려서 성공 페이지 나오는 것 확인 await expect(page).toHaveURL("/payment/success"); await expect( page.getByRole("banner", { name: "promotion-banner" }) ).not.toBeVisible(); }); });
다음과 같이 코드를 작성했습니다.
유저가 입력한 상태에 따라 렌더링되는 Element를 확인하는 것, 페이지가 이동되는 걸 검증한 코드입니다.
해당 테스트 코드를 쓰면서, 어렴풋이 실제 서비스에서의 복잡한 플로우에서 더더욱 효과가 있겠구나 라는 생각이 들었습니다. 테스트 과정에서 에러가 발생할 때를 제외하고, 실제 개발서버의 웹을 켜서 일일히 눌러보는, 기존의 경험과는 많이 다른 과정이었습니다. 아래는 결제 성공 E2E 테스트가 녹화된 장면입니다. CLI 상으로는 들어나지 않지만, 뒤에서는 이러한 검증이 진행되고 있는 셈 입니다. 3개의 브라우저와 모바일을 대상으로 테스트 하니, 테스트 케이스를 한정, 매우 효율적으로 테스트를 처리할 수 있습니다.
결론
테스트 코드의 강점은 피드백 사이클을 줄이는 것이라는 말에, 처음에는 이를 이해하기 어려웠습니다.
HMR로 코드 변경사항을 바로 확인할 수 있으니, 수동으로 테스트하는 것도 충분히 빠르다고 생각했기 때문입니다. 하지만 새로운 기능을 개발할 때마다 모든 테스트 케이스를 직접 확인하는 작업은 단순히 시간 낭비일 뿐만 아니라, 실수로 인한 버그를 놓칠 위험도 있다는 것을 깨달았습니다.
테스트 코드는 이러한 문제를 해결해줍니다. 자동화된 테스트를 통해 피드백 사이클을 단축시키고, 개발자가 이해하기 쉬운 형태로 검증 과정을 문서화할 수 있습니다. 이는 코드의 신뢰성을 높이고 장기적으로 개발 생산성을 향상시키는 핵심 요소입니다.
물론 테스트 코드 도입에는 어려움이 있습니다. 엣지 케이스 검증이 까다롭고, 테스트 코드에 익숙하지 않은 팀원들은 초기에 생산성 저하를 경험할 수 있습니다.
하지만 모닥불에서 강조했듯이, 중요한 것은 "생산성"입니다. 무조건적인 테스트 코드 작성이 아닌, 서비스에 실질적인 가치를 더하는 테스트를 작성하고 유지하는 것이 핵심이라고 생각이 듭니다.
다음 포스팅엔 어떠한 테스트가 더 좋을지 더 고민해보려고 합니다. 또한 테스트 지표도 잘 정리해서 어떤 테스트가 좋을까? 에 대한 나름의 구체적인 기준을 내리고 싶습니다.