Logan.

2026-05-15

Storybook 스토리 작성 표준 argTypes, play 함수, fn vs action

검증 단계에서 200개에 가까운 스토리를 짜며 손에 굳은 한 줄 규칙들. 표준 골격, fn() vs action() 선택 기준, Portal modal 대응, styled-components 금지까지.

by Logan·9분 읽기·part 2 of 본격 Storybook 도입·#storybook #testing #conventions

안녕하세요, 로건입니다 👋

첫 스토리는 누구나 그럴듯해 보이게 짭니다. 문제는 50번째쯤 됐을 때 시작돼요.

argTypes를 빼먹은 스토리가 콘트롤 패널을 비워두고, 어떤 건 action()을 쓰고 어떤 건 fn()을 써서 인터랙션 테스트가 들쭉날쭉해지죠. 보던 스토리에 누군가 play 함수를 새로 추가했는데, 그게 또 showcase grid 위에 달려서 같은 회귀를 N번 잡고 있고요. 저는 그걸 한참 겪은 다음에야 "한 컴포넌트당 대표 스토리에만 play 함수" 같은 한 줄 규칙이 얼마나 중요한지 알았어요.

이 글은 검증 단계에서 200개에 가까운 스토리를 짜며 손에 남은 골격 한 벌과, 거기 따라붙은 잔규칙 열 몇 개에 대한 이야기입니다.

컴포넌트 스토리의 표준 골격

먼저 모든 컴포넌트 스토리가 따라야 하는 골격부터 보여드릴게요. 이건 한 번 정착시키고 나면 다시 손대지 않는 형태입니다.

import type { Meta, StoryObj } from "@storybook/react";
import { fn, userEvent, within, expect } from "@storybook/test";
import { Button } from "@/components/Button";
 
const meta = {
  title: "Atoms/Button",
  component: Button,
  argTypes: {
    tone: { control: "select", options: ["primary", "secondary", "danger"] },
    size: { control: "select", options: ["sm", "md", "lg"] },
    disabled: { control: "boolean" },
    onPress: { action: "pressed" },
  },
  args: {
    tone: "primary",
    size: "md",
    disabled: false,
    children: "Continue",
  },
} satisfies Meta<typeof Button>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const Default: Story = {
  args: { onPress: fn() },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByRole("button"));
    expect(args.onPress).toHaveBeenCalledTimes(1);
  },
};
 
export const Disabled: Story = {
  args: { disabled: true },
};
 
export const AllTones: Story = {
  args: { tone: "primary" }, // ignored — story renders its own grid
  render: () => (
    <>
      <Button tone="primary">Primary</Button>
      <Button tone="secondary">Secondary</Button>
      <Button tone="danger">Danger</Button>
    </>
  ),
};

여기 들어있는 결정들을 하나씩 풀어볼게요.

규칙 1 — argTypes는 전부 노출한다

스토리북의 콘트롤 패널이 비어 있다면, 그 컴포넌트는 사실상 검수가 불가능합니다. 디자이너가 "tone을 danger로 바꿔봐도 잘 보이나요?"라고 물었을 때, 콘트롤이 없으면 코드를 새로 쓰러 가야 하거든요.

모든 props를 argTypes에 노출하세요. 콘트롤은 검수의 도구이지, 개발자의 장난감이 아닙니다.

규칙 2 — 대표 스토리 1개에만 play 함수를

이게 가장 늦게 깨달은 규칙이에요. AllSizes, AllTones 같은 showcase grid 스토리에 play 함수를 달면 어떻게 되는지 아세요? 같은 회귀를 N번 잡는 거미줄이 됩니다.

패턴회귀 1건당 빨간색 표시의미
Default에만 play1개한 번 잡힘 → 한 곳 수정
Default + Disabled + AllTones에 play3개 이상같은 걸 여러 곳에서 잡음 → 마찰

안전망의 목적은 다른 회귀를 한 번씩 잡는 것이지, 같은 것을 여러 번 잡는 게 아니에요. 한 곳에서 잡힐 회귀를 열 곳에서 잡으려는 노력은 안전망이 아니라 마찰입니다.

규칙 3 — fn()action()을 구분한다

이게 처음엔 헷갈렸어요. 두 함수 다 핸들러 자리에 끼우는 더미인 건 같은데, 역할이 달라요.

// action() — 로깅만. Actions 탭에 나타남.
argTypes: {
  onPress: {
    action: "pressed";
  }
}
 
// fn() — spy + 로깅. play 함수에서 .toHaveBeenCalled() 검증 가능.
args: {
  onPress: fn();
}

기준은 단순합니다. play로 검증할 거면 fn(), 콘트롤 패널에서 핸들러가 호출되는 걸 시각적으로만 보고 싶으면 action(). 둘이 섞이면 어떤 스토리는 검증이 되고 어떤 건 안 되는 상태가 만들어져요. 컨벤션을 한 번 박아두는 게 좋습니다.

규칙 4 — production 컴포넌트는 절대 수정하지 않는다

Storybook을 짜다 보면 production 컴포넌트의 어떤 props가 좀 더 있었으면 좋겠다 싶을 때가 있어요. "여기 testID만 추가하면 selector가 깔끔할 텐데..." 같은 생각이요. 그 유혹을 참아야 합니다.

production 컴포넌트 수정은 production 코드 리뷰로. Storybook 작업은 Storybook 디렉토리 안에서만.

이 규칙이 없으면 Storybook 도입이 production 코드의 잔변경을 끌고 들어와 PR review가 끝없이 늘어집니다. Storybook은 production 위에 얹는 관찰자여야 해요.

규칙 5 — RN 컴포넌트의 스타일은 StyleSheet.create

styled-components가 익숙한 분들이 처음 RN으로 넘어오면 흔히 하는 실수예요. styled API가 깔끔해 보여서 가져다 쓰는 건데, RN-Web Storybook에서 두 가지 문제가 생깁니다.

  1. SSR diff에서 className 순서가 어긋나 hydration warning
  2. Chromatic diff에서 같은 화면인데 매번 hash가 다른 className으로 잡혀 false positive

해결은 단순해요. 모든 스타일을 StyleSheet.create로 통일합니다.

const styles = StyleSheet.create({
  container: { paddingHorizontal: 16, paddingVertical: 12 },
  label: { fontSize: 14, fontWeight: "600", color: tokens.color.text },
});

이 한 줄 정책이 Storybook 도입 후 3개월쯤 됐을 때 false positive 수십 건을 한꺼번에 정리하는 비결이었습니다.

규칙 6 — Portal modal은 canvasElement.parentElement

Modal, BottomSheet, Tooltip 같은 게 React Portal로 렌더되면 within(canvasElement)로 찾을 수가 없어요. 컨테이너 밖에 그려지니까요.

play: async ({ canvasElement }) => {
  const root = canvasElement.parentElement!; // ← portal 포함
  const dialog = within(root).getByRole("dialog");
  await userEvent.click(within(dialog).getByText("확인"));
},

처음 만났을 때 1시간을 헤맸어요. 지금은 modal/sheet 계열 컴포넌트에 일관되게 적용합니다.

규칙 7 — 페이지 스토리에는 모듈 스코프 fn() + mockClear()

이건 살짝 고급 패턴인데, 페이지 스토리에서 여러 핸들러를 한 번에 검증해야 할 때 쓰는 패턴이에요.

const onContinue = fn();
const onCancel = fn();
 
export const Submit: Story = {
  args: { onContinue, onCancel },
  play: async ({ canvasElement }) => {
    onContinue.mockClear();
    onCancel.mockClear();
    const c = within(canvasElement);
    await userEvent.click(c.getByText("계속"));
    expect(onContinue).toHaveBeenCalled();
    expect(onCancel).not.toHaveBeenCalled();
  },
};

스토리 사이에 spy 상태가 누적되면 결과가 비결정적이 돼요. 모듈 스코프에 선언하고 play 진입 시 mockClear() 하는 한 줄 패턴이 답입니다.

그래서 200번째 스토리는 어떻게 짜냐면

이 규칙들이 다 박히고 나면, 새 컴포넌트 스토리 한 개를 짜는 데 10분 안쪽이에요. 30초 정도 골격 복붙, argTypes 채우고, Default에 play 하나 달고, showcase grid는 인스트루먼트 없이 — 끝.

좋은 규칙은 처음엔 답답하지만, 50번째쯤 되면 손이 알아서 따라갑니다.

다음 편은 정반대 이야기예요. 야심차게 짜다가 한 번에 다 지운 적이 있어요. 페이지 스토리 106개를. 그 결정에 대해서, 그리고 안 짜는 결정이 짜는 결정보다 어려웠던 그 오후에 대해 풀어볼게요.

related — in series · 본격 Storybook 도입