jamiebuilds / unstated-next

200 bytes to never think about React state management libraries ever again

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Testing in unstated-next?

squarism opened this issue · comments

I read through issue #20 and that had some good perspective. Agree on keeping App away. Some tricks there that I could have used a year ago. 🎈 Anyway ...

I could test unstated "actions" through enzyme but I'm looking at this store.js file I made and it's so simple. I just want to test it. Of course, I can't because it needs hooks, context. It needs to be in a DOM/Tree/JSX place just like the used source file.

Something like this:

// store.test.js
import { StoreContainer } from '../src/store'

describe("the state store", () => {

  // the state here is just flipping a checkbox, a spike
  it("can receive an event", () => {
    // this can't work outside a provider
    const store = StoreContainer.useContainer()

    const event = { target: { value: "test" } }
    const checked = false

    store.handleUpdate(event, checked)

    const expected = { "test": false }
    expect(store.filters).toBe(expected)
  })

})

Running this with jest prints Invariant Violation: Invalid hook call. Yeah. That makes sense. "Hooks can only be called inside of the body of a function component." I see in the repo where the context is expected.

My store.js file looks like this. Works fine great when manually testing (like in the app, in the browser, integrated). But I'm adding jest and want to add complexity. So I thought I'd just test my store in isolation and then later the increasing number of "actions". Maybe this is a "bad" idea or what you wanted to avoid in the first place.

// store.js
import { useState } from 'react'
import { createContainer } from "unstated-next"

const useStore = () => {
  const defaultFilters = {}

  const [filters, updateFilters] = useState(defaultFilters)
  const handleUpdate = (event, checked) => {
    let newState = { [event.target.value]: checked }
    updateFilters({ ...filters, ...newState })
  }

  return { filters, handleUpdate }
}

export const StoreContainer = createContainer(useStore)

Filters is just an object that I'm merging with checkbox clicks (a spike).
See, it's just a little bit of code. Maybe I'm wanted to test it for the wrong reasons. Maybe I'm trying to test it like it's a plain js file when it's not.

In the README:

Testing reducers is a waste of your time, make it easier to test your React components.

Do you mean that I should not test the store directly? It's too simple? I could test the store through my components? (normal enzyme stuff)

First off, I just have to say: Do not create a "StoreContainer". You should have many containers in your application, having a single one is going to cause a performance problem in your application, and it will become increasingly bloated over time. Each container should have one clear responsibility.


For testing, when you create a container, you're creating an API for components.

function useCounter(initialState = 0) {
  let [count, setCount] = useState(initialState)
  let decrement = () => setCount(count - 1)
  let increment = () => setCount(count + 1)
  return { count, decrement, increment } // << -- API for components
}

let Counter = UnstatedNext.createContainer(useContainer)

This API that you return from a hook is only ever meant to be used to build components. Anything that's not visible to components via the interface you are returning is an implementation detail.

You could use an API like @testing-library/react-hooks which would look like this:

import { renderHook, act } from "@testing-library/react-hooks"
import Counter from "./containers/Counter"

test("Counter", () => {
  let { result } = renderHook(() => Counter.useContainer())
  expect(result.current.value).toBe(0)
  act(() => result.current.increment())
  expect(result.current.value).toBe(1)
  act(() => result.current.decrement())
  expect(result.current.value).toBe(0)
})

You could also just create a component in your tests:

import {
  render,
  fireEvent,
  cleanup,
} from '@testing-library/react'
import Counter from './containers/Counter'

afterEach(cleanup)

function CounterExample() {
  let counter = Counter.useContainer()
  return <div>
    <div data-testid="count">{count}</div>
    <button data-testid="increment" onClick={counter.increment}/>
    <button data-testid="decrement" onClick={counter.decrement}/>
  </div>
}

test("Counter", () => {
  let { getByTestId, fireEvent } = render(<CounterExample/>)
  expect(getByTestId("count").textContent).toBe("0")
  fireEvent.click(getByTestId("increment"))
  expect(getByTestId("count").textContent).toBe("1")
  fireEvent.click(getByTestId("decrement"))
  expect(getByTestId("count").textContent).toBe("0")
})

I think directly testing the hook, needs to test the useCounter hook, not the Container.useContainer() function, since it uses Context.

And then the mutation probably needs to be wrapped in an act()

The same applies to the second example. The Example would need to render the Provider, in order to make the container work.

I've been adding tests to a project using unstated-next recently and found you don't need to use the createContainer or useContainer.

just use the hook directly.

import {useCounter} from '../useCounter'

test('Increment', () => {
    const { result } = renderHook(() => useCounter())
    act(() => result.current.increment(2))
    act(() => result.current.decrement(2))
    expect(result.current.value).toBe(0)
  })

Thank for this new version @jamiebuilds hope you are well 👍

My approach is the following:

SingIn.state.tsx

import * as React from 'react';
import { createContainer } from 'unstated-next';

export type UseAccountState = 'advertiser' | 'influencer';

function useAccount(initialState: UseAccountState = 'influencer') {
  const [account, setAccount] = React.useState<UseAccountState>(initialState);

  const setAdvertiser = React.useCallback(() => {
    setAccount('advertiser');
  }, [setAccount]);

  const setInfluencer = React.useCallback(() => {
    setAccount('influencer');
  }, [setAccount]);

  return {
    account,
    setAdvertiser,
    setInfluencer
  };
}

export type UseAccountReturn = ReturnType<typeof useAccount>;
export const AccountState = createContainer(useAccount);

SignIn.test.tsx

import * as React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import { AccountState, UseAccountState } from './SingIn.state';

const AccountStateWrapper: React.FC<{ initialState: UseAccountState }> = ({
  children,
  initialState
}) => (
  <AccountState.Provider initialState={initialState}>
    {children}
  </AccountState.Provider>
);

describe('SignIn Feature', () => {
  describe('Hooks', () => {
    it('should initial state be influencer by default', () => {
      const { result } = renderHook(() => AccountState.useContainer(), {
        wrapper: AccountStateWrapper
      });

      expect(result.current.account).toBe('influencer');
    });

    it('should initial state be advertiser by custom', () => {
      const { result } = renderHook(() => AccountState.useContainer(), {
        wrapper: AccountStateWrapper,
        initialProps: { initialState: 'advertiser' }
      });

      expect(result.current.account).toBe('advertiser');
    });

    it('should mutate state to advertiser', async () => {
      const { result } = renderHook(() => AccountState.useContainer(), {
        wrapper: AccountStateWrapper
      });

      act(() => {
        result.current.setAdvertiser();
      });

      expect(result.current.account).toBe('advertiser');
    });

    it('should mutate state to influencer', async () => {
      const { result } = renderHook(() => AccountState.useContainer(), {
        wrapper: AccountStateWrapper,
        initialProps: { initialState: 'advertiser' }
      });

      act(() => {
        result.current.setInfluencer();
      });

      expect(result.current.account).toBe('influencer');
    });
  });
});

What about testing components with Jest that are consuming resources from a given unstated-next container?

How should we be mocking the container's methods and state? I couldn't find any articles about it.

What about testing components with Jest that are consuming resources from a given unstated-next container?

How should we be mocking the container's methods and state? I couldn't find any articles about it.

this is what I did to mock the unstated-next container

jest.mock("...module path", () => ({
  ...jest.requireActual("...module path"),
  "your state name": {
    Provider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
    useContainer: () => ({
       ... return value that you want to mock
    }),
  },
}));