{"id":"PROENG-11049","slug":"equal-sitemap-counts-url-set-release-test","searchIntent":"implementation","featured":false,"trending":false,"title":"Equal sitemap counts can hide a broken release: test the URL sets","excerpt":"Test sitemap additions and removals with a runnable Node.js example that catches an unexpected URL even when the before and after counts match.","kind":"Tutorial","category":"product-engineering","status":"published","authorId":"edilec-engineering","publishedAt":"2026-09-12","updatedAt":"2026-09-12","readingTime":"6 min","image":"/attachments/article-media/editorial/edilec-sitemap-url-set-comparison.svg","tags":["Sitemap testing","Release checks","Node.js","Technical SEO"],"seoKeywords":["sitemap URL set comparison test","sitemap release validation","compare sitemap additions and removals"],"relatedIds":["PROENG-11045","CLD-3102"],"relatedArticleIds":["PROENG-11045","CLD-3102"],"faqs":[],"sourceCredits":[{"title":"Node.js strict assertion mode","url":"https://nodejs.org/api/assert.html#strict-assertion-mode","author":"Node.js"},{"title":"Node.js deepStrictEqual","url":"https://nodejs.org/api/assert.html#assertdeepstrictequalactual-expected-message","author":"Node.js"},{"title":"Build and submit a sitemap","url":"https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap","author":"Google Search Central"},{"title":"Learn about sitemaps","url":"https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview","author":"Google Search Central"}],"researchSources":[{"title":"Node.js strict assertion mode","url":"https://nodejs.org/api/assert.html#strict-assertion-mode","author":"Node.js"},{"title":"Node.js deepStrictEqual","url":"https://nodejs.org/api/assert.html#assertdeepstrictequalactual-expected-message","author":"Node.js"},{"title":"Build and submit a sitemap","url":"https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap","author":"Google Search Central"},{"title":"Learn about sitemaps","url":"https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview","author":"Google Search Central"}],"body":[{"type":"paragraph","text":"**AI disclosure:** This article and its synthetic example were generated with AI assistance. The example was executed locally; no human review, customer experience or production outcome is claimed."},{"type":"paragraph","text":"A sitemap contains three URLs before a deployment and three afterward. A count check passes. Yet an expected product URL is missing and a preview URL has taken its place. Counts summarize an inventory; they cannot establish that the inventory contains the right members."},{"type":"paragraph","text":"A small release check can make that difference visible. Compare the actual additions and removals against changes explicitly approved for this release. This is useful when a publishing rule, catalog query or route generator changes, even if the resulting XML remains valid."},{"type":"image","src":"/attachments/article-media/editorial/edilec-sitemap-url-set-comparison.svg","alt":"Synthetic sitemap release: three URLs before and after, but the candidate adds preview/draft instead of the approved products/dune. The count passes and the URL-set policy fails.","caption":"Synthetic example.test inventory. The planned retirement is valid; the unexpected replacement URL is not.","width":1440,"height":900},{"type":"heading","id":"make-the-expected-change-concrete","depth":2,"text":"Make the expected change concrete"},{"type":"paragraph","text":"In this hypothetical release, `/products/coral` is retired and `/products/dune` replaces it. The other two product URLs should remain. The broken candidate emits `/preview/draft` instead of the replacement. Both candidates contain three unique URLs."},{"type":"paragraph","text":"The following dependency-free example accepts decoded URL strings, as might be provided by a sitemap parser or publication pipeline. It deliberately isolates the comparison from XML parsing. Every URL uses `example.test`; the script makes no network requests. Save it as `sitemap-set-comparison.mjs` and use Node.js 20 or newer."},{"type":"code","language":"js","code":"import assert from 'node:assert/strict';\n\nconst before = [\n  'https://example.test/products/amber',\n  'https://example.test/products/blue',\n  'https://example.test/products/coral',\n];\nconst broken = [\n  'https://example.test/products/amber',\n  'https://example.test/products/blue',\n  'https://example.test/preview/draft',\n];\nconst planned = [\n  'https://example.test/products/amber',\n  'https://example.test/products/blue',\n  'https://example.test/products/dune',\n];\nconst expected = {\n  added: ['https://example.test/products/dune'],\n  removed: ['https://example.test/products/coral'],\n};\n\nfunction unique(urls, label) {\n  const result = new Set(urls);\n  assert.equal(result.size, urls.length, `${label}: duplicate URL`);\n  return result;\n}\n\nfunction difference(left, right) {\n  return [...left].filter(url => !right.has(url)).sort();\n}\n\nconst scenario = process.argv[2];\nassert.ok(['broken', 'planned'].includes(scenario),\n  'Usage: node sitemap-set-comparison.mjs broken|planned');\nconst previous = unique(before, 'before');\nconst candidate = unique(scenario === 'planned' ? planned : broken, 'after');\n\nassert.equal(candidate.size, previous.size);\nconsole.log(`Count check: PASS (${previous.size} -> ${candidate.size} URLs)`);\n\nconst actual = {\n  added: difference(candidate, previous),\n  removed: difference(previous, candidate),\n};\nconsole.log(`Actual: ${JSON.stringify(actual)}`);\nconsole.log(`Expected: ${JSON.stringify(expected)}`);\n\ntry {\n  assert.deepStrictEqual(actual, expected);\n  console.log('Set policy: PASS');\n} catch (error) {\n  if (!(error instanceof assert.AssertionError)) throw error;\n  console.error('Set policy: FAIL');\n  process.exitCode = 1;\n}"},{"type":"paragraph","text":"Run each scenario separately:"},{"type":"code","language":"sh","code":"node sitemap-set-comparison.mjs broken\nnode sitemap-set-comparison.mjs planned"},{"type":"paragraph","text":"The first command prints `Count check: PASS (3 -> 3 URLs)` but finishes with `Set policy: FAIL` and exit code 1. Its actual addition is the draft URL. The second reports the same passing count, the approved product addition and retirement, and `Set policy: PASS`, exiting with code 0."},{"type":"paragraph","text":"Node's built-in [strict assertions](https://nodejs.org/api/assert.html#strict-assertion-mode) let the script check both the numerical invariant and the complete change object. [`deepStrictEqual`](https://nodejs.org/api/assert.html#assertdeepstrictequalactual-expected-message) throws when the arrays differ; the catch retains a failing process exit status instead of merely logging a warning."},{"type":"heading","id":"keep-the-policy-independent-of-the-output","depth":2,"text":"Keep the policy independent of the output"},{"type":"paragraph","text":"Write the expected additions and removals from the release decision. Do not automatically copy the candidate's diff into the expected object: that would approve whatever the generator happened to emit. Review an unexpected change against the publication records before deciding whether the generator or the expectation needs correction."},{"type":"paragraph","text":"An exact policy catches both an unapproved addition and a missing planned addition. A numerical allowance such as “up to one removal” cannot identify which page disappeared. For an unchanged inventory, the expected arrays are both empty. For a growing catalog, remove the demonstration's equal-count assertion and retain the set policy."},{"type":"paragraph","text":"The duplicate assertion runs before deduplication can hide repeated declarations. Sorting only makes reports deterministic; it does not rewrite the URLs. Keep expected lists in the same sorted order when approving multiple changes. Preserve path case, trailing slashes and query strings during comparison unless the application has a separately tested canonicalization contract. Google recommends listing the preferred canonical URLs rather than every alternate address. [Sitemap construction guidance](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap)"},{"type":"heading","id":"check-the-artifact-you-will-publish","depth":2,"text":"Check the artifact you will publish"},{"type":"paragraph","text":"For a real pipeline, compare the previous successful sitemap artifact with the candidate artifact from the same release under review. Parse XML correctly, collect the intended page `loc` values, and preserve duplicates until checked. A list taken only from the source database can miss an error introduced during sitemap generation."},{"type":"paragraph","text":"Retain the two artifacts, their release identifiers, the approved policy and the resulting diff. Keep an unexpected removal visible until someone can explain it. Replacing the baseline with a failed candidate erases the evidence the next run needs."},{"type":"paragraph","text":"This test checks declared membership. Separate checks must cover XML validity, redirects, canonical tags, page availability and indexing directives. A passing set policy does not show that Google fetched or indexed anything: Google explicitly describes sitemaps as discovery aids without guaranteed crawling or indexing. [Google's sitemap overview](https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview)"}],"mediaAssets":[]}