Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Question] How do you unit test Jotai atoms? #252

Closed
MarkLyck opened this issue Jan 9, 2021 · 19 comments
Closed

[Question] How do you unit test Jotai atoms? #252

MarkLyck opened this issue Jan 9, 2021 · 19 comments
Labels
help wanted Please someone help on this

Comments

@MarkLyck
Copy link

MarkLyck commented Jan 9, 2021

Considering using Jotai for our production application.

It seems to fit all of our needs perfectly, but I haven't been able to find any docs on testing Jotai atoms.

E.g. I created a simple themeAtom and setThemeAtom like this:

import { atom } from 'jotai'
import themes, { themeNameType } from 'src/lib/themes'

const DEFAULT_THEME = themes.light

export const themeAtom = atom(DEFAULT_THEME)
export const setThemeAtom = atom(
    (get) => get(themeAtom),
    (_, set, arg: themeNameType) => {
        set(themeAtom, themes[arg])
    })

How would I go about writing a unit test that makes sure when setTheme is called with a valid theme name, that the themeAtom updates to the expected theme?

Since Jotai only functions inside "React" do I have to write a dummy component with a dummy button to test my state management atoms?

Also interested in how I would test components that use useAtom. I'm guessing in this case I mock the useAtom, but it would be very helpful to have a guide/docs on the recommended way to do this.

Thanks!

@dai-shi
Copy link
Member

dai-shi commented Jan 10, 2021

It seems to fit all of our needs perfectly, but I haven't been able to find any docs on testing Jotai atoms.

It would be really nice to have docs/testing.md for sure.

Since Jotai only functions inside "React" do I have to write a dummy component with a dummy button to test my state management atoms?

Guess so. Do some files in tests help?

If you were just testing the functions for atom like read / write, you could test it without React, because they are just pure functions. But, to test a component/hook behavior, we'd need React.

Also interested in how I would test components that use useAtom.

Not sure what you mean by this. If you are testing a component behavior, you don't need to mock useAtom. 🤔

it would be very helpful to have a guide/docs on the recommended way to do this.

Yeah. I'm not super experienced on testing best practices. Hope someone to volunteer. Otherwise, I'd try my best...

@dai-shi dai-shi added the help wanted Please someone help on this label Jan 10, 2021
@Noitidart
Copy link

Noitidart commented Jan 22, 2021

Maybe mock atom and then assert on the gets and sets?

@dai-shi
Copy link
Member

dai-shi commented Jan 22, 2021

@Noitidart Would you be interested in investigating further and create docs and/or examples?

@Noitidart
Copy link

Yes totally I'll write a test for the above and share here and add it into docs if everyone likes it.

@MarkLyck
Copy link
Author

MarkLyck commented Jan 27, 2021

@dai-shi @Noitidart

I'm still not quite sure what would be best practices for unit testing atoms. But this is what I have been doing so far.

I would love some feedback on how to improve this as it does seem a bit clunky.

Since atoms (afaik) can ONLY be used with react-hooks, I create a dummy component that stores the "get" data,
and a button (or multiple) that calls the set functions.

Then expect the component with the get data to update to the correct snapshots.

import { render, screen, fireEvent } from 'src/test/test-utils'
import React, { useEffect } from 'react'

import { useAtom } from 'jotai'
import { authAtom } from './authAtom'

const spy = jest.fn();

const AuthAtomTest = () => {
    const [auth, setAuth] = useAtom(authAtom)
    useEffect(() => spy(auth), [auth])

    const handleTest = () => {
        setAuth({ authenticating: true, authToken: 'test-token' })
    }

    return (
        <div>
            <button onClick={handleTest} data-testid="test-button-1" />
        </div>
    )
}

describe('authAtom', () => {
    test('it should update Auth', () => {
        render(<AuthAtomTest />)

        const buttonElement = screen.getByTestId('test-button-1')
        expect(spy).toBeCalledWith({ "authToken": null, "authenticating": false })

        expect(buttonElement).toBeInTheDocument()
        fireEvent.click(buttonElement)
        expect(spy).toHaveBeenLastCalledWith({ "authToken": "test-token", "authenticating": true })
    })
})

I feel like there has to be a much cleaner way to do this? Would appreciate some feedback.

EDIT:

I improved it somewhat by making a generic AtomTestComponent:

import { useEffect } from 'react'
import { useAtom } from 'jotai'

export const AtomTestComponent = ({ atom, spy, data }: any) => {
    const [getter, setter] = useAtom(atom)
    useEffect(() => spy(getter), [getter])

    const handleTest = () => {
        setter(data)
    }

    return (<button onClick={handleTest} data-testid="test-button" />)
}

and my test now looks like this:

import React from 'react'
import { render, screen, fireEvent, AtomTestComponent } from 'src/test/test-utils'
import { authAtom } from './authAtom'

describe('authAtom', () => {
    test('it should update auth', () => {
        const spy = jest.fn();

        render(<AtomTestComponent spy={spy} atom={authAtom} data={{ authenticating: true, authToken: 'test-token' }} />)

        const buttonElement = screen.getByTestId('test-button')
        expect(spy).toBeCalledWith({ "authToken": null, "authenticating": false })

        expect(buttonElement).toBeInTheDocument()
        fireEvent.click(buttonElement)
        expect(spy).toHaveBeenLastCalledWith({ "authToken": "test-token", "authenticating": true })
    })
})

But of course that only works for a simple use-case. Not for reducerAtoms etc.

@dai-shi
Copy link
Member

dai-shi commented Jan 28, 2021

atoms (afaik) can ONLY be used with react-hooks

This is correct. Atom values only exist in Provider. I don't see any <Provider> in your snippets. Is it in somewhere else?

How is your authAtom defined? Just a primitive atom? A bit curious what this is testing. Looking at the updated one, it's like testing jotai library itself. I wonder if the goal here is to test the behavior of a component with using atoms. In this case, the first version looks reasonable.

Hope to get some feedbacks from others.

@Aslemammad
Copy link
Member

I think we can test atom.read and atom.write:

const getter = (get) => 1
const setter = (get,set,update) => set(justAtom,3)
const justAtom = atom(getter,setter)
console.log(justAtom.read === getter, justAtom.write === setter) // true true

// testing phase

const justAtom = atom(
  (get) => get(justAtom),
  (get, set, update) => set(justAtom, get(justAtom) + 1)
);

justAtom.init = 0
const get = (a) => {
  return a.testValue || a.init;
};
const set = (a, value) => {
  a.testValue = value;
};
console.log(justAtom.read(get)); // 0
justAtom.write(get, set);
justAtom.write(get, set);
console.log(justAtom.read(get)); // 2

I'm not saying that we should make getter and setter function outside of the atom, but I mean we can use atom.read and atom.write themselves and we can make set/get for testing( similar to jotai's set/get) in jotai/test.
What's your opinion?

@dai-shi
Copy link
Member

dai-shi commented Jan 28, 2021

I thought about that too. Yeah, if it's the goal, it would be fine.
One little thing is it can be said that it's assuming the implementation detail.
If we changed write to w, the test wouldn't work any longer.
To avoid that, we could mock the atom impl.

It totally depends on what we would like to test. Let's say if we replace useAtom with useState, does the test still meaningful? Then, it's testing the component behavior. Or, like you said, testing read and write functions are fine as it's testing atom configs.

@dai-shi dai-shi mentioned this issue Mar 4, 2021
49 tasks
@dai-shi
Copy link
Member

dai-shi commented May 10, 2021

Our plan for this issue for v1 is to add some guide in docs about testing.
Not sure how much it covers the discussion raised in this issue, but we will see.
Let's move forward.

@dai-shi
Copy link
Member

dai-shi commented May 25, 2021

related: #496

@Thisen
Copy link
Collaborator

Thisen commented May 28, 2021

I believe we should echo react-testing-library's guiding principles around focusing on testing like how a user would interact with your code/components.

Therefore we could encourage:

  • You should test your components, using your atoms
  • If you have a alot of logic in an atom, you use react-hooks-testing-library

I'm happy to make a write-up in the new docs.

@dai-shi
Copy link
Member

dai-shi commented May 28, 2021

I'm happy to make a write-up in the new docs.

That's great news. Please go ahead.

@dai-shi
Copy link
Member

dai-shi commented May 30, 2021

Closing this as https://github.com/pmndrs/website/pull/92 is merged.

Although the original question may not be fully answered, please open a new issue or discussion instead of continuing discussions here.

@dai-shi dai-shi closed this as completed May 30, 2021
@sergiotlITX
Copy link

What if you have a custom atom with a custom getter
or if you set the atom in other component? how do you test all this?

@brammerl
Copy link

brammerl commented May 1, 2024

Also running into what @sergiotlITX is talking about. We'd love a way to test our custom atoms as pure functions!

@rothsandro
Copy link
Collaborator

@brammerl Testing atoms as pure functions is probably not possible (I guess) and I don't see the advantage of doing so. You would have to mock Jotai's get / set function which results in less confident tests.

If you want to avoid hooks in your tests, you could use the store directly:

test("example", () => {
  const store = createStore();
  store.set(doubleCounterAtom, 5);
  expect(store.get(counterAtom)).toBe(10);
});

@richcrad
Copy link

richcrad commented Aug 15, 2024

I recently starting using Jotai. I like it a lot, but have also run into the issue described here. I've adopted a pattern in my code to make testing easy without getting too much in the way:

Counter.tsx:

const Counter = (props) => {
  const { saveCount = () => {} } = props;  // for testing!
  const [count, setCountJotai] = useAtom(countAtom);
  const setCount = (newCount) => {
    setCountJotai(newCount);
    saveCount(newCount);
  };
  
  const increment = () => setCount(count +1);
}

In my test file, I pass in a jest function and use a <Provider> as needed (not shown).
Counter.test.tsx:

import Counter from './Counter';

it('Increments the count', () => {
  const saveCount = jest.fn();
  render(<Counter saveCount={saveCount} />);
  expect(saveCount).toHaveBeenCalledWith(1);
}

Since the saveCount prop is optional, the rest of my code remains unchanged.

@rothsandro
Copy link
Collaborator

@richcrad The approach requires changes to the production code to be testable and ships that code also to production. The test also doesn't guarantee that the component actually works, if you refactor the component and forget the setCountJotai(newCount) call the test still passes but it's actually broken. If you can't test the component output directly (i.e. testing that the component renders the new counter value), why not accessing the store to verify that the value is correct? Just my two cents 🤷

@richcrad
Copy link

@rothsandro Yes that's very true. The tradeoff is working well for me, as the added code is minimal and idiomatic, so pretty easy to avoid mistakes. I'd prefer it if you could directly spyOn the useAtom() setter itself, but since that's not possible, this pattern works well enough.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
help wanted Please someone help on this
Projects
None yet
Development

No branches or pull requests

9 participants