Woongjae Lee
NHN Dooray - Frontend Team
JavaScript Unit Test
Jest 사용하기
리액트 컴포넌트 테스트
react-testing-library 활용하기
리덕스 / 비동기작업의 테스트
Software Engineer | Studio XID, Inc.
Microsoft MVP
TypeScript Korea User Group Organizer
Electron Korea User Group Organizer
Marktube (Youtube)
사람을 믿으시겠습니까 ? 테스트 코드를 믿으시겠습니까 ?
실제로는 사람이 아니라, 사람의 감 입니다.
코드는 거짓말을 하지 않습니다.
통합테스트에 비해 빠르고 쉽습니다.
통합테스트를 진행하기 전에 문제를 찾아낼 수 있습니다.
그렇다고, 통합테스트가 성공하리란 보장은 없습니다.
테스트 코드가 살아있는(동작을 설명하는) 명세가 됩니다.
테스트를 읽고 어떻게 동작하는지도 예측 가능합니다.
소프트웨어 장인이 되려면 TDD 해야죠..
선 코딩 후, (몰아서) 단위테스트가 아니라
리액트의 영향이 크겠지만 가장 핫한 테스트 도구
👩🏻💻 Easy Setup
🏃🏽 Instant Feedback
고친 파일만 빠르게 테스트 다시 해주는 기능 등
📸 Snapshot Testing
컴포넌트 테스트에 중요한 역할을 하는 스냅샷
mkdir jest-example
cd jest-example
npm init -y
npm i jest -D
{
"name": "jest-example",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"jest": "^24.9.0"
}
}
test('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
describe('expect test', () => {
it('37 to equal 37', () => {
const received = 37;
const expected = 37;
expect(received).toBe(expected);
});
it('{age: 37} to equal {age: 37}', () => {
const received = {
age: 37,
};
const expected = {
age: 37,
};
expect(received).toBe(expected);
});
it('{age: 37} to equal {age: 37}', () => {
const received = {
age: 37,
};
const expected = {
age: 37,
};
expect(received).toEqual(expected);
});
});
describe('.to~ test', () => {
it('.toBe', () => {
expect(37).toBe(37);
});
it('.toHaveLength', () => {
expect('hello').toHaveLength(5);
});
it('.toHaveProperty', () => {
expect({ name: 'Mark' }).toHaveProperty('name');
expect({ name: 'Mark' }).toHaveProperty('name', 'Mark');
});
it('.toBeDefined', () => {
expect({ name: 'Mark' }.name).toBeDefined();
});
it('.toBeFalsy', () => {
expect(false).toBeFalsy();
expect(0).toBeFalsy();
expect('').toBeFalsy();
expect(null).toBeFalsy();
expect(undefined).toBeFalsy();
expect(NaN).toBeFalsy();
});
it('.toBeGreaterThan', () => {
expect(10).toBeGreaterThan(9);
});
});
describe('.to~ test', () => {
it('.toBeGreaterThanOrEqual', () => {
expect(10).toBeGreaterThanOrEqual(10);
});
it('.toBeInstanceOf', () => {
class Foo {}
expect(new Foo()).toBeInstanceOf(Foo);
});
it('.toBeNull', () => {
expect(null).toBeNull();
});
it('.toBeTruthy', () => {
expect(true).toBeTruthy();
expect(1).toBeTruthy();
expect('hello').toBeTruthy();
expect({}).toBeTruthy();
});
it('.toBeUndefined', () => {
expect({ name: 'Mark' }.age).toBeUndefined();
});
it('.toBeNaN', () => {
expect(NaN).toBeNaN();
});
});
describe('.not.to~ test', () => {
it('.not.toBe', () => {
expect(37).not.toBe(36);
});
it('.not.toBeFalsy', () => {
expect(true).not.toBeFalsy();
expect(1).not.toBeFalsy();
expect('hello').not.toBeFalsy();
expect({}).not.toBeFalsy();
});
it('.not.toBeGreaterThan', () => {
expect(10).not.toBeGreaterThan(10);
});
});
describe('use async test', () => {
it('setTimeout without done', () => {
setTimeout(() => {
expect(37).toBe(36);
}, 1000);
});
it('setTimeout with done', done => {
setTimeout(() => {
expect(37).toBe(36);
done();
}, 1000);
});
});
describe('use async test', () => {
it('promise then', () => {
function p() {
return new Promise(resolve => {
setTimeout(() => {
resolve(37);
}, 1000);
});
}
return p().then(data => expect(data).toBe(37));
});
it('promise catch', () => {
function p() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('error'));
}, 1000);
});
}
return p().catch(e => expect(e).toBeInstanceOf(Error));
});
});
describe('use async test', () => {
it('promise .resolves', () => {
function p() {
return new Promise(resolve => {
setTimeout(() => {
resolve(37);
}, 1000);
});
}
return expect(p()).resolves.toBe(37);
});
it('promise .rejects', () => {
function p() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('error'));
}, 1000);
});
}
return expect(p()).rejects.toBeInstanceOf(Error);
});
});
describe('use async test', () => {
it('async-await', async () => {
function p() {
return new Promise(resolve => {
setTimeout(() => {
resolve(37);
}, 1000);
});
}
const data = await p();
return expect(data).toBe(37);
});
});
describe('use async test', () => {
it('async-await, catch', async () => {
function p() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('error'));
}, 1000);
});
}
try {
await p();
} catch (error) {
expect(error).toBeInstanceOf(Error);
}
});
});
npx create-react-app react-component-test
cd react-component-test
npm test
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
const { getByText } = render(<App />);
const linkElement = getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
__tests__ 폴더 안의 .js 파일
.test.js 로 끝나는 파일
.spec.js 로 끝나는 파일
// src/components/Button.test.js
import React from "react";
import Button from "./Button";
import { render } from "@testing-library/react";
describe("Button 컴포넌트 (@testing-library/react)", () => {
it("컴포넌트가 정상적으로 생성된다.", async () => {
render(<Button />);
});
});
// src/components/Button.jsx
import React from "react";
const Button = () => <></>;
export default Button;
describe("Button 컴포넌트", () => {
// ...
it(`"button" 이라고 쓰여있는 엘리먼트는 HTMLButtonElement 이다.`, () => {
const { getByText } = render(<Button />);
const buttonElement = getByText("button");
expect(buttonElement).toBeInstanceOf(HTMLButtonElement);
});
});
// src/components/Button.jsx
import React from "react";
const Button = () => <button>button</button>;
export default Button;
describe("Button 컴포넌트 (@testing-library/react)", () => {
// ...
it(`버튼을 클릭하면, p 태그 안에 "버튼이 방금 눌렸다." 라고 쓰여진다.`, () => {
const { getByText } = render(<Button />);
const button = getByText("button");
fireEvent.click(button);
const p = getByText("버튼이 방금 눌렸다.");
expect(p).not.toBeNull();
expect(p).toBeInstanceOf(HTMLParagraphElement);
});
});
// src/components/Button.jsx
import React from "react";
const Button = () => (
<>
<button>button</button>
<p>버튼이 방금 눌렸다.</p>
</>
);
export default Button;
describe("Button 컴포넌트 (@testing-library/react)", () => {
// ...
it(`버튼을 클릭하기 전에는, p 태그 안에 "버튼이 눌리지 않았다." 라고 쓰여진다.`, () => {
const { getByText } = render(<Button />);
const p = getByText("버튼이 눌리지 않았다.");
expect(p).not.toBeNull();
expect(p).toBeInstanceOf(HTMLParagraphElement);
});
});
// src/components/Button.jsx
import React, { useState } from "react";
const Button = () => {
const [message, setMessage] = useState("버튼이 눌리지 않았다.");
function click() {
setMessage("버튼이 방금 눌렸다.");
}
return (
<>
<button onClick={click}>button</button>
<p>{message}</p>
</>
);
};
export default Button;
jest.useFakeTimers();
describe("Button 컴포넌트 (@testing-library/react)", () => {
// ...
it(`버튼을 클릭하고 5초 뒤에는, p 태그 안에 "버튼이 눌리지 않았다." 라고 쓰여진다.`, async () => {
const { getByText } = render(<Button />);
const button = getByText("button");
fireEvent.click(button);
jest.advanceTimersByTime(5000);
const p = getByText("버튼이 눌리지 않았다.");
expect(p).not.toBeNull();
expect(p).toBeInstanceOf(HTMLParagraphElement);
});
});
jest.useFakeTimers();
describe("Button 컴포넌트 (@testing-library/react)", () => {
// ...
it(`버튼을 클릭하고 5초 뒤에는, p 태그 안에 "버튼이 눌리지 않았다." 라고 쓰여진다.`, async () => {
const { getByText } = render(<Button />);
const button = getByText("button");
fireEvent.click(button);
act(() => {
jest.advanceTimersByTime(5000);
});
const p = getByText("버튼이 눌리지 않았다.");
expect(p).not.toBeNull();
expect(p).toBeInstanceOf(HTMLParagraphElement);
});
});
// src/components/Button.jsx
import React, { useState } from "react";
const Button = () => {
const [message, setMessage] = useState("버튼이 눌리지 않았다.");
function click() {
setMessage("버튼이 방금 눌렸다.");
setTimeout(() => {
setMessage("버튼이 눌리지 않았다.");
}, 5000);
}
return (
<>
<button onClick={click}>button</button>
<p>{message}</p>
</>
);
};
export default Button;
// src/components/Button.jsx
import React, { useState, useEffect, useRef } from "react";
const Button = () => {
const [message, setMessage] = useState("버튼이 눌리지 않았다.");
const timer = useRef(null);
function click() {
if (timer.current !== null) clearTimeout(timer);
setMessage("버튼이 방금 눌렸다.");
timer.current = setTimeout(() => {
setMessage("버튼이 눌리지 않았다.");
}, 5000);
}
useEffect(() => {
return () => {
if (timer.current !== null) clearTimeout(timer.current);
};
}, []);
return (
<>
<button onClick={click}>button</button>
<p>{message}</p>
</>
);
};
export default Button;
jest.useFakeTimers();
describe("Button 컴포넌트 (@testing-library/react)", () => {
// ...
it(`버튼을 클릭하면, 5초 동안 버튼이 비활성화 된다.`, () => {
const { getByText } = render(<Button />);
const button = getByText("button");
fireEvent.click(button);
expect(button.disabled).toBeTruthy();
act(() => {
jest.advanceTimersByTime(5000);
});
expect(button.disabled).toBeFalsy();
});
});
// src/components/Button.jsx
import React, { useState, useEffect, useRef } from "react";
const Button = () => {
const [message, setMessage] = useState("버튼이 눌리지 않았다.");
const timer = useRef(null);
function click() {
if (timer.current !== null) clearTimeout(timer);
setMessage("버튼이 방금 눌렸다.");
timer.current = setTimeout(() => {
setMessage("버튼이 눌리지 않았다.");
}, 5000);
}
useEffect(() => {
return () => {
if (timer.current !== null) clearTimeout(timer.current);
};
}, []);
return (
<>
<button onClick={click} disabled={message === "버튼이 방금 눌렸다."}>
button
</button>
<p>{message}</p>
</>
);
};
export default Button;
describe("Button 컴포넌트", () => {
// ...
t(`버튼을 클릭하면, 5초 동안 버튼이 비활성화 된다.`, () => {
const { getByText } = render(<Button />);
const button = getByText("button");
fireEvent.click(button);
expect(button).toBeDisabled();
act(() => {
jest.advanceTimersByTime(5000);
});
expect(button).not.toBeDisabled();
});
});
npm i enzyme enzyme-adapter-react-16 -D
// src/components/Button.enzyme.test.js
import React from "react";
import Enzyme, { shallow } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import Button from "./Button";
Enzyme.configure({ adapter: new Adapter() });
describe("Button 컴포넌트 (enzyme)", () => {
it("컴포넌트가 정상적으로 생성된다.", () => {
shallow(<Button />);
});
});
describe("Button 컴포넌트 (enzyme)", () => {
// ...
it(`버튼 엘리먼트에 써있는 텍스트는 "button" 이다.`, () => {
const wrapper = shallow(<Button />);
const button = wrapper.find("button");
expect(button.text()).toBe("button");
});
});
describe("Button 컴포넌트 (enzyme)", () => {
// ...
it(`버튼을 클릭하면, p 태그 안에 "버튼이 방금 눌렸다." 라고 쓰여진다.`, () => {
const wrapper = shallow(<Button />);
const button = wrapper.find("button");
button.simulate("click");
const p = wrapper.find("p");
expect(p.text()).toBe("버튼이 방금 눌렸다.");
});
});
describe("Button 컴포넌트 (enzyme)", () => {
// ...
it(`버튼을 클릭하기 전에는, p 태그 안에 "버튼이 눌리지 않았다." 라고 쓰여진다.`, () => {
const wrapper = shallow(<Button />);
const p = wrapper.find("p");
expect(p.text()).toBe("버튼이 눌리지 않았다.");
});
});
jest.useFakeTimers();
describe("Button 컴포넌트 (enzyme)", () => {
// ...
it(`버튼을 클릭하고 5초 뒤에는, p 태그 안에 "버튼이 눌리지 않았다." 라고 쓰여진다.`, async () => {
const wrapper = shallow(<Button />);
const button = wrapper.find("button");
button.simulate("click");
jest.advanceTimersByTime(5000);
const p = wrapper.find("p");
expect(p.text()).toBe("버튼이 눌리지 않았다.");
});
});
jest.useFakeTimers();
describe("Button 컴포넌트 (enzyme)", () => {
// ...
it(`버튼을 클릭하면, 5초 동안 버튼이 비활성화 된다.`, () => {
const wrapper = shallow(<Button />);
const button = wrapper.find("button");
button.simulate("click");
expect(wrapper.find("button").prop("disabled")).toBeTruthy();
jest.advanceTimersByTime(5000);
expect(wrapper.find("button").prop("disabled")).toBeFalsy();
});
});
import {
setBooks, startLoading, endLoading, setError, clearError,
SET_ERROR, CLEAR_ERROR, START_LOADING, END_LOADING, SET_BOOKS,
} from "./";
describe("actions", () => {
describe("books", () => {
it("setBooks(books) should create action", () => {
const books = [];
expect(setBooks(books)).toEqual({ type: SET_BOOKS, books });
});
});
describe("loading", () => {
it("startLoading() should create action", () => {
expect(startLoading()).toEqual({ type: START_LOADING });
});
it("endLoading should create action", () => {
expect(endLoading()).toEqual({ type: END_LOADING });
});
});
describe("error", () => {
it("setError() should create action", () => {
const error = new Error();
expect(setError(error)).toEqual({ type: SET_ERROR, error });
});
it("clearError should create action", () => {
expect(clearError()).toEqual({ type: CLEAR_ERROR });
});
});
});
import books from "./books";
describe("books reducer", () => {
let state = null;
beforeEach(() => {
state = books(undefined, {});
});
afterEach(() => {
state = null;
});
it("should return the initialState", () => {
expect(state).toEqual([]);
});
});
import books from "./books";
import { setBooks } from "../actions";
describe("books reducer", () => {
...
it("setBooks action should return the newState", () => {
const booksMock = [
{
bookId: 1,
ownerId: "7d26db27-168c-4c6a-bd9a-9e20677b60b8",
title: "모던 자바스크립트 입문",
message: "모던하군요"
},
{
bookId: 2,
ownerId: "7d26db27-168c-4c6a-bd9a-9e20677b60b8",
title: "책 Mock",
message: "메세지 Mock"
}
];
const action = setBooks(booksMock);
const newState = books(state, action);
expect(newState).toEqual(booksMock);
});
});
npm i redux-mock-store enzyme-to-json -D
{
...
"jest": {
"snapshotSerializers": [
"enzyme-to-json/serializer"
]
}
}
import React from "react";
import Enzyme, { mount } from "enzyme";
import BooksContainer from "./BooksContainer";
import configureMockStore from "redux-mock-store";
import Adapter from "enzyme-adapter-react-16";
Enzyme.configure({ adapter: new Adapter() });
describe("BookContainer", () => {
const mockStore = configureMockStore();
// 가짜 스토어 만들기
let store = mockStore({
books: [],
loading: false,
error: null,
token: "",
router: {
location: {
pathname: "/"
}
}
});
it("renders properly", () => {
const component = mount(<BooksContainer store={store} />);
expect(component).toMatchSnapshot();
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BookContainer renders properly 1`] = `
<Connect(Books)
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<Books
books={Array []}
error={null}
loading={false}
requestBooksPromise={[Function]}
requestBooksSaga={[Function]}
requestBooksThunk={[Function]}
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<div />
</Books>
</Connect(Books)>
`;
By Woongjae Lee
Fast Campus React Camp 11