Logo

고지민 개발 블로그

Jest와 RTL로 프로젝트 테스트 도입하기
  • #Frontend
  • #Test

Jest와 RTL로 프로젝트 테스트 도입하기

고지민

2026-02-11

Jest와 React Testing Library를 기반으로 프로젝트에 테스트를 도입한 과정과, 격리된 유닛 테스트에서 사용자 중심의 통합 테스트로 관점을 전환하게 된 이유, 테스트 환경 구축과 주요 패턴들을 정리한 글입니다.

시작하며

이 블로그에도 꾸준히 올리고 있는 ‘모두의 후기’ 프로젝트가 생각보다 장기화되면서 여러 기능이 추가되고, 예상치 못한 문제들을 마주하기 시작했습니다.

바로.. 어느 순간부터 제가 짠 코드인데도 낯설게 느껴졌다는 건데요..

‘어라..? 이 컴포넌트에서 이 조건문은 왜 썼더라..?’

초창기에 구현한 코드는 기억이 가물가물해 PR을 다시 뒤적거리기도 했습니다.

무엇보다 새로운 기능을 신나게 추가하고 배포했는데, 전혀 건드리지 않았다고 생각한 부분이 터져서 식은땀 흘리며 fix 커밋을 날렸던 경험이 제일 힘들었던 것 같습니다.

결국 '내가 수정한 코드가 기존 로직을 망가뜨리지 않았다는 걸 배포 전에 미리 확신할 방법은 테스트뿐이다'라고 뼈저리게 느끼며 대대적으로 프로젝트에 테스트를 도입하게 됩니다.

사실 처음에는 사전에 버그를 잡을 수 있는 방패 정도로만 생각했습니다. 하지만 총 532개의 테스트 케이스를 작성하면서 제가 느낀 테스트의 진짜 가치는 ‘테스트 작성은 문서화다.’ 였는데요.

예시로 댓글의 낙관적 업데이트 기능을 검증하기 위해 작성한 테스트 케이스들을 가져와봤습니다.

it('댓글이 3개인 상태에서 댓글을 추가하면 즉시 4개가 표시된다.') it('댓글 등록이 실패하면 먼저 보여준 4개의 리스트를 다시 3개로 복구한다.') it('사용자가 작성한 댓글을 포함한 3개의 리스트에서 댓글을 삭제하면 즉시 2개의 댓글이 표시된다.') it('댓글 삭제가 실패하면 먼저 보여준 2개의 리스트를 다시 3개로 복구한다.')

이 문장들은 단순한 로직 검증이 아닌 서비스의 기획(UX) 요구사항을 그대로 담고 있는데요. PM이나 기획자의 시선에서 "댓글을 쓰면 새로고침 없이 즉시 화면에 떴으면 좋겠어요!", "삭제했을 때도 기다림 없이 바로 지워지게 해주세요!"라고 요구했던 기능 명세가 코드 레벨에 남았다고 볼 수 있습니다.

나중에 이 코드를 다시 열어보거나 새로운 팀원이 합류했을 때, 복잡한 내부 로직과 상태 관리 코드를 세세히 뜯어보지 않아도 괜찮습니다.

이 테스트 케이스(it 구문)들만 쓱 읽어봐도 "아, 이 컴포넌트는 API 응답을 기다리지 않고 화면을 먼저 업데이트하는구나", "실패했을 때 UI 롤백 처리까지 되어있구나"라는 것을 한눈에 파악할 수 있으니까요.

이번 글에서는 프로젝트에 테스트를 도입하며 환경을 어떻게 구축했고, 어떤 기준으로 테스트 범위를 다시 정리했는지 기록해보려고 합니다.

1. 테스트 도구 선택하기

우선 테스트를 도입하기에 앞서 어떤 레벨의 테스트를, 어떤 도구로 작성할지에 대한 기준을 먼저 세워야 했는데요.

E2E-Test vs Integration-Test

가장 먼저 떠오른건 Cypress나 Playwright 같은 E2E 테스팅 툴이었습니다. 실제 브라우저 환경에서 사용자의 플로우를 눈으로 확인하며 테스트할 수 있으니 굉장히 직관적인데요.

image1.png

하지만 테스트 피라미드 관점에서 보면, E2E 테스트는 피라미드 꼭대기에 위치하고 있습니다. 테스트 피라미드는 상단에 위치할수록 더 많은 통합 환경에서의 테스트가 가능하지만, 그만큼 비용이 많이 들어갑니다.

브라우저를 띄우고 실제 환경과 유사한 리소스를 요구하기 때문에 테스트 실행 속도도 느립니다. 즉, 더 확실한 검증은 가능하지만 그만큼 비용이 많이 드는 테스트입니다.

반대로 가장 하단에 위치한 유닛 테스트는 개별 기능에 대한 테스트 작성 등 비용도 적고 그만큼 속도도 빠르지만 통합 환경에서의 검증은 불가능하다는 단점이 존재합니다.

그래서 저는 속도와 안정성 사이에서의 스윗 스팟인 통합 테스트에 집중해보고자 했는데요. 브라우저를 직접 띄우는 대신 React Testing Library를 사용해 가상의 DOM 환경에서 사용자의 클릭, 타이핑 같은 동작을 검증했습니다.

위에서 언급했던 ‘낙관적 업데이트’처럼 여러 컴포넌트와 상태가 복잡하게 얽힌 플로우도 RTL을 활용한 통합 테스트만으로도 충분히 검증할 수 있었습니다.

그리고 조금 더 세밀하게 검증하거나 엣지 케이스를 검증할 때는 유닛 테스트로 빠르게 쳐내는 방식으로 나름 가성비 있는 테스트 전략을 세우게 됩니다.

Jest

테스트 도구를 선택할 때 최근 빠르다고 많이 언급되는 Vitest와 기존에 사용해봤던 Jest 사이에서 잠시 고민했습니다. Vitest는 Vite 기반 프로젝트에서 동일한 변환 파이프라인을 활용하기 때문에 빠른 실행 속도와 간단한 설정 경험을 제공한다고 합니다.

하지만 프로젝트가 현재 Next.js로 만들어져 있어 vite가 아닌 자체 빌드 시스템을 쓰기 때문에 그 장점을 100% 사용할 수도 없었고, 무엇보다 제 목표는 ‘새로운 테스트 도구 학습’이 아닌 기존 코드베이스에 테스트를 빠르게 도입해 ‘안정성을 확보’하는 것이었습니다.

이미 Jest를 사용해본 경험이 있었기 때문에 별도의 러닝 커브 없이 바로 테스트 환경을 구축할 수 있었고, 이는 초기 도입 비용을 줄이는 데에도 도움이 되었습니다.

사실 Vitest와 Jest는 문법과 API도 거의 동일해 보였습니다. 결국 테스트 환경에서 중요한건 특정 도구보다는 어떤 관점으로 테스트를 작성하고, 얼마나 견고한 테스트를 짜느냐라고 생각했습니다. 이런 이유로 이번 프로젝트에서는 Jest를 선택했습니다.

다만 Vitest 역시 흥미로운 테스트 러너라고 생각하기 때문에 다른 프로젝트에서는 Vitest도 한 번 경험해보고 싶다는 생각은 하고 있습니다.

2. 테스트 환경 구축하기

jest.config.ts

우선 Next.js 환경에서 Jest를 사용하기 위해 next/jest를 사용했는데요.

공식 문서를 참고해보자면, next/jest는 Next.js 프로젝트에서 Jest가 정상적으로 동작하기 위해 필요한 설정을 자동으로 구성해주는 유틸리티라고 합니다.

내부적으로는 SWC 기반 transform 설정, CSS 혹은 이미지 import 및 next/font 처리, .env 로딩, next.config.js 반영 등 Next.js 프로젝트에서 Jest가 동작하기 위해 필요한 여러 설정을 자동으로 구성해준다고 하네요. (babel 등을 사용한 변환 규칙을 직접 설정할 필요가 없습니다.)

import type {Config} from 'jest'; import nextJest from 'next/jest.js'; const createJestConfig = nextJest({ dir: './', }); const config: Config = { coverageProvider: 'v8', testEnvironment: 'jsdom', moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', }, setupFilesAfterEnv: ['<rootDir>/jest.setup.tsx'], collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'], coveragePathIgnorePatterns: [ '/node_modules/', 'api-service.ts', 'query-service.ts', 'stub.ts', '<rootDir>/src/shared/shadcnComponent/ui/', 'Loading\\.(ts|tsx)$', 'index.ts', 'Store.ts', 'type.ts', 'types.ts', '.*[cC]onfig\\.(js|ts|tsx)$', ], coverageThreshold: { global: { statements: 94, branches: 92, functions: 91, lines: 94, }, }, }; export default createJestConfig(config);

제가 프로젝트에 적용한 설정은 위와 같은데요. 하나씩 짚어보자면,

1. moduleNameMapper

moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' },

프로젝트에서 @/ 경로 별칭을 사용하고 있어 테스트 환경에서도 동일하게 동작하도록 moduleNameMapper에 alias 매핑을 추가했습니다.

2. setupFilesAfterEnv

setupFilesAfterEnv: ['<rootDir>/jest.setup.tsx']

테스트 실행 전 공통으로 필요한 설정이 모인 파일로 @testing-library/jest-dom 확장 matcher를 추가하거나, 전역 mock(fetch 등)을 등록하는 작업을 이 파일에서 처리합니다.

3. collectCoverageFrom

collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}']

Jest는 커버리지 수집 범위를 따로 지정하지 않을 경우, 만들어진 테스트 파일들에 대한 커버리지만 수집합니다. (처음엔 지정하지 않아 커버리지가 굉장히 높게 표시됐고, 추가 후 ‘진짜’ 커버리지를 확인했을 때 반토막이 나 절망했습니다.)

프로젝트에 FSD 아키텍처를 적용해 모든 코드가 src 아래에 위치하기 때문에 수집 범위를 src 하위 전체로 지정했습니다.

4. coveragePathIgnorePatterns

coveragePathIgnorePatterns: [ '/node_modules/', 'api-service.ts', 'query-service.ts', 'stub.ts', '<rootDir>/src/shared/shadcnComponent/ui/', 'Loading\\.(ts|tsx)$', 'index.ts', 'Store.ts', 'type.ts', 'types.ts', '.*[cC]onfig\\.(js|ts|tsx)$', ],

모든 파일을 무조건 커버리지 계산에 포함하면, 실제 품질과 무관하게 수치만 올라갈 수 있는데요. (커버리지 뻥튀기) 따라서 몇 가지 파일들을 커버리지 수집에서 제외했습니다.

  • 단순 래퍼, 팩토리 코드

    // src/entities/reviews/apis/api-service.ts export function getKeywordReviews(...) { return requestGet<KeywordReviewsResult>({ endpoint: '/search', }); } export function getCategoryReviews(...) { return requestGet<CategoryReviewsResult>({ endpoint: `/reviews`, }); }
    • api-service.ts: API 요청을 감싸는 래퍼로, 자체 로직보다는 프로젝트 공통 API 함수의 동작에 의존합니다.
    // src/entities/reviews/model/query-service.ts export const reviewsQueryKeys = { all: () => ['reviews'] as const, category: { all: () => [...reviewsQueryKeys.all(), 'category'] as const, category: (categoryId: Category) => [...reviewsQueryKeys.category.all(), categoryId] as const, page: (categoryId: Category, sort: string) => [...reviewsQueryKeys.category.category(categoryId), sort] as const, }, }; export const reviewsQueryOptions = { category: (categoryId: Category, sort: string) => ({ queryKey: reviewsQueryKeys.category.page(categoryId, sort), queryFn: ({pageParam}: {pageParam: number}) => getCategoryReviews(pageParam, categoryId, sort), initialPageParam: 0, }), };
    • query-service.ts: 리액트 쿼리의 쿼리 키 및 옵션 생성 팩토리로 비즈니스 로직 검증과 거리가 멉니다.
  • 테스트를 위한 데이터, 외부 라이브러리

    // src/features/reviews/category/ui/test/stub.ts export const createMockSearchReviewCard = (overrides: Partial<SearchReviewCard> = {}): SearchReviewCard => ({ board_id: 1, title: '테스트 리뷰', author_nickname: 'testUser', category: 'food', preview: '테스트 미리보기', comments_count: 5, bookmarks: 10, image_url: 'https://example.com/image.jpg', created_at: '2026-01-25', ...overrides, });
    • stub.ts: 테스트용 목 데이터 파일로 테스트에 필요한 데이터 생성 함수들이 위치합니다.
    • src/shared/shadcnComponent/ui/: shadcn UI 컴포넌트가 위치하는 폴더로 외부 UI 컴포넌트는 검증하지 않기 위해 제외했습니다.
  • Loading.tsx: 로딩, 스켈레톤 UI 파일

  • type.ts: 타입 정의 파일

  • index.ts: 배럴 export만 담당하는 엔트리 파일

5. coverageThreshold

coverageThreshold: { global: { statements: 94, branches: 92, functions: 91, lines: 94, }, },

마지막으로 커버리지가 일정 비율 아래로 떨어지면 CI 단계에서 실패하게 coverageThreshold를 설정했는데요.

현재 프로젝트 커버리지(약 statements 97%, branches 96%, functions 94%, lines 97%)를 기준으로 2~3% 정도 낮은 값으로 임계값을 잡았습니다.

이유는 이후 기능이 추가될 때 테스트가 누락되면 커버리지가 자연스럽게 떨어지고, 빌드 단계에서 바로 감지할 수 있기 때문입니다.

jest.setup.tsx

다음으로 테스트 실행 전 공통 환경을 설정해야 했는데요. 통합 테스트는 실제 브라우저가 아닌 jsdom에서 실행되기 때문에 일부 브라우저 API와 Next.js 고유 기능을 미리 모킹해야 했습니다.

import '@testing-library/jest-dom'; // useRouter, useSearchParams 등 모킹 jest.mock('next/navigation', () => jest.requireActual('next-router-mock/navigation') ); // Next.js 이미지 컴포넌트 모킹 jest.mock('next/image', () => ({ __esModule: true, default: ({priority, fill, ...props}) => ( <img loading={priority ? 'eager' : 'lazy'} className={fill ? 'absolute' : 'static'} {...props} /> ), })); // shadcn/ui 포인터 이슈 해결을 위한 모킹 window.HTMLElement.prototype.setPointerCapture = jest.fn(); window.HTMLElement.prototype.hasPointerCapture = jest.fn(); window.HTMLElement.prototype.scrollIntoView = jest.fn(); // https://stackoverflow.com/questions/68023284/react-testing-library-user-event-throws-error-typeerror-root-elementfrompoint/77219899#77219899 // Tiptap이 기반하고 있는 Prosemirror가 참조하는 레이아웃 정보를 jsdom에서 지원하지 않음. // 해당 레이아웃 관련 정보를 stub 처리 function getBoundingClientRect(): DOMRect { const rec = { x: 0, y: 0, bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, }; return {...rec, toJSON: () => rec}; } class FakeDOMRectList extends Array<DOMRect> implements DOMRectList { item(index: number): DOMRect | null { return this[index]; } } document.elementFromPoint = (): null => null; HTMLElement.prototype.getBoundingClientRect = getBoundingClientRect; HTMLElement.prototype.getClientRects = (): DOMRectList => new FakeDOMRectList(); Range.prototype.getBoundingClientRect = getBoundingClientRect; Range.prototype.getClientRects = (): DOMRectList => new FakeDOMRectList(); window.scrollBy = jest.fn(); // 모달 루트 요소 생성 - 테스트 종료 후 환경 자체가 사라지기 때문에 beforeAll로 한 번만 수행 beforeAll(() => { if (!document.getElementById('modal-root')) { const modalRoot = document.createElement('div'); modalRoot.setAttribute('id', 'modal-root'); document.body.appendChild(modalRoot); } });

이 설정 파일을 적용하기까지 자잘한 트러블 슈팅이 꽤 많았는데요. 주요 설정들을 하나씩 정리해보겠습니다.

1. Next.js 고유 기능 모킹하기 (router, image)

먼저 jsdom 환경에는 실제 브라우저 URL이나 Next.js 라우터 컨텍스트가 없기 때문에 useRouter, useSearchParams 등을 사용하는 컴포넌트를 테스트하려면 next/navigation 모킹이 필요했습니다.

jest.mock('next/navigation', () => ({ ...jest.requireActual('next/navigation'), useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn(), // 사용하는 메서드를 전부 모킹.. }), useSearchParams: () => ({...}) }));

하지만 컴포넌트마다 사용하는 라우터의 메서드나 파라미터를 모두 체크해 이 모킹 객체를 매번 수정하고 관리하는건 귀찮습니다.

jest.mock('next/navigation', () => jest.requireActual('next-router-mock/navigation') );

매 테스트마다 라우터 메서드와 파라미터를 직접 모킹하는 대신, next-router-mock 라이브러리를 도입해 라우터 환경 자체를 대체했습니다.

// NotificationBell.tsx <Link href="/notifications"> <LucideIcon name="Bell" /> </Link> // NotificationBell.spec.tsx import mockRouter from 'next-router-mock'; import {MemoryRouterProvider} from 'next-router-mock/MemoryRouterProvider'; it('알림벨 클릭 시 알림 페이지로 이동한다.', async () => { const user = userEvent.setup(); render(<NotificationBell />, {wrapper: MemoryRouterProvider}); const link = screen.getByRole('link'); await user.click(link); expect(mockRouter.asPath).toBe('/notifications'); });

덕분에 Link 클릭 후 특정 경로로 이동하는지 같은 라우팅 테스트를 훨씬 간단하게 작성할 수 있었습니다.

jest.mock('next/image', () => ({ __esModule: true, default: ({priority, fill, ...props}) => ( <img loading={priority ? 'eager' : 'lazy'} className={fill ? 'absolute' : 'static'} {...props} /> ), }));

다음으로 Next.js의 Image 컴포넌트 모킹입니다. Next.js의 Image 컴포넌트는 테스트 환경에서 일반 <img>처럼 동작하지 않습니다.

내부적으로 이미지 최적화 경로로 변환되기 때문에, 전달한 src가 그대로 렌더링되지 않아 단순 속성 검증 테스트가 실패할 수 있습니다. 그래서 테스트 환경에서는 next/image를 일반 <img>로 모킹했습니다.

2. jsdom에서 지원하지 않는 브라우저 API 모킹하기

다음으로 jsdom에서 지원하지 않거나 실제 브라우저와 다르게 동작하는 DOM API도 모킹해야 했는데요. jsdom은 실제 렌더링 엔진이 아니기 때문에, 포인터 이벤트나 레이아웃 계산 관련 API가 비어 있거나 충분히 구현되어 있지 않은 경우가 많았습니다.

window.HTMLElement.prototype.setPointerCapture = jest.fn(); window.HTMLElement.prototype.hasPointerCapture = jest.fn(); window.HTMLElement.prototype.scrollIntoView = jest.fn();

먼저 shadcn/ui(Radix UI 기반)처럼 포인터 이벤트를 사용하는 컴포넌트를 위해 몇 가지 API를 모킹했습니다. 실제 브라우저에서는 setPointerCapture, hasPointerCapture, scrollIntoView 등이 자연스럽게 동작하지만, jsdom에서는 비어 있거나 구현되어 있지 않아 테스트가 실패하기 때문입니다.

function getBoundingClientRect(): DOMRect { const rec = { x: 0, y: 0, bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, }; return {...rec, toJSON: () => rec}; } class FakeDOMRectList extends Array<DOMRect> implements DOMRectList { item(index: number): DOMRect | null { return this[index]; } } document.elementFromPoint = (): null => null; HTMLElement.prototype.getBoundingClientRect = getBoundingClientRect; HTMLElement.prototype.getClientRects = (): DOMRectList => new FakeDOMRectList(); Range.prototype.getBoundingClientRect = getBoundingClientRect; Range.prototype.getClientRects = (): DOMRectList => new FakeDOMRectList(); window.scrollBy = jest.fn();

다음은 레이아웃 계산 API 모킹입니다. 현재 프로젝트의 에디터 라이브러리인 Tiptap은 내부적으로 ProseMirror를 사용하며, 이 과정에서 getBoundingClientRect, getClientRects, elementFromPoint 같은 레이아웃 정보를 참조합니다.

하지만 jsdom은 화면을 실제로 렌더링하지 않기 때문에 이런 값을 의미 있게 계산할 수 없습니다.

그래서 테스트 환경에서는 관련 API를 모두 stub 처리해, 라이브러리가 기대하는 최소한의 인터페이스만 맞춰주었습니다. 예를 들어 getBoundingClientRect()는 0으로 채워진 DOMRect를, getClientRects()는 비어 있는 DOMRectList를 반환하도록 설정했습니다.

물론 실제 레이아웃을 재현하는 것은 아니고 라이브러리가 해당 API의 존재를 전제로 동작할 수 있게 만드는 수준입니다.

마지막으로 모달 컴포넌트를 위한 루트 요소도 사전에 생성해뒀는데요.

export function RootLayout() { return ( <html lang="en"> <body> <div id="modal-root" /> </body> </html> ); } // Modal.tsx import {createPortal} from 'react-dom'; export default function Modal() { const modal = document.getElementById('modal-root'); return ( <RemoveScroll> {createPortal( <FocusLock> <section role="alertdialog" aria-modal="true"> </FocusLock>, modal, )} </RemoveScroll> ); }

프로젝트의 모달은 React Portal을 통해 modal-root에 렌더링되도록 구현되어 있었습니다. 하지만 테스트 환경에는 실제 index.html이 없기 때문에 루트 노드도 직접 생성해야 했습니다.

beforeAll(() => { if (!document.getElementById('modal-root')) { const modalRoot = document.createElement('div'); modalRoot.setAttribute('id', 'modal-root'); document.body.appendChild(modalRoot); } });

그래서 테스트 시작 전에 modal-root를 한 번 생성해두었는데요. Jest는 테스트마다 새로운 jsdom 환경을 만들기 때문에 별도의 정리 코드는 필요하지 않았습니다.

테스트 자동화하기

테스트는 로컬에서만 실행하는 것으로 끝나지 않고 코드가 병합되거나 배포되기 전 자동으로 실행되어야 지속적인 품질 보장이 가능합니다. 그런 이유로 저는 github actions를 사용해 CI 단계에서의 테스트를 자동화했습니다.

현재 프로젝트에서는 main 브랜치에 변경이 감지되면 CI 파이프라인이 실행되고 있는데요. 따라서 파이프라인 내 빌드 단계 이전에 테스트가 먼저 실행될 수 있게 아래와 같이 설정했습니다.

name: Modu-Review-Client CI on: push: branches: - main jobs: build: steps: ... - name: Run Tests run: pnpm run test:ci ...빌드 및 배포 단계

test:ci 스크립트는 아래와 같이 구성되어 있습니다.

"test:ci": "jest --ci --watchAll=false --coverage"
  • —ci: CI 환경에서 Jest가 실행되도록 설정
  • —watchAll=false: watch 모드 비활성화
  • —coverage: 테스트 커버리지 리포트 생성

이 설정에 의해 테스트가 실패하거나 위에서 설정한 jest.config.tscoverageThreshold에 의해 일정 커버리지 수치 이하로 떨어지면 이후 빌드 및 배포 단계가 실행되지 않습니다.

즉 테스트가 실패하거나 커버리지 기준을 만족하지 못한 경우 CI 단계에서 파이프라인이 중단되며, 해당 코드는 배포 단계까지 진행되지 않습니다.

Pull Request에서 테스트 커버리지 리포트 확인하기

name: Modu-Review-Client Test Report on: pull_request: branches: - main jobs: coverage: runs-on: ubuntu-latest permissions: pull-requests: write contents: read checks: write steps: - name: checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 with: version: 10 - name: Use Node.js uses: actions/setup-node@v4 with: node-version: 24 cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile - name: Jest coverage report uses: ArtiomTr/jest-coverage-report-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} package-manager: pnpm test-script: pnpm run test:ci annotations: all custom-title: '모두의 후기 테스트 커버리지'

다음으로 Pull Request 단게에서도 우리 코드의 품질을 확인할 수 있는 별도의 워크플로우를 구성했는데요. 자세한 사용 방법은 링크로 첨부합니다.

image2.png

위 사진과 같이 main 브랜치를 대상으로 PR이 생성되면 테스트를 실행하고, 현재 브랜치의 테스트 커버리지가 기존 코드 대비 어떻게 변화했는지 해당 PR에 comment로 표시합니다.

전체 테스트 커버리지와 변경된 코드 기준 커버리지 변화, 파일별 커버리지 정보를 제공하기 때문에 코드 리뷰 과정에서 코드의 품질까지 함께 확인 가능한 환경을 만들 수 있었습니다.

3. 변인 통제 범위 다시 생각해보기

유닛 테스트를 고집했던 이유

처음 테스트를 도입할 당시에는 가능한 철저하게 격리된 유닛 테스트를 작성하려고 했는데요. 하나의 테스트는 오직 하나의 대상만 검증해야 하고, 외부 요인에 의해 결과가 흔들리면 안된다고 생각했기 때문입니다.

당시 AI를 활용해 테스트 코드를 빠르게 작성하고 있었는데, AI에게 내린 테스트 생성 지침의 핵심 원칙은 아래 세 가지였습니다.

  • 단순 UI 렌더링이 아니라 비즈니스 로직과 사용자 상호작용을 검증할 것
  • 테스트 대상 컴포넌트 외부의 모든 의존성(커스텀 훅, 하위 컴포넌트 등)은 모두 모킹할 것
  • 정상 / 엣지 / 에러 케이스를 모두 포함할 것

사용했던 테스트 생성 지침 문서는 아래와 같습니다.

guide1.png guide2.png guide3.png

이 지침을 사용하니, 단 2일만에 60개가 넘는 테스트 파일이 쏟아져 나왔습니다.

처음에는 꽤 만족했습니다. 테스트가 외부 요인에 의해 더럽혀지지 않고 완전히 격리된 상태로 실행됐고, 테스트가 실패하면 원인은 무조건 그 컴포넌트 안에 있었으니까요.

하지만 FSD(Feature-Sliced Design) 방법론에 따라 분리된 features 레이어의 주요 컴포넌트 테스트를 작성하던 중 의문을 가지게 됩니다.

‘다른 레이어의 코드를 죄다 모킹해놓고, 그걸 또 별도로 다시 테스트하는 이 구조가 과연 옳은가?’

  • 현재 프로젝트는 FSD 방법론을 적용해 레이어가 분리된 상태입니다. 해당 내용은 FSD를 적용한 방법에 정리되어 있습니다.

예를 들어 features 레이어의 컴포넌트 테스트를 작성할 때, 내부에서 사용하는 entities 레이어의 훅과 컴포넌트는 모두 모킹되어 있었습니다. 이 말은 features 레이어의 테스트에서는 entities 레이어의 실제 동작이 전혀 검증되지 않는다는 의미였습니다.

결국 entities 레이어의 훅과 컴포넌트에 대한 테스트를 다시 작성해야 하는 문제가 발생하게 됩니다.

처음에는 테스트를 잘게 분리해 효율적으로 검증한다고 생각했지만, 실제로는 하나의 기능(예: 북마크 토글)을 여러 레이어에서 각각 테스트해야 하는 구조가 만들어지고 있었습니다.

철저한 유닛 테스트가 만든 문제

앞서 작성한 문제점을 하나의 예시로 가져와봤는데요. 게시글의 북마크 수를 표시하고, 북마크를 토글할 수 있는 Bookmarks 컴포넌트입니다.

import {useIsLoggedIn} from '@/entities/auth'; import {useGetReviewBookmarks, useToggleBookmark} from '@/entities/review'; export default function Bookmarks({reviewId, openLoginModal}: Props) { const { data: {bookmarks, hasBookmarked}, } = useGetReviewBookmarks(reviewId); const isLoggedIn = useIsLoggedIn(); const {toggleBookmark, isPending} = useToggleBookmark(); const handleClick = () => { if (!isLoggedIn) { openLoginModal(); return; } toggleBookmark({reviewId}); }; return ( <button onClick={handleClick} disabled={isPending}> {/* ... 아이콘 및 북마크 수 렌더링 ... */} </button> ); }

이 컴포넌트는 단순히 버튼 하나를 그리는 것이 아니라,

  • 현재 게시글의 북마크 수를 조회하고 (useGetReviewBookmarks)
  • 로그인 여부에 따라 동작을 분기하고 (useIsLoggedIn, handleClick)
  • 현재 북마크 상태에 따라 서로 다른 요청을 보내고 (useToggleBookmark)
  • 낙관적 업데이트를 통해 UI를 즉시 변경하는 (useToggleBookmark)

여러 비즈니스 로직이 결합된 컴포넌트였습니다. 하지만 당시 작성된 테스트를 보자면 아래와 같이 모든 내부 훅들을 모킹하고 있었습니다.

jest.mock('@/entities/review'); jest.mock('@/entities/auth'); const mockUseGetReviewBookmarks = useGetReviewBookmarks as jest.MockedFunction<typeof useGetReviewBookmarks>; const mockUseToggleBookmark = useToggleBookmark as jest.MockedFunction<typeof useToggleBookmark>; const mockUseIsLoggedIn = useIsLoggedIn as jest.MockedFunction<typeof useIsLoggedIn>; mockUseGetReviewBookmarks.mockReturnValue({ data: { bookmarks: 5, hasBookmarked: true, }, } as unknown as ReturnType<typeof useGetReviewBookmarks>); mockUseToggleBookmark.mockReturnValue({ toggleBookmark: jest.fn(), isPending: false, } as unknown as ReturnType<typeof useToggleBookmark>);

이렇게 모든 의존성을 mock으로 대체하면 검증 가능한건 ‘로그인 상태에서 토글 함수가 호출되는가?’ 같은 수준에 머뭅니다.

정작 사용자가 실제로 경험하는 핵심 기능, 예를 들어 낙관적 업데이트로 UI가 즉시 바뀌는지, 실패 시 이전 상태로 복구되는지는 테스트 범위 밖에 남게 됩니다.

결국 제게 두 가지 문제가 남게 되는데요.

  1. 핵심 로직에 대한 검증을 별도의 훅 테스트로 다시 작성해야 한다.
  2. 코드가 실제로 결합된 상태에서 발생하는 버그를 잡을 수 없다.

테스트 수는 빠르게 늘었지만 사용자 경험을 검증한다는 목적에서는 점점 멀어지게 됩니다.

테스트 범위 다시 생각하기 - 구현 상세에서 사용자 경험으로

이 시점에 스스로에게 ‘정말 테스트해야 하는 것은 무엇일까?’라는 질문을 하게 됐는데요. 처음에는 모든 의존성을 모킹해 컴포넌트를 완전히 격리하는 것이 좋은 테스트라고 생각했습니다. 하지만 실제로 이 방식이 오히려 핵심 비즈니스 로직을 놓치게 만든다는걸 깨닫게 됩니다.

게다가 제가 격리하겠다며 모킹해 버렸던 entities 레이어의 훅들은 대부분 리액트 쿼리를 감싼 형태였는데요. 이 훅들을 컴포넌트와 분리해서 따로 유닛 테스트를 작성해 봤자 결국 리액트 쿼리의 캐싱이나 상태 전이 같은 '라이브러리의 내부 동작'을 무의미하게 다시 검증하는 꼴이었습니다.

제가 진짜 확인해야 했던 것은 라이브러리가 잘 도는지, 혹은 개별 훅이 고립된 상태에서 어떤 값을 뱉는지가 아니었습니다. 그 훅들이 컴포넌트와 실제로 결합되었을 때 그 결과가 우리 서비스의 UI에 올바르게 반영되고 사용자의 상호작용에 맞게 변하는가였습니다.

이런 관점에서 변인 통제 범위를 다시 정의했는데요.

  • 변경 전 (격리된 유닛 테스트): 커스텀 훅, 하위 컴포넌트, 네트워크 요청 등 모든 것을 모킹
  • 변경 후 (사용자 중심의 통합 테스트):
    • API 요청만 테스트 데이터로 통제한다.
    • 외부 라이브러리는 필요한 최소 범위만 모킹한다. (framer-motion 등)
    • 어플리케이션 내부의 훅과 컴포넌트는 가능한 실제 구현을 그대로 실행한다.

코드 구조보다 사용자가 화면에서 무엇을 보고 어떤 상호작용을 하는지를 기준으로 시나리오를 다시 구성했습니다.

이 과정에서 DRY보다 DAMP에 더 가까운 방향을 택했습니다. 테스트 코드의 중복을 조금 감수하더라도 각 파일만 읽어도 기능 명세를 이해할 수 있게 만들고 싶었습니다.

이 기준을 적용해 기존 테스트를 다시 작성하기 시작했고, 그 과정에서 격리된 테스트에선 보이지 않던 몇 가지 버그도 발견할 수 있었는데요. 한 가지 사례를 가져와봤습니다.

댓글 등록 실패 시 라우트가 복구되지 않던 문제

우리 서비스의 게시글 댓글은 한 페이지당 8개씩 페이지네이션 형태로 제공하고 있습니다.

사용자 경험을 위해 8개가 꽉 찬 페이지에서 새로운 댓글을 작성하면 API 응답을 기다리지 않고 즉시 다음 페이지 캐시를 생성한 뒤 라우트(?page=2)를 이동시키는 낙관적 업데이트를 적용하고 있는데요.

이런 로직을 구현한 usePostReviewComment 훅의 초기 코드는 아래와 같았습니다.

export default function usePostReviewComment(page: number) { const queryClient = useQueryClient(); const router = useRouter(); const {mutate} = useMutation({ mutationFn: (payload) => postReviewComment(payload), onMutate: async ({content}) => { // ... 낙관적 업데이트 로직 ... if (wasNewPageCreated) { // 꽉 찬 상태에서 댓글 등록 시, 다음 페이지 캐시를 만들고 라우트를 이동 (예: 1 => 2) router.push(`?page=${page + 1}`, {scroll: false}); } return {previousComments, wasNewPageCreated}; }, onError: (_, _, context) => { if (!context) return; const {previousComments, wasNewPageCreated} = context; // 에러 시 이전 페이지로 복구 - 문제가 발생한 지점! if (wasNewPageCreated) { router.push(`?page=${page}`, {scroll: false}); } }, }); return { postComment: mutate }; }

이렇게만 보면 논리적으로 완벽해 보입니다. onMutate에서 page + 1로 이동했으니, 실패(onError)하면 원래 인자로 받았던 page로 다시 돌려보내면 된다고 생각했습니다.

하지만 변경된 통합 테스트 환경에서 '댓글 등록이 실패했을 경우'를 테스트하자 바로 실패하게 됐는데요.

it('댓글 등록이 실패하면 이동한 경로를 복구한다.', async () => { // 1. 초기 상태: 1페이지에 8개의 댓글이 꽉 차 있음 mockRouter.push('/?page=1'); // 2. API 요청이 무조건 실패(reject)하도록 모킹 let rejectPostReviewComment: any; mockPostReviewComment.mockImplementation(() => new Promise((_, reject) => { rejectPostReviewComment = reject; })); // 3. 댓글을 입력하고 등록 버튼 클릭 await user.type(textArea, TEST_CONTENT); await user.click(button); // 4. 낙관적 업데이트 성공 검증: 즉시 2페이지로 이동하고 댓글이 1개 보여야 함 expect(mockRouter.asPath).toBe('/?page=2'); expect(within(commentList).getAllByRole('listitem')).toHaveLength(1); // 5. API 실패 시키기 rejectPostReviewComment(); // 6. 에러 복구 검증 - 여기서 테스트가 실패함! await waitFor(() => { expect(mockRouter.asPath).toBe('/?page=1'); // Expected: /?page=1, Received: /?page=2 expect(within(commentList).getAllByRole('listitem')).toHaveLength(8); }); });

에러가 났음에도 라우터는 1페이지로 돌아가지 못하고 2페이지에 머물러 있었습니다. 왜 2페이지에 머물고 있었을까요?

원인은 URL 변경으로 인해 컴포넌트가 다시 렌더링되면서 훅에 전달되는 page 값이 바뀌기 때문이었는데요.

  1. onMutate에서 낙관적 업데이트로 router.push('?page=2')를 실행합니다.
  2. URL이 변경되었으므로 컴포넌트가 리렌더링됩니다.
  3. 리렌더링되면서 훅에 전달되는 page 파라미터의 값이 1에서 2로 바뀝니다.
  4. API 요청이 실패하여 onError 콜백이 실행될 때, 이 콜백은 가장 최근에 렌더링된 훅의 page 값(2)을 참조하게 됩니다.
  5. 결국 router.push('?page=2')가 실행되어 원래 페이지(1)로 돌아가지 못하게 됩니다.

예전처럼 훅을 모킹한 상태였다면 URL 변경에 따른 리렌더링 자체를 재현할 수 없었을 것이고, 비즈니스 로직을 함께 검증하는 통합 테스트였기 때문에 발견할 수 있었습니다.

이 문제를 발견한 이후 onMutate에서 요청 시점의 page 값을 context로 함께 반환하고, onError에서는 해당 값을 기준으로 라우트를 복구하도록 수정했습니다.

이 경험으로 개별 로직이 아니라 상태와 라우팅이 함께 얽힌 실제 사용자 입장에서 검증해야만 잡히는 버그가 분명히 존재한다는 걸 체감하게 됩니다.

4. 테스트 작성 패턴

위에서 정리한 기준에 맞춰 테스트를 다시 작성하면서 몇 가지 공통 패턴이 생겼습니다. 핵심은 API 응답만 테스트 데이터로 통제하고, 어플리케이션 내부의 훅과 컴포넌트는 가능한 실제 구현 그대로 실행하는 것이었는데요.

제가 반복적으로 사용했던 패턴들을 몇 가지 정리해보겠습니다.

패턴 1. API 요청만 stub 데이터로 통제하기

기존 테스트에서는 컴포넌트 내부의 커스텀 훅과 하위 컴포넌트를 모두 모킹했습니다. 하지만 이후에는 네트워크 요청(API 함수)만 테스트 데이터로 통제하고 나머지 로직은 실제 코드가 실행되도록 변경했는데요.

API 응답을 생성하는 stub 데이터 생성 함수를 별도로 두고 테스트에서는 해당 함수가 반환하는 데이터를 API 요청의 응답으로 사용했습니다.

예를 들어 검색 리뷰 데이터를 테스트할 때는 아래와 같은 stub 생성 함수를 사용했습니다.

// 검색 결과 stub 생성 함수 export const createMockKeywordReviewsResult = ( keyword: string, reviewCount: number = 3, currentPage: number = 1, totalPages: number = 1, ): KeywordReviewsResult => ({ results: Array.from({length: reviewCount}, (_, idx) => createMockSearchReviewCard({ board_id: idx + 1, title: `${keyword} 검색 리뷰 page: ${currentPage}`, }), ), current_page: currentPage, total_pages: totalPages, });

그리고 테스트 환경에서는 API 호출 함수(getKeywordReviews)만 모킹해 이 stub 데이터를 반환하도록 설정했습니다.

jest.mock('@/entities/reviews/apis/api-service'); const mockGetKeywordReviews = getKeywordReviews as jest.MockedFunction<typeof getKeywordReviews>; // API가 호출되면 실제 서버 대신 미리 준비한 stub 데이터를 반환 mockGetKeywordReviews.mockImplementation(keyword => Promise.resolve(createMockKeywordReviewsResult(keyword, 3)), );

테스트 대상 컴포넌트는 내부적으로 리액트 쿼리 기반 훅을 사용해 데이터를 불러오고 그 결과를 화면에 렌더링합니다. (아래 코드는 설명을 위해 단순화한 예시입니다!)

function useKeywordReviews(...) { const {data} = useSuspenseQuery({ queryFn: getKeywordReviews, ... }); return data; } export default function ReviewWithPagination({sort}: Props) { const {keyword} = useParams(); const {results} = useKeywordReviews(keyword, sort); return ( <ul> {results.map((review) => ( <li key={review.board_id}>{review.title}</li> ))} </ul> ); }

최종적으로 이런 형식의 테스트를 작성하게 됩니다.

it('검색어에 대한 리뷰 목록이 렌더링된다.', async () => { mockRouter.push('/search/pizza'); mockGetKeywordReviews.mockImplementation(keyword => Promise.resolve(createMockKeywordReviewsResult(keyword, 3)), ); render( withAllContext( <Suspense fallback={<div>loading</div>}> <ReviewWithPagination sort="recent" /> </Suspense>, ), ); await waitForElementToBeRemoved(screen.getByText('loading')); expect(mockGetKeywordReviews).toHaveBeenCalledWith('pizza', 1, 'recent'); expect(screen.getAllByText(/pizza 검색 리뷰/)).toHaveLength(3); });

이 테스트는 세 가지를 함께 검증합니다.

  • 라우터 상태에 따라 올바른 파라미터로 API가 호출되는가?
  • Suspense fallback이 실제로 렌더링되었다가 사라지는가?
  • API 응답으로 주입한 stub 데이터가 UI에 정상적으로 반영되는가?

이렇게 내부 구현에 의존하지 않으면서도 실제 사용자 경험과 가까운 환경에서 기능을 검증할 수 있게 됩니다.

패턴 2. 테스트 전용 실행 환경 주입하기

API 응답만 통제하고 내부 훅은 실제로 실행하려면, 리액트 쿼리 같은 상태 관리 라이브러리가 동작할 수 있는 실행 환경도 테스트에 함께 주입해야 하는데요. 따라서 테스트에는 공통으로 사용할 컨텍스트 주입 함수를 만들었습니다.

import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; export function withAllContext(children: React.ReactNode) { const testQueryClient = createTestQueryClient(); return ( <QueryClientProvider client={testQueryClient}> {children} </QueryClientProvider>; ) } function createTestQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: 0, }, }, }); }

현재는 리액트 쿼리만 주입하면 되지만 이후 필요한 컨텍스트가 생겨도 이 함수 안에서 확장할 수 있도록 구성했습니다.

실서비스에서는 아래와 같이 리액트 쿼리 프로바이더를 별도로 두고, 에러 처리나 캐시 동작 정책까지 함께 설정하고 있습니다.

const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 1000 * 60 * 5, gcTime: 1000 * 60 * 5, retry: 0, throwOnError: (error: Error) => error instanceof RequestGetError && error.errorHandlingType === 'errorBoundary', }, }, queryCache: new QueryCache({ onError(error) { if (error instanceof RequestGetError && error.errorHandlingType === 'errorBoundary') return; if (error instanceof RequestError) updateError(error); }, }), mutationCache: new MutationCache({ onError(error) { if (error instanceof RequestError) updateError(error); }, }), }), );

테스트에서는 이 설정을 그대로 사용하지 않고, 필요한 최소한의 QueryClient만 별도로 생성했습니다.

전역 에러 처리나 캐시 정책 같은 기능에 테스트가 불필요하게 의존하지 않게 하고, 실패 케이스에서 자동 재시도로 인해 테스트가 느려지거나 흔들리는 것도 막기 위해서였습니다. 쉽게 실제 훅은 그대로 실행하되 실행 환경만 테스트 목적에 맞게 가볍게 조정했습니다.

render( withAllContext( <Suspense fallback={<div>loading</div>}> <ReviewWithPagination sort="recent" /> </Suspense>, ), );

공통 래퍼를 두니 QueryClientProvider를 반복해서 작성하지 않아도 됐고, 테스트 환경 설정도 한 곳에서 관리할 수 있었습니다.

패턴 3. 접근성 기반 쿼리와 userEvent로 ‘실제 사용자’처럼 테스트하기

저는 테스트를 작성할 때 요소를 찾고 상호작용을 재현하는 방식에 접근성 기반 쿼리와 userEvent를 사용하고 있습니다.

예를 들자면 사용자가 클릭하거나 입력하는 요소를 찾을 때 getByText로 문자열만 찾기보다, 아래처럼 getByRole, getByLabelText, getByPlaceholderText 같은 접근성 기반 쿼리를 우선적으로 사용하는 편입니다.

const foodCategoryButton = screen.getByRole('button', {name: '카테고리: 음식'}); const sortOptions = screen.getByRole('combobox'); const searchBar = screen.getByPlaceholderText('후기를 검색하세요');

이렇게 요소를 찾으면 단순히 '음식'이나 ‘정렬' 같은 문자열이 화면에 존재하는지에서 더 나아가 사용자가 실제로 인식하는 역할과 이름을 기준으로 요소를 찾을 수 있습니다.

아래와 같은 방식은 겉보기에는 간단해 보일 수 있습니다.

const comboBox = screen.getByText('정렬'); const foodCategoryButton = screen.getByText('음식');

하지만 테스트가 화면에 보이는 문자열 자체에 과하게 의존하게 됩니다. 문구가 조금만 바뀌거나 텍스트 위치가 달라져도 기능은 그대로인데 테스트가 쉽게 깨질 수 있습니다.

역할과 접근 가능한 이름을 기준으로 요소를 찾으면 테스트의 의도가 더 분명해지고 내부 마크업이 조금 바뀌더라도 사용자가 경험하는 동작이 유지되는 한 상대적으로 더 견고한 테스트를 작성할 수 있습니다.

또한 상호작용은 fireEvent 대신 userEvent를 사용해 실제 사용자 플로우에 가깝게 재현했는데요.

fireEvent는 특정 DOM 이벤트를 직접 발생시키는 데는 유용하지만, 실제 사용자의 행동을 재현하기에는 한계가 있습니다. 예를 들어 아래와 같은 폼 컴포넌트가 있다고 가정해보겠습니다.

function ReviewForm({ onSubmit }) { const [content, setContent] = useState(''); const handleSubmit = (e) => { e.preventDefault(); onSubmit(content); } return ( <form aria-label="리뷰 폼" onSubmit={handleSubmit}> <textarea value={content} onChange={(e) => setContent(e.target.value)} /> {/* 내용이 비어있으면 클릭할 수 없는 제출 버튼 */} <button type="submit" disabled={content.trim().length === 0}> 제출 </button> </form> ) }

내용이 비어있을 때 사용자는 버튼이 disabled 상태이므로 절대 폼을 제출할 수 없습니다. 하지만 이 상황을 fireEvent로 테스트하면 어떨까요?

const form = screen.getByRole('form', { name: '리뷰 폼' }); fireEvent.submit(form); expect(mockOnSubmit).toHaveBeenCalled(); // 통과

fireEvent.submit은 UI 레이어의 버튼 비활성화를 무시하고 로우 레벨의 DOM 이벤트를 폼 요소에 직접 발생시켜 버립니다. 실제 환경에서는 동작하지 않는 코드임에도 테스트는 통과합니다.

const user = userEvent.setup(); const submitButton = screen.getByRole('button', { name: '제출' }); // 버튼이 disabled 상태이므로 클릭 이벤트 무시 await user.click(submitButton); expect(mockOnSubmit).not.toHaveBeenCalled();

하지만 userEvent는 실제 사용자가 물리적으로 상호작용하는 메커니즘을 따르기 때문에 비활성화된 버튼에서는 클릭 자체가 발생하지 않습니다.

user.type(searchBar, 'macbook{enter}'); user.tab();

추가로 userEvent는 타이핑이나 탭 이동처럼 실제 입력 과정을 더 가깝게 재현한다는 점에서도 유용했습니다.

이제 앞서 살펴본 접근성 기반 쿼리와 userEvent를 사용해 ‘카테고리 검색' 기능을 검증한 코드를 가져와봤는데요. (더 자세한 코드는 아래 링크로 첨부합니다.)

export default function CategoryReviews() { const {selectedCategory, handleSelectCategory} = useSelectCategoryFromUrl(); const {sort, handleChange} = useSelectSortOption({ options: {categoryId: selectedCategory}, }); return ( <section> <CategoryBar selectedCategory={selectedCategory} onSelectCategory={handleSelectCategory} /> <SelectSortOptions sort={sort} onValueChange={handleChange} /> <ReviewsWithScroll selectedCategory={selectedCategory} sort={sort} /> </section> ); }

이 기능에서 중요했던 것은 내부 훅의 호출 순서가 아니라 사용자가 옵션을 바꿨을 때 화면의 리뷰 데이터가 기대한 대로 바뀌는가였습니다. 이런 기준으로 작성한 테스트는 아래와 같습니다.

it("카테고리를 '음식'으로 바꾸면 음식 관련 리뷰를 표시한다.", async () => { const user = userEvent.setup(); mockGetCategoryReviews.mockImplementation((_cursor, categoryId) => { if (categoryId === 'food') { return Promise.resolve(createMockCategoryReviewsPage('food', 3)); } return Promise.resolve(createMockCategoryReviewsPage('all', 4)); }); mockRouter.push('/?categoryId=all&sort=recent'); render(withAllContext(<CategoryReviews />)); await waitForElementToBeRemoved(screen.getByText('loading')); const foodCategoryButton = screen.getByRole('button', {name: '카테고리: 음식'}); await user.click(foodCategoryButton); await waitFor(() => { expect(mockRouter.asPath).toBe('/?categoryId=food&sort=recent'); expect(mockGetCategoryReviews).toHaveBeenLastCalledWith(0, 'food', 'recent'); expect(screen.getAllByText(/food 리뷰/)).toHaveLength(3); }); });

이 테스트는 아래의 사용자 동작을 그대로 검증합니다.

  • 사용자가 카테고리 버튼을 클릭한다.
  • URL의 categoryId가 변경된다.
  • 해당 값에 맞는 데이터 요청이 발생한다.
  • 화면에 렌더링되는 리뷰 목록이 변경된다.

함수 호출 여부만 따로 확인하는 것이 아니라 사용자의 행동 ⇒ 상태 변화 ⇒ UI 결과를 동시에 검증합니다.

마찬가지로 드롭다운을 통한 정렬 옵션 변경도 실제 사용자가 드롭다운을 열고 옵션을 선택하는 흐름 그대로 테스트를 작성했습니다.

const sortOptions = screen.getByRole('combobox'); await user.click(sortOptions); const bookmarkSortOption = screen.getByText('북마크순'); await user.click(bookmarkSortOption);

이렇게 사용자 기준으로 테스트를 작성하면 내부 구현에 덜 의존하게 됩니다. 함수 구조나 props 전달 방식이 바뀌더라도 사용자가 경험하는 동작이 같다면 테스트도 안정적으로 유지됩니다. 또 요소를 역할과 접근 가능한 이름으로 찾다 보니 자연스럽게 시맨틱 마크업이나 aria-label 같은 접근성 리팩터링도 진행하게 됐습니다.

무한 스크롤 테스트하기

우리 프로젝트의 카테고리별 리뷰 목록과 사용자별 리뷰 목록에는 무한 스크롤이 적용되어 있었습니다. 예시로 무한 스크롤이 적용된 컴포넌트를 가져와봤는데요.

export default function ReviewsWithScroll({selectedCategory, sort}: Props) { const {data, hasNextPage, fetchNextPage, isFetchingNextPage} = useCategoryReviews(selectedCategory, sort); const observerRef = useCallback( (node: HTMLDivElement | null) => { if (!node) return; const observer = new IntersectionObserver(entries => { if (entries[0].isIntersecting && hasNextPage) fetchNextPage(); }); observer.observe(node); return () => observer.disconnect(); }, [hasNextPage, fetchNextPage], ); return ( <> <ul>{/* 데이터 표시 */}</ul> {hasNextPage ? ( <div ref={observerRef} data-testid="observer"> {isFetchingNextPage && <ReviewArticleLoading />} </div> ) : ( <p>더 이상 불러올 게시글이 없어요.</p> )} </> ); }

하지만 이 코드는 테스트 환경에서 그대로 동작하지 않습니다. JSDOM에는 IntersectionObserver가 없기 때문에 단순한 scroll 이벤트만으로는 무한 스크롤을 재현할 수 없었습니다. 그래서 스크롤 자체가 아니라 ‘관찰 대상이 뷰포트에 들어왔다’는 상태를 직접 만들어줘야 했습니다.

별도로 이런 동작을 구현하는 방법을 찾던 중, 이미 이런 동작을 구현한 jsdom-testing-mocks 라이브러리의 mockIntersectionObserver를 찾게 됐습니다. (자세한 사용 방법은 링크를 참고해주세요.)

테스트 코드를 보기 전에 useCategoryReviews가 페이지 데이터를 어떻게 가져오는지만 간단히 보면 아래와 같습니다.

// query-service.ts (쿼리 옵션 팩토리) export const reviewsQueryOptions = { category: (categoryId: Category, sort: string) => ({ queryKey: ['reviews', 'category', categoryId, sort], // 무한 스크롤의 pageParam이 getCategoryReviews API의 첫 번째 인자(cursor)로 전달됨 queryFn: ({pageParam}: {pageParam: number}) => getCategoryReviews(pageParam, categoryId, sort), initialPageParam: 0, }), }; // useCategoryReviews.ts (커스텀 훅) export default function useCategoryReviews(categoryId: Category, sort: string) { return useSuspenseInfiniteQuery({ ...reviewsQueryOptions.category(categoryId, sort), getNextPageParam: lastPage => { // API 응답에 has_next가 참이면 next_cursor를 다음 페이지 파라미터로 설정 if (lastPage.has_next) return lastPage.next_cursor; }, }); }

테스트에서는 getCategoryReviews를 모킹할 때 첫 번째 인자인 cursor를 기준으로 각 페이지 데이터를 반환하도록 제어할 수 있습니다. 이 동작을 바탕으로 작성한 테스트 시나리오는 아래와 같습니다.

import {act} from 'react'; import {mockIntersectionObserver} from 'jsdom-testing-mocks'; const createMockCategoryReviewsPage = ( category: Category = 'all', reviewCount: number = 3, hasNext: boolean = false, startId: number = 1, ): CategoryReviewsResult => ({ results: Array.from({length: reviewCount}, (_, idx) => createMockSearchReviewCard({ board_id: startId + idx, title: `리뷰 ${startId + idx}`, category, }), ), next_cursor: hasNext ? startId + reviewCount : null, has_next: hasNext, }); const intersectionObserver = mockIntersectionObserver(); it('마지막 리뷰까지 스크롤하면 다음 목록을 불러오고, 더 이상 불러올 리뷰가 없다면 문구를 표시한다.', async () => { mockGetCategoryReviews.mockImplementation(cursor => { if (cursor === 0) { return Promise.resolve(createMockCategoryReviewsPage('all', 8, true, 0)); } if (cursor === 8) { return Promise.resolve(createMockCategoryReviewsPage('all', 8, true, 8)); } return Promise.resolve(createMockCategoryReviewsPage('all', 6, false, 16)); }); render( withAllContext( <ReviewsWithScroll selectedCategory="all" sort="recent" /> ) ); await waitForElementToBeRemoved(screen.getByText('loading')); expect(screen.getAllByRole('listitem')).toHaveLength(8); const observer = screen.getByTestId('observer'); act(() => { intersectionObserver.enterNode(observer); }); await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(16); }); act(() => { intersectionObserver.enterNode(observer); }); await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(22); expect(screen.getByText('더 이상 불러올 게시글이 없어요.')).toBeInTheDocument(); }); });
  1. cursor 값에 따라 1페이지(8개) ⇒ 2페이지(8개) ⇒ 마지막 페이지(6개)를 응답하도록 모킹합니다.
  2. Suspensefallback이 사라지길 기다린 후, 첫 렌더링 시 1페이지(8개) 요소가 표시되는지 검증합니다.
  3. 옵저버 요소를 찾습니다.
  4. 물리적인 스크롤 대신 act(() => intersectionObserver.enterNode(observer))를 호출해 관찰 대상 요소가 뷰포트에 들어온 상황을 강제로 트리거합니다.
  5. 화면에 2페이지(8개) 요소가 표시되는지 검증합니다.
  6. 다시 관찰 요소가 뷰포트에 들어온 상황을 트리거합니다.
  7. 화면에 마지막 페이지(6개) 요소가 표시되는지와 문구가 표시되는지 검증합니다.

이런 방식으로 브라우저 API에 의존하는 무한 스크롤도 테스트 환경에서 통제하며 검증할 수 있었습니다. 무한 스크롤은 사용자 인터랙션에 따라 데이터 요청 시점이 달라지는 기능이었기 때문에 테스트로 확인하고 싶었던 부분이기도 합니다.

5. 유닛 테스트와 통합 테스트 적절히 섞기

이번 프로젝트에 테스트를 도입하면서 가장 뿌듯했던 성과라면 **코드 커버리지 97%**라는 높은 수치를 달성하고 유지할 수 있는 환경을 구축했다는 점인데요. 하지만 이 수치는 단순히 '유닛 테스트를 무식하게 많이 작성해서' 얻은 결과는 아닙니다.

오히려 모든 컴포넌트와 훅을 잘게 쪼개 유닛 테스트만 작성했을 때는 커버리지를 올리기도, 유지하기도 더 어려웠습니다. (어차피 AI가 무식하게 쏟아내긴 했지만 그걸 검수하는건 여전히 피곤했습니다.)

높은 커버리지 수치를 달성한건 사용자 중심의 통합 테스트를 주로 작성하고 세세한 로직만 유닛 테스트로 보완하는 전략 덕분이지 않았나 싶습니다.

큼직한 기능은 통합 테스트로 커버하기

우리 서비스의 알림 기능을 예로 들어보겠습니다. 이 기능은 내부적으로 꽤 깊은 컴포넌트 계층과 훅으로 쪼개져 있는데요. 실제 코드를 간략하게 살펴보자면

// 1. 최상단 컨테이너 - 데이터를 불러오고 목록을 렌더링 export default function NotificationList() { const {results} = useGetNotifications(currentPage); // 목록 조회 훅 return ( <ul> {results.map(notification => ( <li key={notification.id}> <NotificationCard notification={notification} /> </li> ))} </ul> ); } // 2. 카드 래퍼 - 아이템과 삭제 버튼을 묶어서 렌더링 export default function NotificationCard({notification}: Props) { return ( <> <NotificationItem notification={notification} /> <NotificationDeleteButton id={notification.id} /> </> ); } // 3. 아이템 컴포넌트 - 클릭 시 읽음 처리 API 호출 및 라우팅 export default function NotificationItem({notification}: Props) { const {markNotificationAsRead} = useMarkNotificationAsRead(); // 읽음 처리 훅 // ...클릭 핸들러 등 } // 4. 삭제 버튼 컴포넌트 - 클릭 시 삭제 API 호출 및 낙관적 업데이트 export default function NotificationDeleteButton({id}: Props) { const {deleteNotification, isPending} = useDeleteNotification(); // 삭제 훅 // ...클릭 핸들러 등 }

이렇게 4개의 UI 컴포넌트와 3개의 상태 관리 훅(useGetNotifications, useMarkNotificationAsRead, useDeleteNotification), 그리고 리액트 쿼리 키를 관리하는 query-service.ts까지 총 8개의 파일이 얽혀 있습니다.

이 8개의 파일을 어떻게 한 번에 테스트할 수 있을까요? 바로 NotificationList.spec.tsx라는 단 하나의 통합 테스트를 작성하는 것입니다.

테스트 시나리오를 설명하기 전에, 통합 테스트 환경을 구성하기 위해 반복적으로 사용되는 렌더링 세팅을 setupRender라는 유틸 함수로 분리했는데요.

const setupRender = async (route = '/?page=1') => { const user = userEvent.setup(); mockRouter.push(route); render( withAllContext( <Suspense fallback={<div>loading...</div>}> <NotificationList /> </Suspense>, ), ); await waitForElementToBeRemoved(screen.queryByText('loading...')); return {user}; };

통합 테스트는 하위 컴포넌트와 커스텀 훅들이 모두 실제로 동작하기 때문에 라우터 컨텍스트와 리액트 쿼리 프로바이더(withAllContext), 그리고 Suspense 처리가 필요합니다. 이제 3가지 사용자 시나리오를 하나씩 검증해 보겠습니다.

시나리오 1. 알림 보기

가장 먼저 사용자가 알림 페이지에 진입했을 때 알림 목록이 화면에 정상적으로 표시되는지 검증합니다.

describe('렌더링 테스트', () => { it('알림이 있는 경우 알림 목록을 정상적으로 보여준다.', async () => { mockGetNotifications.mockResolvedValue(createNotificationListStub(3)); await setupRender(); const notificationList = screen.getByRole('list', {name: '알림 목록'}); expect(within(notificationList).getAllByRole('listitem')).toHaveLength(3); expect(screen.getByText('게시글2에 새로운 댓글이 달렸어요.')).toBeInTheDocument(); }); });

이 테스트 하나로 useGetNotifications가 데이터를 가져오는지, 그리고 그 데이터가 NotificationList => NotificationCard => NotificationItem을 거쳐 화면에 정상적으로 렌더링되는지를 함께 검증할 수 있습니다.

내부 구조와 상관없이 ‘데이터가 주어지면 알림 목록이 화면에 표시된다’는 사용자 경험을 기준으로 검증할 수 있고, 동시에 여러 컴포넌트와 훅을 한 번에 커버할 수 있습니다.

시나리오 2. 알림 읽기

다음으로 알림 목록 중 아직 읽지 않은 알림을 클릭했을 때 올바른 게시글 주소로 이동하는지, 그리고 서버에 읽음 처리 요청을 제대로 보내는지 검증합니다.

it('안 읽은 알림을 클릭하면 해당 게시글로 이동하고 읽음 처리 요청한다.', async () => { const {user} = await setupRender(); const notificationItem = screen.getByLabelText('게시글2로 이동', {selector: 'button'}); await user.click(notificationItem); expect(mockRouter.asPath).toBe('/reviews/2'); expect(mockMarkAsRead).toHaveBeenCalledTimes(1); expect(mockMarkAsRead).toHaveBeenCalledWith(2); });

이 테스트는 클릭 한 번으로 두 가지를 함께 검증합니다. 사용자가 알림을 클릭했을 때 라우터가 올바른 게시글 주소로 이동하는지, 그리고 useMarkNotificationAsRead가 함께 실행되어 읽음 처리 요청이 전달되는지를 확인합니다.

중요한 건 ‘라우터와 뮤테이션이 어떤 순서로 실행되는지’가 아니라, ‘사용자가 버튼을 눌렀을 때 화면이 이동하고 서버에 읽음 기록이 남는가’였습니다. 통합 테스트는 이 비즈니스적 요구사항 자체를 그대로 검증하게 됩니다.

시나리오 3. 알림 삭제하기

마지막으로 사용자가 알림 삭제 버튼을 눌렀을 때의 동작을 검증합니다.

이 테스트에서는 사용자 경험을 위해 API 응답을 기다리지 않고 화면에서 알림을 즉시 지워버리는 낙관적 업데이트가 제대로 동작하는지도 함께 검증합니다.

it('알림 삭제 버튼 클릭 시 API 응답을 기다리지 않고 목록에서 즉시 제거된다.', async () => { const targetNotification = createNotificationStub(99); const targetNotificationMessage = NOTIFICATION_CONFIG[targetNotification.type].getMessage(targetNotification.title); // ... 모킹 세팅 생략 ... let resolveDeleteNotification: any; mockDeleteNotification.mockImplementation(() => { return new Promise(resolve => { resolveDeleteNotification = resolve; }); }); const {user} = await setupRender(); const notificationList = screen.getByRole('list', {name: '알림 목록'}); expect(within(notificationList).getAllByRole('listitem')).toHaveLength(4); const deleteButton = screen.getByLabelText(`${targetNotification.title} 알림 삭제`, {selector: 'button'}); await user.click(deleteButton); expect(within(notificationList).getAllByRole('listitem')).toHaveLength(3); expect(screen.queryByText(targetNotificationMessage)).not.toBeInTheDocument(); resolveDeleteNotification(); await waitFor(() => { expect(within(notificationList).getAllByRole('listitem')).toHaveLength(3); expect(screen.queryByText(targetNotificationMessage)).not.toBeInTheDocument(); }); });
  1. mockDeleteNotification이 즉시 완료되지 않도록 Promise를 반환하게 하고, resolve를 외부로 꺼내 요청을 의도적으로 대기 상태에 둡니다.
  2. 버튼 클릭 직후, 아직 응답이 오지 않았음에도 알림 개수가 4개에서 3개로 줄어드는지 확인해 onMutate의 낙관적 업데이트를 검증합니다.
  3. 이후 resolve를 호출한 뒤에도 UI가 롤백되지 않고 삭제 상태를 유지하는지 확인합니다.

이런 방식으로 Promiseresolve를 따로 캡쳐해두면, 뮤테이션 훅의 onMutate가 의도한 대로 동작하는지도 검증할 수 있었습니다.

조회, 읽음, 삭제라는 사용자 시나리오 3개만으로도 관련된 8개 파일의 로직이 함께 실행되며 높은 커버리지를 얻을 수 있었는데요. 파일별로 비슷한 테스트를 반복하지 않아도 되고, 컴포넌트들이 실제로 결합된 상태에서 기능이 동작한다는 확신도 얻을 수 있었습니다.

세부 로직은 유닛 테스트로 보완하기

통합 테스트가 큰 기능 단위를 검증한다면, 유닛 테스트는 그 과정에서 놓치기 쉬운 세부 UI 분기나 디테일을 보완하는 역할을 합니다. 모든 경우의 수를 통합 테스트에 넣기 시작하면 테스트 코드가 비대해지기 때문에, 경우의 수가 많거나 복잡한 개별 요소는 별도의 유닛 테스트로 분리했습니다.

이전 통합 테스트에서 다뤘던 알림 기능 중 단일 알림을 렌더링하는 NotificationItem 컴포넌트를 예로 들어보겠습니다.

// notification-config.ts export const NOTIFICATION_CONFIG = { comment: { icon: 'MessageCircle', bgColor: 'bg-red-300', title: '누군가 댓글을 남겼어요.', getMessage: (title: string) => `'${title}' 게시글에 댓글을 남겼어요!`, }, bookmark: { icon: 'Bookmark', bgColor: 'bg-black', title: '누군가 게시글을 저장했어요.', getMessage: (title: string) => `'${title}' 게시글을 저장했어요!`, }, } as const; // NotificationItem.tsx export default function NotificationItem({notification}: Props) { const {isRead, title, created_at, type} = notification; const config = NOTIFICATION_CONFIG[type]; return ( <button className={`... ${isRead ? 'bg-gray-200' : 'bg-white'} ...`} > <div className={`${config.bgColor} ...`}> <LucideIcon name={config.icon} /> </div> <div> <p>{config.title}</p> <p>{config.getMessage(title)}</p> </div> <p>{created_at}</p> </button> ); }

NotificationItem은 타입과 읽음 여부에 따라 아이콘, 배경색, 텍스트가 달라집니다.

이런 UI 분기까지 NotificationList 통합 테스트에서 모두 검증하기 시작하면 테스트가 비대해지기 때문에, ‘알림을 누르면 이동한다’ 같은 기능 단위는 통합 테스트로, 상태에 따른 시각적 분기는 NotificationItem.spec.tsx 같은 유닛 테스트로 분리했습니다.

describe('NotificationItem.tsx', () => { const defaultNotificationStub = { id: 1, board_id: 1, isRead: true, title: '테스트', created_at: '2026년 1월 24일', type: 'bookmark' }; it('북마크 타입의 알림은 검은색 배경과 Bookmark 아이콘을 표시한다.', () => { const notification = { ...defaultNotificationStub, type: 'bookmark' }; render(withAllContext(<NotificationItem notification={notification} />)); const icon = screen.getByText('Bookmark'); expect(icon).toBeInTheDocument(); expect(icon.parentElement).toHaveClass('bg-black'); }); it('댓글 타입의 알림은 빨간색 배경과 MessageCircle 아이콘을 표시한다.', () => { const notification = { ...defaultNotificationStub, type: 'comment' }; render(withAllContext(<NotificationItem notification={notification} />)); const icon = screen.getByText('MessageCircle'); expect(icon).toBeInTheDocument(); expect(icon.parentElement).toHaveClass('bg-red-300'); }); it('읽지 않은 알림은 배경 색을 흰색으로 표시한다.', () => { const notification = { ...defaultNotificationStub, isRead: false }; render(withAllContext(<NotificationItem notification={notification} />)); const button = screen.getByLabelText('테스트 게시글로 이동', {selector: 'button'}); expect(button).toHaveClass('bg-white'); }); it('이미 읽은 알림은 배경 색을 회색으로 표시한다.', () => { const notification = { ...defaultNotificationStub, isRead: true }; render(withAllContext(<NotificationItem notification={notification} />)); const button = screen.getByLabelText('테스트 게시글로 이동', {selector: 'button'}); expect(button).toHaveClass('bg-gray-200'); }); });

이렇게 타입과 상태에 따라 아이콘, 배경색, 텍스트가 올바르게 조합되는지 같은 UI 디테일을 별도로 검증할 수 있습니다.

복잡한 순수 로직도 유닛 테스트로 검증하기

유닛 테스트는 UI 디테일뿐 아니라, 예외 처리와 엣지 케이스가 많은 순수 함수에도 유용했는데요. 예를 들어 에디터에서 링크 삽입 시 안전한 URL인지 검증하는 유틸리티 함수를 구현했었습니다.

function validateLinkUrl({url, ctx, onError}: Props): boolean { const parsedUrl = url.includes(':') ? new URL(url) : new URL(`${ctx.defaultProtocol}://${url}`); if (!ctx.defaultValidate(parsedUrl.href) || url.startsWith('./')) { const linkError = createClientError('INVALID_LINK_URL'); onError(linkError); return false; } const disallowedProtocols = ['javascript', 'data', 'ftp', 'file', 'mailto', 'http']; const protocol = parsedUrl.protocol.replace(':', ''); if (protocol !== 'https' || disallowedProtocols.includes(protocol)) { const protocolError = createClientError('INVALID_LINK_PROTOCOL'); onError(protocolError); return false; } return true; }

이 함수는 프로토콜이 없는 URL에 기본 프로토콜을 붙여 파싱하고, 기본 검증 실패나 상대 경로를 차단하며, 허용되지 않은 프로토콜도 막아 최종적으로 안전한 URL에만 true를 반환합니다.

구현 직후에는 당연하게 보였던 규칙도, 몇 달 뒤 다시 보면 쉽게 흐려집니다. mailto:도 막았는지, example.com처럼 프로토콜 없는 입력은 어떻게 처리하는지 코드를 다시 뜯어봐야 할 수도 있습니다. 이런 경우 유닛 테스트는 검증 도구이면서 동시에 좋은 명세서가 됩니다.

describe('src/features/review/editor/lib/validateLinkUrl.ts', () => { // ...초기 모킹 세팅 describe('정상 케이스', () => { it('유효한 https URL은 true를 반환한다.', () => { // ... }); it('프로토콜 없는 URL은 https를 추가하여 검증한다.', () => { const result = validateLinkUrl({ url: 'example.com', ctx: defaultCtx, onError: mockOnError }); expect(result).toBe(true); }); it('쿼리 파라미터가 있는 https URL은 true를 반환한다.', () => { // ... }); }); describe('보안 - 위험한 프로토콜 차단', () => { it('javascript: 프로토콜은 false를 반환하고 에러를 호출한다.', () => { const result = validateLinkUrl({ url: 'javascript:alert("XSS")', ctx: defaultCtx, onError: mockOnError }); expect(result).toBe(false); expect(mockOnError).toHaveBeenCalledWith(createClientError('INVALID_LINK_PROTOCOL')); }); it('data: 프로토콜은 false를 반환하고 에러를 호출한다.', () => { // ... }); // ftp, file, mailto, http 등 차단 테스트 }); describe('보안 - 상대경로 차단', () => { it('./ 로 시작하는 상대경로는 false를 반환하고 에러를 호출한다.', () => { const result = validateLinkUrl({ url: './path/to/page', ctx: defaultCtx, onError: mockOnError }); expect(result).toBe(false); expect(mockOnError).toHaveBeenCalledWith(createClientError('INVALID_LINK_URL')); }); }); });

유닛 테스트의 장점은 분명했는데요. 화면을 띄우는 통합 테스트보다 훨씬 빠르게 많은 엣지 케이스를 검증할 수 있었고 describeit 문장 자체가 함수의 스펙을 설명하는 문서 역할도 해줬습니다.

결론적으로 제가 가져간 테스트 전략은 ‘큰 덩어리의 사용자 경험은 통합 테스트로 가성비 있게, 복잡한 UI 분기나 많은 엣지 케이스를 가진 순수 로직은 유닛 테스트로 검증하기’ 였습니다.

이 전략이 코드 커버리지 97%를 안정적으로 달성하고 유지할 수 있었던 최적의 밸런스였던 것 같습니다. 만약 제가 모킹 범위를 좁게만 잡고 모든 컴포넌트와 훅을 유닛 테스트로만 검증하려고 고집했다면, 97%라는 수치는 달성하기도 어려웠을뿐더러 유지보수조차 불가능한 '예쁜 쓰레기'가 되었을지도 모릅니다.

마치며

총 532개의 테스트를 작성하며 프로젝트 전반에 테스트 환경을 도입하는 과정은 꽤 길고 지루했습니다. 어느정도 마무리된 지금 돌아보면 아쉬운 점도 있었고 그만큼 얻은 것도 많았는데요.

테스트의 도입 시기

아쉬웠던 점은 테스트를 도입한 시기였습니다. 물론 테스트를 작성하면서 미처 잡아내지 못했던 버그들을 몇 가지 발견하고 고치는 성과도 있었습니다.

하지만 대부분의 기능 구현이 끝난 이후에 테스트를 덧붙이는 방식으로 진행하다 보니 테스트를 기반으로 안전하게 리팩터링하거나 버그를 사전에 예방하는 경험까지 이어지지는 못했습니다.

이번 경험을 통해 테스트의 가치를 체감한 만큼 다음에는 처음부터 테스트를 함께 가져가는 방식으로 개발해보고 싶다는 생각이 들었습니다.

그럼에도 얻은 것

가장 크게 달라진 건 코드를 바라보는 기준이라고 생각하는데요.

통합 테스트를 작성하면서 자연스럽게 ‘이 코드가 어떻게 동작하는가?’보다 ‘사용자가 이 화면을 어떻게 보고, 어떻게 행동하는가?’를 먼저 고민하게 됐습니다.

그 과정에서 시맨틱 마크업, aria 속성, 명확한 role이 왜 중요한지도 자연스럽게 체감하게 됐습니다. 테스트를 작성하기 위해 코드를 바꾸는 과정이 곧 더 접근 가능한 UI로 리팩터링하는 과정이기도 했습니다.

결국 이번 테스트 도입에서 얻은 가장 큰 수확은 커버리지 수치가 아니라 ‘무엇을 어떤 수준에서 검증해야 하는지를 판단하는 기준’이라고 생각합니다.

큰 사용자 경험은 통합 테스트로, 복잡한 로직과 디테일은 유닛 테스트로 나누어 바라보는 시각. 이 기준이 앞으로 기능을 추가하고 코드를 변경하는 과정에서도 계속 기준점이 되어줄 것이라고 생각합니다.

긴 글 읽어주셔서 감사합니다.

내가 FSD를 적용한 방법

내가 FSD를 적용한 방법

AI로 데이터 콜드 스타트 극복하기 (서버 편)

AI로 데이터 콜드 스타트 극복하기 (서버 편)