Testing Sanity Studio: Schemas, GROQ Queries, and Content Pipelines
Sanity Studio has three testable layers: schema definitions (TypeScript/JavaScript), GROQ queries (data fetching logic), and content pipelines (transformations). Testing all three gives you confidence that schema changes don't break queries, queries return the expected shape, and your transformation code handles all content states correctly.
Key Takeaways
Test schema structure with TypeScript. Sanity v3 schemas are plain TypeScript objects. Type-check them at build time and write Jest tests for any runtime validation logic.
Use groq-js to test GROQ queries against local fixture data. The groq-js library evaluates GROQ queries without a Sanity backend — fast, offline, CI-friendly.
Mock the Sanity client for unit tests. The @sanity/client is an HTTP client — mock it at the module boundary to avoid network calls in unit tests.
Test portable text transformers. Sanity's portable text is an AST like Contentful's rich text. Test that your @portabletext/react or @portabletext/to-html configuration produces correct output.
Use the Sanity HTTP API for integration tests. Sanity's CDN-cached Read API doesn't require authentication — safe to call in CI with a useCdn: true client against a test dataset.
Sanity's Testable Layers
Sanity is a headless CMS with a hosted content lake and Studio editing environment. The parts worth testing in your codebase are:
- Schema definitions — the TypeScript files in
schemaTypes/that define document types and field shapes - GROQ queries — the query strings used to fetch data from the Sanity Content Lake
- Content transformers — code that converts Sanity responses into domain models
- Portable text renderers — components or functions that render Sanity's block content format
Testing Schema Definitions
Sanity v3 schemas are plain JavaScript/TypeScript objects exported from schemaTypes/. You can test them directly with Jest:
// schemaTypes/article.ts
import { defineType, defineField } from 'sanity';
export const articleType = defineType({
name: 'article',
title: 'Article',
type: 'document',
fields: [
defineField({
name: 'title',
type: 'string',
validation: Rule => Rule.required().min(5).max(200),
}),
defineField({
name: 'slug',
type: 'slug',
options: { source: 'title' },
validation: Rule => Rule.required(),
}),
defineField({
name: 'body',
type: 'array',
of: [{ type: 'block' }],
}),
defineField({
name: 'publishedAt',
type: 'datetime',
}),
],
});Schema tests:
// __tests__/schema.test.ts
import { articleType } from '../schemaTypes/article';
describe('Article schema', () => {
it('has required fields defined', () => {
const fieldNames = articleType.fields.map(f => f.name);
expect(fieldNames).toContain('title');
expect(fieldNames).toContain('slug');
expect(fieldNames).toContain('body');
});
it('title field has string type', () => {
const titleField = articleType.fields.find(f => f.name === 'title');
expect(titleField?.type).toBe('string');
});
it('slug field sources from title', () => {
const slugField = articleType.fields.find(f => f.name === 'slug');
expect(slugField?.options?.source).toBe('title');
});
it('body field is an array of blocks', () => {
const bodyField = articleType.fields.find(f => f.name === 'body');
expect(bodyField?.type).toBe('array');
expect(bodyField?.of).toContainEqual(expect.objectContaining({ type: 'block' }));
});
});Testing GROQ Queries with groq-js
The groq-js library evaluates GROQ queries against in-memory data — no Sanity backend required:
npm install --save-dev groq-jsCreate fixtures representing Sanity documents:
// tests/fixtures/documents.js
export const fixtures = [
{
_id: 'article-1',
_type: 'article',
title: 'Introduction to Testing',
slug: { current: 'intro-to-testing' },
publishedAt: '2026-05-01T00:00:00.000Z',
body: [{ _type: 'block', style: 'normal', children: [{ text: 'Content here' }] }],
author: { _ref: 'author-1' },
},
{
_id: 'article-2',
_type: 'article',
title: 'Advanced Patterns',
slug: { current: 'advanced-patterns' },
publishedAt: '2026-05-10T00:00:00.000Z',
body: [],
_id_ref: 'author-1',
},
{
_id: 'author-1',
_type: 'author',
name: 'Jane Smith',
bio: 'Senior Engineer',
},
];// tests/groq.test.js
import { evaluate, parse } from 'groq-js';
import { fixtures } from './fixtures/documents.js';
async function runQuery(query, params = {}) {
const tree = parse(query, { params });
const result = await evaluate(tree, { dataset: fixtures, params });
return result.get();
}
describe('Article GROQ queries', () => {
it('fetches all published articles', async () => {
const query = `*[_type == "article" && defined(publishedAt)] | order(publishedAt desc)`;
const result = await runQuery(query);
expect(result).toHaveLength(2);
// Most recent first
expect(result[0].title).toBe('Advanced Patterns');
});
it('fetches article by slug', async () => {
const query = `*[_type == "article" && slug.current == $slug][0]`;
const result = await runQuery(query, { slug: 'intro-to-testing' });
expect(result).not.toBeNull();
expect(result.title).toBe('Introduction to Testing');
});
it('returns null for non-existent slug', async () => {
const query = `*[_type == "article" && slug.current == $slug][0]`;
const result = await runQuery(query, { slug: 'does-not-exist' });
expect(result).toBeNull();
});
it('projects only needed fields', async () => {
const query = `*[_type == "article"]{title, "slug": slug.current}`;
const result = await runQuery(query);
expect(result[0]).toHaveProperty('title');
expect(result[0]).toHaveProperty('slug');
expect(result[0]).not.toHaveProperty('body');
expect(result[0]).not.toHaveProperty('publishedAt');
});
it('joins author via reference', async () => {
const query = `*[_type == "article" && _id == "article-1"][0]{
title,
"author": author->{name, bio}
}`;
const result = await runQuery(query);
expect(result.author.name).toBe('Jane Smith');
expect(result.author.bio).toBe('Senior Engineer');
});
});Mocking the Sanity Client
For application code that calls client.fetch(), mock the client at the module level:
// lib/sanity.js
import { createClient } from '@sanity/client';
export const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET,
apiVersion: '2026-05-01',
useCdn: true,
});// __tests__/queries.test.js
import { jest } from '@jest/globals';
jest.mock('../lib/sanity.js', () => ({
client: { fetch: jest.fn() },
}));
import { client } from '../lib/sanity.js';
import { getArticleBySlug, getRecentArticles } from '../lib/queries.js';
describe('getArticleBySlug', () => {
it('calls fetch with correct GROQ query', async () => {
client.fetch.mockResolvedValue({
_id: 'article-1',
title: 'Test',
slug: { current: 'test' },
});
const result = await getArticleBySlug('test');
expect(client.fetch).toHaveBeenCalledWith(
expect.stringContaining('slug.current == $slug'),
{ slug: 'test' }
);
expect(result.title).toBe('Test');
});
it('returns null when fetch returns null', async () => {
client.fetch.mockResolvedValue(null);
const result = await getArticleBySlug('missing');
expect(result).toBeNull();
});
it('propagates fetch errors', async () => {
client.fetch.mockRejectedValue(new Error('Network error'));
await expect(getArticleBySlug('any')).rejects.toThrow('Network error');
});
});Testing Portable Text Renderers
Sanity stores body content as Portable Text (an AST format). Test that your renderer produces correct HTML:
// lib/portable-text.js
import { toHTML } from '@portabletext/to-html';
const portableTextComponents = {
types: {
image: ({ value }) =>
`<figure><img src="${value.url}" alt="${value.alt ?? ''}" /></figure>`,
codeBlock: ({ value }) =>
`<pre data-language="${value.language}"><code>${escapeHtml(value.code)}</code></pre>`,
},
marks: {
link: ({ children, value }) =>
`<a href="${value.href}" ${value.blank ? 'target="_blank" rel="noopener"' : ''}>${children}</a>`,
highlight: ({ children }) =>
`<mark>${children}</mark>`,
},
};
export function renderPortableText(blocks) {
return toHTML(blocks, { components: portableTextComponents });
}// __tests__/portable-text.test.js
import { renderPortableText } from '../lib/portable-text.js';
const paragraphBlock = {
_type: 'block',
style: 'normal',
_key: 'key1',
children: [{ _type: 'span', text: 'Hello world', marks: [] }],
};
const linkBlock = {
_type: 'block',
style: 'normal',
_key: 'key2',
children: [
{
_type: 'span',
text: 'Click here',
marks: ['link1'],
},
],
markDefs: [
{ _key: 'link1', _type: 'link', href: 'https://example.com', blank: true },
],
};
const codeBlock = {
_type: 'codeBlock',
_key: 'key3',
code: 'console.log("test")',
language: 'javascript',
};
describe('renderPortableText', () => {
it('renders paragraph', () => {
const html = renderPortableText([paragraphBlock]);
expect(html).toContain('<p>Hello world</p>');
});
it('renders link with target="_blank"', () => {
const html = renderPortableText([linkBlock]);
expect(html).toContain('href="https://example.com"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener"');
expect(html).toContain('Click here');
});
it('renders code block with language attribute', () => {
const html = renderPortableText([codeBlock]);
expect(html).toContain('data-language="javascript"');
expect(html).toContain('console.log');
});
it('handles empty blocks array', () => {
const html = renderPortableText([]);
expect(html).toBe('');
});
});Integration Tests with the Sanity API
For integration tests that validate your GROQ queries work against real data, use a dedicated test dataset:
// tests/integration/sanity.test.js
import { createClient } from '@sanity/client';
const SKIP = !process.env.SANITY_TEST_TOKEN;
describe.skipIf(SKIP)('Sanity Content Lake integration', () => {
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: 'test', // separate test dataset
apiVersion: '2026-05-01',
token: process.env.SANITY_TEST_TOKEN,
useCdn: false,
});
it('fetches articles from test dataset', async () => {
const articles = await client.fetch(`*[_type == "article"] | order(publishedAt desc)[0..4]`);
expect(Array.isArray(articles)).toBe(true);
articles.forEach(article => {
expect(article).toHaveProperty('title');
expect(article).toHaveProperty('slug');
});
});
it('article slug field has current property', async () => {
const article = await client.fetch(`*[_type == "article"][0]{ slug }`);
if (article) {
expect(article.slug).toHaveProperty('current');
expect(typeof article.slug.current).toBe('string');
}
});
});Testing Content Webhooks
Sanity can trigger webhooks on content changes. Test webhook handler functions:
// api/sanity-webhook.js
export async function handleArticlePublished(body) {
const { _id, _type, title } = body;
if (_type !== 'article') {
return { skipped: true };
}
// Trigger CDN purge, search index update, etc.
await invalidateCacheForDocument(_id);
await reindexDocument({ id: _id, title, type: _type });
return { processed: true };
}// __tests__/webhook.test.js
import { jest } from '@jest/globals';
jest.mock('../lib/cache.js', () => ({ invalidateCacheForDocument: jest.fn() }));
jest.mock('../lib/search.js', () => ({ reindexDocument: jest.fn() }));
import { invalidateCacheForDocument } from '../lib/cache.js';
import { reindexDocument } from '../lib/search.js';
import { handleArticlePublished } from '../api/sanity-webhook.js';
describe('handleArticlePublished', () => {
it('processes article documents', async () => {
const result = await handleArticlePublished({
_id: 'article-1',
_type: 'article',
title: 'New Post',
});
expect(result).toEqual({ processed: true });
expect(invalidateCacheForDocument).toHaveBeenCalledWith('article-1');
expect(reindexDocument).toHaveBeenCalledWith({
id: 'article-1',
title: 'New Post',
type: 'article',
});
});
it('skips non-article documents', async () => {
const result = await handleArticlePublished({
_id: 'author-1',
_type: 'author',
name: 'Jane',
});
expect(result).toEqual({ skipped: true });
expect(invalidateCacheForDocument).not.toHaveBeenCalled();
});
});CI Configuration
name: Sanity Tests
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npx tsc --noEmit # Type-check schemas
- run: npm test
integration:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run test:integration
env:
SANITY_PROJECT_ID: ${{ secrets.SANITY_PROJECT_ID }}
SANITY_TEST_TOKEN: ${{ secrets.SANITY_TEST_TOKEN }}Summary
The Sanity testing stack:
| Layer | Tool | Speed | Coverage |
|---|---|---|---|
| Schema definitions | TypeScript + Jest | Fast | Field presence, types, config |
| GROQ queries | groq-js + fixtures | Fast | Query correctness, filtering, joins |
| Client calls | Jest mocks | Fast | Application integration |
| Portable text | @portabletext/to-html tests | Fast | Renderer output |
| Live API | Sanity test dataset | Slow | Schema/data alignment |
For rendering tests that verify the full user experience — that Sanity content appears correctly across pages, layouts, and breakpoints — HelpMeTest adds browser-level coverage on top of your unit test suite.