실전 웹 어플리케이션 개발 4장 정리

이수빈·2023년 10월 24일
0

Next.js

목록 보기
8/15
post-thumbnail

Presentation Component vs Container Component

  • 아토믹 디자인과 함께 리액트에서 빈번하게 사용되는 형태와 작동을 분리하기 위한 컴포넌트 규칙임.

Presentation Component

  • 형태를 구현하는 컴포넌트임 prop으로 전달된 데이터를 기반으로 적절한 UI 부품을 표시하기만함.

  • 스타일적용 + 내부에 상태나 비즈니스 로직을 수행하지 않음.

Container Component

  • 비즈니스 로직을 담당하는 컴포넌트임. api호출 및 상태등을 관리함.

아토믹 디자인패턴

  • 5계층으로 디자인을 나눔

  • 아톰 : 가장 아래계층, 버튼, 텍스트와 같이 더 이상 분할 할 수 없는 컴포넌트임

    • 상태를 갖이 않고, 색상, 크기, 문장과 같이 화면을 그리는 파라미터들은 props를 통해 받음(재사용성이 목적임)
  • 몰리큘 : 아톰을 조합해서 만듬 아톰을 여러개 조합해서 구축한 UI 컴포넌트임 상태나 작동을 갖지 않으며 부모로부터 데이터를 받음

  • 오거니즘 : 입력폼, 헤더와 같이 구체적인 UI 컴포넌트임. 독자적인 로직을 가질 수 있음.

  • 템플릿 : 전체 페이지 레이아웃을 구성함.

  • 페이지 : 최상위 컴포넌트, 레이아웃은 템플릿을 통해 구현했으므로, 여기서는 상태관리나 라우터처리, api 호출등의 값을 담당함.

Story Book

  • 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>
  )
}
  • 스토리북의 Control 탭에서는 컴포넌트에 전달한 props를 제어할 수 있음.

=> 스토리북으로부터 컴포넌트에 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에 애드온을 지정해야함.

addon-essentials & lilnk

  • Docs : 스토리상에서 도큐먼트를 표시하는 기능. 메타데이터를 기반으로 도큐먼트를 자동 생성해서 표시해줌

  • linkTo : 스토리상에서 다른 스토리로 이동가능

컴포넌트 단위 테스트

React Testing Library

  • 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('')
  })
})

비동기 컴포넌트 테스트 작성

  • jest의 타이머 존재 (useFakeTimer)
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()
  })
})
profile
응애 나 애기 개발자

0개의 댓글