Testing/Interaction tests

김동현·2026년 3월 22일

인터랙션 테스트 (Interaction tests)

React Vue Angular Web Components More

스토리북에서 인터랙션 테스트는 스토리(story)의 일부로 빌드됩니다. 해당 스토리는 컴포넌트를 초기 상태로 배치하기 위해 필요한 프롭스(props)와 컨텍스트(context)를 가지고 컴포넌트를 렌더링하죠. 그런 다음 여러분은 플레이 함수(play function)를 사용해서 클릭, 타이핑, 폼 제출과 같은 사용자 행동을 시뮬레이션하고 최종 결과를 검증(assert)하게 됩니다.

여러분은 스토리북 UI의 'Interactions' 패널을 사용해서 인터랙션 테스트를 미리 보고 디버깅할 수 있어요. 또한 Vitest 애드온을 사용하면 이 테스트들을 자동화할 수 있는데, 이를 통해 스토리북 안에서나 터미널, 혹은 CI 환경에서도 프로젝트의 테스트를 실행할 수 있답니다.


인터랙션 테스트 작성하기 (Writing interaction tests)

여러분이 작성하는 모든 스토리는 '렌더 테스트'가 가능해요. 렌더 테스트는 컴포넌트가 주어진 상태에서 성공적으로 렌더링되는지 능력만 테스트하는 아주 간단한 버전의 인터랙션 테스트예요. 버튼 같은 단순하고 정적인 컴포넌트에는 이 정도로도 충분하죠. 하지만 더 복잡하고 상호작용이 많은 컴포넌트라면 여기서 더 나아가야 해요.

플레이 함수를 사용해서 사용자 행동을 시뮬레이션하고, DOM 구조나 함수 호출 같은 기능적인 측면을 검증할 수 있습니다. 컴포넌트가 테스트될 때 플레이 함수가 실행되고 그 안의 모든 검증 내용이 확인됩니다.

이 예시에서 EmptyForm 스토리는 LoginForm 컴포넌트의 렌더링을 테스트하고, FilledForm 스토리는 폼 제출을 테스트해요.

CSF 3 | CSF Next 🧪

LoginForm.stories.ts|tsx

// 여러분이 사용 중인 프레임워크(예: react-vite, nextjs, vue3-vite 등)로 교체하세요.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { expect } from 'storybook/test';
 
import { LoginForm } from './LoginForm';
 
const meta = {
  component: LoginForm,
} satisfies Meta<typeof LoginForm>;
export default meta;
 
type Story = StoryObj<typeof meta>;
 
export const EmptyForm: Story = {};
 
export const FilledForm: Story = {
  play: async ({ canvas, userEvent }) => {
    // 👇 컴포넌트와의 상호작용 시뮬레이션
    await userEvent.type(canvas.getByTestId('email'), 'email@provider.com');
 
    await userEvent.type(canvas.getByTestId('password'), 'a-random-password');
 
    // Actions 패널에서 로깅을 설정하는 방법은 아래 링크를 참고하세요.
    // [https://storybook.js.org/docs/essentials/actions#automatically-matching-args](https://storybook.js.org/docs/essentials/actions#automatically-matching-args)
    await userEvent.click(canvas.getByRole('button'));
 
    // 👇 DOM 구조 검증
    await expect(
      canvas.getByText(
        'Everything is perfect. Your account is ready and we should probably get you started!',
      ),
    ).toBeInTheDocument();
  },
};

이 코드 샘플에 정말 많은 내용이 담겨 있죠? 이제 사용된 API들을 하나씩 차근차근 살펴볼게요.


캔버스(Canvas) 쿼리하기

canvas는 테스트 중인 스토리를 담고 있는 쿼리 가능한 요소이며, 플레이 함수의 파라미터로 제공됩니다. 상호작용하거나 검증할 특정 요소를 찾기 위해 이 canvas를 사용할 수 있어요. 모든 쿼리 메서드는 Testing Library에서 직접 가져온 것이며, <type><subject> 형식을 따릅니다.

사용 가능한 타입들은 아래 표에 요약되어 있고, 상세 내용은 Testing Library 공식 문서에 잘 나와 있어요.

쿼리 타입검색 결과 0개검색 결과 1개검색 결과 1개 초과Await 여부
getBy...에러 발생요소 반환에러 발생No
queryBy...null 반환요소 반환에러 발생No
findBy...에러 발생요소 반환에러 발생Yes
getAllBy...에러 발생배열 반환배열 반환No
queryAllBy...[] 반환배열 반환배열 반환No
findAllBy...에러 발생배열 반환배열 반환Yes

검색 대상(Subjects)들은 다음과 같으며, 각 링크를 통해 Testing Library의 상세 문서를 확인하실 수 있어요.

  • ByRole — 접근 가능한 역할(role)로 요소를 찾습니다.
  • ByLabelText — 연결된 라벨 텍스트로 요소를 찾습니다.
  • ByPlaceholderText — 플레이스홀더 값으로 요소를 찾습니다.
  • ByText — 포함된 텍스트로 요소를 찾습니다.
  • ByDisplayValue — input, textarea, select 요소의 현재 값으로 찾습니다.
  • ByAltText — alt 속성값으로 요소를 찾습니다.
  • ByTitle — title 속성값으로 요소를 찾습니다.
  • ByTestId — data-testid 속성값으로 요소를 찾습니다.

이 리스트의 순서를 꼭 기억하세요! 스토리북과 Testing Library는 실제 사용자가 UI와 상호작용하는 방식과 가장 흡사한 방식으로 요소를 찾는 것을 강력하게 권장합니다. 예를 들어, 접근 가능한 역할로 요소를 찾으면 더 많은 사람이 여러분의 컴포넌트를 사용할 수 있게 도와주죠. 반면 data-testid를 사용하는 건 다른 모든 방법을 다 써본 뒤에 사용하는 마지막 수단이어야 해요.

이 모든 걸 종합해 보면, 전형적인 쿼리는 대략 이런 모습일 거예요.

// 접근 가능한 이름이 "Submit"인 첫 번째 버튼 요소를 찾습니다.
await canvas.findByRole('button', { name: 'Submit' });
 
// "An example heading"이라는 텍스트를 가진 첫 번째 요소를 가져옵니다.
canvas.getByText('An example heading');
 
// listitem 역할을 가진 모든 요소를 가져옵니다.
canvas.getAllByRole('listitem');

userEvent로 행동 시뮬레이션하기

요소를 쿼리한 뒤에는 컴포넌트의 동작을 테스트하기 위해 해당 요소와 상호작용을 해야겠죠? 이때 플레이 함수의 파라미터로 제공되는 userEvent 유틸리티를 사용합니다. 이 유틸리티는 버튼 클릭, 입력창 타이핑, 옵션 선택 같은 사용자 상호작용을 시뮬레이션해 줘요.

userEvent에는 많은 메서드가 있는데, 상세 내용은 user-event 문서에 나와 있어요. 아래 표는 자주 쓰이는 메서드들을 보여줍니다.

메서드설명예시
click요소를 클릭합니다.await userEvent.click(<element>)
dblClick요소를 더블 클릭합니다.await userEvent.dblClick(<element>)
hover요소 위에 마우스를 올립니다.await userEvent.hover(<element>)
unhover요소에서 마우스를 뗍니다.await userEvent.unhover(<element>)
tabTab 키를 누릅니다.await userEvent.tab()
type입력창이나 textarea에 텍스트를 씁니다.await userEvent.type(<element>, 'Some text');
keyboard키보드 이벤트를 시뮬레이션합니다.await userEvent.keyboard('{Shift}');
selectOptionsselect 요소의 특정 옵션을 선택합니다.await userEvent.selectOptions(<element>, ['1','2']);
deselectOptionsselect 요소의 특정 옵션 선택을 해제합니다.await userEvent.deselectOptions(<element>, '1');
clear입력창이나 textarea의 텍스트를 선택해서 삭제합니다.await userEvent.clear(<element>);

중요! 플레이 함수 안에서 userEvent 메서드들은 항상 await 해야 합니다. 그래야 Interactions 패널에서 상호작용이 제대로 로깅되고 디버깅될 수 있거든요.


expect로 검증하기

마지막으로, 요소를 쿼리하고 행동을 시뮬레이션한 뒤에 그 결과를 검증해야 테스트가 완료되겠죠? 이를 위해 storybook/test 모듈에서 제공하는 expect 유틸리티를 사용합니다.

import { expect } from 'storybook/test';

여기 있는 expect 유틸리티는 Vitest의 expect 메서드들과 @testing-library/jest-dom의 메서드들을 합쳐놓은 거예요 (이름은 저렇지만 Vitest 테스트에서도 잘 작동한답니다). 정말 많은 메서드가 있지만, 자주 쓰이는 것들을 표로 정리해 볼게요.

메서드설명예시
toBeInTheDocument()요소가 DOM 안에 있는지 확인합니다.await expect(<element>).toBeInTheDocument()
toBeVisible()요소가 사용자에게 보이는지 확인합니다.await expect(<element>).toBeVisible()
toHaveAttribute()요소가 특정 속성을 가지고 있는지 확인합니다.await expect(<element>).toHaveAttribute('aria-disabled', 'true')
toHaveBeenCalled()스파이(spy) 함수가 호출되었는지 확인합니다.await expect(<function-spy>).toHaveBeenCalled()
toHaveBeenCalledWith()스파이 함수가 특정 파라미터와 함께 호출되었는지 확인합니다.await expect(<function-spy>).toHaveBeenCalledWith('example')

중요! 플레이 함수 안에서 expect 호출도 항상 await 해야 합니다. 그래야 Interactions 패널에서 제대로 확인될 수 있어요.


fn으로 함수 스파이하기

컴포넌트가 특정 함수를 호출할 때, storybook/test 모듈의 fn 유틸리티(Vitest 기반)를 사용해서 그 함수를 감시(spy)하고 동작을 검증할 수 있어요.

import { fn } from 'storybook/test'

보통 스토리를 작성할 때 fn을 인자(arg) 값으로 사용하고, 테스트에서 해당 인자에 접근하는 방식으로 사용합니다.

CSF 3 | CSF Next 🧪

LoginForm.stories.ts

import type { Meta, StoryObj } from '@storybook/your-framework';
import { fn, expect } from 'storybook/test';
 
import { LoginForm } from './LoginForm';
 
const meta = {
  component: LoginForm,
  args: {
    // 👇 onSubmit 인자를 감시하기 위해 `fn`을 사용합니다.
    onSubmit: fn(),
  },
} satisfies Meta<typeof LoginForm>;
export default meta;
 
type Story = StoryObj<typeof meta>;
 
export const FilledForm: Story = {
  play: async ({ args, canvas, userEvent }) => {
    await userEvent.type(canvas.getByLabelText('Email'), 'email@provider.com');
    await userEvent.type(canvas.getByLabelText('Password'), 'a-random-password');
    await userEvent.click(canvas.getByRole('button', { name: 'Log in' }));
 
    // 👇 이제 onSubmit 인자가 호출되었는지 검증할 수 있어요!
    await expect(args.onSubmit).toHaveBeenCalled();
  },
};

컴포넌트가 렌더링되기 전에 코드 실행하기 (mount 사용)

play 메서드 안에서 mount 함수를 사용하면 렌더링 직전에 코드를 실행할 수 있어요.
예를 들어 mockdate 패키지를 사용해 날짜(Date)를 모킹하면 스토리를 항상 일정한 상태로 렌더링할 수 있어 아주 유용하죠.

CSF 3 | CSF Next 🧪

Page.stories.ts

import MockDate from 'mockdate';
 
// ...나머지 스토리 파일 내용
 
export const ChristmasUI: Story = {
  async play({ mount }) {
    MockDate.set('2024-12-25');
    // 👇 모킹된 날짜를 가지고 컴포넌트를 렌더링합니다.
    await mount();
    // ...나머지 테스트 내용
  },
};

mount 함수를 사용하려면 두 가지 조건이 필요해요:
1. 플레이 함수의 인자인 컨텍스트에서 반드시 mount 속성을 구조 분해 할당해서 가져와야 해요. 그래야 플레이 함수가 시작되기 전에 스토리북이 렌더링을 시작하지 않거든요.
2. 프로젝트의 빌더 설정이 ES2017 이상으로 트랜스파일되도록 설정되어야 해요. 구조 분해 할당이나 async/await 문법이 사라지면 스토리북이 mount 사용 여부를 인식하지 못할 수 있기 때문입니다.

렌더링 전에 모의 데이터 생성하기

mount를 사용해서 컴포넌트에 넘겨줄 모의 데이터를 미리 만들 수도 있어요. 플레이 함수 안에서 데이터를 먼저 만들고, 그 데이터가 설정된 컴포넌트와 함께 mount 함수를 호출하는 방식이죠.

CSF 3 | CSF Next 🧪

Page.stories.tsx

import type { Meta, StoryObj } from '@storybook/your-framework';
 
// 👇 오토모킹된 모듈은 '../lib/__mocks__/db'로 해석됩니다.
import db from '../lib/db';
import { Page } from './Page';
 
const meta = { component: Page } satisfies Meta<typeof Page>;
export default meta;
 
type Story = StoryObj<typeof meta>;
 
export const Basic: Story = {
  play: async ({ mount, args, userEvent }) => {
    const note = await db.note.create({
      data: { title: 'Mount inside of play' },
    });
 
    const canvas = await mount(
      // 👇 플레이 함수 안에서 생성된 데이터를 컴포넌트에 전달합니다.
      // 예: 방금 생성된 UUID 등
      <Page {...args} params={{ id: String(note.id) }} />,
    );
 
    await userEvent.click(await canvas.findByRole('menuitem', { name: /login to add/i }));
  },
  argTypes: {
    // 👇 play 함수에서 항상 값을 덮어쓰므로, params 프롭의 컨트롤을 비활성화합니다.
    params: { control: { disable: true } },
  },
};

아무 인자 없이 mount()를 호출하면, 기본 렌더링 함수(암시적 기본값이든 명시적 커스텀 정의든)를 사용해 컴포넌트가 그려져요. 하지만 위 예시처럼 특정 컴포넌트를 직접 넣으면 스토리의 렌더 함수는 무시됩니다. 그래서 반드시 args를 컴포넌트에 직접 전달해 줘야 해요.


파일 내 모든 스토리 전에 코드 실행하기

한 파일 안의 모든 스토리 전에 똑같은 코드를 실행해야 할 때가 있죠? 컴포넌트나 모듈의 초기 상태를 설정해야 할 때 말이에요. 이때는 컴포넌트 메타(meta)에 비동기 beforeEach 함수를 추가하면 됩니다.

beforeEach에서 정리(cleanup) 함수를 반환할 수도 있어요. 이 정리 함수는 각 스토리가 끝난 뒤, 즉 스토리가 다시 마운트되거나 다른 곳으로 이동할 때 실행됩니다.

CSF 3 | CSF Next 🧪

Page.stories.ts

import type { Meta, StoryObj } from '@storybook/your-framework';
import MockDate from 'mockdate';
import { Page } from './Page';
 
const meta = {
  component: Page,
  // 👇 파일 내의 모든 스토리에 대해 Date 값을 설정합니다.
  async beforeEach() {
    MockDate.set('2024-02-14');
 
    // 👇 각 스토리 이후에 Date를 리셋합니다.
    return () => {
      MockDate.reset();
    };
  },
} satisfies Meta<typeof Page>;
export default meta;
 
type Story = StoryObj<typeof meta>;
 
export const Basic: Story = {
  async play({ canvas }) {
    // ... 이 코드는 모킹된 Date 환경에서 실행됩니다.
  },
};

모든 테스트를 위한 상태 설정 및 리셋

컴포넌트의 상태를 변경했다면, 테스트 간의 격리를 유지하기 위해 다음 스토리를 렌더링하기 전에 그 상태를 리셋하는 게 중요해요. 상태 리셋에는 beforeAllbeforeEach 두 가지 옵션이 있습니다.

beforeAll

.storybook/preview.js|ts 파일에 정의하며, 프로젝트 내의 어떤 스토리가 실행되기 전 딱 한 번만 실행됩니다. 테스트를 시작할 때 프로젝트를 부트스트랩하거나 프로젝트 전체가 의존하는 설정을 실행하기에 좋습니다.

beforeEach

beforeAll과 달리 프로젝트 내의 각 스토리마다 실행됩니다. 모든 스토리에 공통으로 사용되는 상태나 모듈을 리셋할 때 가장 좋아요.

참고로 fn() 모크는 스토리북이 렌더링 전에 자동으로 복원해 주므로 따로 처리할 필요가 없어요. 더 자세한 내용은 parameters.test.restoreMocks API를 확인해 보세요.


인터랙션 후에 검증 수행하기 (afterEach)

컴포넌트가 렌더링되고 상호작용이 끝난 에 검증을 하거나 코드를 실행해야 할 때 afterEach를 사용합니다.

afterEach는 프로젝트 수준(preview.js), 컴포넌트 수준(meta), 또는 스토리 수준에서 정의할 수 있어요. 플레이 함수처럼 context 객체를 인자로 받습니다.

CSF 3 | CSF Next 🧪

Page.stories.ts

const meta = {
  component: Page,
  // 👇 이 파일의 각 스토리가 끝난 뒤 실행됩니다.
  async afterEach(context) {
    console.log(`✅ Tested ${context.name} story`);
  },
} satisfies Meta<typeof Page>;

주의! 테스트 상태를 리셋할 때 afterEach를 사용하지 마세요. 스토리가 끝난 직후 실행되기 때문에, 여기서 상태를 리셋해버리면 여러분이 확인하고 싶은 스토리의 최종 결과 상태를 보지 못할 수 있거든요. 대신 beforeEach에서 반환하는 정리 함수를 사용하세요.


step 함수로 인터랙션 그룹화하기

복잡한 흐름의 경우, 관련 있는 상호작용들을 step 함수를 사용해 그룹으로 묶는 게 좋아요. 각 단계에 커스텀 라벨을 붙여서 설명할 수 있거든요.

CSF 3 | CSF Next 🧪

MyComponent.stories.ts

export const Submitted: Story = {
  play: async ({ args, canvas, step, userEvent }) => {
    await step('이메일과 비밀번호 입력', async () => {
      await userEvent.type(canvas.getByTestId('email'), 'hi@example.com');
      await userEvent.type(canvas.getByTestId('password'), 'supersecret');
    });
 
    await step('폼 제출', async () => {
      await userEvent.click(canvas.getByRole('button'));
    });
  },
};

이렇게 하면 인터랙션 패널에서 단계별로 접고 펼칠 수 있는 그룹으로 예쁘게 표시됩니다.


모킹된 모듈 (Mocked modules)

컴포넌트 내부에서 임포트하는 모듈에 의존하고 있다면, 모듈 모킹 가이드에 따라 해당 모듈을 모킹할 수 있어요. 그런 다음 모킹된 모듈을 스토리에 가져와서 컴포넌트의 동작을 검증하는 데 사용할 수 있습니다.

CSF 3 | CSF Next 🧪

NoteUI.stories.ts

export const SaveFlow: Story = {
  play: async ({ canvas, userEvent }) => {
    const saveButton = canvas.getByRole('menuitem', { name: /done/i });
    await userEvent.click(saveButton);
    // 👇 모킹된 함수가 호출되었는지 검증합니다.
    await expect(saveNote).toHaveBeenCalled();
  },
};

인터랙션 테스트 실행하기 (Running interaction tests)

Vitest 애드온을 사용 중이라면 다음과 같은 방법으로 테스트를 실행할 수 있어요:

  • 스토리북 UI에서 직접 실행
  • 코드 에디터에서 실행
  • CLI(터미널)를 통해 실행
  • CI(지속적 통합) 환경에서 실행

스토리북 UI에서는 사이드바의 테스팅 위젯에 있는 'Run component tests' 버튼을 누르거나, 스토리/폴더의 메뉴(점 세 개)에서 선택해 실행할 수 있습니다.

test-runner를 사용하면 터미널이나 CI 환경에서도 실행 가능해요.


인터랙션 테스트 디버깅하기

Interactions 패널을 보면 각 스토리의 플레이 함수에 정의된 흐름을 단계별로 볼 수 있어요. 일시정지, 재개, 뒤로 가기, 단계별 실행 같은 편리한 컨트롤 도구도 제공하죠.

테스트가 실패하면 실패 지점이 어디인지 정확히 보여줍니다. 예를 들어 로그인 버튼을 누른 뒤 '제출됨' 상태가 되어야 하는데 로직이 빠져 있다면 바로 찾아낼 수 있어요.

스토리북은 웹 앱이기 때문에, URL만 있으면 팀원 누구라도 추가 설정 없이 똑같은 실패 상황을 확인하고 디버깅할 수 있어요.


CI 자동화

Vitest 애드온으로 테스트를 실행한다면, CI 환경에서 테스트를 실행하는 것만으로 자동화가 완료됩니다. 상세 내용은 CI 테스팅 가이드를 참고하세요.


문제 해결 (Troubleshooting)

인터랙션 테스트 vs 시각적 테스트(Visual tests)

모든 컴포넌트에 인터랙션 테스트를 일일이 적용하는 건 유지보수 비용이 많이 들 수 있어요. 유지보수 부담을 줄이면서도 꼼꼼하게 검증하려면 시각적 테스트와 병행하는 것을 추천합니다.

인터랙션 테스트 vs Vitest + Testing Library 단독 사용

인터랙션 테스트의 가장 큰 장점은 컴포넌트를 실제 브라우저에서 보면서 테스트할 수 있다는 점이에요. 터미널에서 가짜 DOM(JSDOM)의 한계에 부딪히는 대신, 시각적으로 디버깅할 수 있어 훨씬 강력하죠. 스토리와 테스트를 한 파일에 모아둘 수 있어 관리도 편리하고요.


더 많은 테스팅 리소스

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

0개의 댓글