React Testing

 

JavaScript Unit Test

Jest 사용하기

리액트 컴포넌트 테스트

react-testing-library 활용하기

리덕스 / 비동기작업의 테스트

Lead Software Engineer @ProtoPie

Microsoft MVP

TypeScript Korea User Group Organizer

Marktube (Youtube)

Mark Lee

이 웅재

JavaScript Unit Test

Unit Test

  • 사람을 믿으시겠습니까 ? 테스트 코드를 믿으시겠습니까 ?

    • 실제로는 사람이 아니라, 사람의 감 입니다.

    • 코드는 거짓말을 하지 않습니다.

  • 통합테스트에 비해 빠르고 쉽습니다.

  • 통합테스트를 진행하기 전에 문제를 찾아낼 수 있습니다.

    • 그렇다고, 통합테스트가 성공하리란 보장은 없습니다.

  • 테스트 코드가 살아있는(동작을 설명하는) 명세가 됩니다.

    • 테스트를 읽고 어떻게 동작하는지도 예측 가능합니다.

  • 소프트웨어 장인이 되려면 TDD 해야죠..

    • 선 코딩 후, (몰아서) 단위테스트가 아니라

facebook/jest

  •  리액트의 영향이 크겠지만 가장 핫한 테스트 도구

  • 👩🏻‍💻 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"
  }
}

package.json

test('adds 1 + 2 to equal 3', () => {
  expect(1 + 2).toBe(3);
});

example.test.js

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);
  });
});

it (= test), describe, expect

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);
  });
});

.to~

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);
  });
});

.not.to~

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);
  });
});

async test with done callback

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));
  });
});

async test with promise

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);
  });
});

async test with async-await

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);
    }
  });
});

React Component Test

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();
});

App.test.js

어떤 파일을 테스트 해주나요?

  • __tests__ 폴더 안의 .js 파일

  • .test.js 로 끝나는 파일

  • .spec.js 로 끝나는 파일

Button 컴포넌트

  • 컴포넌트가 정상적으로 생성된다.
  • "button" 이라고 쓰여있는 엘리먼트는 HTMLButtonElement 이다.
  • 버튼을 클릭하면, p 태그 안에 "버튼이 방금 눌렸다." 라고 쓰여진다.
  • 버튼을 클릭하기 전에는, p 태그 안에 "버튼이 눌리지 않았다." 라고 쓰여진다.
  • 버튼을 클릭하고 5초 뒤에는, p 태그 안에 "버튼이 눌리지 않았다." 라고 쓰여진다.
  • 버튼을 클릭하면, 5초 동안 버튼이 비활성화 된다.

컴포넌트가 정상적으로 생성된다.

// 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;

"button" 이라고 쓰여있는 엘리먼트는 HTMLButtonElement 이다.

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;

버튼을 클릭하면, p 태그 안에 "버튼이 방금 눌렸다." 라고 쓰여진다.

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;

버튼을 클릭하기 전에는, p 태그 안에 "버튼이 눌리지 않았다." 라고 쓰여진다.

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;

버튼을 클릭하고 5초 뒤에는, p 태그 안에 "버튼이 눌리지 않았다." 라고 쓰여진다.

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);
  });
});

버튼을 클릭하고 5초 뒤에는, p 태그 안에 "버튼이 눌리지 않았다." 라고 쓰여진다.

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;

버튼을 클릭하면, 5초 동안 버튼이 비활성화 된다.

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;

버튼을 클릭하면, 5초 동안 버튼이 비활성화 된다.

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();
  });
});

enzyme 활용하기

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 />);
  });
});

버튼 엘리먼트에 써있는 텍스트는 "button" 이다.

describe("Button 컴포넌트 (enzyme)", () => {
  // ...
  
  it(`버튼 엘리먼트에 써있는 텍스트는 "button" 이다.`, () => {
    const wrapper = shallow(<Button />);

    const button = wrapper.find("button");
    expect(button.text()).toBe("button");
  });
});

버튼을 클릭하면, p 태그 안에 "버튼이 방금 눌렸다." 라고 쓰여진다.

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("버튼이 방금 눌렸다.");
  });
});

버튼을 클릭하기 전에는, p 태그 안에 "버튼이 눌리지 않았다." 라고 쓰여진다.

describe("Button 컴포넌트 (enzyme)", () => {
  // ...
  
  it(`버튼을 클릭하기 전에는, p 태그 안에 "버튼이 눌리지 않았다." 라고 쓰여진다.`, () => {
    const wrapper = shallow(<Button />);

    const p = wrapper.find("p");
    expect(p.text()).toBe("버튼이 눌리지 않았다.");
  });
});

버튼을 클릭하고 5초 뒤에는, p 태그 안에 "버튼이 눌리지 않았다." 라고 쓰여진다.

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("버튼이 눌리지 않았다.");
  });
});

버튼을 클릭하면, 5초 동안 버튼이 비활성화 된다.

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();
  });
});

리덕스의 테스트

Test 대상

  • 액션 생성 함수

  • 리듀서

  • 컨테이너

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([]);
  });
});

리듀서 테스트 1

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);
  });
});

리듀서 테스트 2

npm i redux-mock-store enzyme-to-json -D
{
  ...
  "jest": {
    "snapshotSerializers": [
      "enzyme-to-json/serializer"
    ]
  }
}

jest

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)>
`;

Snapshot