[코드숨 리액트 13기] 3주차 과제 피드백

박세진·2022년 11월 3일
0

해당 포스팅에는 과제에 대한 코드가 포함되어 있습니다!

3주차

3주차에는 TDD(테스트 주도 개발)을 적용하여, 2주차에 만들었던 Todo 앱을 테스트 하는 것이었다.

TDD(테스트 주도 개발)

TDD를 할 때, RED → GREEN → Refactor 이와 같은 사이클을 짧은 주기로 반복하는 것이다. Red에서는 통과하지 못하는 테스트를 작성한 다음에 Green 단계에서 테스트를 통과하도록 작성하고, Refactoring을 한다.

Jest

Jest는 단순함에 초점을 맞춘 자바스크립트 테스팅 프레임워크이다.

  • 간단한 설정만으로 테스트를 실행
  • 풍부한 matcher를 제공하여 별도의 모듈 없이 테스트를 더 풍부하게 표현
  • coverage도 별도의 설치 없이 확인할 수 있음
  • Mocking 등을 지원하여 테스트를 더 쉽게 가능하게 해주는 프레임워크

설치방법

$ npm i -D jest @types/jest babel-jest

React testing library

리액트 테스팅 라이브러리는 사용자와 동일한 방식으로 DOM 쿼리를 사용할 수 있게 해준다.
실제 사용자가 우리의 앱을 사용하는 방식으로 테스트하여 우리의 앱이 올바르게 동작하는지 테스트 할 수 있다.

설치방법

$ npm i -D @testing-library/react @testing-library/jest-dom
  • @testing-library/jest-dom
    jest의 mathcer들을 확장하여 테스트의 의도를 더 명확하게 표현할 수 있다. jest dom을 사용하기 위해서 매번 import를 해주는 게 불편하니까 jest.setup.js 파일을 만들어서 jest.config.jssetupFilesAfterEnv에 설정을 해주면 편리하게 사용할 수 있다.
// jest.setup.js 파일
import '@testing-library/jest-dom'
// jest.config.js 파일
module.exports = {
  setupFilesAfterEnv: [
    'jest-plugin-context/setup', // 이건 context 구문을 사용하기 위한 설정
    './jest.setup', // jest setup 파일
  ],
};

테스트를 실행하기

$ npx jest

테스트를 실행할 수 있고, 계속해서 모든 테스팅 파일을 감시하길 바란다면 watchAll을 사용하면 된다.

$ npx jest --watchAll
$ npx jest --watchAll --verbose

verbose 해당 명령어를 사용하면 계층 구조화되어 볼 수 있다.

Todo 앱을 테스트 작성

모든 컴포넌트에 테스트를 작성하고, coverage 100%를 달성하는 게 과제의 목표였다.

App.test.jsx

import { render, fireEvent } from '@testing-library/react';

import App from './App';

describe('App', () => {
  function renderApp() {
    return render((<App />));
  }

  it('App이 렌더링된다.', () => {
    const { container } = renderApp();

    expect(container).toHaveTextContent('To-do');
    expect(container).toHaveTextContent('할 일이 없어요!');
    expect(container).toHaveTextContent('추가');
  });

  it('할 일을 입력시 handleChange 함수가 실행되어 input의 value가 변경된다.', () => {
    const { getByLabelText } = renderApp();

    fireEvent.change(getByLabelText('할 일'), {
      target: { value: 'TDD 하기' },
    });

    expect(getByLabelText('할 일').value).toBe('TDD 하기');

    fireEvent.change(getByLabelText('할 일'), {
      target: { value: '집에 가기' },
    });

    expect(getByLabelText('할 일').value).toBe('집에 가기');
  });

  it('추가 버튼을 누르면 handleClickAdd 함수가 실행되어 할 일이 렌더링된다.', () => {
    const { container, getByLabelText, getByText } = renderApp();

    fireEvent.change(getByLabelText('할 일'), {
      target: { value: 'TDD 하기' },
    });

    expect(getByLabelText('할 일').value).toBe('TDD 하기');

    fireEvent.click(getByText('추가'));

    expect(container).toHaveTextContent('TDD 하기');
  });

  it('완료 버튼을 누르면 handleClickDelete 함수가 실행되어 추가되었던 할 일이 삭제된다.', () => {
    const { container, getByLabelText, getByText } = renderApp();

    fireEvent.change(getByLabelText('할 일'), {
      target: { value: 'TDD 하기' },
    });

    fireEvent.click(getByText('추가'));

    fireEvent.click(getByText('완료'));

    expect(container).not.toHaveTextContent('TDD 하기');
  });
});

Input.test.jsx

import { render, fireEvent } from '@testing-library/react';

import Input from './Input';

describe('Input', () => {
  const handleChange = jest.fn();
  const handleClick = jest.fn();

  const renderInput = () => render(
    <Input
      value="운동하기"
      onChange={handleChange}
      onClick={handleClick}
    />,
  );

  it('Input과 button이 렌더링된다.', () => {
    const { container } = renderInput();

    expect(container).toHaveTextContent('할 일');
    expect(container).toHaveTextContent('추가');
  });

  it('input에 입력시 handleChange 함수가 실행된다.', () => {
    const { getByLabelText } = renderInput();

    const input = getByLabelText('할 일');

    fireEvent.change(input, { target: { value: 'TDD 하기' } });

    expect(handleChange).toBeCalled();
  });

  it('추가 버튼을 클릭하면 handleClick 함수가 실행된다.', () => {
    const { getByText } = renderInput();

    expect(handleClick).not.toBeCalled();

    fireEvent.click(getByText('추가'));

    expect(handleClick).toBeCalled();
  });
});

Item.test.jsx

import { render, fireEvent } from '@testing-library/react';

import Item from './Item';

test('Item', () => {
  const task = {
    id: 1,
    title: '뭐라도 하기',
  };

  const handleClick = jest.fn();

  const { container, getByText } = render((
    <Item
      task={task}
      onClickDelete={handleClick}
    />
  ));

  expect(container).toHaveTextContent('뭐라도 하기');
  expect(container).toHaveTextContent('완료');

  expect(handleClick).not.toBeCalled();

  fireEvent.click(getByText('완료'));

  expect(handleClick).toBeCalledWith(1);
});

List.test.jsx

import { fireEvent, render } from '@testing-library/react';

import List from './List';

import tasks from '../fixtures/tasks';

describe('List', () => {
  const handleClick = jest.fn();

  const renderList = (todoList) => render(
    <List
      tasks={todoList}
      onClickDelete={handleClick}
    />,
  );

  context('tasks가 있을 경우', () => {
    it('List 컴포넌트가 렌더링된다.', () => {
      const { container } = renderList(tasks);

      expect(container).toHaveTextContent('리액트 공부하기');
      expect(container).toHaveTextContent('블로그 작성하기');
    });

    it('완료 버튼을 누르면 handleClick 함수가 실행된다.', () => {
      const { getAllByText } = renderList(tasks);

      const buttons = getAllByText('완료');

      fireEvent.click(buttons[0]);

      expect(handleClick).toBeCalled();
    });
  });

  context('tasks가 없을 경우', () => {
    const emptyTasks = [];

    it('할 일이 없어요 메시지가 보인다.', () => {
      const { container } = renderList(emptyTasks);

      expect(container).toHaveTextContent('할 일이 없어요!');
    });
  });
});

Page.test.jsx

import { render, fireEvent } from '@testing-library/react';

import Page from './Page';

import tasks from '../fixtures/tasks';

describe('Page', () => {
  const handleChangeTitle = jest.fn();
  const handleClickAddTask = jest.fn();
  const handleClickDeleteTask = jest.fn();

  function renderPage(taskTitle = '') {
    return render(
      (<Page
        tasks={tasks}
        taskTitle={taskTitle}
        onChangeTitle={handleChangeTitle}
        onClickAddTask={handleClickAddTask}
        onClickDeleteTask={handleClickDeleteTask}
      />),
    );
  }

  it('Page가 렌더링된다.', () => {
    const { container, getAllByText } = renderPage();

    expect(container).toHaveTextContent('리액트 공부하기');
    expect(container).toHaveTextContent('블로그 작성하기');

    const buttons = getAllByText('완료');

    fireEvent.click(buttons[0]);
  });
});

fixtures

여러 곳에서 사용되는 테스트용 데이터를 fixture라고 한다. 테스트용 데이터를 관리하는 fixtures 폴더를 생성하여, tasks라는 파일을 만들어서 관리했다.

// tasks.js
const tasks = [
  {
    id: 1,
    title: '리액트 공부하기',
  },
  {
    id: 2,
    title: '블로그 작성하기',
  },
];

export default tasks;

과제 피드백

BDD (describe - (context) - it )

참고 - • https://johngrib.github.io/wiki/junit5-nested/#describe---context---it-%ED%8C%A8%ED%84%B4

Describe-Context- It은 상황을 설명하기보다는 테스트 대상을 주인공 삼아 행동을 더 섬세하게 설명하는데 적합하다.

키워드설명
Describe설명할 테스트 대상을 명시한다
Given에 해당. 테스트할 대상을 지정할 때 사용하는 키워드테스트 대상이 되는 클래스, 메소드 이름을 명시한다
Context테스트 대상이 놓인 상황을 명시한다.
조건을 정의할 때 사용한다. When에 해당하는 부분테스트할 메소드에 입력할 파라미터를 설명한다
It테스트 대상의 행동을 설명한다테스트 대상 메소드가 무엇을 리턴하는지 설명한다
  • 테스트 시나리오를 작성할 경우, describe - context - it에 있는 문장들이 하나로 합쳐졌을 때, 한 문장처럼 자연스럽게 읽히도록 작성하는 게 중요하다.

그 외

  • 화살표 함수에서 () => {} 중괄호를 해주는 편이 가독성이 더 좋다.

  • props가 2개 이상이면 세로 정렬해주기. (왜 항상 세로 정렬하는 건 까먹을까?)

  • 여러 곳에서 사용되는 테스트용 데이터를 보통 fixture라고 한다. fixtures 폴더 하나를 만들어서 따로 관리하는 게 좋다.

  • const handleChange = jest.fn() 등 이러한 것을 describe('컴포넌트 이름') 밑에 선언해두는 편이 낫다.


3주차 TDD를 들어가면서 너무 힘들었다.
처음 언어를 배우는데, 참 직관적인 단어들을 사용하지만 이렇게 어려울수가...?!
무엇을 어떻게 테스트 해야 되는 지를 몰라서, 많이 헤맸었고, 테스트는 값을 가상으로 준다는 게 익숙치 않아서 힘들었다.
아무튼 하는 동안 react-testing-library 공식문서랑 jest의 공식문서를 정말 자주 방문했다. (앞으로도 많이 방문할 예정...🥺)
지금 4주차 과제를 하는 중인데, 왜 다시 리셋된 느낌일까? 리덕스를 사용했다고 이렇게 달라 보일 수가 있는 걸까. 슬프다.

profile
경험한 것을 기록

0개의 댓글