How to Unit Test MCP Apps, ChatGPT Apps, and Claude Connectors (June 2026)

Unit testing MCP App resource components and tool handlers with Vitest.
MCP Apps, ChatGPT Apps, and Claude Connectors now share more of the same shape than they did earlier in 2026. A tool returns content, structuredContent, and optional _meta. A resource renders in a sandboxed iframe. The app can receive host context, tool state, display mode, theme, locale, and app state through the MCP Apps bridge.
That makes unit tests useful, but only in the right places. Unit tests should test your code in isolation: resource branches, tool handler logic, data formatting, validation, state reducers, and small custom hooks. They should not pretend to prove the host runtime, the MCP transport, or the iframe.
TL;DR
Use Vitest and happy-dom for fast unit tests in sunpeak framework projects. Mock sunpeak hooks with vi.mock("sunpeak"), render resource components with Testing Library, call tool handlers directly, and assert on structuredContent, content, _meta, loading, error, cancelled, and success states.
For an existing MCP server that is not built with the sunpeak framework, start with npx sunpeak test init --server http://localhost:8000/mcp and write protocol plus inspector tests first. Unit tests are still useful for your server code, but sunpeak’s standalone testing path works over the MCP protocol rather than importing your app internals.
What Changed Since April 2026
The portable path for interactive AI-host UI is now the official MCP Apps extension. The extension describes interactive HTML interfaces rendered inside MCP hosts, with bidirectional communication between the host and the app iframe. The MCP extension support matrix also makes host support more explicit, which means test suites should separate shared protocol behavior from host-specific behavior.
OpenAI’s Apps SDK reference still documents ChatGPT-specific app fields, but the durable contract is the same one MCP Apps use: tools, resources, structured tool results, metadata, and bridge actions. Claude Connectors follow the same MCP server pattern. If you write tests around the protocol-shaped data first, your app has a better chance of surviving host changes.
For sunpeak, the testing story has also grown. A new sunpeak project includes unit tests, inspector E2E tests, simulation files, visual regression support, live host test scaffolding, and eval scaffolding. The sunpeak testing docs also support existing MCP servers in any stack through npx sunpeak test init --server URL.
Where Unit Tests Fit
Unit tests answer narrow questions:
- Does this resource render the right thing for a known
useToolData()state? - Does this reducer update state correctly?
- Does this formatter handle empty, long, null, and unexpected values?
- Does this tool handler return the shape the resource expects?
- Does this schema helper reject bad input before the handler calls an API?
They do not answer these questions:
- Can the host read your MCP resource?
- Does the tool expose
_meta.ui.resourceUricorrectly? - Does the iframe render correctly in ChatGPT, Claude, or another host?
- Does picture-in-picture behave like fullscreen?
- Does the app still work when the host sends a bridge event later than expected?
Use integration tests or protocol tests with the mcp fixture for server contracts. Use E2E tests with the inspector fixture for rendered app behavior. Use visual regression tests when layout matters. Use live tests for the few real-host checks local replicas cannot prove.
Run Unit Tests in a sunpeak Project
In a sunpeak framework project, unit tests are preconfigured with Vitest and happy-dom:
pnpm test:unit
Run the default local suite with:
pnpm test
Unit tests usually live in tests/unit/, but any file matched by your Vitest config can work. A practical layout looks like this:
tests/
unit/
pr-list.test.tsx
search-repos.test.ts
format-date.test.ts
e2e/
pr-list.spec.ts
simulations/
pr-list-success.json
pr-list-empty.json
pr-list-error.json
Keep the boundary obvious. If a test imports a React resource or a pure function, it is a unit test. If it calls the MCP server, it is a protocol or integration test. If it renders the app in a browser frame, it is an E2E test.
Unit Test Resource Components
Resource components read host and tool state through hooks such as useToolData, useAppState, useDisplayMode, and useHostInfo. In a unit test, mock those hooks and render the component.
Here is a resource component that displays pull requests:
// src/resources/pr-list/pr-list.tsx
import { SafeArea, useToolData } from 'sunpeak';
interface PullRequest {
id: number;
title: string;
author: string;
status: 'open' | 'merged' | 'closed';
}
interface PrListOutput {
repo: string;
pullRequests: PullRequest[];
}
export function PrListResource() {
const { output, isError, isLoading, isCancelled } = useToolData<unknown, PrListOutput>();
if (isLoading) {
return (
<SafeArea>
<p>Loading pull requests...</p>
</SafeArea>
);
}
if (isError) {
return (
<SafeArea>
<p>Failed to load pull requests.</p>
</SafeArea>
);
}
if (isCancelled) {
return (
<SafeArea>
<p>Request stopped.</p>
</SafeArea>
);
}
if (!output) return null;
return (
<SafeArea>
<h2>{output.repo}</h2>
{output.pullRequests.length === 0 ? (
<p>No pull requests found.</p>
) : (
<ul>
{output.pullRequests.map((pr) => (
<li key={pr.id}>
<span>{pr.status}</span> {pr.title} by {pr.author}
</li>
))}
</ul>
)}
</SafeArea>
);
}
And here is the unit test:
// tests/unit/pr-list.test.tsx
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PrListResource } from '../../src/resources/pr-list/pr-list';
let mockToolData: Record<string, unknown>;
vi.mock('sunpeak', () => ({
useToolData: () => mockToolData,
useAppState: () => [{}, vi.fn()],
useDisplayMode: () => 'inline',
useHostInfo: () => ({ hostVersion: undefined, hostCapabilities: { serverTools: true } }),
SafeArea: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe('PrListResource', () => {
beforeEach(() => {
mockToolData = {
output: null,
input: null,
inputPartial: null,
isError: false,
isLoading: false,
isCancelled: false,
cancelReason: null,
};
});
it('renders pull requests', () => {
mockToolData = {
...mockToolData,
output: {
repo: 'acme/widgets',
pullRequests: [
{ id: 1, title: 'Add search', author: 'alice', status: 'open' },
{ id: 2, title: 'Fix pagination', author: 'bob', status: 'merged' },
],
},
};
render(<PrListResource />);
expect(screen.getByRole('heading', { name: 'acme/widgets' })).toBeInTheDocument();
expect(screen.getByText(/Add search/)).toBeInTheDocument();
expect(screen.getByText(/Fix pagination/)).toBeInTheDocument();
});
it('renders loading, error, cancelled, and empty states', () => {
mockToolData = { ...mockToolData, isLoading: true };
const { rerender } = render(<PrListResource />);
expect(screen.getByText('Loading pull requests...')).toBeInTheDocument();
mockToolData = { ...mockToolData, isLoading: false, isError: true };
rerender(<PrListResource />);
expect(screen.getByText('Failed to load pull requests.')).toBeInTheDocument();
mockToolData = { ...mockToolData, isError: false, isCancelled: true };
rerender(<PrListResource />);
expect(screen.getByText('Request stopped.')).toBeInTheDocument();
mockToolData = {
...mockToolData,
isCancelled: false,
output: { repo: 'acme/widgets', pullRequests: [] },
};
rerender(<PrListResource />);
expect(screen.getByText('No pull requests found.')).toBeInTheDocument();
});
});
Use semantic queries when possible. getByRole, getByLabelText, and getByText make tests read like user behavior. They also catch accessibility mistakes earlier than selectors like .status-open.
Mock the Hooks You Actually Use
Mock only the sunpeak exports your component imports. These are the common ones:
useToolData: () => ({
output: null,
input: null,
inputPartial: null,
isError: false,
isLoading: false,
isCancelled: false,
cancelReason: null,
});
output is the tool result data your resource usually renders. input is the final tool input. inputPartial is useful when the host streams partial arguments before the final tool call. isLoading, isError, and isCancelled let you test lifecycle branches without waiting for a model or host.
For interactive resources:
const mockSetState = vi.fn();
let mockAppState = { activeTab: 'overview' };
vi.mock('sunpeak', () => ({
useAppState: () => [mockAppState, mockSetState],
}));
For display mode tests:
let mockDisplayMode: 'inline' | 'pip' | 'fullscreen' = 'inline';
vi.mock('sunpeak', () => ({
useDisplayMode: () => mockDisplayMode,
}));
For host capability branches:
let mockHostInfo = {
hostVersion: undefined,
hostCapabilities: { serverTools: true },
};
vi.mock('sunpeak', () => ({
useHostInfo: () => mockHostInfo,
}));
Keep these mocks small. If your mock starts recreating a host runtime, move the test to the inspector fixture.
Test Display Mode Branches Carefully
Display mode logic is a good unit-test target when it changes your component tree. For example, inline mode might hide a sidebar while fullscreen shows it.
it('uses compact layout in inline mode', () => {
mockDisplayMode = 'inline';
render(<DashboardResource />);
expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
});
it('uses full layout in fullscreen mode', () => {
mockDisplayMode = 'fullscreen';
render(<DashboardResource />);
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
});
Do not stop there for layout-heavy resources. A unit test can prove that the branch changed. It cannot prove the app fits inside the host chrome, respects safe areas, or looks right in a real iframe. Add an inspector E2E or visual regression test for the same display modes before release.
Unit Test useAppState Interactions
useAppState is where many interactive MCP Apps get local state wrong. Test both directions: the component renders the state it receives, and user actions call the setter with the next state.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const mockSetState = vi.fn();
let mockAppState: Record<string, unknown> = {};
vi.mock('sunpeak', () => ({
useToolData: () => mockToolData,
useAppState: () => [mockAppState, mockSetState],
useDisplayMode: () => 'inline',
SafeArea: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
it('updates active tab when the user clicks Details', async () => {
mockAppState = { activeTab: 'overview' };
render(<DashboardResource />);
await userEvent.click(screen.getByRole('tab', { name: 'Details' }));
expect(mockSetState).toHaveBeenCalledWith({ activeTab: 'details' });
});
it('renders the active tab from app state', () => {
mockAppState = { activeTab: 'details' };
render(<DashboardResource />);
expect(screen.getByText('Detail view content')).toBeInTheDocument();
});
Prefer userEvent over fireEvent for real user interactions. It catches more timing and focus behavior, which matters for apps that run inside keyboard-heavy chat hosts.
Unit Test Tool Handlers
Tool handlers are plain server-side functions. They validate input, call your database or API, and return MCP tool results. Unit tests should mock the API module, not the handler.
// src/tools/search-repos/handler.ts
import { searchGitHub } from '../../lib/github';
interface SearchInput {
query: string;
language?: string;
}
export async function handler(input: SearchInput) {
const repos = await searchGitHub(input.query, input.language);
return {
content: [{ type: 'text' as const, text: `Found ${repos.length} repositories.` }],
structuredContent: {
query: input.query,
results: repos.map((repo) => ({
name: repo.full_name,
stars: repo.stargazers_count,
description: repo.description,
})),
},
_meta: {
source: 'github',
},
};
}
Test the returned shape directly:
// tests/unit/search-repos.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { handler } from '../../src/tools/search-repos/handler';
import { searchGitHub } from '../../src/lib/github';
vi.mock('../../src/lib/github', () => ({
searchGitHub: vi.fn(),
}));
const mockSearchGitHub = vi.mocked(searchGitHub);
describe('search-repos handler', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns formatted structuredContent', async () => {
mockSearchGitHub.mockResolvedValue([
{
full_name: 'facebook/react',
stargazers_count: 220000,
description: 'A JavaScript library for building UIs',
},
]);
const result = await handler({ query: 'react' });
expect(result.content[0].text).toBe('Found 1 repositories.');
expect(result.structuredContent).toEqual({
query: 'react',
results: [
{
name: 'facebook/react',
stars: 220000,
description: 'A JavaScript library for building UIs',
},
],
});
expect(result._meta.source).toBe('github');
});
it('passes filters to the API client', async () => {
mockSearchGitHub.mockResolvedValue([]);
await handler({ query: 'orm', language: 'rust' });
expect(mockSearchGitHub).toHaveBeenCalledWith('orm', 'rust');
});
it('handles empty results', async () => {
mockSearchGitHub.mockResolvedValue([]);
const result = await handler({ query: 'no-results' });
expect(result.structuredContent.results).toHaveLength(0);
expect(result.content[0].text).toBe('Found 0 repositories.');
});
});
This test catches field renames before the resource breaks. If you rename stars to starCount in the handler, the unit test should fail near the code that changed. Add an integration test too, because the resource reads the handler output through the MCP server, not by importing the handler directly.
Test content, structuredContent, and _meta Separately
MCP tool results have three different audiences:
contentis text or media the model and transcript can use.structuredContentis typed data the model can reason over and the app can render._metais app-only or host-only metadata that should not become model context.
Unit tests should assert each one when the split matters. For example, a billing dashboard might expose summary totals in structuredContent, keep raw rows in _meta, and return a short text summary in content.
expect(result.content).toEqual([
{ type: 'text', text: 'Loaded 12 invoices totaling $42,100.' },
]);
expect(result.structuredContent).toMatchObject({
invoiceCount: 12,
totalDue: 42100,
});
expect(result._meta).toMatchObject({
traceId: expect.any(String),
});
If a field should be hidden from the model, test that it is not in content or structuredContent. If the model needs to explain a value later, test that the value appears in content or structuredContent, not only _meta.
Use Schema Tests for Tool Boundaries
Most production bugs happen where one layer assumes a data shape another layer does not return. Keep schema tests close to tool handlers and resource fixtures.
import { describe, expect, it } from 'vitest';
import { searchOutputSchema } from '../../src/tools/search-repos/schema';
it('accepts the handler output shape', () => {
const output = {
query: 'react',
results: [{ name: 'facebook/react', stars: 220000, description: 'A UI library' }],
};
expect(() => searchOutputSchema.parse(output)).not.toThrow();
});
it('rejects missing result names', () => {
const output = {
query: 'react',
results: [{ stars: 220000, description: 'A UI library' }],
};
expect(() => searchOutputSchema.parse(output)).toThrow();
});
Schema tests are cheap, and they make fixture drift easier to spot. Reuse the same builders for handler tests, resource unit tests, simulation files, and E2E tests when the shape is complex.
What to Skip in Unit Tests
Skip unit tests that mostly test your mocks:
- MCP JSON-RPC transport
postMessagebridge wiring- iframe sandbox behavior
- host CSS variables
- framework-provided
SafeAreabehavior - simple components that only pass
useToolData().outputto child components - browser layout and screenshots
Move those checks to the right layer. Use the mcp fixture for listTools, callTool, listResources, and readResource. Use the inspector fixture for host rendering. Use visual regression tests for layout. Use live tests when you need to prove real account setup, real host behavior, or submission readiness.
A Small Test Plan That Works
For a resource plus one tool handler, start with this:
- Unit test the handler’s success, empty, validation, and error branches.
- Unit test the resource’s loading, error, cancelled, empty, and success states.
- Add a protocol test that calls the tool through the MCP server and asserts
structuredContent. - Add an inspector E2E test that renders the tool in ChatGPT and Claude host modes.
- Add one visual test if the resource has real layout risk.
This gives you fast feedback without pretending unit tests can cover the whole app runtime.
Get Started
For a new sunpeak MCP App:
npx sunpeak new sunpeak-app
cd sunpeak-app
pnpm test:unit
For an existing MCP server:
npx sunpeak test init --server http://localhost:8000/mcp
npx sunpeak test
The first path is for a full sunpeak framework project with unit tests built in. The second path adds protocol, inspector, visual, live, and eval test scaffolding around an existing server without forcing a rewrite.
If you are deciding what to add next, make the cheapest useful test pass first. Unit test code with real branches. Use protocol tests for MCP contracts. Use inspector tests for host rendering. That split keeps the suite fast, and it keeps each failure close to the layer that broke.
Get Started
npx sunpeak newFurther Reading
- sunpeak unit testing documentation
- sunpeak MCP testing framework overview
- Complete guide to testing ChatGPT Apps and MCP Apps
- MCP App testing strategy - which tests to write first
- Mocking and stubbing in MCP App tests
- Integration testing MCP Apps
- E2E testing MCP Apps
- Visual regression testing MCP Apps
- MCP App tool results - content, structuredContent, and _meta
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- Testing framework
- Official MCP Apps specification
- MCP extension support matrix
- OpenAI Apps SDK reference
- Vitest guide
- Testing Library React docs
Frequently Asked Questions
How do I unit test an MCP App resource component?
Mock the sunpeak hooks your component reads, render the component with Testing Library, and assert against user-visible output. For example, mock useToolData for tool input, structuredContent, loading, error, and cancelled states, then use screen queries to verify the resource renders the right UI for each state.
What is the difference between unit tests and E2E tests for MCP Apps?
Unit tests import one component, hook helper, tool handler, or utility function directly and run in Vitest with happy-dom. E2E tests use Playwright fixtures from sunpeak/test to call MCP tools, render resources in simulated ChatGPT and Claude runtimes, and check behavior inside a real browser frame. Unit tests are faster, while E2E tests catch iframe, host, theme, display mode, and protocol issues that unit tests cannot prove.
Should I unit test the MCP protocol itself?
No. Do not unit test JSON-RPC transport, iframe sandboxing, host bridge wiring, or MCP serialization. Use protocol-level tests with the mcp fixture from sunpeak/test for listTools, callTool, listResources, and readResource checks. Use inspector E2E tests for rendered UI behavior.
How do I mock useToolData in MCP App unit tests?
Use vi.mock("sunpeak") at the top of the test file and return a mutable mockToolData object from useToolData. Reset that object before each test, then set output, input, inputPartial, isLoading, isError, isCancelled, and cancelReason for each case.
How do I unit test MCP App tool handlers?
Export the handler logic as a plain function, mock API or database modules with vi.mock(), call the handler with typed input, and assert on content, structuredContent, _meta, and isError. If the handler declares an output schema, add tests that prove the returned structuredContent matches it.
Do I need a ChatGPT or Claude account to unit test MCP Apps?
No. Unit tests run locally with Vitest and happy-dom. They do not connect to ChatGPT, Claude, a browser, or a hosted MCP server. For host-like browser tests without paid accounts, use sunpeak inspector E2E tests, which run against local ChatGPT and Claude runtime replicas.
What should I unit test in an MCP App?
Unit test resource branches, state reducers, formatter functions, tool handler branches, schema helpers, and small custom hooks. Skip simple pass-through components that only display useToolData output without logic, and cover those with integration or E2E tests instead.
How do I run only unit tests for a sunpeak MCP App?
In a sunpeak framework project, run pnpm test:unit. Run pnpm test for the default test suite. If you are using sunpeak only as a standalone testing layer for an existing MCP server, unit tests are not the main path, use npx sunpeak test for protocol and inspector tests against the server.