CoursesTesting Sanity StudioTesting Studio React Components
Testing Sanity Studio

Testing Studio React Components

Test custom input components that render UI, use Studio hooks, and handle user interactions. Configure a browser-like test environment with React Testing Library, create provider fixtures that supply Studio context, and verify component behavior through simulated user actions. Learn what aspects of components are worth testing and how to balance thorough coverage with maintainable tests.
Log in to mark your progress for each Lesson and Task

The validation logic and access control functions you've tested so far run in Node.js with no UI. Custom input components are different—they render DOM elements, depend on Sanity UI's theming context, use Studio hooks like useFormValue and useClient, and respond to user interactions. Testing these components requires a browser-like environment, provider setup for Sanity UI, and tools to simulate user behavior like clicks and typing.

Testing components verifies not just that your code runs, but that content editors can successfully interact with your custom inputs. A permission check might pass its pure function test but fail when integrated into a component's readOnly callback. A date calculation might work in isolation but render incorrectly when formatted for display. Component tests catch these integration issues by exercising the full user interaction flow.

Install React Testing Library and jsdom:

npm install -D @testing-library/react @testing-library/user-event jsdom @testing-library/jest-dom

These packages provide:

  • jsdom - A browser environment implementation for Node.js
  • @testing-library/react - Utilities for rendering and querying React components
  • @testing-library/user-event - Functions that simulate realistic user interactions
  • @testing-library/jest-dom - Additional matchers for DOM testing
jsdom simulates a browser environment in Node.js. It's not a real browser, so some browser-specific features (like layout calculations) won't work. For most Studio components, jsdom is sufficient.

Your test suite now has different needs: pure functions run in Node, React components need a browser environment.

Update apps/studio/vitest.config.ts to define test-type projects (not to be confused with workspace projects):

import {defineProject} from 'vitest/config'
export default defineProject({
test: {
name: 'studio',
// Define test-type projects within the Studio workspace project
projects: [
{
test: {
name: 'unit',
environment: 'node',
include: ['**/*.test.ts'],
},
},
{
test: {
name: 'component',
environment: 'jsdom',
include: ['**/*.test.tsx'],
setupFiles: ['./__tests__/setup.ts'],
},
},
],
},
})
Important: Vitest requires unique project names across your entire workspace. If you add other workspace projects, you may need to adjust the naming convention.

And we'll need to create the setup file for the component test suite:

import {cleanup} from '@testing-library/react'
import {afterEach} from 'vitest'
import '@testing-library/jest-dom/vitest'
afterEach(() => {
cleanup()
})
Component tests are slower than unit tests because they render real DOM elements. This is why we separate them into different test projects with the .test.tsx extension.

This configuration creates nested projects:

  1. Workspace project - studio (defined at root vitest.config.ts with projects: ['apps/*'])
  2. Test-type projects - unit and component (defined within Studio's config)

The component project:

  • Uses jsdom to provide DOM APIs that React needs
  • Only runs files with .test.tsx extension (TypeScript with JSX)
  • Runs a setup file before each test

React components in Sanity Studio don't work in isolation—they expect certain contexts to exist. Sanity UI components read theme values from a ThemeProvider to render correctly (colors, spacing, typography). Without this provider, components throw errors or render incorrectly. Similarly, components that trigger toast notifications need a ToastProvider, and dialogs need a LayerProvider for z-index management.

In production, your Studio wraps everything with these providers automatically. In tests, you need to recreate this harness manually. Rather than wrap each component individually in every test, create a reusable fixture that provides all required contexts:

import {LayerProvider, ThemeProvider, ToastProvider} from '@sanity/ui'
import {defaultTheme} from 'sanity'
import {render, type RenderOptions} from '@testing-library/react'
import type {ReactElement} from 'react'
export function TestProviders({children}: {children: React.ReactNode}) {
return (
<ThemeProvider theme={defaultTheme}>
<ToastProvider>
<LayerProvider>{children}</LayerProvider>
</ToastProvider>
</ThemeProvider>
)
}
export function renderWithProviders(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
return render(ui, {wrapper: TestProviders, ...options})
}
Start with these three providers for most Sanity UI components. Add more providers (like FormBuilderProvider) only when your components require them. Keep provider setup minimal.

The TestProviders component recreates the essential Studio provider hierarchy for these component tests:

  • ThemeProvider - Provides theme values (colors, spacing, typography)
  • ToastProvider - Enables toast notifications (some inputs use these)
  • LayerProvider - Manages z-index stacking for dialogs and popovers

These are the providers the DoorsOpenInput component needs. Other custom components might require additional providers—like FormBuilderProvider for components that use form-level hooks, or custom context providers for specialized features. Add providers to TestProviders as your test suite requires them.

The renderWithProviders() helper wraps React Testing Library's render() function, automatically including these providers for every component test. This fixture becomes your test harness for Sanity UI components—call it instead of render() and components work as they would in production.

The DoorsOpenInput component shows when doors open based on the event date and a minutes-before value. It uses:

  • useFormValue hook to read the event date
  • useClient hook to access the Sanity client
  • Custom logic to calculate and display the doors open time

The DoorsOpenInput component displays when doors open for an event, calculated from the event date and a minutes-before value:

import {getPublishedId, type NumberInputProps, useClient, useFormValue} from 'sanity'
import {Stack, Text} from '@sanity/ui'
import {useEffect} from 'react'
// ..
export function DoorsOpenInput(props: NumberInputProps) {
const date = useFormValue(['date']) as string | undefined
const client = useClient({apiVersion: '2025-05-08'})
const documentId = useFormValue(['_id']) as string | undefined
// Query for document versions (demonstration purposes)
useEffect(() => {
if (!documentId) return
// ... query logic
}, [client, documentId])
return (
<Stack space={3}>
{props.renderDefault(props)}
{typeof props.value === 'number' && date ? (
<Text size={1}>
Doors open{' '}
{subtractMinutesFromDate(date, props.value).toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
})}
</Text>
) : null}
</Stack>
)
}

The component uses Sanity hooks (useFormValue, useClient) to access form data and the Sanity client. Testing it requires mocking these hooks to control what values the component receives:

import {describe, it, expect, vi, beforeEach} from 'vitest'
import {screen} from '@testing-library/react'
import {useFormValue, useClient} from 'sanity'
import {renderWithProviders} from '../../../__tests__/fixtures/providers'
import {DoorsOpenInput} from './DoorsOpenInput'
// Mock Sanity hooks
vi.mock('sanity', async () => {
const actual = await vi.importActual('sanity')
return {
...actual,
useFormValue: vi.fn(),
useClient: vi.fn(),
}
})
describe('DoorsOpenInput', () => {
const mockProps = {
value: 60,
onChange: vi.fn(),
renderDefault: vi.fn((props) => <input type="number" {...props} />),
} as any
beforeEach(() => {
vi.clearAllMocks()
})
it('shows doors open time when date and value exist', () => {
vi.mocked(useFormValue).mockImplementation((path) => {
if (path[0] === 'date') return '2025-06-15T20:00:00Z'
if (path[0] === '_id') return 'event-1'
return undefined
})
vi.mocked(useClient).mockReturnValue({} as any)
renderWithProviders(<DoorsOpenInput {...mockProps} />)
expect(screen.getByText(/Doors open/i)).toBeInTheDocument()
expect(screen.getByText(/June/i)).toBeInTheDocument()
})
it('hides doors open time when date is missing', () => {
vi.mocked(useFormValue).mockImplementation((path) => {
if (path[0] === 'date') return undefined
if (path[0] === '_id') return 'event-2'
return undefined
})
vi.mocked(useClient).mockReturnValue({} as any)
renderWithProviders(<DoorsOpenInput {...mockProps} />)
expect(screen.queryByText(/Doors open/i)).not.toBeInTheDocument()
})
it('hides doors open time when value is missing', () => {
vi.mocked(useFormValue).mockImplementation((path) => {
if (path[0] === 'date') return '2025-06-15T20:00:00Z'
if (path[0] === '_id') return 'event-3'
return undefined
})
vi.mocked(useClient).mockReturnValue({} as any)
const propsWithoutValue = {...mockProps, value: undefined}
renderWithProviders(<DoorsOpenInput {...propsWithoutValue} />)
expect(screen.queryByText(/Doors open/i)).not.toBeInTheDocument()
})
})
Module mocks apply to the entire test file. If you need different mock behavior per test, use mockImplementation() in each test's setup rather than at the file level.

This test mocks the entire sanity module to control what useFormValue and useClient return:

  1. vi.mock('sanity') - Intercepts all imports from the sanity package
  2. vi.importActual() - Preserves original exports (types, utilities)
  3. useFormValue: vi.fn() - Replaces hook with mock function
  4. mockImplementation() - Controls what the hook returns based on the path argument
  5. beforeEach() - Clears mocks between tests for isolation

The mockImplementation() function lets you return different values based on which form field is being accessed. When the component calls useFormValue(['date']), the mock checks if path[0] === 'date' and returns the appropriate value. This pattern works for any component that reads multiple form fields through useFormValue.

Focus on behavior that matters to content editors and enforced data integrity:

Test:

  • Components render with expected content
  • User interactions trigger correct callbacks
  • Interactive components mutate documents accurately
  • Accessibility attributes are present

Don't test:

  • Implementation details (state variable names, hooks used)
  • Sanity internals (we ensure test coverage for our own code)
  • CSS styles and exact positioning
  • Third-party library behavior (they should be testing it)

Custom inputs that modify documents deserve thorough testing. A bug in a read-only component might confuse an editor temporarily, but a bug in a component that writes data to the published document rather than the current draft would be a headache. Test these rigorously to ensure they write the correct values to the intended fields in the intended document.

Add a test that verifies the component calls onChange when the user modifies the input value. Use userEvent.type() to simulate typing.

You've learned to test React components in Sanity Studio:

  • Setting up jsdom for browser-like testing
  • Creating provider fixtures for Studio context
  • Rendering components with React Testing Library
  • Simulating user interactions with userEvent
  • Mocking Sanity hooks like useFormValue and useClient
  • Using accessible queries to find elements

In the next lesson, you'll integrate tests into GitHub Actions, implement coverage reporting, and develop a testing strategy that scales with your Studio.

You have 1 uncompleted task in this lesson
0 of 1