Testing/Stories in unit tests

김동현·2026년 3월 22일

유닛 테스트에서 스토리 재사용하기 (Stories in unit tests)

React | Vue | Angular | Web Components | 그 외(More)

개발팀은 각기 다른 여러 도구들을 사용해서 UI의 다양한 측면을 테스트합니다. 문제는 이 도구들을 쓸 때마다 똑같은 컴포넌트의 똑같은 상태를 매번 새로 구현해 줘야 한다는 거죠. 이건 정말 귀찮고 유지보수하기 힘든 일입니다. 가장 이상적인 건 테스트 환경을 비슷하게 하나 차려두고, 이 도구 저 도구에서 다 같이 재사용하는 거겠죠.

스토리북은 컴포넌트를 분리하고 그 사용 사례들을 *.stories.js|ts 파일에 깔끔하게 담아낼 수 있게 해 줍니다. 이 스토리 파일들은 전체 자바스크립트 생태계 어디서든 쓸 수 있는 아주 평범한(standard) 자바스크립트 모듈이에요.

즉, 스토리는 UI 테스트를 시작하기에 완벽한 베이스캠프라는 뜻입니다! Jest, Testing Library, Vitest, 그리고 Playwright 같은 테스트 도구들에 스토리를 임포트(import)해서 사용하면, 시간도 절약하고 유지보수 수고도 확 덜어낼 수 있습니다.

Testing Library로 테스트 작성하기 (Write a test with Testing Library)

Testing Library는 브라우저 환경에서 컴포넌트를 테스트할 수 있도록 도와주는 아주 유명한 유틸리티 라이브러리 모음입니다. 스토리들이 컴포넌트 스토리 포맷(CSF)으로 작성되어 있다면, Testing Library에서 그대로 재사용할 수 있어요. 이름이 지정된(named export) 각각의 스토리는 테스트 환경 안에서 바로 렌더링 가능한 형태거든요.

예를 들어, 로그인 컴포넌트를 만들고 잘못된 비밀번호를 입력했을 때의 시나리오를 테스트하고 싶다면, 아래처럼 작성할 수 있습니다.

스토리북은 테스트 파일에서 스토리를 가져와 JSDOM을 사용하는 Node 테스트 환경에서 렌더링 할 수 있도록 도와주는 composeStories라는 마법 같은 유틸리티를 제공합니다. 이 함수는 프로젝트에 설정해 둔 스토리북 기능들(예: 데코레이터(decorators), args)까지 테스트 환경에 그대로 적용해 줍니다. 덕분에 JestVitest 같은 원하는 테스트 환경에서 스토리를 마음껏 재사용할 수 있고, 스토리를 고치면 테스트도 자동으로 동기화되니 테스트를 두 번 작성할 필요가 없죠! 스토리북에서는 이렇게 자유롭게 넘나들 수 있는 스토리를 포터블 스토리(portable stories)라고 부릅니다.

import { fireEvent, render, screen } from '@testing-library/react';
 
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import { composeStories } from '@storybook/your-framework';
 
import * as stories from './LoginForm.stories'; // 👈 Our stories imported here.
 
const { InvalidForm } = composeStories(stories);
 
test('Checks if the form is valid', async () => {
  // Renders the composed story
  await InvalidForm.run();
 
  const buttonElement = screen.getByRole('button', {
    name: 'Submit',
  });
 
  fireEvent.click(buttonElement);
 
  const isFormValid = screen.getByLabelText('invalid-form');
  expect(isFormValid).toBeInTheDocument();
});

⚠️ 주의: 스토리가 데코레이터 같은 스토리북 설정 요소들과 완벽하게 합쳐지려면, 반드시 테스트 환경이 포터블 스토리를 사용하도록 설정(configure your test environment to use portable stories)되어 있어야 합니다!

테스트가 실행되면 먼저 스토리가 불려 와서 화면에 렌더링 됩니다. 그런 다음 Testing Library가 마치 진짜 사용자인 것처럼 행동(버튼 클릭 등)을 흉내 내고, 컴포넌트의 상태가 예상대로 업데이트되었는지 확인(check)하게 됩니다.

스토리 속성 덮어쓰기 (Override story properties)

기본적으로 setProjectAnnotations 함수는 기존 테스트 환경에 여러분이 스토리북에 지정해 둔 전역 설정들(preview.js|ts에 있는 parameters나 decorators 등)을 싹 밀어 넣습니다(injects). 하지만 가끔은 특정 테스트에서 이 전역 설정 때문에 골치 아픈 사이드 이펙트가 발생할 수도 있죠. 예를 들어 어떤 특정 언어(locale) 환경에서만 스토리를 테스트하고 싶다거나(globalTypes 활용), 특정 스토리에만 고유한 decoratorsparameters를 적용하고 싶을 때 말이에요.

이런 상황을 피하고 싶다면, composeStorycomposeStories 함수를 확장해서 테스트 전용 설정을 덮어씌워(override) 주시면 됩니다.

// compose-stories를 사용하는 경우
import { fireEvent, render, screen } from '@testing-library/react';
 
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import { composeStories } from '@storybook/your-framework';
 
import * as stories from './LoginForm.stories';
 
const { ValidForm } = composeStories(stories, {
  decorators: [
    // 여기서 정의된 데코레이터들은 이 함수를 통해 만들어진 '모든' 스토리에 일괄 추가됩니다.
  ],
  globalTypes: {
    // 이 함수를 통해 만들어진 '모든' 스토리들의 전역 변수(globals)를 덮어씁니다.
  },
  parameters: {
    // 이 함수를 통해 만들어진 '모든' 스토리들의 파라미터(parameters)를 덮어씁니다.
  },
});
// compose-story를 사용하는 경우
import { fireEvent, screen } from '@testing-library/react';
 
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import { composeStory } from '@storybook/your-framework';
 
import Meta, { ValidForm as ValidFormStory } from './LoginForm.stories';
 
const ValidForm = composeStory(ValidFormStory, Meta);
 
test('Validates form', async () => {
  await ValidForm.run();
 
  const buttonElement = screen.getByRole('button', {
    name: 'Submit',
  });
 
  fireEvent.click(buttonElement);
 
  const isFormValid = screen.getByLabelText('invalid-form');
  expect(isFormValid).not.toBeInTheDocument();
});
 
test('Tests filled form', async () => {
  await ValidForm.run();
 
  const buttonElement = screen.getByRole('button', {
    name: 'Submit',
  });
 
  fireEvent.click(buttonElement);
 
  const isFormValid = screen.getByLabelText('invalid-form');
  expect(isFormValid).not.toBeInTheDocument();
});

여러 스토리를 하나의 테스트에 모아 담기 (Combine stories into a single test)

만약 하나의 테스트 안에서 여러 개의 스토리를 묶어서 검사하고 싶다면, composeStories 함수를 써보세요. 이 함수는 여러분이 지정한 컴포넌트의 모든 스토리들을(argsdecorators까지 전부 다 포함해서) 한꺼번에 처리해 줍니다.

// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { Page } from './Page';
 
//👇 Header의 모든 스토리를 임포트합니다.
import * as HeaderStories from './Header.stories';
 
const meta = {
  component: Page,
} satisfies Meta<typeof Page>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const LoggedIn: Story = {
  args: {
    // Header 컴포넌트의 LoggedIn 스토리에 있는 args를 그대로 가져와 씁니다!
    ...HeaderStories.LoggedIn.args,
  },
};

특정 스토리 딱 하나만 테스트하기 (Run tests on a single story)

composeStory 함수를 사용하면 딱 하나의 스토리에 대해서만 집중적으로 테스트를 돌릴 수도 있습니다.

다만 이 방식을 쓰실 땐, composeStory 함수에 그 스토리의 원본 메타데이터(즉, 기본 내보내기(default export))를 함께 넘겨주시는 걸 강력히 추천드려요. 그래야 테스트 환경이 그 스토리에 대한 정확한 정보(args, parameters 등)를 제대로 파악할 수 있거든요.

import { render, screen } from '@testing-library/react';
 
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import { composeStories } from '@storybook/your-framework';
 
import * as stories from './Button.stories';
 
const { Primary } = composeStories(stories);
 
test('reuses args from composed story', () => {
  render(<Primary />);
 
  const buttonElement = screen.getByRole('button');
  // 스토리 자체에 정의된 값을 가져와서 테스트합니다! 불필요한 코드 중복이 없죠.
  expect(buttonElement.textContent).toEqual(Primary.args.label);
});

문제 해결 (Troubleshooting)

Next.js Vite 모듈을 찾을 수 없을 때 (Next.js Vite cannot find the module)

만약 Cannot find module 'sb-original/image-context'와 같은 에러 메시지가 나온다면, Vite 설정에 storybookNextJsPlugin이 제대로 포함되었는지 꼭 확인해 주세요!

// vitest.config.ts
import { defineConfig } from 'vite';
import { storybookNextJsPlugin } from '@storybook/nextjs-vite/vite-plugin';
 
export default defineConfig(<{
  // 이 플러그인은 @storybook/addon-vitest를 쓰지 '않을' 때만 수동으로 넣어주시면 됩니다. 
  // 애드온을 쓰면 알아서 자동으로 불러오거든요!
  plugins: [storybookNextJsPlugin()],
});

더 유용한 테스팅 관련 자료들

profile
프론트에_가까운_풀스택_개발자

0개의 댓글