๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
๐Ÿ‘จ‍๐Ÿ’ป web.dev/fe

Storybook ์—์„œ ์ปดํฌ๋„ŒํŠธ ๋‹จ์œ„ ํ…Œ์ŠคํŠธํ•˜๊ธฐ

by HandHand 2023. 6. 24.

 

๐Ÿ“Œ ๋“ค์–ด๊ฐ€๋ฉฐ

์Šคํ† ๋ฆฌ๋ถ์€ component driven development ํŒจ๋Ÿฌ๋‹ค์ž„์„ ์œ„ํ•œ ์œ ์šฉํ•œ ๊ฐœ๋ฐœ ๋„๊ตฌ๋กœ

๋‹จ์ˆœํžˆ UI ๋ฅผ ํ™•์ธํ•˜๋Š” ์šฉ๋„๋ฅผ ๋„˜์–ด์„œ์„œ ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ๋‹ค์–‘ํ•œ ์†”๋ฃจ์…˜๋„ ํ•จ๊ป˜ ์ œ๊ณตํ•ด์ฃผ๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

์ด๋ฒˆ ํฌ์ŠคํŠธ์—์„œ๋Š” ์ด๋ฅผ ํ™œ์šฉํ•œ ํ…Œ์ŠคํŠธ ์ ์šฉ ์‚ฌ๋ก€์— ๋Œ€ํ•ด ์‚ดํŽด๋ณด๊ณ ์žํ•ฉ๋‹ˆ๋‹ค.

์šฐ์„  ์ด๋ฒˆ ํฌ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ํ”„๋กœ์ ํŠธ์˜ ํŒจํ‚ค์ง€ ๋ฒ„์ „ ์ •๋ณด๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.

๐Ÿ“ฆ project dependency

- react/react-dom: 17.0.2
- next: 12.1.1
- storybook: 7.0.4
- typescript: 5.0.3

 

โœจ Next.js + jest & storybook ์„ค์ •ํ•˜๊ธฐ

๐Ÿ“Œ Next.js + jest ์„ค์ •

์œ ๋‹›ํ…Œ์ŠคํŠธ, ์Šค๋ƒ…์ƒทํ…Œ์ŠคํŠธ ์ž‘์„ฑํ•˜๊ธฐ ์ „์— jest ๋จผ์ € ์„ค์น˜ํ•ฉ๋‹ˆ๋‹ค.

Next.js v12 ๋ฅผ TypeScript ์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์ด์™€ ๋งž๋Š” ์„ค์ •์„ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค.

Next.js v12 ๋ถ€ํ„ฐ๋Š” Babel ๋Œ€์‹  swc ๋ฅผ ํ™œ์šฉํ•œ ์„ค์ •์„ ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

 

Testing | Next.js

 

Optimizing: Testing | Next.js

Cypress is a test runner used for End-to-End (E2E) and Component Testing. You can use create-next-app with the with-cypress example to quickly get started. Run Cypress for the first time to generate examples that use their recommended folder structure: You

nextjs.org

 

ํ•„์š”ํ•œ ํŒจํ‚ค์ง€ ์„ค์น˜

npm i -D jest @types/jest
npm i -D jest-environment-jsdom
npm i -D @testing-library/jest-dom
npm i -D @testing-library/react // โœ… NOTE: ํ”„๋กœ์ ํŠธ์—์„œ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๋Š” react ๋ฒ„์ „์— ํ˜ธํ™˜๋˜๋Š” ๋ฒ„์ „์œผ๋กœ ์„ค์น˜

 

  • @testing-library/jest-dom
    • DOM ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ์œ ์šฉํ•œ custom matcher ๋“ค์„ ์ œ๊ณต
  • @testing-library/react
    • React DOM ๋ฐ hook ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ์œ ์šฉํ•œ ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜๋“ค ์ œ๊ณต (render, act, renderHook)

Jest ์„ค์ • ์ถ”๊ฐ€

// jest.config.js

/** @type {import('ts-jest').JestConfigWithTsJest} */

const nextJest = require('next/jest.js')

const createJestConfig = nextJest({
  dir: './',
})

/** @type {import('jest').Config} */
const config = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jest-environment-jsdom',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
}

module.exports = createJestConfig(config)

 

Jest ์‹คํ–‰์‹œ์— ๊ฐ€์žฅ ๋จผ์ € ์‹คํ–‰๋œ task ๋ฅผ ๋“ฑ๋กํ•  ์„ค์ •ํŒŒ์ผ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

์—ฌ๊ธฐ์„œ jest-dom ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ต๋‹ˆ๋‹ค.

 

// jest.setup.js

import '@testing-library/jest-dom'
// tsconfig.json

{
    "compilerOptions": {
        "types": ["jest"], // โœ… 
    }
}

 

๐Ÿ“Œ Next.js + Storybook 7 ์„ค์ •

๋จผ์ € ํ”„๋กœ์ ํŠธ์— ์Šคํ† ๋ฆฌ๋ถ ์„ค์ •์„ ํ•ด์ค๋‹ˆ๋‹ค.

storybook cli ๋ฅผ ์ด์šฉํ•˜๋ฉด ํ˜„์žฌ ํ”„๋กœ์ ํŠธ์— ๊ฐ€์žฅ ์ ํ•ฉํ•œ ์„ค์ •์„ ์ž๋™์œผ๋กœ ํ•ด์ค๋‹ˆ๋‹ค.

 

> npx storybook@latest init // ํ˜„์žฌ ์ตœ์‹ ๋ฒ„์ „ ๊ธฐ์ค€์œผ๋กœ storybook@7.0.4๊ฐ€ ์„ค์น˜๋ฉ๋‹ˆ๋‹ค.ใ„ฑ

 

๊ทธ๋ฆฌ๊ณ  CSF3 ํฌ๋งท์œผ๋กœ ์Šคํ† ๋ฆฌ ํŒŒ์ผ์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.

 

import type { Meta, StoryObj } from '@storybook/react'

import MOCK_TRANSACTION from '@/__mock__/ready.transaction.json'

import OrderItem from './order-items'

const meta: Meta<typeof OrderItem> = {
  title: 'Element / Order Item',
  component: OrderItem,
}

export default meta

export const Basic: StoryObj<typeof OrderItem> = {
  render: (args) => <OrderItem {...args} />,
  args: {
    items: MOCK_TRANSACTION.cart.items,
  },
}

๋งŒ์•ฝ Context Provider ๊ฐ€ ํ•„์š”ํ•˜๋‹ค๋ฉด

์ผ๋ถ€ ์ปดํฌ๋„ŒํŠธ๋“ค์—์„œ provider injection ์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

์ „์—ญ์ ์œผ๋กœ ์„ค์ •์ด ํ•„์š”ํ•œ ๊ฒƒ๋“ค์€ preview.tsx ์— ์Šคํ† ๋ฆฌ๋ณ„๋กœ ์ถ”๊ฐ€๊ฐ€ ํ•„์š”ํ•œ ๊ฒƒ์€ ๊ฐœ๋ณ„ ์Šคํ† ๋ฆฌํŒŒ์ผ์— decorator ๋กœ ์ถ”๊ฐ€ํ•ด์ค๋‹ˆ๋‹ค.

 

โœจ storybook ๋ฅผ ํ™œ์šฉํ•œ ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ

์„ค์ •์„ ๋งˆ์ณค์œผ๋‹ˆ ์ด์ œ ๋ณธ๊ฒฉ์ ์œผ๋กœ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•ด๋ด…๋‹ˆ๋‹ค.

๐Ÿ“Œ ์œ ๋‹› ํ…Œ์ŠคํŠธ

testing-library ์™€ ํ•จ๊ป˜ @storybook/react ์—์„œ ์ œ๊ณตํ•˜๊ณ  ์žˆ๋Š” API๋“ค์„ ํ•จ๊ป˜ ํ™œ์šฉํ•˜๋ฉด

์ด๋ฏธ ์ž‘์„ฑํ•ด๋†“์€ story ํŒŒ์ผ์„ ์žฌ์‚ฌ์šฉํ•œ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

 

๐Ÿ’ก โš ๏ธ ์ฃผ์˜!
Storybook 7 ๋ถ€ํ„ฐ๋Š” @storybook/react ํŒจํ‚ค์ง€์— @storybook/testing-react(https://storybook.js.org/addons/@storybook/testing-react) ๊ฐ€ ๋‚ด์žฅ๋˜์–ด์„œ ์ด์ œ๋Š” ๋ณ„๋„ ์„ค์น˜๊ฐ€ ํ•„์š”์—†์Šต๋‹ˆ๋‹ค.

๋‹ค์Œ์€ ์Šคํ† ๋ฆฌํŒŒ์ผ์„ ์ด์šฉํ•ด์„œ ์ž‘์„ฑํ•œ Jest ํ…Œ์ŠคํŠธ์ฝ”๋“œ ์˜ˆ์‹œ์ž…๋‹ˆ๋‹ค.

์Šคํ† ๋ฆฌ ์ปดํฌ๋„ŒํŠธ์—์„œ ์‚ฌ์šฉํ•œ mock ๋ฐ์ดํ„ฐ๋ฅผ ๊ทธ๋Œ€๋กœ ์žฌํ™œ์šฉ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

import { render } from '@testing-library/react'
import { composeStories } from '@storybook/react'

import * as stories from './radio-box.stories'

const { Basic } = composeStories(stories)

describe('<RadioBox />', () => {
  it('should render 4 payment option', () => {
    const { getAllByTestId } = render(<Basic />)

    const options = getAllByTestId('radio-option')

    expect(options).toHaveLength(4)
  })

  it('should highlight selected option', () => {
    const { getAllByTestId } = render(<Basic />)

    const creditOption = getAllByTestId('radio-option')[0]

    expect(creditOption).toHaveStyle('border: solid 1.2px #368fff;')
  })
})

 

๐Ÿ“Œ ์Šค๋ƒ…์ƒท ํ…Œ์ŠคํŠธ

์‹œ๊ฐ ํ…Œ์ŠคํŠธ(visual test) ํ˜น์€ ์‹œ๊ฐ์  ํšŒ๊ท€ ํ…Œ์ŠคํŠธ(visual regression test) ๋Š” ์ฝ”๋“œ ์ˆ˜์ •์— ๋”ฐ๋ฅธ UI ๋ฒ„๊ทธ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ์ข‹์€ ํ…Œ์ŠคํŠธ ๋ฐฉ๋ฒ•์ž…๋‹ˆ๋‹ค.

์ด ํ…Œ์ŠคํŠธ ๋ฐฉ์‹์—์„œ๋Š” ์Šคํ† ๋ฆฌ ํŒŒ์ผ์˜ ์Šคํฌ๋ฆฐ์ƒท์„ ๋น„๊ตํ•˜์—ฌ UI ์˜ ๋ณ€๊ฒฝ์‚ฌํ•ญ์„ ์ถ”์ ํ•ฉ๋‹ˆ๋‹ค.

์Šคํ† ๋ฆฌ๋ถ์€ ํด๋ผ์šฐ๋“œ ํ˜ธ์ŠคํŒ… ๋ฐฉ์‹์˜ chromatic ๊ณผ self-managed ๋ฐฉ์‹์˜ StoryShots ๋ฅผ ํ™œ์šฉํ•˜๋Š” ๋‘ ๊ฐ€์ง€ ์˜ต์…˜์„ ์ œ๊ณตํ•˜๋Š”๋ฐ,

์ €๋Š” ์‚ฌ์ด๋“œ ํ”„๋กœ์ ํŠธ์—์„œ ci workflow ์—์„œ chromatic ์ž๋™ํ™” ํ…Œ์ŠคํŠธ ๋‹จ๊ณ„๋ฅผ ์ถ”๊ฐ€ํ•ด์„œ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๊ฐœ์ธ์ ์ธ ์˜๊ฒฌ์œผ๋กœ chromatic ์ด ์Šค๋ƒ…์ƒท ๋น„๊ต ๋ฐ ๋นŒ๋“œ ํžˆ์Šคํ† ๋ฆฌ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š”๋ฐ ์šฉ์ดํ•˜๋‹ค๊ณ  ๋Š๊ผˆ์Šต๋‹ˆ๋‹ค. ๐Ÿ˜„

Storybook Tutorials

 

Storybook Tutorials

Learn how to develop UIs with components and design systems. Our in-depth frontend guides are created by Storybook maintainers and peer-reviewed by the open source community.

storybook.js.org

 

๐Ÿ“Œ interaction ํ…Œ์ŠคํŠธ

์Šคํ† ๋ฆฌ๋ถ 6.4 ๋ถ€ํ„ฐ ๊ณต์‹์ ์œผ๋กœ interaction stories ๋ผ๋Š” ๊ธฐ๋Šฅ์„ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

interaction test vs jest dom testing

์ด์ฏค์—์„œ interaction test ๊ฐ€ Jest DOM ํ…Œ์ŠคํŠธ๋ฅผ ๋Œ€์ฒดํ•˜๋Š” ๊ฒƒ์ผ๊นŒ? ํ•˜๋Š” ์˜๋ฌธ์ด ๋“œ๋Š”๋ฐ์š”.

์ด์™€ ๊ด€๋ จํ•ด์„œ ๋ฉ”์ธํ…Œ์ด๋„ˆ์˜ ์˜๊ฒฌ์ด ์žˆ์Šต๋‹ˆ๋‹ค.

Use the test runner in most cases
Use Jest/JSDOM (or the tool of your choice)
    * If you prefer another testing mechanism
    * If there are certain kinds of setup/teardown that you can't achieve with the test runner

Storybook Interactions - a replacement for Jest DOM testing? · storybookjs/storybook · Discussion #16861

 

Storybook Interactions - a replacement for Jest DOM testing? · storybookjs/storybook · Discussion #16861

Hey, I understand the roadmap for 6.5 will include a test runner within StoryBook, as mentioned in this QA. Does the StoryBook team see the Interactions (play functions) as: An Arrange-Act, to allo...

github.com

์œ ์ € ์ธํ„ฐ๋ ‰์…˜์ด ํฌํ•จ๋˜์–ด ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•˜๊ธฐ ๊นŒ๋‹ค๋กญ๊ฑฐ๋‚˜, Jest DOM ์˜ ๊ตฌํ˜„ ํ•œ๊ณ„์ ์œผ๋กœ ์ธํ•ด

์‹ค์ œ ๋ธŒ๋ผ์šฐ์ €ํ™˜๊ฒฝ์—์„œ ์ปดํฌ๋„ŒํŠธ ๋‹จ์œ„๋กœ ํ…Œ์ŠคํŠธ๋ฅผ ์ˆ˜ํ–‰ํ•ด์•ผํ•˜๋Š” ๊ฒฝ์šฐ์—๋Š” interaction test ๋ฅผ ํ™œ์šฉํ•˜๊ณ 

๊ทธ ์™ธ์—๋Š” ์„ ํ˜ธ๋ฐฉ์‹์— ๋”ฐ๋ผ Jest DOM ์„ ํ™œ์šฉํ•˜์—ฌ ํ…Œ์ŠคํŠธ๋ฅผ ์ˆ˜ํ–‰ํ•˜๋ฉด ๋  ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.

 

interaction test ๋ฅผ ์ง์ ‘ ์‚ฌ์šฉํ•ด๋ณด๋‹ˆ, ์‹ค์ œ ๋ธŒ๋ผ์šฐ์ €์—์„œ ์‹œ๊ฐ์ ์ธ ๋””๋ฒ„๊น…์ด ๊ฐ€๋Šฅํ•œ ๊ฒƒ์ด ์ข€ ๋” ํŽธ๋ฆฌํ•œ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.

๋‹ค๋งŒ interaction test ๊ฐ€ ์‹ค์ œ ๋ธŒ๋ผ์šฐ์ €๋ฅผ ํ™œ์šฉํ•˜์—ฌ ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํ–‰ํ•˜๊ธฐ ๋•Œ๋ฌธ์—

ํ…Œ์ŠคํŠธ ์‹คํ–‰์— ํ•„์š”ํ•œ ์‹œ๊ฐ„์ด ์กฐ๊ธˆ ๋” ๊ฑธ๋ฆฐ๋‹ค๋Š” ๊ฒƒ๋„ ์œ ์˜ํ•  ํ•„์š”๊ฐ€ ์žˆ์„ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.

 

๋ชจ๋“  ์œ ๋‹› ํ…Œ์ŠคํŠธ ๋กœ์ง์„ ์ „๋ถ€ ๋Œ€์ฒดํ•˜๊ธฐ๋ณด๋‹ค๋Š” ๋‘ ํ…Œ์ŠคํŠธ ๋ฐฉ์‹์˜ ์ง€์›๋ฒ”์œ„์— ๊ทผ๊ฑฐํ•ด

์ƒํ™ฉ์— ๋งž๊ฒŒ ๋‘ ํ…Œ์ŠคํŠธ ๋„๊ตฌ๋ฅผ ์ ์ ˆํžˆ ํ™œ์šฉํ•˜๋ฉด ์ข‹์„ ๊ฒƒ ๊ฐ™๋„ค์š” ๐Ÿ˜€

 

ํŒจํ‚ค์ง€ ์„ค์น˜

npm i -D @storybook/testing-library
npm i -D @storybook/jest
npm i -D @storybook/addon-interactions 

 

  • @storybook/testing-library
    • interaction addon ๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•œ testing-library instrument ๋ฒ„์ „
  • @storybook/jest
    • storybook interaction ์„ ์œ„ํ•œ jest integration ํŒจํ‚ค์ง€

 

main.ts ์„ค์ • ์ถ”๊ฐ€ํ•˜๊ธฐ

// .storybook/main.ts

const config: StorybookConfig = {
  // ...
  addons: [
    // .. Other Storybook addons
    '@storybook/addon-interactions', // ๐Ÿ‘ˆ Register the addon
  ],
};

 

๊ทธ๋Ÿผ ์œ„์—์„œ ์ž‘์„ฑํ•œ unit test ๋ฅผ interaction test ๋กœ ๋ฐ”๊ฟ”๋ด…์‹œ๋‹ค.

 

import { within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';

export const Basic: StoryObj<typeof RadioBox> = {
  // ...
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement)

    await step('should render 4 payment option', () => {
      const options = canvas.getAllByTestId('radio-option')
      expect(options).toHaveLength(4)
    })
  },
}

 

play function ์€ ๋ Œ๋”๋ง๋œ ์ปดํฌ๋„ŒํŠธ์˜ ๋™์ž‘(interaction) ์„ ํ…Œ์ŠคํŠธํ•ด๋ณผ ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

userEvent ๋กœ event trigger ๋„ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

 

DemoStory.play = async ({ canvasElement }) => {
  const canvas = within(canvasElement)
  const inputElement = canvas.getByRole<HTMLInputElement>('textbox')
  const deleteButton = canvas.getByRole('button')

  await userEvent.type(inputElement, 'test', { delay: 100 })
  expect(inputElement.value).toBe('test')
  await userEvent.click(deleteButton)
}

 

์ด๋ฅผ ํ™œ์šฉํ•˜๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ธํ„ฐ๋ ‰์…˜ ํ…Œ์ŠคํŠธ๋„ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

 

 

๐Ÿ“Œ Storybook test runner

Storybook test runner ๋Š” ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ์Šคํ† ๋ฆฌ๋“ค์„ ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ ํ…Œ์ŠคํŠธ๋กœ ๋ณ€ํ™˜ํ•ด์ค๋‹ˆ๋‹ค.

์Šคํ† ๋ฆฌ๊ฐ€ ์ž˜ ๋…ธ์ถœ๋˜๋Š”์ง€, ๊ทธ๋ฆฌ๊ณ  ์Šคํ† ๋ฆฌ์˜ interaction test ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ˆ˜ํ–‰๋˜๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

test runner ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ•ด์ค๋‹ˆ๋‹ค.

 

> npm i -D @storybook/test-runner

 

๊ทธ๋ฆฌ๊ณ  ํ…Œ์ŠคํŠธ ์‹คํ–‰์„ ์œ„ํ•œ script ๋ฅผ ์ถ”๊ฐ€ํ•ด์ค๋‹ˆ๋‹ค.

 

// package.json

{
  "scripts": {
    "storybook:test": "test-storybook"
  }
}

 

test runner ๋Š” ํ•ญ์ƒ ์Šคํ† ๋ฆฌ๋ถ์ด ์‹คํ–‰์ค‘์ธ ์ƒํƒœ์—์„œ ์‹คํ–‰๋˜์–ด์•ผํ•ฉ๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ ๋จผ์ € ์Šคํ† ๋ฆฌ๋ถ์„ ์‹คํ–‰์‹œํ‚จ ๋‹ค์Œ, ์œ„ task ๋ฅผ ์‹คํ–‰ํ•ด์ฃผ์„ธ์š”!

 

๋ฐฐํฌ๋˜์ง€ ์•Š์€ ์Šคํ† ๋ฆฌ๋ถ์„ CI ํ™˜๊ฒฝ์—์„œ test runner ์™€ ํ•จ๊ป˜ ์‹คํ–‰ํ•˜๊ธฐ

๊ธฐ๋ณธ์ ์œผ๋กœ Storybook test runner ๋Š” storybook ์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋œ ์ƒํƒœ์—์„œ ์‚ฌ์šฉ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

๋•Œ๋ฌธ์— CI ํ™˜๊ฒฝ์—์„œ๋Š” ๋ณ„๋„์˜ ํ”„๋กœ์„ธ์Šค์—์„œ ์Šคํ† ๋ฆฌ๋ถ์„ ๋นŒ๋“œํ•˜๊ณ , ์ด๋ฅผ ์ด์šฉํ•ด ์ •์  ํŒŒ์ผ์„ ์„œ๋น™ํ•˜๋Š” ์„œ๋ฒ„๋ฅผ ์‹คํ–‰์‹œํ‚จ ๋’ค์— ํ…Œ์ŠคํŠธ๋ฅผ ์ˆ˜ํ–‰ํ•ด์•ผํ•ฉ๋‹ˆ๋‹ค.

์ด ๊ณผ์ •์„ ๋„์™€์ค„ ํŒจํ‚ค์ง€(concurrently)๋ฅผ ์„ค์น˜ํ•ฉ๋‹ˆ๋‹ค.

 

npm i -D concurrently

 

CI ๋ฅผ ์œ„ํ•œ run script ๋ฅผ ์ถ”๊ฐ€ํ•œ ๋’ค์— ๋กœ์ปฌ์—์„œ ์‹คํ–‰ํ•ด๋ด…๋‹ˆ๋‹ค.

 

// package.json

scripts: {
    "storybook:test": "test-storybook",
  "storybook:build": "storybook build",
  "storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"npm run storybook:build --quiet && npx http-server storybook-static --port 6006 --silent\" \"wait-on tcp:6006 && npm run storybook:test --maxWorkers=2\"",
}
> npm run storybook:ci

 

์ž˜ ๋˜๋Š”๊ตฐ์š” ๐Ÿ˜„

 

์ด์ œ ๊ฐ์ž ์‚ฌ์šฉํ•˜๋Š” CI ํ™˜๊ฒฝ์— ๋งž๊ฒŒ ํŒŒ์ดํ”„๋ผ์ธ์„ ์ถ”๊ฐ€ํ•ด์ค๋‹ˆ๋‹ค.

์ €๋Š” Github Action ์„ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ถ”๊ฐ€ํ•ด์คฌ์Šต๋‹ˆ๋‹ค.

 

steps:
    - name: storybook test
    run: npm run storybook:ci

 

๐Ÿ“Œ storybook msw addon

์Šคํ† ๋ฆฌ์—์„œ ํ…Œ์ŠคํŠธ๊ฐ€ ํ•„์š”ํ•œ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์™ธ๋ถ€ API ์˜ ๋ฐ์ดํ„ฐ์— ์˜์กดํ•˜๊ณ  ์žˆ๋‹ค๋ฉด ์–ด๋–ป๊ฒŒ ํ• ๊นŒ์š”?

๋ชจํ‚น๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋„๋ก fetch ๋ฅผ ๊ฐ์‹ธ๋Š” ๋ฐฉ๋ฒ•๋„ ์žˆ๊ฒ ์ง€๋งŒ, ๋ชจ๋“  fetch ๋งˆ๋‹ค ์ด ๊ณผ์ •์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

๋Œ€์‹ ์— msw ๋ฅผ ์ด์šฉํ•ด์„œ API ๋ชจํ‚น์ด ํ•„์š”ํ•œ endpoint ๋ฅผ ์ถ”๊ฐ€ํ•ด์ฃผ๋Š” ๋ฐฉํ–ฅ์ด ์œ ์ง€๊ด€๋ฆฌ์— ์ข€ ๋” ์šฉ์ดํ•ฉ๋‹ˆ๋‹ค.

1๏ธโƒฃ msw ์™€ storybook msw addon ์„ค์น˜

๋จผ์ € ํ•„์š”ํ•œ ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ•ฉ๋‹ˆ๋‹ค.

 

> npm i msw msw-storybook-addon -D

 

msw ๋Š” ์ดˆ๊ธฐ ์„ค์ •์„ ์œ„ํ•œ script ๋ฅผ ์ œ๊ณตํ•ด์ฃผ๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

public/ ํ•˜์œ„์— msw ์‚ฌ์šฉ์„ ์œ„ํ•œ ์ดˆ๊ธฐ ์„ค์ •์„ ์ง„ํ–‰ํ•ด์ค๋‹ˆ๋‹ค.

 

> npx msw init public/

2๏ธโƒฃ addon ์„ค์ •

์ด์ œ ์Šคํ† ๋ฆฌ๋ถ์—์„œ msw ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•œ ์„ค์ •์„ ์ถ”๊ฐ€ํ•ด์ค๋‹ˆ๋‹ค.

 

// ./storybook/preview.(ts|tsx)

import type { Preview } from '@storybook/react'
import { initialize, mswDecorator } from 'msw-storybook-addon';

initialize() // โœ… Initialize MSW

const preview: Preview = {
  decorators: [mswDecorator, ...] // โœ… serve msw decorator globally
    // ... and other config options
}

export default preview
// ./storybook/main.ts

const config: StorybookConfig = {
  staticDirs: ['../public'], // ๐Ÿ‘ˆ Configures the static asset folder in Storybook
    // ... and other config options
};

3๏ธโƒฃ global msw handler ์ž‘์„ฑํ•˜๊ธฐ

๋ชจ๋“  ์Šคํ† ๋ฆฌ์—์„œ ํ˜ธ์ถœ์ด ํ•„์š”ํ•œ API ์˜ ๊ฒฝ์šฐ global handler ๋กœ ๋“ฑ๋กํ•ด๋‘๋ฉด ํŽธ๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

๋งŒ์•ฝ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒํ•˜๋Š” /api/get/some/data ๋ผ๋Š” API ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ์žˆ๋‹ค๋ฉด,

 

// src/mocks/handlers.js

import { rest } from 'msw'

export const handlers = {
  GET_SOME_DATA: rest.get(
    '/api/get/some/data',
    (req, res, ctx) => {
      return res(ctx.status(200), ctx.json({ data: 'some' }))
    },
  ),
}

 

.storybook/preview.js ์—์„œ ํ•ด๋‹น ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด global decorator ๋กœ ์ถ”๊ฐ€ํ•˜๋ฉด๋ฉ๋‹ˆ๋‹ค.

 

import { handlers } from '../src/mocks/handlers'

const preview: Preview = {
  parameters: {
        msw: {
            handlers: {
                plcc: [handlers.GET_SOME_DATA]    
            }
        }
    }
    // ... and other config options
}

export default preview

4๏ธโƒฃ ๊ฐœ๋ณ„ story ๋งˆ๋‹ค msw handler ์ถ”๊ฐ€ํ•˜๊ธฐ

๊ฐœ๋ณ„ ์Šคํ† ๋ฆฌ์—์„œ handler ๋ฅผ ์ •์˜ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.

์Šคํ† ๋ฆฌ๋ถ์€ global handler ์™€ story handler ๋ฅผ ๋ณ‘ํ•ฉํ•ด์„œ API ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

๋‹ค์Œ์€ ๊ฐœ๋ณ„ ์Šคํ† ๋ฆฌ์—์„œ benefitPromotion ์ด๋ผ๋Š” ๋ชจํ‚น๋œ API ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ์˜ˆ์‹œ์ž…๋‹ˆ๋‹ค.

 

import { handlers } from '@/__mocks__/handlers'

export const Basic: StoryObj<typeof PaymentGateway> = {
    parameters: {
    msw: {
      handlers: {
        benefitPromotion: [handlers.GET_BENEFIT_PROMOTION],
      },
    },
  },
    // ... and other config options
}

5๏ธโƒฃ API whitelist ์ถ”๊ฐ€ํ•˜๊ธฐ

๊ฒฝ์šฐ์—๋”ฐ๋ผ ํŠน์ • API ์š”์ฒญ์„ ๋ฌด์‹œํ•ด์•ผํ•˜๋Š” ๊ฒฝ์šฐ๋„ ์žˆ์Šต๋‹ˆ๋‹ค.

์ €์˜ ๊ฒฝ์šฐ ์ด๋ฏธ์ง€ ์š”์ฒญ์€ ๋ณ„๋„์˜ ๋ฏธ๋“ค์›จ์–ด์—์„œ ์ฒ˜๋ฆฌํ•˜๊ณ  ์žˆ์—ˆ๊ธฐ ๋•Œ๋ฌธ์—,

์ด๋ฏธ์ง€ URL ํ˜•์‹์œผ๋กœ ์˜ค๋Š” ์š”์ฒญ์„ ๋ฌด์‹œํ•ด์ฃผ๋„๋ก ๋‹ค์Œ ์„ค์ •์„ ์ถ”๊ฐ€ํ•ด์คฌ์Šต๋‹ˆ๋‹ค.

 

// .storybook/preview.(ts|tsx)

// โœ… Initialize MSW
initialize({
  onUnhandledRequest: ({ url }) => {
    if (url.pathname.startsWith('/payment/static/images')) {
      return // storybook middleware ์—์„œ ์ฒ˜๋ฆฌํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๋ฌด์‹œํ•ฉ๋‹ˆ๋‹ค.
    }
  },
})

 

๐Ÿ“Œ ์ฐธ๊ณ ์ž๋ฃŒ

Mock Service Worker Addon | Storybook: Frontend workshop for UI development

Import stories in tests

Visual tests

๋ฐ˜์‘ํ˜•

๐Ÿ’ฌ ๋Œ“๊ธ€