Mert Özen Further With Every Line
SEO & GEO Structured Data and Schema.org — #05

Testing and Monitoring Structured Data: Rich Results Test, Schema Markup Validator, Search Console and Automated CI Checks

Mert Özen Nov 14, 2026 13 min 5 views
Testing and Monitoring Structured Data: Rich Results Test, Schema Markup Validator, Search Console and Automated CI Checks

The Rich Results Test checks eligibility for Google's rich results and the Schema Markup Validator checks schema.org validity, but only tests that run on every build catch regressions. We cover URL Inspection and Search Console reports, JSON-LD tests with xUnit and WebApplicationFactory, Playwright checks for Angular SSR and post-deploy smoke tests, with real passing and failing output.

Version note: The code in this post was built and run with .NET 10, xUnit v3 4.0, Microsoft.AspNetCore.Mvc.Testing 10.0, AngleSharp 1.10, Angular 22.2 and Playwright 1.65; Google's tools and Search Console reports are described as of November 2026.

Guess First

Bookstore's JSON-LD passes the Schema Markup Validator with zero errors. A teammate says "then there's no problem in Google either". Someone else opens a pull request that switches the encoder in JsonLd.cs to UnsafeRelaxedJsonEscaping "so Turkish characters don't get escaped"; all tests are green. A third change simplifies the JSON-LD service on the Angular side, and the SSR output looks fine when checked with curl. Question: which of these three is really safe?

None of them. The first uses the wrong tool for the wrong question; the second and third are just a test suite that does not test structured data staying green. This post is about choosing the right tool for the right question and putting these checks into CI.

Two Tools, Two Questions

Google's old Structured Data Testing Tool was split in two under a plan announced in December 2020: the Google-specific checks stayed in the Rich Results Test, and general schema.org validation moved to the schema.org community in 2021 as the Schema Markup Validator at validator.schema.org. The two tools ask different questions:

QuestionRich Results TestSchema Markup Validator
What does it check?Eligibility for the rich result types Google supportsValidity against schema.org
A type Google does not recognizeIgnores itParses and shows it
A retired type (FAQ, HowTo)No longer shows itShows it as valid schema.org
A required property is missingCritical error, item invalidUsually no problem
InputURL or codeURL or code

The problem with the first claim is in this table. The mertozen.com tool page from the fourth post of the series carries a WebApplication that is completely valid according to the Schema Markup Validator; but because it has no rating or review, which Google requires for the Software App rich result, you will not see eligibility in the Rich Results Test. "Valid" and "eligible" are separate questions.

Both tools have a URL mode and a code mode. Google's documentation on generating structured data with JavaScript recommends the URL mode of the Rich Results Test, because the code mode has JavaScript limitations such as CORS. We tried the Schema Markup Validator on a mertozen.com post both ways: given the HTML as code, 2 items, 0 errors, 0 warnings; given the same URL, the response came back with isRendered: true and again 2 items, 0 errors, 0 warnings. But our first attempt in URL mode returned fetchError: UNREACHABLE, and in the same minutes the site was briefly returning 503. When the tool cannot reach the page, it says nothing about the markup itself; do not confuse "no errors" with "could not look".

What Google Sees: URL Inspection

The Rich Results Test fetches the page at that moment; the version in Google's index is shown by URL Inspection in Search Console. The Enhancements section of the indexed version lists the rich results found, the number of valid items, and any warnings or errors; "View crawled page" shows the raw HTML and the loaded resources. "Test live URL" runs the same check on the live page and adds a screenshot. One note: URL Inspection does not show every rich result type; Google's help page says unsupported types may still be valid and appear in Search, they just are not listed in this tool. General use of URL Inspection is covered in the Search Console post of the SEO Fundamentals series.

The difference between these two views is the first clue for "passes in the test tool but missing live" problems. If the markup is missing from the indexed version but present in the live test, Google has not processed the new version of the page yet; if it is missing from both, the problem is in your rendering.

Search Console Reports

Search Console opens a rich result status report for every supported type whose valid markup it finds. The report shows valid and invalid item counts over time: critical issues that keep an item from becoming a rich result appear under "Why items are invalid", and non-critical issues that would improve its appearance under "Improve item appearance". The examples table shows at most 1,000 rows, so the counts may be lower than the real number of items on the site.

The reports are tied to Google's list of supported types, so when a type is retired its report goes too. For HowTo in 2023, the report and Rich Results Test support were removed 30 days after the feature went away, and Search Console API support after 180 days. The six types in the 2025 wave were removed from reports, the Rich Results Test and the search appearance filters on 9 September 2025; API support ended in December 2025. The same process started for Practice problem in January 2026. A dashboard or bulk export query that relies on these reports goes quietly empty on those dates. Google's September 2025 note said the retired appearance fields in bulk export would return NULL and recommended rewriting queries in a form that works with both false and NULL, such as is_learning_videos IS NOT TRUE.

Testing the Page with xUnit and WebApplicationFactory

The tools check a page once; catching regressions requires the check to run on every build. Bookstore's test project starts the application in memory with WebApplicationFactory, fetches the page with a real HTTP request and reads the JSON-LD with an HTML parser. On the .NET 10 SDK, xUnit v3 needs the Microsoft.Testing.Platform runner selected explicitly for dotnet test; otherwise it stops with an error saying the VSTest target is no longer supported. The choice goes in global.json:

{
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}

The first piece is a small helper that flattens a page's JSON-LD blocks into a single node list:

using System.Text.Json;
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;

namespace Bookstore.Web.Tests;

// The JSON-LD blocks of one rendered page, flattened into a node list
public sealed class JsonLdPage(string rawHtml, IHtmlDocument document, IReadOnlyList<JsonElement> nodes)
{
    public string RawHtml { get; } = rawHtml;
    public IHtmlDocument Document { get; } = document;
    public IReadOnlyList<JsonElement> Nodes { get; } = nodes;

    public static async Task<JsonLdPage> LoadAsync(HttpClient client, string path)
    {
        string html = await client.GetStringAsync(path, TestContext.Current.CancellationToken);
        // a real HTML parser, not a regex: attribute values may be entity-encoded
        IHtmlDocument document = new HtmlParser().ParseDocument(html);
        var nodes = new List<JsonElement>();
        foreach (var script in document.QuerySelectorAll("script[type='application/ld+json']"))
        {
            JsonElement root = JsonDocument.Parse(script.TextContent).RootElement;
            Assert.Equal("https://schema.org", root.GetProperty("@context").GetString());
            nodes.AddRange(root.TryGetProperty("@graph", out JsonElement graph) ? graph.EnumerateArray() : [root]);
        }
        return new JsonLdPage(html, document, nodes);
    }

    public JsonElement Single(string type) => Assert.Single(Nodes, n => n.GetProperty("@type").GetString() == type);

    public bool HasNode(string id) => Nodes.Any(n => n.TryGetProperty("@id", out JsonElement x) && x.GetString() == id);
}

The reason for AngleSharp instead of a regex is the ld&#x2B;json example from the second post: attribute values can arrive entity-encoded, and the only thing that reads them correctly is a real HTML parser. The tests check three things:

using System.Text.Json;
using Bookstore.Web.Content;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;

namespace Bookstore.Web.Tests;

public sealed class StructuredDataTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Theory]
    [InlineData("/blog/csharp-books-for-beginners")]
    [InlineData("/en/blog/csharp-books-for-beginners")]
    public async Task BlogPosting_matches_visible_content(string path)
    {
        JsonLdPage page = await JsonLdPage.LoadAsync(factory.CreateClient(), path);
        JsonElement posting = page.Single("BlogPosting");

        Assert.Equal(page.Document.QuerySelector("h1")!.TextContent, posting.GetProperty("headline").GetString());
        DateTimeOffset published = posting.GetProperty("datePublished").GetDateTimeOffset();
        Assert.Matches(@"[+-]\d{2}:\d{2}$|Z$", posting.GetProperty("datePublished").GetString()!);
        if (posting.TryGetProperty("dateModified", out JsonElement modified))
        {
            Assert.True(modified.GetDateTimeOffset() >= published, "dateModified is earlier than datePublished");
        }
        foreach (JsonElement image in posting.GetProperty("image").EnumerateArray())
        {
            Assert.StartsWith("https://", image.GetString());
        }
        // every author reference must resolve to a node on the same page
        foreach (JsonElement author in posting.GetProperty("author").EnumerateArray())
        {
            Assert.True(page.HasNode(author.GetProperty("@id").GetString()!), "dangling author @id");
        }
    }

    [Fact]
    public async Task Person_id_is_the_same_on_post_profile_and_both_languages()
    {
        HttpClient client = factory.CreateClient();
        JsonLdPage trPost = await JsonLdPage.LoadAsync(client, "/blog/csharp-books-for-beginners");
        JsonLdPage enPost = await JsonLdPage.LoadAsync(client, "/en/blog/csharp-books-for-beginners");
        JsonLdPage profile = await JsonLdPage.LoadAsync(client, "/authors/ada-kaplan");

        string? personId = trPost.Single("Person").GetProperty("@id").GetString();
        Assert.Equal(personId, enPost.Single("Person").GetProperty("@id").GetString());
        Assert.Equal(personId, profile.Single("ProfilePage").GetProperty("mainEntity").GetProperty("@id").GetString());
    }

    [Fact]
    public async Task Hostile_title_cannot_close_the_script_element()
    {
        const string title = "Why </script><script>alert(1)</script> breaks pages";
        var catalog = new PostCatalog();
        Post original = catalog.Posts[0];
        Post hostile = original with { Slug = "hostile", Title = new Localized(title, title) };

        HttpClient client = factory.WithWebHostBuilder(builder => builder.ConfigureServices(services =>
            services.AddSingleton(new PostCatalog { Posts = [hostile] }))).CreateClient();
        JsonLdPage page = await JsonLdPage.LoadAsync(client, "/blog/hostile");

        Assert.DoesNotContain("<script>alert(1)", page.RawHtml);
        Assert.Equal(title, page.Single("BlogPosting").GetProperty("headline").GetString());
    }
}

The first test checks that the markup is consistent with the visible page: does headline match the h1 on the page, does the date carry a time zone, is dateModified after publication, are the images absolute, and does every author reference resolve to a node on the same page? It runs for both languages; the EN breadcrumb problem from the third post is exactly the job of a test like this. The second test checks @id consistency across pages: is the Person identity the same on the TR post, the EN post and the author page? The third test puts a post with </script> in its headline into the catalog with WithWebHostBuilder and puts the encoder to the test. For this, PostCatalog's lists can be set with init, and Program.cs ends with public partial class Program;.

$ dotnet test --project Bookstore.Web.Tests
Test run summary: Passed!
  total: 8
  failed: 0
  succeeded: 4
  skipped: 4

The four skipped tests are the deploy smoke tests below. Now let's apply the pull request from the start: switch the encoder in JsonLd.cs to UnsafeRelaxedJsonEscaping and run the same command:

failed Bookstore.Web.Tests.StructuredDataTests.Hostile_title_cannot_close_the_script_element
  Xunit.MicrosoftTestingPlatform.XunitException: System.Text.Json.JsonReaderException : Expected end of string, but instead reached end of data. LineNumber: 0 | BytePositionInLine: 137.
Test run summary: Failed!
  total: 8
  failed: 1
  succeeded: 3
  skipped: 4

The error message says exactly what happened: the HTML parser closed the script element at the </script> in the headline, so the JSON was cut off at byte 137. Without this test, the same pull request would have passed green.

Playwright for Angular SSR

On the Angular side two separate things need checking: the initial HTML the server sends and the DOM after hydration. Playwright can do both: the request fixture gives the raw HTTP response, and the page fixture gives the page running in Chromium.

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  reporter: 'list',
  use: { baseURL: 'http://localhost:4321' },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
  webServer: {
    command: 'node dist/bookstore-ssr/server/server.mjs',
    url: 'http://localhost:4321/blog/csharp-books-for-beginners',
    env: { PORT: '4321', NG_ALLOWED_HOSTS: 'localhost' },
  },
});
import { expect, test } from '@playwright/test';

const slugs = ['csharp-books-for-beginners', 'html-in-titles'];

for (const slug of slugs) {
  test(`SSR HTML of ${slug} has one parseable BlogPosting`, async ({ request }) => {
    const html = await (await request.get(`/blog/${slug}`)).text();
    const blocks = [...html.matchAll(/<script type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g)];
    expect(blocks).toHaveLength(1);
    const data = JSON.parse(blocks[0][1]);
    expect(data['@type']).toBe('BlogPosting');
    expect(data.datePublished).toMatch(/[+-]\d{2}:\d{2}$|Z$/);
    // HTML entities inside JSON-LD are data corruption, not escaping
    expect(data.headline).not.toMatch(/&(lt|gt|amp);/);
  });

  test(`${slug} keeps a single JSON-LD block after hydration`, async ({ page }) => {
    await page.goto(`/blog/${slug}`);
    await page.waitForLoadState('networkidle');
    const scripts = page.locator('script[type="application/ld+json"]');
    await expect(scripts).toHaveCount(1);
    const data = JSON.parse((await scripts.first().textContent()) ?? '');
    await expect(page.locator('h1')).toHaveText(data.headline);
  });
}

The webServer block starts the built SSR server before the tests. The regex here is deliberate: Angular SSR does not encode the type attribute, and this test wants to inspect exactly the text the server sends. With the correct service:

$ npx playwright test
Running 4 tests using 1 worker
  ✓  1 [chromium] › e2e/json-ld.spec.ts:6:3 › SSR HTML of csharp-books-for-beginners has one parseable BlogPosting
  ✓  2 [chromium] › e2e/json-ld.spec.ts:17:3 › csharp-books-for-beginners keeps a single JSON-LD block after hydration
  ✓  3 [chromium] › e2e/json-ld.spec.ts:6:3 › SSR HTML of html-in-titles has one parseable BlogPosting
  ✓  4 [chromium] › e2e/json-ld.spec.ts:17:3 › html-in-titles keeps a single JSON-LD block after hydration
  4 passed

With the first, simple version of the service from the second post, the one that adds a new script on every call and does not escape the JSON:

  ✓  1 [chromium] › e2e/json-ld.spec.ts:6:3 › SSR HTML of csharp-books-for-beginners has one parseable BlogPosting
  ✘  2 [chromium] › e2e/json-ld.spec.ts:17:3 › csharp-books-for-beginners keeps a single JSON-LD block after hydration
  ✘  3 [chromium] › e2e/json-ld.spec.ts:6:3 › SSR HTML of html-in-titles has one parseable BlogPosting
  ✘  4 [chromium] › e2e/json-ld.spec.ts:17:3 › html-in-titles keeps a single JSON-LD block after hydration
    Error: expect(locator).toHaveCount(expected) failed
    Expected: 1
    Received: 2
    Error: expect(received).not.toMatch(expected)
    Expected pattern: not /&(lt|gt|amp);/
    Received string:      "Why &lt;/script> in a title breaks pages"
  3 failed
  1 passed

Two separate bugs, two separate tests. The hydration tests catch the second script. The SSR test catches what is easy to miss when looking with curl: because Angular's server DOM writes the < character as &lt;, the headline is corrupted. Without this test, the problem would show only in systems that read the page's initial HTML; in the browser, hydration fixes the value, so nobody would notice.

Smoke Tests After Deploy

Code that passes the tests can behave differently in production: a CDN keeps old HTML in its cache, production config supplies a different base URL, or the SSR server falls back to CSR because of an error. For that, a small test class that points the same JsonLdPage helper at a real address is enough:

using System.Text.Json;

namespace Bookstore.Web.Tests;

// Runs only when SMOKE_BASE_URL is set, e.g. right after a deploy
public sealed class DeployedSmokeTests
{
    private static readonly string? BaseUrl = Environment.GetEnvironmentVariable("SMOKE_BASE_URL");

    [Theory]
    [InlineData("/", "WebSite")]
    [InlineData("/blog/csharp-books-for-beginners", "BlogPosting")]
    [InlineData("/en/blog/csharp-books-for-beginners", "BlogPosting")]
    [InlineData("/authors/ada-kaplan", "ProfilePage")]
    public async Task Live_page_serves_expected_type(string path, string expectedType)
    {
        if (string.IsNullOrEmpty(BaseUrl))
        {
            Assert.Skip("SMOKE_BASE_URL is not set");
        }

        using var client = new HttpClient { BaseAddress = new Uri(BaseUrl) };
        JsonLdPage page = await JsonLdPage.LoadAsync(client, path);

        JsonElement node = page.Single(expectedType);
        Assert.StartsWith("https://", node.GetProperty("@id").GetString());
    }
}

Without SMOKE_BASE_URL the tests are skipped with Assert.Skip, which is why four tests showed as "skipped" in the normal dotnet test run. Run with the environment variable as the last step of the deploy pipeline, only this class runs. Here against the local application:

$ SMOKE_BASE_URL=http://localhost:5321 dotnet test --project Bookstore.Web.Tests -- --filter-class "*DeployedSmokeTests"
Test run summary: Passed!
  total: 4
  failed: 0
  succeeded: 4
  skipped: 0

Keep the list of sample URLs short: the home page, one post per language, one author page. The goal is not to crawl every page but to see within minutes whether the deploy broke the markup wholesale.

A Maintenance Routine

Structured data is not something you write once and forget. Follow three sources regularly. The first is Google's Search Central changelog: every date in this series came from there, and a type's deprecation notice usually appears there weeks before the removal. The second is the schema.org release notes: 30.0 came out in March 2026 and 30.1 in September 2026, and new types or changes in property meaning are announced there. The third is the Manual Actions report in Search Console: a manual action for structured data spam removes rich result eligibility and shows up there.

Measuring the effect of a markup change is a separate job. The search appearance filter in Search Console's Performance report lets you separate impressions and clicks shown as rich results; but before concluding "we added markup and traffic went up", you need to rule out the other variables. That topic is covered in detail in the Analytics and Measurement series being written in this round.

In Production

Run the tests for both languages and every page type. In Bookstore TR and EN are produced by the same code, but as the mertozen.com example showed, building URLs per language is a separate code path and a separate source of bugs.

Leave the Rich Results Test as a manual check. Google does not offer an API for this tool; in CI you test your own rules, not Google's eligibility decision. Writing Google's required properties into those rules narrows the gap between the two.

Separate warnings from errors. A warning under "Improve item appearance" in Search Console does not make an item invalid; an error under "Why items are invalid" does. Filling in a property that is not in the visible content just to clear the warnings creates a bigger problem than it solves.

Common Pitfalls

Markup that passes in the test tool but differs in production. Old HTML in a cache, a page rendered differently by language, or a route that fell back to CSR because of an SSR error separate the page the tool sees from the one visitors and Googlebot see. Smoke tests after deploy and the indexed version in URL Inspection catch this gap.

Mistaking warnings for errors, or the other way round. Writing made-up values to clear every warning on one side, postponing a critical error because "it passes anyway" on the other; both come from misreading the report.

Thinking the problem is solved when the report for a retired type disappears. The report left because the type is no longer supported; the markup is still on the page and still needs maintenance.

Extracting JSON-LD with a regex. From ASP.NET Core 9 on, the type attribute can be encoded, the script can have other attributes, and the same page can have several blocks. Use an HTML parser in test code; where you deliberately inspect the raw text, as in the Playwright test, say so explicitly.

A Small Experiment

★☆☆ Add one more URL to DeployedSmokeTests and see which message the test fails with if the expected type is not on that page.

★★☆ Add a test to StructuredDataTests that checks every item value in the page's breadcrumb starts with the page's language prefix. It should pass on Bookstore and fail when run as a smoke test against a mertozen.com EN post.

★★★ Write a pipeline in GitHub Actions or Azure DevOps that runs dotnet test first, then the Angular build and npx playwright test, and after the deploy runs the smoke tests with SMOKE_BASE_URL.

The series ends here. We started from what structured data is, learned to generate JSON-LD safely, built an entity graph for a tech blog, saw what Google supports and what it has retired, and tied it all to tests. The natural next step is the content you are marking up: the Content SEO series being written in this round covers the content Google is trying to understand and the link structure between pages.