React component testing based on drag and drop real-life example.

Why functional components may be difficult to test?

Many programmers, after their first approach to React Hooks complained that „new” framework feature is not easy in testing. I was also wondering how to test my components when I moved them from old-fashioned class-based to functional ones with hooks (because in new approach I can’t test my internal methods which are not exported anywhere). Code sample below, presets simple React component with function called handleClick. We are not able to call it like SomeComponent.handleClick(), pass any parameters and test its result because of JavaScript limitations.

const SomeComponent = () => {
  const [state, setState] = useState({}); // hook

  const handleClick = () => { // you can call this function only from SomeComponent scope
     ...
  }
  return <div onClick={handleClick}>...</div>
}

After some research I realized that I don’t have to test each function separately. Instead of, I should test side effects. What is side effect? A side effect is anything that affects something outside the scope of the function being executed. If we can’t invoke function directly we have to check how this function affects on, for example, our component’s HTML.

Sometimes testing side-effects is not enough and does not cover all cases. To improve it, you can also use mocking feature for exported functions or hooks (if it is possible) and test them in old-fashion way.

The meat

Let’s build some drag and drop feature which ables to add image file from desktop/finder to our website. So, what we want to do is basically:

  1. Handle drag and drop file to application
  2. Add „drop area” which highlights when file is dragging
  3. Display file name in drop area (it is enough for our purposes)
  4. Test above functionalities

I won’t focus in this article on implementation details, the code is simple enough. In short, I added some drag drop event handlers to document element and checking if dragged file is dropped over drag area. If yes, I display its name inside it.

import React, { useRef } from "react";
import { useDraggable } from "./useDraggable";
import "./styles.css";

export default () => {
  const dropAreaRef = useRef(null);
  const { isDragging } = useDraggable({ dropAreaRef });

  const classes = isDragging ? "dropArea dropAreaActive" : "dropArea";

  return (
    <div className="app">
      <div ref={dropAreaRef} className={classes} />
    </div>
  );
};
import { useState, useEffect } from "react";

const useDraggable = ({ dropAreaRef }) => {
  const [isDragging, setDragging] = useState(false);

  useEffect(() => {
    const handleDragOver = event => {
      event.preventDefault();

      setDragging(true);
    };

    const handleDragLeave = event => {
      event.preventDefault();
    };

    const handleDrop = event => {
      event.preventDefault();

      setDragging(false);

      if (event.target !== dropAreaRef.current || !event.dataTransfer) {
        return;
      }

      dropAreaRef.current.innerHTML = [...event.dataTransfer.files]
        .map(file => file.name)
        .toString();
    };

    const handleMouseLeave = () => {
      setDragging(false);
    };

    document.addEventListener("dragover", handleDragOver);
    document.addEventListener("dragleave", handleDragLeave);
    document.addEventListener("drop", handleDrop);
    document.addEventListener("mouseleave", handleMouseLeave);

    return () => {
      document.removeEventListener("dragover", handleDragOver);
      document.removeEventListener("dragleave", handleDragLeave);
      document.removeEventListener("drop", handleDrop);
      document.removeEventListener("mouseleave", handleMouseLeave);
    };
  }, [dropAreaRef]);

  return { isDragging };
};

export { useDraggable };

Look at LIVE example

Writing tests

To write our tests, we are going to use Jest.js with React testing library package. I will present two ways of doing this. Scoped, with mocked hook and extended, which use hook implementation as it is (we can call it integration tests) and scoped with mocked hook.

Firstly, I will create isolated, prepared environment and unit test only a component behavior for mocked hook return value. The test cases for it will be:

  1. Should not have highlighting class when isDraggable is false
  2. Should add highlighting class if isDraggable is true

That is pretty much all we should test in this component. So, let’s write some units:

import React from "react";
import { render } from "@testing-library/react";
import App from "./App";
import { useDraggable } from "./useDraggable";

// mocking useDraggable hook
jest.mock("./useDraggable");

describe("Drag drop - units", () => {
  it("Should not have highlighting class when isDraggable is false", () => {
    // mocking useDraggable module
    useDraggable.mockReturnValueOnce(() => ({
      useDraggable: () => ({ isDraggable: true })
    }));

    const { getByTestId } = render(<App />);
    const dropArea = getByTestId("dropArea");
    expect(dropArea.getAttribute("class")).not.toContain("dropAreaActive");
  });

  it("Should not have highlighting class when isDraggable is false", () => {
    useDraggable.mockReturnValueOnce(() => ({
      useDraggable: () => ({ isDraggable: true })
    }));

    const { getByTestId } = render(<App />);
    const dropArea = getByTestId("dropArea");
    expect(dropArea.getAttribute("class")).toContain("dropAreaActive");
  });
});

As you noticed, this method will test only the App.js component and we assume that useDraggable hook works as expected. It is of course very naive assumption and will make our code uncovered.

To fix problem above we have to also test hook behavior. Now, we assume, that our component will receive data based on real events. Firstly. write down all use cases we want to cover for our extended tests:

  1. Should highlight drop area when file is dragged over document
  2. Should off drop area highlighting when mouse leaves document
  3. Should off drop area highlighting when file is dropped
  4. Should display filename when dropped over drop area

And code them:

import React from "react";
import { createEvent, fireEvent, render } from "@testing-library/react";
import App from "./App";

describe("Drag drop file", () => {
  it("Should highlight drop area when file is dragged over document", () => {
    const { getByTestId } = render(<App />); // render component

    const dropArea = getByTestId("dropArea"); // get drop area element
    const event = createEvent.dragOver(document); // create drag over event
    fireEvent(document, event); // simulate triggering event

    expect(dropArea.getAttribute("class")).toContain("dropAreaActive"); // check if drop area has highlighting class
  });

  it("Should off drop area highlighting when mouse leaves document", () => {
    const { getByTestId } = render(<App />);

    const dropArea = getByTestId("dropArea");
    const dragOverEvent = createEvent.dragOver(document);
    const mouseLeaveEvent = createEvent.mouseLeave(document);
    fireEvent(document, dragOverEvent);
    fireEvent(document, mouseLeaveEvent);

    expect(dropArea.getAttribute("class")).not.toContain("dropAreaActive");
  });

  it("Should off drop area highlighting when file is dropped", () => {
    const { getByTestId } = render(<App />);

    const dropArea = getByTestId("dropArea");
    const dragOverEvent = createEvent.dragOver(document);
    const dropEvent = createEvent.drop(document);

    fireEvent(document, dragOverEvent);
    fireEvent(document, dropEvent);

    expect(dropArea.getAttribute("class")).not.toContain("dropAreaActive");
  });

  it("Should display filename when dropped over drop area", () => {
    const { getByTestId } = render(<App />);

    const dropArea = getByTestId("dropArea");
    const dragOverEvent = createEvent.dragOver(document);
    const dropEvent = createEvent.drop(document);
    dropEvent.dataTransfer = {
      files: [
        {
          name: "Filename"
        }
      ]
    };

    fireEvent(document, dragOverEvent);
    fireEvent(dropArea, dropEvent);

    expect(dropArea.innerHTML).toContain("Filename");
  });
});

That is pretty much all. Our component is tested. As you can see most of tests just simulate drag drop events and check side effects by comparing class names in drop area container and container’s content. It should be enough for testing side effects and cover all the cases.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *