next-themes icon indicating copy to clipboard operation
next-themes copied to clipboard

Add documentation for testing implementations

Open GriffinSauce opened this issue 3 years ago • 1 comments

Hi, thanks for this library! It was so easy and fast to implement exactly what I needed! 🙌 Just wanted to share how I've started testing my implementations with React Testing Library:

First, create a custom render function that includes the theme provider with an optional value:

// test-utils.tsx
import React, { ReactElement } from 'react';
import { render, RenderOptions, RenderResult } from '@testing-library/react';
import { ThemeProvider } from 'next-themes';

interface TestProviderOptions {
  theme?: string;
}

interface CustomOptions extends RenderOptions, TestProviderOptions {}

const createTestProviders = ({
  theme = 'dark',
}: TestProviderOptions): React.FC => ({ children }) => (
  <ThemeProvider defaultTheme={theme} enableSystem={false} attribute="class">
    {children}
  </ThemeProvider>
);

const customRender = (
  ui: ReactElement,
  { theme, ...options }: CustomOptions = {},
): RenderResult =>
  render(ui, { wrapper: createTestProviders({ theme }), ...options });

// re-export everything
export * from '@testing-library/react';

// override render method
export { customRender as render };

Second: Add a test-id to select your select (😁):

// components/ThemeToggle.tsx
   <select
      className="font-semibold border border-gray-100 rounded"
      value={theme}
      data-testid="theme-select"
      onChange={handleChange}
    >
      <option value="light">Light Mode</option>
      <option value="dark">Dark Mode</option>
    </select>

Third: Test that the toggle actually changes the theme. Of course the exact implementation here will differ depending on how you write your toggle.

(technically you could assert the select value when you control it directly from the hook value, but I figured using a spy would be a bit more robust)

// components/ThemeToggle.test.tsx
import React from 'react';
import { useTheme } from 'next-themes';
import { render, fireEvent } from '../test/test-utils';
import ThemeToggle from './ThemeToggle';

const ThemeSpy: React.FC = () => {
  const { theme } = useTheme();
  return <span data-testid="theme-spy">{theme}</span>;
};

it('toggles the theme', async () => {
  const { getByTestId } = render(
    <>
      <ThemeToggle />
      <ThemeSpy />
    </>,
    { theme: 'dark' }, // Is also the default value, explicitly adding it here makes the test a bit more easy to read
  );
  const select = getByTestId('theme-select');
  const spy = getByTestId('theme-spy');

  fireEvent.change(select, { target: { value: 'light' } });

  expect(spy).toHaveTextContent('light');
});

Let me know if you see anything that could be improved of course! I think it would be nice to add this as an example for the next person. :)

GriffinSauce avatar Jan 11 '21 23:01 GriffinSauce

Thanks for this! It was very helpful. I figured I would contribute additional points for how to integrate this with testing.

Perhaps since this issue was made, the repo changed, but when this solution is ran as is with the current code, jest will complain that matchMedia isn't a function and needs to be mocked.

The solution can actually be stolen from this repo lol. All one needs to do is add the following code to your setup file or test file as needed to mock out everything this library needs mocked:

let localStorageMock: { [key: string]: string } = {}

beforeAll(() => {
  // Create a mock of the window.matchMedia function
  global.matchMedia = jest.fn(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn()
  }))

  // Create mocks of localStorage getItem and setItem functions
  global.Storage.prototype.getItem = jest.fn(
    (key: string) => localStorageMock[key]
  )
  global.Storage.prototype.setItem = jest.fn((key: string, value: string) => {
    localStorageMock[key] = value
  })
})

beforeEach(() => {
  // Clear the localStorage-mock
  localStorageMock = {}
})

If you set resetMocks: true in your jest config like I do (ref to the docs for resetMocks), then you will need all of this in a before each since the mocks get wiped before each test

let localStorageMock: { [key: string]: string } = {}

beforeEach(() => {
  global.matchMedia = jest.fn(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn()
  }))

  global.Storage.prototype.getItem = jest.fn(
    (key: string) => localStorageMock[key]
  )
  global.Storage.prototype.setItem = jest.fn((key: string, value: string) => {
    localStorageMock[key] = value
  })

  localStorageMock = {}
})

Feel free to let me know if I missed anything

RyanClementsHax avatar Oct 19 '21 14:10 RyanClementsHax