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.tsximport { 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.
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.tsximport {
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.tsxit('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.tsimport { 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.