Woongjae Lee
Daangn - Frontend Core Team ex) NHN Dooray - Frontend Team Leader ex) ProtoPie - Studio Team
JavaScript Unit Test
Jest 사용하기
리액트 컴포넌트 테스트
react-testing-library 활용하기
리덕스 / 비동기작업의 테스트
Lead Software Engineer @ProtoPie
Microsoft MVP
TypeScript 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 testimport 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 Frontend Developer School 17th
Daangn - Frontend Core Team ex) NHN Dooray - Frontend Team Leader ex) ProtoPie - Studio Team