@k8ordo/color-scheme

Testing

@k8ordo/color-scheme uses localStorage, the class on <html> and matchMedia, which only a browser has, so its tests run in a real browser, such as Vitest’s browser mode. This page covers putting things back between tests, and rendering the hook under the provider to check what it does.

On this page

Reset between tests

The provider reads and writes the localStorage row, the class on <html>, and an @k8ordo/state store. All three outlive a test, so put all three back before each one.

color-scheme.test.tsx
import { resetStateRegistry } from '@k8ordo/state';
import { beforeEach } from 'vitest';

beforeEach(() => {
  localStorage.clear();
  document.documentElement.classList.remove('dark');
  resetStateRegistry();
});
  • localStorage.clear(): removes the choice an earlier test stored. Left there, the next test does not start from nothing chosen.
  • classList.remove('dark'): takes off the class the provider’s effect put on in an earlier test.
  • resetStateRegistry(): drops @k8ordo/state’s stores. One is made per key and outlives a test, so without this it keeps an earlier test’s values.
Information

Note

Call resetStateRegistry() with no component using the hook still mounted, because a mounted hook keeps the old store it was given. With vitest-browser-react, the previous test’s render is cleaned up before each test.

Render the hook under the provider

useColorScheme() throws outside the provider, so render it with the provider as renderHook’s wrapper.

color-scheme.test.tsx
import {
  ColorSchemeProvider,
  colorSchemeState,
  useColorScheme,
} from '@k8ordo/color-scheme';
import type { ReactNode } from 'react';
import { expect, it, vi } from 'vitest';
import { renderHook } from 'vitest-browser-react';

const wrapper = ({ children }: { children: ReactNode }) => (
  <ColorSchemeProvider>{children}</ColorSchemeProvider>
);

it('starts from a stored preference', async () => {
  localStorage.setItem(
    colorSchemeState.storageKey,
    JSON.stringify({ preference: 'dark' }),
  );
  const { result } = await renderHook(() => useColorScheme(), {
    wrapper,
  });

  expect(result.current.preference).toBe('dark');
  expect(result.current.scheme).toBe('dark');
  expect(
    document.documentElement.classList.contains('dark'),
  ).toBe(true);
});

Read the localStorage key from colorSchemeState.storageKey rather than spelling it out: how the key is built is @k8ordo/state’s decision.

Change the choice and check

After changing the choice with setPreference, wait for the re-render with vi.waitFor before checking.

color-scheme.test.tsx
it('stores a choice, and stores none for system', async () => {
  const { result } = await renderHook(() => useColorScheme(), {
    wrapper,
  });

  result.current.setPreference('dark');
  await vi.waitFor(() => {
    expect(result.current.scheme).toBe('dark');
  });
  expect(
    document.documentElement.classList.contains('dark'),
  ).toBe(true);
  expect(localStorage.getItem(colorSchemeState.storageKey)).toBe(
    '{"preference":"dark"}',
  );

  result.current.setPreference('system');
  await vi.waitFor(() => {
    expect(result.current.preference).toBe('system');
  });
  expect(
    localStorage.getItem(colorSchemeState.storageKey),
  ).toBe('{}');
});

Calling setPreference does not re-render on the spot, and the write to localStorage is batched into a microtask. Once the re-render has happened the write has too, so wait for scheme or preference to change, then check the row and the class.

Going back to 'system' does not remove the row: {} remains, with no preference in it.

Test against a dark system

What 'system' resolves to is the test browser’s prefers-color-scheme, and a browser Playwright launches prefers light unless told otherwise.

To check that a visitor starts from the default, a wrapper whose provider is given defaultPreference="dark" is enough. To check that it follows the OS itself, make the browser prefer dark.

vite.config.ts
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: playwright({
        contextOptions: { colorScheme: 'dark' },
      }),
      instances: [{ browser: 'chromium' }],
    },
  },
});

The setting applies to every test that runs in that browser.

The inline script does not run

Rendered only in the browser, as in a test, the provider’s inline script does not run: React never executes an inline <script> it creates in the browser.

React’s development build logs this as an error in the console, starting with Encountered a script tag while rendering React component. It does not mean the test failed.

So the class a test sees on <html> is the one the provider’s effect wrote. That the script puts the class on before the first paint is covered by this package’s own tests.

k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2