How to test remove event listener in useEffect hook’s clean-up

2 분 소요


RECOMMEND POSTS BEFORE THIS

0. 들어가면서

이 글은 리액트(react) useEffect 훅(hook)의 클린-업(clean-up) 함수에서 등록된 이벤트 리스너(event listener) 제거를 테스트하는 방법에 대해 정리했다.

1. Problem Context

React useEffect hook and clean-up function 글에서 다룬 예제를 다시 사용한다. 예시 코드에 대한 설명이 필요하다면 이전 글을 참고하길 바란다. 필자는 useEffect 훅에서 등록한 새로운 이벤트를 잘 해제했는지 확인할 수 있는 단위 테스트를 만들고 싶었다. 클린-업 함수에서 등록한 이벤트를 해제하지 않는 경우 메모리 누수가 발생할 수 있기 때문이다.

  1. 클릭 이벤트를 등록한다.
  2. 클릭 이벤트를 해제한다.
import { useEffect, useState } from "react";

function CustomButton(props) {
  const clickHandler = () => {
    console.log(`this click handler is added when count is ${props.count}`);
  };

  useEffect(() => {
    window.addEventListener("click", clickHandler); // 1
    return () => {
      window.removeEventListener("click", clickHandler); // 2
    };
  }, []);

  return <button onClick={props.onClick}>Custom Click me</button>;
}

function App() {
  const [count, setCount] = useState(0);

  const increaseCount = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <p>You clicked {count} times</p>
      {count % 2 === 0 && <button onClick={increaseCount}>Click me</button>}
      {count % 2 === 1 && (
        <CustomButton onClick={increaseCount} count={count} />
      )}
    </div>
  );
}

export default App;

새로운 이벤트 리스너를 등록하고 제거하는 일은 window 객체의 책임이다. 단위 테스트에서 window 객체를 테스트 더블(test double)로 대체하고 싶지만, 어려움이 있다. 어떻게 처리하면 좋을까?

2. Solve the problem

테스트 코드를 작성할 때 테스트를 어렵게 만드는 객체(혹은 모듈)은 내가 다룰 수 있는 객체로 바꾸면 된다. 자바(java)에서 테스트를 어렵게 만드는 요소 중 하나인 시간을 스텁(stub)으로 만들기 위한 제공자(provider) 객체를 만드는 것을 하나의 예로 들 수 있다. 필자는 이벤트 등록과 제거를 위한 event-listeners 모듈을 만들고 이를 테스트 더블로 사용했다. event-listeners 모듈은 다음과 같다.

export const addClickEvent = (eventListener) => {
  window.addEventListener("click", eventListener);
};

export const removeClickEvent = (eventListener) => {
  window.removeEventListener("click", eventListener);
};

CustomButton 컴포넌트를 별도로 분리한 후 테스트를 수행한다. 테스트 코드는 다음과 같다.

  1. event-listeners 모듈에서 제공하는 이벤트 리스너 등록, 삭제 함수들을 테스트 더블로 만든다.
  2. 컴포넌트를 언마운트(unmount) 한다.
  3. 다음과 같은 내용을 확인한다.
    • 각 테스트 더블이 예상되는 파라미터로 호출되었는지 확인한다.
    • 두 테스트 더블에 전달된 인자가 동일한 함수 객체인지 확인한다. 이벤트 리스너를 추가하고 삭제할 때 동일한 함수 객체가 아닌 경우 삭제가 제대로 되지 않으므로 이를 확인할 필요가 있다.
import { render } from "@testing-library/react";

import * as eventListeners from "../utils/event-listeners";
import CustomButton from "./CustomButton";

describe("CustomButton", () => {
  it("when unmount custom button then remove event listener call", () => {
    const spyAddClickEvent = jest.spyOn(eventListeners, "addClickEvent"); // 1
    const spyRemoveClickEvent = jest.spyOn(eventListeners, "removeClickEvent");
    const { unmount } = render(<CustomButton />);

    unmount(); // 2

    expect(spyAddClickEvent).toHaveBeenNthCalledWith(1, expect.any(Function)); // 3
    expect(spyRemoveClickEvent).toHaveBeenCalledTimes(1, expect.any(Function));
    const addClickEventArg = spyAddClickEvent.mock.calls[0][0];
    const removeClickEventArg = spyRemoveClickEvent.mock.calls[0][0];
    expect(addClickEventArg).toEqual(removeClickEventArg);
  });
});

CustomButton 컴포넌트를 다음과 같이 변경한다.

  • event-listeners 모듈을 통해 클릭 이벤트 리스너를 등록, 삭제한다.
import { useEffect } from "react";
import { addClickEvent, removeClickEvent } from "../utils/event-listeners";

function CustomButton(props) {
  const clickHandler = () => {
    console.log(`this click handler is added when count is ${props.count}`);
  };

  useEffect(() => {
    addClickEvent(clickHandler);
    return () => {
      removeClickEvent(clickHandler);
    };
  }, []);

  return <button onClick={props.onClick}>Custom Click me</button>;
}

export default CustomButton;

TEST CODE REPOSITORY

REFERENCE

댓글남기기