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? #126

Closed adrianbw closed 3 years ago

adrianbw commented 4 years ago

What's the best method for testing functions around Recoil? For example, if I had a function like this:

export const setAddComplete =
    (addComplete: boolean, setNotesState: SetterOrUpdater<State>) => {
        setNotesState(state => {
            return {
                ...state,
                addComplete,
            };
        });
    };

How should I test it? I noticed the TestingUtils folder, but it looks like they aren't included in the package.

khpatel4991 commented 4 years ago

I would recommend not to test like that, rather test the overall functionality by asserting on changes that should happen on the UI/screen.

jonolo6 commented 4 years ago

I would recommend not to test like that, rather test the overall functionality by asserting on changes that should happen on the UI/screen.

I think the question is around how to unit test this function in isolation. UI Testing has its place of course. However, devs/teams may be structured in a way that UI testing is not doable by the dev writing this functionality + unit testing is good practice anyway for code that is reused in many other places (speeds up build, etc.).

acutmore commented 4 years ago

Hi @adrianbw. Recoil requires React to run. You can test your Recoil Atoms and Selectors by creating a small React component that uses them and testing that in the way you would test any React component. https://reactjs.org/docs/testing.html

For example:

const React = require("react");
const TestRenderer = require("react-test-renderer");
const Recoil = require("recoil");
const mySelector = require("../path/to/your/selector");

test("...", () => {
  let value = null;

  function TestSelector() {
    const selectorValue = Recoil.useRecoilValue(mySelector);
    React.useEffect(() => {
      value = selectorValue;
    });
    return null;
  }

  TestRenderer.act(() => {
    TestRenderer.create(<Link page="https://www.facebook.com/">Facebook</Link>);
  });

  expect(value).toEqual(expectedValue);
});

If you want to just test your state update function and not run Recoil then you could extract, export and test that code in isolation.

Before:

export const setAddComplete = (
  addComplete: boolean,
  setNotesState: SetterOrUpdater<State>
) => {
  setNotesState((state) => {
    return {
      ...state,
      addComplete,
    };
  });
};

After:

// Can directly test this function without needed Recoil
export updateState(state, addComplete) {
  return {...state, addComplete };
}

export const setAddComplete = (
  addComplete: boolean,
  setNotesState: SetterOrUpdater<State>
) => {
  setNotesState(updateState);
};
adrianbw commented 4 years ago

For anyone who comes upon this and likes to write unit tests (a discussion I'm not going to get into), here's a pattern I adopted, which uses jest.fn() to report out the atom's value.

interface IGenericUpdater<ValType, StateType extends object> {
    (value: ValType, setState: SetterOrUpdater<StateType>): void;
}

interface ISetterTest<U, V extends object> {
    atom: Atom;
    function: IGenericUpdater<U, V>;
    value: U;
}

type UpdaterFunction = (property: any, updaterFunction: SetterOrUpdater<any>) => void;

type TestComponentProps = {
    atom: Atom;
    function: UpdaterFunction;
    value: any;
    reporterFunction: (state: State) => void;
};

const TestComponent: React.FunctionComponent<TestComponentProps> = (props: TestComponentProps) => {
    const [state, setState]: [State, any] = useRecoilState(props.atom);
    React.useEffect(() => {
        props.function(props.value, setState);
        props.reporterFunction(state);
    },              [state]);
    return <div />;
};

const createMountedWrapper = (additionalProps: Partial<TestComponentProps>) => {
    const props = {
        atom: null as any,
        function: jest.fn() as any,
        value: null as any,
        reporterFunction: jest.fn() as any,
        ...additionalProps,
    };
    const wrapper = mount(<RecoilRoot><TestComponent {...props} /></RecoilRoot>).rendered;
    const instance = wrapper.find(TestComponent).instance() as any;
    return {wrapper, instance, ...props};
};

describe('recoil tests', () => {
    let componentWrapper: any;
    afterEach(() => {
        if (componentWrapper?.unmount) {
            componentWrapper.unmount();
        }
    });
   it('setAddComplete', () => {
        const props: ISetterTest<boolean, State> = {
            atom: notesStore,
            function: setAddComplete,
            value: true,
        };
        const { wrapper, reporterFunction } = createMountedWrapper(props);
        componentWrapper = wrapper;
        expect(reporterFunction).toHaveBeenCalledWith({...initState, addComplete: props.value});
    });
});

Before adding wrapper.unmount(), I got in some loops with useEffect testing multiple functions (presumably because it was adding an instance on every mount?). There may be other ways to prevent this that I don't know of.

drarmstr commented 3 years ago

You can now use snapshots for testing outside of React.