facebookexperimental / Recoil

Recoil is an experimental state management library for React apps. It provides several capabilities that are difficult to achieve with React alone, while being compatible with the newest features of React.
https://recoiljs.org/
MIT License
19.59k stars 1.19k forks source link

Testing components with @testing-library/react example #128

Open jonolo6 opened 4 years ago

jonolo6 commented 4 years ago

I implemented the basic tutorial (https://recoiljs.org/docs/basic-tutorial/atoms) and wanted to add tests for components. I'm especially wondering how to mock out/simplify setting up global state that's accessed via recoil hooks. For example useRecoilState as below.

Here's my test for the TodoItem class (it is in typescript, but that's not relevant). It works but I find it very convoluted to wrap my Component to test twice in order to get this to work correctly. I must be doing this in a very convoluted way.

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

import { RecoilRoot, useRecoilState } from "recoil";

import { TodoItem } from "./TodoItem";
import { todoListState } from "../../state/atoms/todoListState";
import { Todo } from "../../state/atoms/todoState";

const RecoiledTodoItem = ({ todo }: { todo: Todo }) => {
  const [todoList, setTodoList] = useRecoilState<any>(todoListState);
  useEffect(() => {
    setTodoList((todoList: Todo[]) => {
      return [...todoList, todo];
    });
  }, []);
  if (todoList.length == 0) {
    return null;
  }
  return (
    <>
      {todoList.map((todo: Todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </>
  );
};

const WrappedTodoItem = ({ todo }: { todo: Todo }) => {
  return (
    <RecoilRoot>
      <RecoiledTodoItem todo={todo} />
    </RecoilRoot>
  );
};

test("updates todo item", async () => {
  const todo: Todo = {
    id: "testId",
    isComplete: false,
    title: "old title",
  };

  // Act
  const { getByTestId, getByDisplayValue } = render(
    <WrappedTodoItem todo={todo} />
  );

  fireEvent.change(getByTestId(/todo-text/i), {
    target: { value: "new title" },
  });

  // Assert
  expect(getByDisplayValue(/new title/i)).toBeInTheDocument();
});

Adding some example tests into an example/tests folder or similar using @testing-library/react (which is included in CRA) would be great!

acutmore commented 4 years ago

Hi @jonolo6. Recoil requires components to be wrapped with <RecoilRoot> so it has a context to orchestrate storing state.

One small way to reduce the need for the WrappedTodoItem is to inline the wrapper where you call render, like this:

test("updates todo item", async () => {
  const todo: Todo = {
    id: "testId",
    isComplete: false,
    title: "old title",
  };

  // Act
  const { getByTestId, getByDisplayValue } = render(
    <RecoilRoot>
      <RecoiledTodoItem todo={todo} />
    </RecoilRoot>
  );

  fireEvent.change(getByTestId(/todo-text/i), {
    target: { value: "new title" },
  });

  // Assert
  expect(getByDisplayValue(/new title/i)).toBeInTheDocument();
});

You could create a custom render function and use that in your tests.

// test-utils.js
import { render } from "@testing-library/react";
export {fireEvent} from "@testing-library/react";

export function render(elements) {
  return render(<RecoilRoot>{elements}</RecoilRoot>);
}

// todo.test.js
import {render, fireEvent} from './test-utils';

...

 const { getByTestId, getByDisplayValue } = render(
      <RecoiledTodoItem todo={todo} />
  );
jonolo6 commented 4 years ago

Thx @acutmore . What annoys me most is that I've not been able to mock out useRecoilState<any>(todoListState); which means I have to do the whole useEffect, map over the list malarkey in the RecoiledTodoItem. Anyways - big thanks for the reply! Will leave it at this for now. Will test if I can use some jest.fn() style mocking somehow...

orrybaram commented 4 years ago

@jonolo6 not sure if you've figured out your own pattern yet, but we ran into the same issue with testing out recoil states. We ended up creating a small library that extends @testing-library/react-hooks. It works by injecting RecoilRoot into the wrapper and creating dummy components with state updaters for each piece of state you want to update. Check it out here https://github.com/inturn/react-recoil-hooks-testing-library would love some feedback!

schnerd commented 4 years ago

This simple solution seems to work well if you just need to initialize some atom states before testing a component:

  render(
    <RecoilRoot initializeState={(snap) => snap.set(myAtom, {foo: 'bar'})}>
      <MyComponent />
    </RecoilRoot>,
  );
icfantv commented 3 years ago

The example by @acutmore is perfect if the updated state is reflected in the component being tested. What happens if the state is not reflected in the component being tested? One solution would be to create a dummy component for use in the test that did use this slice of state so that it could be checked. I can confirm that using the UNSTABLE snapshot API from the docs won't work as it's a different "copy" of the state graph.

Is there a way to spy on the atom or selector to see if it was updated and how? If not, is the only real way to test to use a dummy component as I'd previously mentioned? Finally, should I even be testing that the atom state was updated when an event happened in my component (my gut says yes)?

dnsco commented 2 years ago

I've made a library to test recoil at the hook-level, to allow testing of data an async effects in isolation. dnsco/recoil-test-render-hooks if this helps anyone. It's very similar to inturn/react-recoil-hooks-testing-library in that it's a thin wrapper on the render-hooks testing library, but it allows you to build mutable scenarios and allows more strict typings.