디자인 시스템 개발 환경 구성하기 - 2 (feat. styled-components + jest + storybook)

2022. 4. 28

이전 글에서 yarn berry + typescript + rollup + react를 활용하여 환경구성을 진행했었다.

그 다음 글이 제목과도 같은 추가적인 몇가지에 대한 글로 예고를 했었는데 흐름에 맞게 바로 반강제(?)로 진행하게 되었다.

이전 글의 결론은 React 컴포넌트를 만들어서 외부 프로젝트에서 사용하는 것 까지 진행했었다.

그래서 이번엔 styled-components를 통해 컴포넌트를 꾸미고, jest로 그걸 테스트하고, 스토리북을 통해 문서화까지 진행한 경험을 공유하려한다.

styled-components Setup

styled-components를 사용하기 위해서는 먼저 디자인 시스템을 사용할 프로젝트를 위해 peerDependencies를 추가해야한다.

peerDependencies 추가

yarn add -P styled-components

추가가 되었다면 이제 타입스크립트를 대응하기 위해 devDependencies에 의존성을 추가한다.

devDependencies 추가

yarn add -D @types/styled-components

이렇게 되면 개발 준비 자체는 다 끝!

babel-plugin-styled-components 의존성 추가

만약 여기서 디버깅을 용이하게 하고싶다면 아래 의존성을 추가한다.

yarn add -D babel-plugin-styled-components

// .babelrc

{
  "plugins": ["babel-plugin-styled-components"]
}

자 그러면 디버깅까지 준비가 끝났다.

이제 한 번 간단한 버튼 컴포넌트를 통해 테스트해보자

// src/button/index.tsx

import React, { PropsWithChildren } from 'react'

import styled from 'styled-components'

interface ButtonProps {
  color?: string
}

const ButtonComponent = styled.button<ButtonProps>`
  color: ${({ color }) => color || 'black'};
`

function Button({ color, children }: PropsWithChildren<ButtonProps>) {
  return <ButtonComponent color={color}>{children}</ButtonComponent>
}

export default Button

버튼 완성! 🎉

이걸 이제 프로젝트에서 실행을 시켜보자

import { Button } from '@fronttigger/design-system'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <Button color="red">버튼</Button>
      </header>
    </div>
  )
}

export default App

button

완벽하게 의도한대로 동작한다!

이제 styled-components를 통해 컴포넌트를 개발할 수 있다. 👍

Testing Setup

리액트에서도 여러가지 컴포넌트 테스팅 라이브러리가 있지만 가장 대표적인 react-testing-library가 있다.

위 라이브러리를 활용하여 리액트 컴포넌트 테스트 환경을 구성해보자

의존성 설치

먼저 위 라이브러리를 사용하기 위해서는 jest가 필요하다.

yarn add -D jest babel-jest ts-jest @types/jest identity-obj-proxy

위 의존성을 모두 설치했다면 react-testing-library 관련된 의존성을 설치하자

yarn add -D @testing-library/dom @testing-library/jest-dom @testing-library/react @testing-library/react-hooks @testing-library/user-event @types/testing-library__jest-dom

벌써 엄청난 양의 라이브러리를 추가했지만 아직 멀었다.

그 이유는 테스트는 개발환경에서 작성하는 것이기 때문에 리액트가 개발환경에서 테스트될 수 있도록 babel과 react에 관련된 의존성을 또 추가해줘야한다. 😇

yarn add -D @babel/preset-env @babel/preset-react babel-preset-react-app react@17.0.2 react-dom@17.0.2 react-is

자 이제 거의 다왔다.

babel 설정

방금 설치한 babel을 설정한다.

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-react",
    ["react-app", { "flow": false, "typescript": true }]
  ],
  "plugins": ["babel-plugin-styled-components"]
}

jest 설정

자 이제 테스트의 대망의 마지막 설정 단계다.

jest를 사용하기 위해서는 두가지 파일이 필요한데 jest.config.jsjest.setup.js다.

jest.setup.js

import '@testing-library/jest-dom'

jest.config.js

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['./jest.setup.js'],
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
  moduleDirectories: ['src'],
  testRegex: '(/__(tests|specs)__/.*|(\\.|/)(test|spec))\\.([tj]sx?)$',
  transform: {
    '^.+\\.jsx?$': 'babel-jest',
    '^.+\\.tsx?$': 'ts-jest',
  },
  moduleNameMapper: {
    '\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
      'identity-obj-proxy',
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
  globals: {
    __PATH_PREFIX__: '',
    'ts-jest': {
      diagnostics: false,
      tsconfig: 'tsconfig.jest.json',
      babelConfig: true,
    },
  },
}

여기서 참고할 점은 jest를 활용할 tsconfig를 따로 분리하였다. (tsconfig.json으로 한 번에 처리해도 무방하다.)

이 두가지 파일을 통해 test에 대한 설정값을 제공한다.

테스트

이제 임의의 컴포넌트를 구현하고 test를 진행해보자

// src/Accordion/index.spec.tsx

import React, { useState } from 'react'
import { render, screen } from '@testing-library/react'
import { renderHook, act } from '@testing-library/react-hooks'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'

import Accordion from './index'

describe('Accordion', () => {
  test('제목 prop인 title에 "유의사항"을 입력하면 제목에 "유의사항"이 출력된다.', () => {
    render(<Accordion title="유의사항" />)

    const titleElement = screen.getByText('유의사항')
    expect(titleElement).toHaveTextContent('유의사항')
  })

  test('최초 상태의 아코디언을 클릭하면 입력한 아코디언 상세 내용인 "조심하세요"가 보여진다.', () => {
    render(<Accordion>조심하세요</Accordion>)

    const contentElement = screen.getByRole('button')
    userEvent.click(contentElement)

    const detailElement = screen.getByText('조심하세요')
    expect(detailElement).toHaveTextContent('조심하세요')
  })

  test('아코디언의 이벤트를 외부에서 받아왔을 때 상세 내용인 "조심하세요"가 보여진다.', () => {
    const { result } = renderHook((value) => useState(!!value), {
      initialProps: false,
    })

    const handleIsOpenToggle = () => {
      result.current[1]((state) => !state)
    }

    const { rerender } = render(
      <Accordion isOpen={result.current[0]} onToggle={handleIsOpenToggle}>
        조심하세요
      </Accordion>,
    )

    expect(result.current[0]).toBe(false)

    act(() => {
      handleIsOpenToggle()
    })

    rerender(
      <Accordion isOpen={result.current[0]} onToggle={handleIsOpenToggle}>
        조심하세요
      </Accordion>,
    )

    expect(result.current[0]).toBe(true)

    const detailElement = screen.getByText('조심하세요')
    expect(detailElement).toHaveTextContent('조심하세요')
  })
})

이제 테스트를 돌려보자

yarn jest


test
cool

드디어 길고 긴 테스트를 위한 여정이 끝났다. (테스트 하기도 전에 지쳐버림.. 😭)

이제 마지막 단계인 Storybook을 추가해보자

Storybook Setup

Storybook은 UI를 문서화하는 서비스다.

특히 디자인 시스템과 같이 공통으로 사용되는 컴포넌트가 있을 경우 개발자와 디자이너간의 소통을 정말 용이하게 만들어주기 때문에 만약 디자인 시스템을 구현하게 된다면 필수로 함께 구현해야한다고 개인적으론 생각한다. 🤔

Init

storybook은 비교적 개발자들이 쉽게 사용할 수 하도록 CLI와 template를 편리하게 제공하고 있다.

공식문서도 매우 잘되어있어서 보면서 세팅하기에는 큰 무리는 없을 것 같다.

지금은 CRA와 같은 환경이 아니기 때문에 존재하고 있는 프로젝트에 아래의 명령어를 작성한다.

npx sb init

위 명령어를 입력하면 여러개의 폴더와 파일이 생성된다.

살펴보기 전에 일단 실행해보자

실행

yarn storybook

실행하면 아래와 같은 에러가 뜬다.

Error: @storybook/addon-interactions tried to access core-js, but it isn't declared in its dependencies; this makes the require call ambiguous and unsound.

아마 addon에서 문제가 발생하는 것 같다.

로그대로 core-js를 추가 설치한다.

yarn add -D core-js

설치한 뒤 다시 실행해보면 정상적으로 화면이 출력될 것이다.

storybook

정상적으로 설치된 후 실행까지 확인헀는데 뭔가 덜한 것처럼 찜찜하다. 😕

바로 아까 만든 테스트한 컴포넌트들이 storybook에 앞으로 보여져야 한다는 것이다.

그런데 현재 rollup.config.js에서는 input을 src/**/* 형태로 주었기 때문에 이를 수정해야한다.

rollup.config.js 수정

먼저 src 내부에 각각의 컴포넌트 디렉토리 내부에 테스트, 스토리북, 컴포넌트를 위치시키려고 한다.

// rollup.config.js

const inputs = fs.readdirSync('./src', { withFileTypes: true }).reduce((config, f) => {
  if (f.isDirectory() && f.name !== 'stories') {
    const name = f.name
    const dir = `src/${name}`

    const files = fs.readdirSync(dir).reduce((result, file) => {
      if (file.match(/spec|test|stories/i)) {
        return result
      }

      const filename = path.parse(file).name
      result[`${name}/${filename}`] = `${dir}/${file}`

      return result
    }, {})

    return { ...config, ...files }
  }

  return config
}, {})

const input = { ...inputs, index: './src/index.ts' }

그래서 src 내에 생성된 stories 디렉토리를 제외한 디렉토리(컴포넌트 디렉토리)를 순회하여 index.stories.tsx 를 제외한 컴포넌트를 input에 넣어주었다.

이렇게 되면 아래와 같은 디렉토리 구조가 된다.

directory

자 그러면 이 상태에서 다시 실행해보자

storybook success

구현한 Accordion 컴포넌트가 잘 출력된다. (물론 storybook 내부 구현은 진행했다.) 이제 각 프로젝트의 특성에 맞게 UI를 만들고 storybook을 구현하면 된다.

지금까지 간단한 것 같으면서 간단하지 않은 디자인 시스템 프로젝트 환경 구성을 진행해보았다.

글로 작성하려니 필요한 부분만 최소화 해서 작성하려니까 힘들었는데 최근 React의 18 버전 릴리즈로 인한 이슈, 상호 버전 호환에 대한 이슈, 라이브러리 여부에 대한 이슈 등 엄청나게 많은 이슈들을 만났었다.

일단 개발환경은 완성했으니 gitlab ci를 이용한 CI/CD 를 구축해보려고 하는데 아마 다음 글은 그 글이 되지 않을까 싶다. (또 아님 말고 😙)