Testing Sanity Studio: Schemas, GROQ Queries, and Content Pipelines

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:

  1. Schema definitions — the TypeScript files in schemaTypes/ that define document types and field shapes
  2. GROQ queries — the query strings used to fetch data from the Sanity Content Lake
  3. Content transformers — code that converts Sanity responses into domain models
  4. 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-js

Create 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.

Read more

Start now free