형태를 구현하는 컴포넌트임 prop으로 전달된 데이터를 기반으로 적절한 UI 부품을 표시하기만함.
스타일적용 + 내부에 상태나 비즈니스 로직을 수행하지 않음.
5계층으로 디자인을 나눔
아톰 : 가장 아래계층, 버튼, 텍스트와 같이 더 이상 분할 할 수 없는 컴포넌트임
몰리큘 : 아톰을 조합해서 만듬 아톰을 여러개 조합해서 구축한 UI 컴포넌트임 상태나 작동을 갖지 않으며 부모로부터 데이터를 받음
오거니즘 : 입력폼, 헤더와 같이 구체적인 UI 컴포넌트임. 독자적인 로직을 가질 수 있음.
템플릿 : 전체 페이지 레이아웃을 구성함.
페이지 : 최상위 컴포넌트, 레이아웃은 템플릿을 통해 구현했으므로, 여기서는 상태관리나 라우터처리, api 호출등의 값을 담당함.
UI 컴포넌트 개발용 지원도구임. 컴포넌트 카탈로그를 구축 할 수 있음 => 개별 컴포넌트 단위 테스트 가능함.
다음과 같은 명령어를 통해 가동함.
npm sb init
npm run storybook
예를들어 아래와 같은 파일이 있을때, 컴포넌트를 스토리북에 표시하는 방법은 다음과 같다.
컴포넌트에 대응하는 .stories.tsx로 끝나는 파일명의 파일을 작성. export default는 스토리 설정(메타데이터) 이고, export하는 것은 테스트할 컴포넌트들임.
이벤트가 발생할때 콜백이 적절하게 호출되는지 또한 스토리북에서 확인가능함 => 메타데이터 객체 안에 새롭게 argTypes를 추하고, 그안에 체크하고자 하는 콜백명을 키로하는 객체를 추가함.
임의의 데이터를 푯기하고 싶을때 => storybook/addon-action에서 action을 import 해온 뒤 이벤트 핸들러에서 이를 전달.
import { ComponentMeta } from '@storybook/react'
import { StyledButton } from '../components/StyledButton'
import { action } from '@storybook/addon-actions'
// 파일 안의 Story의 설정(메타 데이터 객체)
export default {
// 그룹명
title: 'StyledButton (1)',
// 사용하는 컴포넌트
component: StyledButton,
argTypes: {
onClick: { action: 'clicked' },
// onClick 이벤트가 발생했을 때 click이라는 문구가 출력됨.
} as ComponentMeta<typeof StyledButton>
// increment라는 이름으로 action을 출력하기 위한 함수를 만든다
const incrementAction = action('increment')
export const Primary = (props) => {
const [count, setCount] = useState(0)
const onClick = (e: React.MouseEvent) => {
// 현재 계정을 전달하고, Action을 호출한다
incrementAction(e, count)
setCount((c) => c + 1)
}
return (
<StyledButton {...props} variant="primary" onClick={onClick}>
Primary
</StyledButton>
)
}
export const Success = (props) => {
return (
<StyledButton {...props} variant="success">
Primary
</StyledButton>
)
}
export const Transparent = (props) => {
return (
<StyledButton {...props} variant="transparent">
Transparent
</StyledButton>
)
}
=> 스토리북으로부터 컴포넌트에 props를 전달하므로 템플릿을 작성하고 각 스토리를 템플릿의 bind 함수를 이용해서 작성함.
(제어하고자 하는 데이터는 메타데이터의 argTypes로 정의)
import { ComponentMeta, ComponentStory } from '@storybook/react'
import { StyledButton } from '../components/StyledButton'
export default {
title: 'StyledButton (4) - Control을 사용한다',
component: StyledButton,
argTypes: {
// props에 전달하는 variant를 Storybook으로부터 변경할 수 있도록 추가
variant: {
// 라디오 버튼으로 설정할 수 있도록 지정
control: { type: 'radio' },
options: ['primary', 'success', 'transparent'],
},
// props에 전달하는 children을 Storybook으로부터 변경할 수 있도록 추가
children: {
// 텍스트 박스에서 입력할 수 있도록 지정
control: { type: 'text' },
},
},
} as ComponentMeta<typeof StyledButton>
// 템플릿 컴포넌트를 구현
// Storybook으로부터 전달된 props를 그대로 Button에 전달한다
const Template: ComponentStory<typeof StyledButton> = (args) => <StyledButton {...args} />
// bind를 호출해 Story를 작성
export const TemplateTest = Template.bind({})
// 기본 props를 설정한다
TemplateTest.args = {
variant: 'primary',
children: 'Primary',
}
addon은 스토리북에서 추가기능을 제공함. npx sb init으로 초기화 한 경우 addon-essentials는 이미 설치되어있음.
추가할때는 패키치 설치 후 , .storybook/ main.js의 객체에 addons에 애드온을 지정해야함.
Docs : 스토리상에서 도큐먼트를 표시하는 기능. 메타데이터를 기반으로 도큐먼트를 자동 생성해서 표시해줌
linkTo : 스토리상에서 다른 스토리로 이동가능
Dom Testing Library를 이용해 컴포넌트의 테스트를 작동시킴.
jest.setup.js, jest.config.js 설정
// jest.setup.js
import '@testing-library/jest-dom/extend-expect'
// jest.config.js
const nextJest = require('next/jest')
const createJestConfig = nextJest({ dir: './' })
const customJestConfig = {
testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jsdom',
}
module.exports = createJestConfig(customJestConfig)
테스트 파일은 .spec.tsx or .test.tsx로 끝나는 파일명을 사용해야함.
describe함수를 사용하면 테스트를 모을 수 있음. beforeEach, afterEach 함수에서는 테스트 실행 전, 실행 후의 처리를 기술합니다.
it안에서는 실제 테스트를 작성함. screen.getByLabelText를 이용해 화면에 그려지지 않은 dom으로부터 지정한 이름의 라벨에 대응하는 input을 얻음.
이벤트를 지정하는 경우 fireEvent를 사용함. 첫번째의 input의 dom, 두번째에 객체안에 입력할 문자열을 지정함.
getByRole : Dom에 role이나 aria-label 등의 역할이 설정된 경우 역할에 매치하는 dom을 얻기 위한 함수.
import { render, screen, RenderResult, fireEvent, getByRole } from '@testing-library/react'
import { Input } from './index'
// describe로 처리를 모은다
describe('Input', () => {
let renderResult: RenderResult
// 각 테스트 케이스 이전에 컴포넌트를 그리고, renderResult에 설정한다
beforeEach(() => {
renderResult = render(<Input id="username" label="Username" />)
})
// 테스트 케이스 실행 후에 그리던 컴포넌트를 릴리스한다
afterEach(() => {
renderResult.unmount()
})
// 초기 그리기 시에 input 요소가 비어있는 것을 테스트
it('should empty in input on initial render', () => {
// label이 Username인 컴포넌트에 대응하는 input의 요소를 얻는다
const inputNode = screen.getByLabelText('Username') as HTMLInputElement
// input 요쇼 표시가 비었는지 확인한다
expect(inputNode).toHaveValue('')
})
// 문자를 입력하면, 입력한 내용이 표시되는 것을 테스트
it('should show input text', () => {
const inputText = 'Test Input Text'
const inputNode = screen.getByLabelText('Username') as HTMLInputElement
// fireEvent를 사용해 input 요소의 onChange 이벤트를 트리거한다
fireEvent.change(inputNode, { target: { value: inputText } })
// input 요소에 입력한 텍스트가 표시되는지 확인한다
expect(inputNode).toHaveValue(inputText)
})
// 버튼이 클릭되었다면 입력 텍스트가 삭제되는지 확인
it('should reset when user clicks button', () => {
// 처음에 input에 텍스트를 입력한다
const inputText = 'Test Input Text'
const inputNode = screen.getByLabelText('Username') as HTMLInputElement
fireEvent.change(inputNode, { target: { value: inputText } })
// 버튼을 얻는다
const buttonNode = screen.getByRole('button', {
name: 'Reset',
}) as HTMLButtonElement
// 버튼을 클릭한다
fireEvent.click(buttonNode)
// input 요소 표시가 비어있는지 확인한다
expect(inputNode).toHaveValue('')
})
})
import { render, screen, RenderResult, fireEvent, act } from '@testing-library/react'
import { DelayInput } from './index'
// DelayInput 컴포넌트에 관한 테스트
describe('DelayInput', () => {
let renderResult: RenderResult
let handleChange: jest.Mock
beforeEach(() => {
// 목 함수를 작성한다
handleChange = jest.fn()
// 목 함수를 DelayButton에 전달해서 그린다
renderResult = render(<DelayInput onChange={handleChange} />)
// 타이머를 jest의 것으로 치환한다
jest.useFakeTimers()
})
afterEach(() => {
renderResult.unmount()
// 타이머를 원래 것으로 되돌린다
jest.useFakeTimers()
})
// span 요소의 텍스트가 비어있는 것을 테스트
it('should display empty in span on initial render', () => {
const spanNode = screen.getByTestId('display-text') as HTMLSpanElement
// 초기 표시는 비어있다
expect(spanNode).toHaveTextContent('입력한 텍스트:')
})
// 입력 직후는 span 요소가 '입력 중...'이라 표시하는지 테스트
it('should display 「입력 중...」 immediately after onChange event occurs', () => {
const inputText = 'Test Input Text'
const inputNode = screen.getByTestId('input-text') as HTMLInputElement
// input의 onChange 이벤트를 호출한다
fireEvent.change(inputNode, { target: { value: inputText } })
const spanNode = screen.getByTestId('display-text') as HTMLSpanElement
// 입력 중이라고 표시하는지 확인
expect(spanNode).toHaveTextContent('입력 중...')
})
// 입력해서 1초 후에 텍스트가 표시되는지 테스트
it('should display input text 1 second after onChange event occurs', async () => {
const inputText = 'Test Input Text'
const inputNode = screen.getByTestId('input-text') as HTMLInputElement
// input의 onChange 이벤트를 호출한다
fireEvent.change(inputNode, { target: { value: inputText } })
// act 함수 안에서 실행함으로써, 타이머의 콜백 안에서 발생하는 상태 변경이 반영되는 것을 보증한다
await act(() => {
// 타이머에 설정된 timeout을 모두 실행한다
jest.runAllTimers()
})
const spanNode = screen.getByTestId('display-text') as HTMLSpanElement
// 입력한 텍스트가 표시되는지 확인
expect(spanNode).toHaveTextContent(`입력한 텍스트: ${inputText}`)
})
// 입력해서 1초 후에 onChange가 호출되는지 테스트
it('should call onChange 1 second after onChange event occurs', async () => {
const inputText = 'Test Input Text'
const inputNode = screen.getByTestId('input-text') as HTMLInputElement
// input의 onChange 이벤트를 호출한다
fireEvent.change(inputNode, { target: { value: inputText } })
// 타이머 실행
await act(() => {
jest.runAllTimers()
})
// 목 함수를 전달해, 호출되었는지 확인한다
expect(handleChange).toHaveBeenCalled()
})
})