E2E Testing MCP Apps, ChatGPT Apps, and Claude Connectors (October 2026)

E2E testing MCP Apps across ChatGPT and Claude hosts with the inspector fixture.
Unit tests tell you whether your React component behaves with mocked hooks. Integration tests tell you whether your MCP server returns the right tool result. Neither tells you whether the app works inside the host iframe that users actually see.
End-to-end tests fill that gap. They render your MCP App through a host runtime, with the same tool result shape, iframe boundary, display mode, theme, safe area, and bridge behavior that ChatGPT Apps and Claude Connectors depend on.
TL;DR
Use the inspector fixture from sunpeak/test and assert against the iframe through result.app(). Run fixture-backed tests for known UI states and input-backed tests for real local server calls. Check result.source so a test cannot silently exercise the wrong path. Add live host checks for the few behaviors a local replica cannot prove.
Which Parts of the App Does an E2E Test Cover?
The MCP Apps overview defines the render path: a tool descriptor links to a ui:// resource through _meta.ui.resourceUri; a host fetches the HTML resource, isolates it, and exchanges JSON-RPC messages with the view. The ChatGPT UI guide recommends those shared MCP Apps fields and bridge methods for new ChatGPT UI. Claude’s custom connector guide covers the server connection side.
That means an E2E test for an MCP App should not stop at “did the component mount?” It should prove that the server result, resource metadata, host iframe, bridge state, and rendered UI agree with each other.
sunpeak’s E2E reference has two useful render paths in one fixture. Without tool input, inspector.renderTool() uses a simulation when one exists. With input, it calls the real local MCP server. Both render through a local host replica, so you can check the iframe and interactions without a paid host account. Existing MCP servers can add the test stack with npx sunpeak test init --server URL, including servers written in Python, Go, or Rust.
What E2E tests catch
MCP Apps have a longer render path than normal web apps:
- The model or test harness calls an MCP tool.
- The server returns
content,structuredContent, and sometimes_meta. - The tool descriptor links the host to a resource through
_meta.ui.resourceUri. - The host loads that resource into a sandboxed iframe.
- The app reads tool data, host context, display mode, theme, safe-area values, and bridge state.
- The user clicks, types, filters, expands, closes, or triggers more tool calls.
Every step can break in a way unit tests will miss. Good E2E tests catch:
- Tool result and UI mismatches, such as
structuredContentchanging shape while the component still expects the old field. - Resource metadata mistakes, including missing
ui://resource links, wrong MIME type handling, CSP blocks, or a resource that works in one host and fails in another. - Iframe-only bugs, including focus traps, blocked network calls, broken CSS isolation, and code that assumes direct access to the parent window.
- Display mode bugs, such as content that fits inline but overflows in picture-in-picture, or fullscreen layouts that hide the primary action below host chrome.
- Theme and safe-area bugs, especially hardcoded text colors, fixed heights, and bottom controls clipped by the host.
- Bridge and interaction bugs, including
useAppState,callServerTool,sendMessage, and host context updates.
Interactive MCP Apps need tests that click through their flows: dashboards, file browsers, editors, approval steps, forms, and data tools can all render a good first frame and still fail on the next action.
The inspector fixture
The inspector fixture from sunpeak/test renders a tool result inside a local host replica. It starts the dev server when needed, opens the inspector, selects the configured host runtime, calls the tool or loads a simulation, and returns handles you can use in Playwright assertions.
import { test, expect } from 'sunpeak/test';
test('dashboard renders revenue chart', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', {
quarter: 'Q2',
year: 2026,
});
const app = result.app();
await expect(app.getByRole('heading', { name: 'Q2 2026' })).toBeVisible();
await expect(app.getByTestId('revenue-chart')).toBeVisible();
});
inspector.renderTool() takes:
- The tool name, matching a tool exposed by your MCP server.
- Optional tool input. Passing input calls the real local server; omitting it uses a simulation when available.
- Optional render settings such as
displayMode,theme,prodResources, and timeout values.
The result gives you:
result.app(), a Playwright frame locator scoped to your resource iframe.result.structuredContent, the structured data the app received.result.isError, which is useful for server error cases.result.source, either'fixture'or'server'.result.screenshot(), which feeds visual regression tests.
Use result.app() for UI assertions. Use result.structuredContent sparingly, usually to make sure a failure is in the renderer and not the server result. If all your E2E test does is inspect JSON, it belongs in the integration-test layer.
Prove Which Data Path Ran
A fixture-backed browser test can pass while the real server returns a different field name. A real-server test can also stop being deterministic if a developer expected a simulation. Keep one of each for an important resource and assert result.source:
import { test, expect } from 'sunpeak/test';
test('dashboard renders a known simulation', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard');
expect(result.source).toBe('fixture');
await expect(result.app().getByRole('heading', { name: 'Q2 2026' })).toBeVisible();
});
test('dashboard renders a real local tool call', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', {
quarter: 'Q2',
year: 2026,
});
expect(result.source).toBe('server');
expect(result.isError).toBe(false);
await expect(result.app().getByRole('heading', { name: 'Q2 2026' })).toBeVisible();
});
The second test needs a local server with stable test data. It checks more of the server-to-view path, while the simulation remains useful for empty, error, and large data states. Add a protocol check for the tool’s _meta.ui.resourceUri and resources/read when a missing iframe could come from resource registration; the UI resource guide shows that contract. A local host replica still cannot prove that a deployed server connects to a real host, which is why a small live test remains useful.
Start with a small host matrix
The easiest way to make MCP App E2E tests noisy is to multiply every state by every host, display mode, theme, viewport, and data case. You want coverage, but you also want a suite developers will run before pushing.
Start with this matrix:
| Test target | Minimum coverage |
|---|---|
| Hosts | ChatGPT and Claude runtime replicas |
| Themes | Light and dark for the main resource |
| Display modes | Inline and fullscreen, plus picture-in-picture when the host supports it |
| Viewports | One desktop size and one narrow mobile-ish size |
| Data states | Happy path, empty state, error state, high-volume state |
| Interactions | One complete flow per resource |
Do not apply the full matrix to every edge case. Pick one or two canonical states for broad matrix coverage, then test narrow logic in unit tests.
With sunpeak, host coverage comes from defineConfig():
// playwright.config.ts
import { defineConfig } from 'sunpeak/test/config';
export default defineConfig();
That config creates separate Playwright projects for the configured host runtimes. Your test code stays host-neutral:
import { test, expect } from 'sunpeak/test';
test('primary action is available', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard');
const app = result.app();
await expect(app.getByRole('button', { name: 'Export' })).toBeVisible();
});
When the test fails on one host and passes on another, the report tells you which runtime failed. That is much more useful than a manual note saying “check Claude later.”
Test display modes by behavior
ChatGPT Apps and MCP Apps can render in different display modes depending on host support and user context. Treat display modes as layout contracts. The test should check what changes for the user, not only that the root element exists.
import { test, expect } from 'sunpeak/test';
test('inline mode shows the compact summary', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', undefined, {
displayMode: 'inline',
});
const app = result.app();
await expect(app.getByTestId('summary-strip')).toBeVisible();
await expect(app.getByRole('button', { name: 'Open details' })).toBeVisible();
await expect(app.getByTestId('full-data-table')).not.toBeVisible();
});
test('fullscreen mode exposes analysis controls', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', undefined, {
displayMode: 'fullscreen',
});
const app = result.app();
await expect(app.getByTestId('full-data-table')).toBeVisible();
await expect(app.getByRole('button', { name: 'Export' })).toBeVisible();
});
For picture-in-picture, write the test around the actual compact behavior. A good PiP test checks that the user can still read the main value, close or expand the surface, and avoid hidden controls. Do not assume the same host supports the same modes forever. Feature-detect where your app logic depends on it, and test the fallback.
Test themes, safe areas, and host context together
Theme bugs are rarely just “dark mode is wrong.” They often combine with host context: a dark host, a narrow viewport, a bottom safe area, and a sticky footer. E2E tests can cover that combination directly.
const themes = ['light', 'dark'] as const;
for (const theme of themes) {
test(`dashboard works in ${theme} theme`, async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', undefined, {
theme,
displayMode: 'fullscreen',
});
const app = result.app();
await expect(app.getByTestId('dashboard-root')).toBeVisible();
await expect(app.getByRole('button', { name: 'Export' })).toBeVisible();
});
}
Prefer user-facing assertions over brittle CSS checks. Checking background-color: rgb(32, 33, 35) is useful only when that exact token is the contract. Most of the time, assert that text is visible, controls are reachable, content does not overflow, and keyboard focus can reach the footer action.
When you do need CSS assertions, connect them to a known contract, such as your own component token or a documented host variable. See the host context and safe area guide for the context values worth testing.
Use simulation files for deterministic states
Simulation files are the difference between a stable E2E test and a slow manual reproduction. They describe the tool call and result that the host should render:
{
"tool": "show-dashboard",
"userMessage": "Show the Q2 revenue dashboard",
"toolInput": {
"quarter": "Q2",
"year": 2026
},
"toolResult": {
"content": [{ "type": "text", "text": "Dashboard loaded." }],
"structuredContent": {
"quarter": "Q2 2026",
"revenue": 142500,
"orders": 1203,
"topProducts": [
{ "name": "Wireless Headphones", "unitsSold": 412, "revenue": 32960 },
{ "name": "USB-C Hub", "unitsSold": 287, "revenue": 14350 }
]
}
}
}
Then your test can render the state without calling a production API:
test('dashboard renders the simulated result', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard');
expect(result.source).toBe('fixture');
const app = result.app();
await expect(app.getByText('Q2 2026')).toBeVisible();
await expect(app.getByTestId('product-row')).toHaveCount(2);
});
Create simulations for:
- Normal data, with a representative result.
- Empty data, with no records but valid structure.
- Error data, with
isErroror your app’s error payload. - Permission-limited data, where the user can see some content but not all actions.
- High-volume data, where scroll, virtualization, and truncation matter.
Keep simulations close to real tool outputs. If your production server returns structuredContent.orders.items, do not test a simplified fixture named orders. The point is to catch contract drift before users do.
If the view calls a second server tool after the first render, put a serverTools response in the simulation. The simulation reference supports a fixed result or conditional results matched against the call arguments. Click the control in Playwright and assert the response appears in the iframe. Then keep a separate real-server test for the action path you need to verify over MCP. Otherwise a fixture can make the first frame look right while every button that calls the server still fails.
Test interactions through the iframe
Interactive MCP Apps need browser tests that click through real flows. Use Playwright locators on result.app(), because that keeps assertions scoped to the resource iframe.
test('filters products and opens detail panel', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', undefined, {
displayMode: 'fullscreen',
});
const app = result.app();
await app.getByRole('textbox', { name: 'Search products' }).fill('headphones');
await expect(app.getByText('Wireless Headphones')).toBeVisible();
await expect(app.getByText('USB-C Hub')).not.toBeVisible();
await app.getByRole('button', { name: 'Wireless Headphones' }).click();
await expect(app.getByRole('dialog', { name: 'Product details' })).toBeVisible();
});
A good interaction test proves a user can complete a task:
- Change tabs, filters, sort order, or selected records.
- Submit a form and see the success or validation state.
- Trigger an app-only server tool and render the response.
- Expand or close the app when the host supports display mode transitions.
- Recover after an error without reloading the whole host.
Do not test implementation details like a state hook being called with a particular object unless that object is the contract. Test the behavior the user sees.
Add accessibility checks where the host boundary matters
Accessibility testing belongs in E2E when the host iframe changes the outcome. Keyboard focus, modal boundaries, color contrast, scroll regions, and screen reader labels are all easy to break in an embedded app.
Start with Playwright assertions:
test('keyboard users can reach the primary action', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', undefined, {
displayMode: 'fullscreen',
});
const app = result.app();
await app.getByRole('button', { name: 'Export' }).focus();
await expect(app.getByRole('button', { name: 'Export' })).toBeFocused();
});
Then add an automated accessibility scan for one or two representative states. Run it against both light and dark themes. The accessibility testing guide covers deeper checks, but the practical rule is simple: do not let a host iframe become an excuse for inaccessible UI.
Add visual regression for host drift
Visual regression tests are E2E tests with screenshot baselines. They are especially useful for MCP Apps because small host changes can move your iframe, change available height, or alter the safe area. A locator test may still pass while the UI looks broken.
test('dashboard fullscreen screenshot is stable', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', undefined, {
displayMode: 'fullscreen',
theme: 'dark',
});
await result.screenshot('dashboard-fullscreen-dark');
});
Use screenshots for stable states:
- Main resource in fullscreen.
- Inline compact view.
- Dark theme.
- Empty state.
- Error state.
Avoid screenshotting data that changes every run. Freeze dates, random IDs, animation states, timestamps, and remote images. If a screenshot changes every time, it will train the team to ignore failures.
Test production resources before release
Dev resources can hide build-time problems. A component may render with HMR but fail after bundling because an import path changes, CSS loads in a different order, or a package relies on dev-only behavior.
Add at least one production-resource smoke test:
test('production resource bundle renders', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', undefined, {
displayMode: 'fullscreen',
prodResources: true,
});
await expect(result.app().getByTestId('dashboard-root')).toBeVisible();
});
Run this in CI after the build step. It is cheap insurance against shipping a resource that only works on localhost.
Keep live tests separate from local E2E tests
Local E2E tests and live host tests answer different questions.
Local E2E tests ask:
- Does the app render correctly for known tool states?
- Does it behave across simulated hosts, themes, display modes, and viewports?
- Does it work without paid host accounts or API credits?
- Can CI run it on every pull request?
Live tests ask:
- Does the deployed server connect to the real host?
- Does auth work with the host’s current flow?
- Does the real model select the expected tool for a normal prompt?
- Does the real host load the iframe and pass data as expected?
Keep local E2E broad enough to cover each important resource and interaction. Keep live tests to the deployed connection, auth, tool selection, and rendering paths that local replicas cannot prove. Use live testing after your deterministic suite is green.
CI gating that developers will tolerate
A useful MCP App CI pipeline usually has three lanes:
| Lane | When it runs | What it catches |
|---|---|---|
| Fast checks | Every push | Type errors, unit tests, protocol integration issues |
| Inspector E2E | Pull requests | iframe rendering, host matrix, interactions, accessibility smoke checks |
| Release checks | Main branch or manual release | production resources, visual regression, live host tests, evals |
For GitHub Actions, keep secrets out of the default E2E path. Local inspector tests should not need host credentials. Put live tests and evals behind a scheduled job, manual workflow, or protected environment. The MCP App CI/CD guide has the full setup.
Debugging failures
When an E2E test fails, first identify which layer broke:
- Did the tool call fail? Check
result.isErrorand the raw tool result. - Did the resource fail to load? Check resource metadata, CSP, network errors, and console errors.
- Did the iframe render but the UI assertion fail? Use Playwright’s trace viewer and inspect the frame.
- Did only one host fail? Look at display mode, safe area, host CSS variables, and feature detection.
- Did only CI fail? Check viewport size, timing, missing fonts, remote images, and production resource build output.
Use the Playwright UI runner when you need to see the iframe:
pnpm test:e2e -- --ui
Use trace capture when the failure is intermittent:
pnpm test:e2e -- --trace on
If the bug is host-specific, reproduce it in the local inspector first. If it reproduces only in the real host, promote it to a live test after the fix so it does not regress silently.
Where E2E fits in the full testing strategy
E2E tests are the right layer for host rendering and user flows. They should not replace every other test type.
- Unit tests: component logic, formatter functions, validation, small state transitions.
- Integration tests: MCP tool handlers, protocol shape, auth branches, server errors.
- E2E inspector tests: iframe rendering, host context, display modes, themes, user interactions.
- Visual regression tests: pixel-level layout drift in known states.
- Live tests: real host connection, auth, model tool selection, deployed iframe loading.
- Evals: whether models call the right tools with the right arguments.
If you are starting from zero, write one E2E test for each resource’s happy path, then add empty, error, and high-volume simulations. After that, add the display mode and theme matrix for the resource that matters most to the product.
Get started
New sunpeak projects come with E2E testing scaffolding:
npx sunpeak new my-app
cd my-app
pnpm test:e2e
For an existing MCP server:
npx sunpeak test init --server http://localhost:8000/mcp
npx sunpeak test --e2e
The test framework works with any MCP server. Use the testing docs for setup, E2E reference for the inspector fixture, and simulation docs for deterministic states.
If you already have a ChatGPT App or Claude Connector, start by turning one manual smoke test into an inspector E2E test. Pick the state you most often check by hand, freeze it as a simulation, assert the rendered UI, and run it in CI. That one test usually pays for itself the next time a resource metadata, display mode, or host theme change would have reached production.
Get Started
npx sunpeak newFurther Reading
- sunpeak testing framework documentation
- sunpeak E2E testing reference
- sunpeak simulation files reference
- MCP App CI/CD - run your tests in GitHub Actions
- MCP App testing strategy - which tests to write first
- Cross-host compatibility testing for MCP Apps
- Live testing for Claude Connectors and ChatGPT Apps
- Accessibility testing for MCP Apps
- Visual regression testing MCP Apps
- MCP App resource metadata, CSP, permissions, and widget fields
- Testing MCP App data flow across tools and views
- MCP App host implementation guide
- Fixing flaky MCP App tests
- MCP Apps overview
- Official MCP Apps testing guide
- ChatGPT UI guide for MCP Apps
- Claude custom connectors with remote MCP
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
Frequently Asked Questions
How do I E2E test an MCP App?
Use the inspector fixture from sunpeak/test. Call inspector.renderTool("tool-name") for a simulation fixture, or pass input to call the real local MCP server. Assert which path ran through result.source, then check the iframe with Playwright through result.app(). Cover primary result states, host modes, and interactions before a small live-host check.
What should an MCP App E2E test verify?
A useful MCP App E2E test verifies that the tool result renders inside the iframe, the main content is visible, the layout works in the requested display mode, the UI responds to user interaction, theme and safe-area values are respected, and any host bridge action behaves correctly. It should not duplicate every unit-test edge case.
Do I need a ChatGPT or Claude subscription to run MCP App E2E tests?
No. Local E2E tests run against the sunpeak inspector, which replicates ChatGPT and Claude MCP App runtimes on localhost. They do not need paid host accounts, API keys, or AI credits. Use live tests only for a smaller pre-release check against real hosts.
How do I test MCP Apps across ChatGPT and Claude automatically?
Use defineConfig() from sunpeak/test/config. It creates Playwright projects for the configured host runtimes, so the same test file can run once for ChatGPT and once for Claude. When a test fails, the Playwright report shows which host failed.
How do I test display modes in MCP App E2E tests?
Pass displayMode to inspector.renderTool(), for example { displayMode: "fullscreen" }. Test inline, picture-in-picture, and fullscreen behavior where the host supports them, and assert against stable UI outcomes such as visible controls, scroll boundaries, and non-overflowing content instead of only checking the root node.
What are simulation files in MCP App testing?
Simulation files are JSON fixtures that define deterministic tool states. toolInput holds the arguments directly, while toolResult holds content, structuredContent, and optional error data. They render known states without a server call. Use serverTools mocks for tool calls the app makes after loading, and keep separate tests that call the real local server.
How do E2E tests differ from live host tests?
E2E tests run against a local host replica. They are fast, deterministic, and appropriate for pull requests and CI. Live host tests run through real ChatGPT or Claude sessions with real host auth, model routing, and iframe behavior. Keep live tests smaller and run them before release or on a schedule.
Where do accessibility and visual regression tests fit?
Accessibility and visual checks build on the same E2E foundation. Use Playwright locators for keyboard and focus behavior, axe-style checks for common accessibility regressions, and screenshot baselines for theme, viewport, and host layout drift. They should run against representative states, not every test case.