준영
도은
- 코드 작성 만큼이나, 좋은 코드를 작성할 수 있는 환경을 구착하는 것이 중요
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}`);
}
테스트 코드를 작성하는 방식은 보통 다음과 같은 과정을 거친다
- 테스트할 함수나 모듈을 선정한다.
- 함수나 모듈이 반환하길 기대하는 값을 적는다.
- 함수나 모듈의 실제 반환 값을 적는다.
- 3번의 기대에 따라 2번의 결과가 일치하는지 확인한다.
- 기대하는 결과를 반환한다면 테스트는 성공이며, 만약 기대와 다른 결과를 반환하면 에러를 던진다.
8.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 테스트를 작성하기에 앞서 고려해야 할 점
- 테스트 커버리지가 만능은 아니다.
- 테스트 커버리지는 얼마나 많은 코드가 테스트되고 있는지를 나타내고 있는 지표일 뿐
- 테스트가 잘되고 있는지를 나타내는 것은 아니다.
💡 테스트 코드를 작성하기 전에 생각해 봐야 할 최우선 과제는
애플리케이션에서 가장 취약하거나 중요한 부분을 파악하는 것