Testing the Schema You Actually Ship
When we rebuilt the local SEO layer for our site, we had a problem: how do you verify that JSON-LD is actually in the HTML? You can write the component, you can check the source in a browser, but neither of those catches regressions. A refactor that accidentally moves schema markup behind a useEffect would silently break structured data with no failing test to catch it.
So we tested the schema directly. Here's the pattern.
The tool: renderToStaticMarkup
renderToStaticMarkup from react-dom/server renders a React component tree to a plain HTML string, no browser required. In a Vitest test, you render the component and get back the HTML you'd ship to a crawler.
That HTML contains the <script type="application/ld+json"> block if the component is a server component that emits one. You pull the JSON out of the script tag, parse it, and assert on the structure.
import { renderToStaticMarkup } from "react-dom/server";
import { ServiceHub } from "@/components/sections/ServiceHub";
it("emits FAQPage JSON-LD matching the rendered FAQs", () => {
const html = renderToStaticMarkup(<ServiceHub {...props} />);
const match = html.match(
/<script[^>]*application\/ld\+json[^>]*>(.*?)<\/script>/s
);
const data = JSON.parse(match![1]);
expect(data["@type"]).toBe("FAQPage");
expect(data.mainEntity).toHaveLength(2);
});
If the schema is missing because the component was accidentally made a client component, match is null and the test fails immediately. No crawling tools needed.
What we test
We have three schema-related tests:
json-ld.test.ts: Tests the faqPageSchema() builder function in isolation. Given an array of FAQ pairs, does it return a valid FAQPage object with the right shape?
const schema = faqPageSchema([{ q: "Q1?", a: "A1." }]);
expect(schema["@type"]).toBe("FAQPage");
expect(schema.mainEntity[0].acceptedAnswer.text).toBe("A1.");
This is a pure function test. It runs in milliseconds and confirms the builder is correct before anything is rendered.
service-hub.test.tsx: Tests the ServiceHub component. Does it render the title and local intro text? Does the HTML contain a script tag with valid FAQPage schema? The test uses real props with two FAQ items and confirms mainEntity has length 2.
city-landing.test.tsx: Tests the CityLanding component against real city data from local.ts. It renders the first city (Kokomo), checks the <h1> contains the city name, checks the blurb text is present, and verifies the JSON-LD has the right number of FAQ entries.
const city = cities[0];
const html = renderToStaticMarkup(<CityLanding city={city} />);
const match = html.match(/application\/ld\+json[^>]*>(.*?)<\/script>/s);
const data = JSON.parse(match![1]);
expect(data.mainEntity.length).toBe(city.faqs.length);
That last assertion catches a specific class of bug: if you add a FAQ to the city config but the component doesn't pass it to the schema builder, the rendered FAQ list and the JSON-LD get out of sync. That's invisible at build time and invisible in the browser. The test catches it.
Why the city test uses real config data
The ServiceHub test uses hardcoded props. The CityLanding test imports cities from the real local.ts config. That's intentional.
For CityLanding, the interesting property is that FAQ count in the rendered markup matches FAQ count in the schema. If we use hardcoded test data, we test the component's behavior in isolation. Using real config data, we're also testing that nothing in the pipeline between local.ts and the rendered JSON-LD drops an item.
It's a lightweight integration check. Not a full end-to-end test, but more honest than a test that only runs against fixture data.
What this doesn't catch
renderToStaticMarkup only renders to HTML. It doesn't validate the schema against Schema.org rules: it won't tell you if you used the wrong @type, if a required field is missing, or if your @id pattern is wrong. For that, Google's Rich Results Test and Schema Markup Validator are the right tools.
The tests we wrote catch structural regressions: missing schema, wrong item counts, broken builder output. Semantic validation is a separate step we run manually after shipping changes.
The pattern is simple and the payoff is real. If structured data matters to your site, it deserves the same test coverage as your component markup.
If you want this kind of architecture for your service business, start with a free consultation.