반디북
모던 리액트 Deep Dive
Ch8
준영
도은
  • 코드 작성 만큼이나, 좋은 코드를 작성할 수 있는 환경을 구착하는 것이 중요

8.1 ESLint를 활용한 정적 코드 분석

💡 정적 코드 분석
   코드의 실행과는 별개로 코드 그 자체만으로 버그를 야기할 수 있는 코드를 찾아내어
   문제의 소지가 있는 코드를 사전에 수정하는 것

8.1.1 ESLint 살펴보기

  • ESLint는 도대체 어떤 방식으로 자바스크립트 코드를 정적 분석할 수 있을까?

ESLint는 어떻게 코드를 분석할까?

  • ESLint는 자바스크립트 코드를 정적 분석하여
  • 잠재적인 문제를 발견하고, 수정까지 도와주는 도구
💡 ESLint가 자바스크립트 코드를 읽어서 분석하는 방법 요약

   1. 자바스크립트 코드를 문자열로 읽는다
   2. 자바스크립트 코드를 분석할 수 있는 파서(parser)로 코드를 구조화한다
   3. 2번에서 구조화한 트리를 AST(Abstract Syntax Tree)를 기준으로 각종 규칙과 대조한다
   4. 규칙과 대조했을 때 이를 위반한 코드를 알리거나 수정한다
  • ESLint는 espree (opens in a new tab)라는 파서를 사용
  • espree로 분석하면 JSON 구조화된 결과를 얻을 수 있다
  • espree 같은 분석 도구는 단순히 변수/함수/함수명은 무엇인지 등만 파악하는 것이 아니라
  • 코드의 정확한 위치와 같은 아주 세세한 정보도 분석해 알려준다.

ESLint가 espree로 분석한 결과를 바탕으로, 어떤 코드가 잘못된 코드이며 어떻게 수정해야 할지도 정해야 한다

  • 이를 ESLint 규칙(rules)
  • 특정한 규칙의 모음을 plugins라고 한다.

예를 들어, debugger의 사용을 금지하고 싶다고 가정해보자

  • debugger는 코드 개발 과정에서만 사용해야 하는 구문으로
  • 프로덕션 애플리케이션에서는 절대 존재 X
  • 이 구문을 ESLint를 이용해 사용을 금지하는 규칙을 만든다고 가정해보자

👇 debugger만 있는 코드를 espree로 분석한 모습

{
  "type": "Program",
  "body": [
    {
      "type": "DebuggerStatement",
      "range": [0, 8]
    }
  ],
  "sourceType": "module",
  "range": [0, 8]
}
  • body의 type이 DebuggerStatement를 반환하는 것을 확인 가능
  • 이 debugger 사용을 제한하는 규칙인 no-debugger를 확인해보자

👇 no-debugger 규칙

module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description: 'Disallow the use of `debugger`'
      recommended: true,
      url: "https://eslint.org/docs/rules/no-debugger"
    },
    fixable: null,
    schema: [],
    message: {
        unexpected: "Unexpected 'debugger' statement"
    }
  },
  create(context) {
    return {
        DebuggerStatement(node) {
            context.report({
                node,
                messageId: 'unexpected'
            })
        }
    }
  }
};
  • create가 실제로 코드에서 문제점을 확인하는 곳
  • create에 있는 함수는 espree로 만들어진 AST 트리를 순회해, 여기서 선언한 특정 조건을 만족하는 코드를 찾고
  • 이러한 작업을 코드 전체에서 반복
  • 즉, 여기서는 DebuggerStatement를 만나면 해당 노드를 리포트해 debugger를 사용했다는 것을 알려준다.

8.1.2 eslint-plugin과 eslint-config

  • ESLint를 설치해 본 적이 있다면
  • eslint-plugin-이나 eslint-config-로 시작하는 각종 패키지를 본 적이 있을 것..

eslint-plugin

  • 규칙을 모아놓은 패키지
  • 예를 들어 eslint-plugin-import 라는 패키지는
    • 자바스크립트에서 다른 모듈을 불러오는 import와 관련된 다양한 규칙을 제공
  • 예를 들어 eslint-plugin-react 라는 패키지는
    • JSX 배열에 키를 선언하지 않았다는 경고 메시지 같은 것
  • 만약, 이러한 규칙이 없다면 뒤늦게 브라우저의 콘솔에서 경고해주는 내용을 확인해야만 수정이 가능

eslint-config

  • eslint-plugin를 한데 묶어서 완벽하게 한 세트를 제공하는 패키지
  • 내가 원하는 규칙들을 한데 모아서 설치하고 적용하는 것도 좋지만
  • ESLint를 설정하는 것 또한 만만치 않아
  • 이미 존재하는 eslint-config를 설치해 빠르게 적용하는 경우가 일반적
  • eslint-config-airbnb, @titicaca/triple-config-kit, eslint-config-next

8.1.3 나만의 ESLint 규칙 만들기

이미 존재하는 규칙을 커스터마이징해서 적용하기: import React를 제거하기 위한 ESLint 규칙 만들기

  • 리액트 17 버전부터는 새로운 JSX 런타임 덕분에 import React 구문이 필요 X
  • 이에 따라 import React를 삭하게 되면, 번들러 크기 다이어트 가능
  • no-restricted-imports 규칙을 커스텀해서 만들어보자

👇 .eslintrc.js 파일

module.exports = {
    rules: {
        'no-restricted-imports': [
            'error',
            {
                // paths에 금지시킬 모듈을 추가한ㄷ.
                paths: [
                    {
                        // 모듈명
                        name: 'react',
                        // 모듈의 이름
                        importNames: ['default'],
                        // 경고 메시지
                        message: 'import React from 'react'는 react 17부터 더 이상 필요하지 않습니다. 필요한 것만 react로부터 import해서 사용해 주세요'
                    }
                ]
            }
        ]
    }
}
  • 이러한 원리를 활용하면 트리쉐이킹이 되지 않는 lodash 같은 라이브러리를 import 하는 것도 방지 가능
module.exports = {
  rules: {
    'no-restricted-imports': [
      'error',
      {
        name: 'lodash',
        message:
          'lodash는 CommonJS로 작성돼 있어 트리쉐이킹이 되지 않아 번들 사이즈를 크게 합니다. lodash/* 형식으로 import 해주세요',
      },
    ],
  },
};

8.1.4 주의할 점

  • ESLint를 잘못 설정해두면 원치 않는 결과가 계속해서 발생

Prettier와의 충돌

  • Prettier는 ESLint와 마찬가지로 코드를 정적 분석해서 문제를 해결한다는 점은 동일
  • **자바스크립트에서만 작동하는 **ESLint와는 다르게 Prettier는 다양한 언어에도 적용 가능
  • 둘은 충돌을 일으킬 수 있다는 것
  • 해결 방법은
    • 서로 규칙이 충돌되지 않게끔 규칙을 잘 선언하는 방법
    • JS, TS는 ESLint에게, 그 외 파일은 Prettier에게, 대신 JS에 추가적으로 필요한 Prettier는 eslint-config-prettier를 사용

8.2 리액트 팀이 권장하는 리액트 테스트 라이브러리

  • 프론트엔드 테스트는 일반적인 사용자와 동일하거나 유사한 환경에서 수행
  • 사용자가 프로그램에서 수행할 수요 비즈니스 로직이나 모든 경우의 수를 고려
  • 코드가 어떻게 되었든, 의도한 대로 작동하는지를 확인하는 데 좀 더 초점

8.2.1 React Testing Library란?

💡 React Testing Library란
   DOM Testing Library를 기반으로 만들어진 테스팅 라이브러리
   리액트를 기반으로 한 테스트를 수행하기 위해 만들어졌다.
  • DOM Testing Library는 jsdom을 기반
  • jsdom이란 순수하게 자바스크립트로 작성한 라이브러리로
  • Node.js 같은 환경에서 HTML과 DOM을 사용할 수 있도록 해주는 라이브러리
  • jsdom을 사용하면 자바스크립트 환경에서도 HTML을 사용할 수 있으므로
  • 이를 기반으로 DOM Testing Library에서 제공하는 API를 사용해 테스트를 수행 가능

👇 jsdom을 사용해 DOM 조작

const jsdom = require('jsdom');
 
const { JSDOM } = jsdom;
const dom = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`);
 
console.log(dom.window.document.querySelector('p').textContent); // "Hello world"
  • 이처럼 jsdom을 사용하면 마치 HTML이 있는 것처럼 DOM을 불러오고 조작 가능
  • 리액트 테스팅 라이브러리를 활용하면
    • 실제로 리액트 컴포넌트를 렌더링하지 않고도
    • 즉, 브라우저를 직접 실행해 눈으로 확인하지 않아도
    • 리액트 컴포넌트가 원하는 대로 렌더링되고 있는지 확인 가능
    • 컴포넌트뿐만 아니라 Provider, 훅 등 리액트를 구성하는 다양한 요소들을 테스트 가능

8.2.2 자바스크립트 테스트의 기초

만약 인수 두 개의 합을 더하는 함수를 만들었다고 가정

function sum(a, b) {
  return a + b;
}
  • 테스트 코드란, 내가 코드를 작성했던 당시의 의도와 목적에 맞는지 확인하는 코드
  • 그런 의미에서 다음과 같은 테스트 코드를 작성해볼 수 있을 것 같다
// 테스트 1
// 함수를 실행했을 때의 실제 결과
let actual = sum(1, 2);
// 함수를 실행했을 때 기대하는 결과
let expected = 3;
 
if (expected !== actual) {
  throw new Error(`${expected} is not equal to ${actual}`);
}
 
// 테스트 2
actual = sum(2, 2);
expected = 4;
 
if (expected !== actual) {
  throw new Error(`${expected} is not equal to ${actual}`);
}

테스트 코드를 작성하는 방식은 보통 다음과 같은 과정을 거친다

  1. 테스트할 함수나 모듈을 선정한다.
  2. 함수나 모듈이 반환하길 기대하는 값을 적는다.
  3. 함수나 모듈의 실제 반환 값을 적는다.
  4. 3번의 기대에 따라 2번의 결과가 일치하는지 확인한다.
  5. 기대하는 결과를 반환한다면 테스트는 성공이며, 만약 기대와 다른 결과를 반환하면 에러를 던진다.

8.2.3 리액트 컴포넌트 테스트 코드 작성하기

기본적으로 리액트에서 컴포넌트 테스트는 다음과 같은 순서로 진행된다.

  1. 컴포넌트를 렌더링한다.
  2. 필요하다면 컴포넌트에서 특정 액션을 수행한다.
  3. 컴포넌트 렌더링과 2번의 액션을 통해 기대하는 결과와 실제 결과를 비교한다.

정적 컴포넌트

  • 별도의 상태 존재 X → 항상 같은 결과를 반환하는 컴포넌트를 테스트

컴포넌트에 링크가 제대로 있는지 확인한다면 다음과 같이 테스트 코드를 작성해 볼 수 있다.

import { render, screen } from '@testing-libray/react';
 
// import StaticComponent from './index'
 
beforeEach(() => {
  render(<StaticComponent />);
});
 
describe('링크 확인', () => {
  it('링크가 3개 존재한다.', () => {
    const ul = screen.getByTestId('ul');
    expect(ul.children.length).toBe(3);
  });
 
  it('링크 목록의 스타일은 square다.', () => {
    const ul = screen.getByTestId('ul');
    expect(ul).toHaveStyle('list-style-type: square;');
  });
});
 
describe('리액트 링크 테스트', () => {
  it('리액트 링크가 존재한다.', () => {
    const reactLink = screen.getByText('리액트');
    expect(reactLink).toBeVisible();
  });
 
  it('리액트 링크가 올바른 주소로 존재한다.', () => {
    const reactLink = screen.getByText('리액트');
    expect(reactLinke.tagName).toEqual('A');
    expect(reactLink).toHaveAttribte('href', 'https://reactjs.org');
  });
});

동적 컴포넌트

  • 사용자의 입력을 useState로 받아서 처리하는 컴포넌트가 제일 흔할 것
import { fireEvent, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
 
// import { InputComponent } from '.';
 
describe('InputComponent 테스트', () => {
  const setup = () => {
    const screen = render(<InputComponent />);
    const input = screen.getByLabelText('input');
    const button = screen.getByText(/제출하기/i);
 
    return { input, button, ...screen };
  };
 
  it('input의 초깃값은 빈 문자열이다.', () => {
    const { input } = setup();
    expect(input.value).toEqual('');
  });
 
  it('input의 최대 길이가 20자로 설정되어 있다.', () => {
    const { input } = setup();
    expect(input).toHaveAtribute('maxlength', 20);
  });
 
  it('영문과 숫자만 입력된다,', () => {
    const { input } = setup();
    const inputValue = '안녕하세요123';
    userEvent.type(input, inputValue);
    expect(input.value).toEqual('123');
  });
 
  it('아이디를 입력하지 않으면 버튼이 활성회되지 않는다.', () => {
    const { button } = setup();
    expect(button).toBeDisabled();
  });
 
  it('아이디를 입력하면 버튼이 활성화된다.', () => {
    const { input, button } = setup();
 
    const inputValue = 'helloworld';
    userEvent.type(input, inputValue);
 
    expect(input.value).toEqual(inputValue);
    expect(button).toBeEnabled();
  });
 
  it('버튼을 클릭하면 alert가 해당 아이디로 표시된다.', () => {
    const alertMock = jest.spyOn(window, 'alert').mockImplementation(() => undefined);
    const { button, input } = setup();
    const inputValue = 'helloworld';
    userEvent.type(input, inputValue);
    fireEvent.click(button);
 
    expect(alertMock).toHaveBeenCalledTimes(1);
    expect(alertMock).toHaveBeenCalledWith(inputValue);
  });
});

비동기 이벤트가 발생하는 컴포넌트

  • fetch를 어떻게 테스트할 수 있을까?
  • 가장 먼저 떠오르는 방법은 jest.spyOn이다.
jest.spyOn(window, 'fetch').mockImplementation(
  jest.fn(() => {
    Promise.resolve({
      ok: true,
      status: 200,
      json: () => Promise.resolve(MOCK_TODO_RESPONSE),
    });
  }),
);
  • 모든 경우를 새롭게 모킹해야 하므로 테스트 코드가 길고 복잡해진다.
  • fetch가 할 수 있는 다양한 일(headers를 설정하건, text()로 파싱하거나, status의 값을 다르게 보는 등)을 일일이 모킹해야 하므로 테스트 코드가 길어지고 유지보수도 어렵다.
💡 이러한 문제를 해결하기 위해 등장한 것이 MSW(Mock Service Worker)
  • 브라우저에서 서비스 워커를 활용해 실제 네트워크 요청을 가로채는 방식으로 모킹을 구현
import { fireEvent, render, screen } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
 
// import { FetchComponent } from '.';
 
const MOCK_TODO_REPONSE = {
  userId: 1,
  id: 1,
  title: 'delectus aut autem',
  completed: false,
};
 
const server = setupServer(
  rest.get('/todos/:id', (req, res, ctx) => {
    const todoId = req.params.id;
 
    if (Number(todoId)) {
      return res(ctx.json({ ...MOCK_TODO_RESPONSE, id: Number(todoId) }));
    } else {
      return res(ctx.status(404));
    }
  }),
);
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
 
beforeEach(() => {
  render(<FetchComponent />);
});
 
describe('FetchComponent 테스트', () => {
  it('데이터를 불러오기 전에는 기본 문구가 뜬다.', async () => {
    const nowLoading = screen.getByText(/불러온 데이터가 없습니다./);
    expect(nowLoading).toBeInTheDocument();
  });
 
  it('버튼을 클릭하면 데이터를 불러온다', async () => {
    const button = screen.getByRole('button', { name: /1번/ });
    fireEvent.click(button);
 
    const data = await screen.findByText(MOCK_TODO_RESPONSE.title);
    expect(data).toBeInTheDocument;
  });
 
  it('버튼을 클릭하고 서버 요청에서 에러가 발생하면 에러 문구를 노출한다.', async () => {
    server.use(
      rest.get('/todos/:id', (req, res, ctx) => {
        return res(ctx.stats(503));
      }),
    );
 
    const button = screen.getByRole('button', { name: /1번/ });
    fireEvent.click(button);
 
    const error = await screen.findByText(/에러가 발생했습니다/);
    expect(error).toBeInTheDocument();
  });
});

8.2.5 테스트를 작성하기에 앞서 고려해야 할 점

  • 테스트 커버리지가 만능은 아니다.
  • 테스트 커버리지는 얼마나 많은 코드가 테스트되고 있는지를 나타내고 있는 지표일 뿐
  • 테스트가 잘되고 있는지를 나타내는 것은 아니다.
💡 테스트 코드를 작성하기 전에 생각해 봐야 할 최우선 과제는
   애플리케이션에서 가장 취약하거나 중요한 부분을 파악하는 것