SEO by Role 16 min read

SEO Testing in CI/CD Pipelines: Catch Ranking Breaks Before Deploy

Integrate SEO checks into continuous deployment. Automated testing catches meta tag regressions, canonicalization errors, and indexing blocks before they hit production.

V
Victor Romo
|

SEO Testing in CI/CD Pipelines: Catch Ranking Breaks Before Deploy

Quick Summary

- What this covers: Integrate SEO checks into continuous deployment. Automated testing catches meta tag regressions, canonicalization errors, and indexing blocks before they hit production.

- Who it's for: SEO practitioners at every career stage

- Key takeaway: Read the first section for the core framework, then use the specific tactics that match your situation.

Your engineering team deploys 47 times per week. Last Thursday's release accidentally noindexed 2,000 product pages. You discovered it Monday when organic traffic dropped 40%. By then, Google had already deindexed half your catalog.

Modern development velocity breaks SEO without automated safeguards. Manual QA can't catch every meta tag regression or canonical misconfiguration across thousands of pages. The solution isn't slowing down deploys—it's integrating SEO validation into your CI/CD pipeline so broken changes never reach production.

This framework structures SEO testing like unit tests: fast, automated, and blocking deploys when critical checks fail.

The SEO Testing Stack

Your pipeline needs three testing layers.

Pre-commit hooks catch developer errors before code enters the repository. Fast checks (< 5 seconds) that prevent obviously broken commits. Build-time tests run during CI before merging to main. Moderate checks (< 2 minutes) that validate SEO requirements across the application. Post-deploy monitoring verifies production state matches expectations. Continuous checks that alert when live issues emerge despite passing earlier tests.

Most teams skip straight to post-deploy monitoring. That's reactive—you're catching problems after users and Google see them. Pre-commit and build-time tests shift SEO left, catching issues where they're cheapest to fix.

Layer 1: Pre-Commit Hooks

Install these checks in .git/hooks/pre-commit or use a tool like Husky (for JavaScript projects) or pre-commit (for Python projects).

Test 1: Meta Tag Format Validation

What it catches: Missing title tags, meta descriptions exceeding character limits, malformed robots meta tags. Implementation:

``bash

#!/bin/bash

Check for pages missing title tags

grep -r "\.html" src/ | while read file; do

if ! grep -q "" "$file"; then</p> <p>echo "ERROR: Missing title tag in $file"</p> <p>exit 1</p> <p>fi</p> <p>done</p> <h1>Check meta description length</h1> <p>grep -r "meta name=\"description\"" src/ | while read line; do</p> <p>content=$(echo "$line" | sed -n 's/.<em>content="\([^"]</em>\)".*/\1/p')</p> <p>length=${#content}</p> <p>if [ "$length" -gt 160 ]; then</p> <p>echo "WARNING: Meta description exceeds 160 characters in $file ($length chars)"</p> <p>fi</p> <p>done</p> </code>`<code> <strong>Speed</strong>: < 2 seconds for codebases with < 1,000 templates. <strong>When to block commit</strong>: Missing title tags (critical). Don't block on description length (warning only). <h3 id="test-2-canonical-tag-consistency" class="text-xl font-semibold mt-8 mb-4">Test 2: Canonical Tag Consistency</h3> <strong>What it catches</strong>: Pages with multiple canonical tags, canonical pointing to non-existent URLs, missing canonical on templated pages. <strong>Implementation</strong> (pseudo-code for a Node.js project): </code>`<code>javascript <p>// scripts/check-canonicals.js</p> <p>const fs = require('fs');</p> <p>const glob = require('glob');</p> <p>const cheerio = require('cheerio');</p> <p>glob('src/<em>*/</em>.html', (err, files) => {</p> <p>files.forEach(file => {</p> <p>const html = fs.readFileSync(file, 'utf8');</p> <p>const $ = cheerio.load(html);</p> <p>const canonicals = $('link[rel="canonical"]');</p> <p>if (canonicals.length === 0) {</p> <p>console.error(</code>ERROR: Missing canonical tag in ${file}<code>);</p> <p>process.exit(1);</p> <p>}</p> <p>if (canonicals.length > 1) {</p> <p>console.error(</code>ERROR: Multiple canonical tags in ${file}<code>);</p> <p>process.exit(1);</p> <p>}</p> <p>});</p> <p>});</p> </code>`<code> <strong>Speed</strong>: < 3 seconds for 500 files. <strong>When to block commit</strong>: Multiple canonicals or missing canonicals on core templates. <h3 id="test-3-robots-txt-modification-alert" class="text-xl font-semibold mt-8 mb-4">Test 3: Robots.txt Modification Alert</h3> <strong>What it catches</strong>: Accidental blocks added to robots.txt. <strong>Implementation</strong>: </code>`<code>bash <p>#!/bin/bash</p> <p>if git diff --cached --name-only | grep -q "robots.txt"; then</p> <p>echo "WARNING: robots.txt modified. Review carefully before committing."</p> <p>git diff --cached robots.txt</p> <p>read -p "Proceed with commit? (y/n) " -n 1 -r</p> <p>echo</p> <p>if [[ ! $REPLY =~ ^[Yy]$ ]]; then</p> <p>exit 1</p> <p>fi</p> <p>fi</p> </code>`<code> <strong>Speed</strong>: Instant. <strong>When to block commit</strong>: Require explicit confirmation. Accidental </code>Disallow: /<code> has deindexed entire sites. <h2 id="layer-2-build-time-tests-ci-pipeline" class="text-2xl sm:text-3xl font-bold mb-6">Layer 2: Build-Time Tests (CI Pipeline)</h2> <p>Run these in your CI environment (GitHub Actions, CircleCI, Jenkins, etc.) before merging pull requests.</p> <h3 id="test-4-crawl-simulation" class="text-xl font-semibold mt-8 mb-4">Test 4: Crawl Simulation</h3> <strong>What it catches</strong>: Orphan pages, redirect chains, broken internal links, pages returning non-200 status codes. <strong>Implementation</strong>: <p>Use Puppeteer or Playwright to crawl your staging environment, or use a dedicated crawler like Screaming Frog in headless mode.</p> </code>`<code>javascript <p>// tests/seo/crawl-test.js</p> <p>const { chromium } = require('playwright');</p> <p>async function crawlSite(baseUrl) {</p> <p>const browser = await chromium.launch();</p> <p>const context = await browser.newContext();</p> <p>const page = await context.newPage();</p> <p>const visited = new Set();</p> <p>const queue = [baseUrl];</p> <p>const errors = [];</p> <p>while (queue.length > 0) {</p> <p>const url = queue.shift();</p> <p>if (visited.has(url)) continue;</p> <p>visited.add(url);</p> <p>const response = await page.goto(url, { waitUntil: 'networkidle' });</p> <p>if (response.status() !== 200) {</p> <p>errors.push(</code>${url} returned ${response.status()}<code>);</p> <p>}</p> <p>// Extract internal links</p> const links = await page.$eval('a[href]', anchors => <p>anchors.map(a => a.href).filter(href => href.startsWith(baseUrl))</p> <p>);</p> <p>queue.push(...links);</p> <p>}</p> <p>await browser.close();</p> <p>if (errors.length > 0) {</p> <p>console.error('Crawl errors found:', errors);</p> <p>process.exit(1);</p> <p>}</p> <p>}</p> <p>crawlSite(process.env.STAGING_URL);</p> </code>`<code> <strong>Speed</strong>: 30 seconds to 2 minutes depending on site size. Limit crawl depth to critical paths if timeout is an issue. <strong>When to block merge</strong>: Any 404 or 500 errors on key pages (homepage, product pages, top 10 trafficked URLs). <h3 id="test-5-schema-markup-validation" class="text-xl font-semibold mt-8 mb-4">Test 5: Schema Markup Validation</h3> <strong>What it catches</strong>: Malformed JSON-LD structured data, missing required properties, incorrect schema types. <strong>Implementation</strong>: </code>`<code>javascript <p>// tests/seo/schema-validation.js</p> <p>const Ajv = require('ajv');</p> <p>const ajv = new Ajv();</p> <p>const schemaOrg = require('schema-dts'); // Schema.org types</p> <p>async function validateSchema(url) {</p> <p>const response = await fetch(url);</p> <p>const html = await response.text();</p> <p>const jsonLdMatches = html.match(/<script type="application\/ld\+json">(.*?)<\/script>/gs);</p> <p>if (!jsonLdMatches) {</p> <p>console.error(</code>No JSON-LD found on ${url}<code>);</p> <p>return false;</p> <p>}</p> <p>jsonLdMatches.forEach(match => {</p> <p>const json = match.replace(/<\/?script[^>]*>/g, '');</p> <p>try {</p> <p>const data = JSON.parse(json);</p> <p>// Validate against Schema.org types</p> <p>if (!data['@type']) {</p> <p>throw new Error('Missing @type property');</p> <p>}</p> <p>// Additional validation logic here</p> <p>} catch (error) {</p> <p>console.error(</code>Invalid JSON-LD on ${url}:<code>, error);</p> <p>process.exit(1);</p> <p>}</p> <p>});</p> <p>}</p> </code>`<code> <strong>Speed</strong>: 5-10 seconds per page tested. <strong>When to block merge</strong>: Malformed JSON or missing required fields on product/article pages. <h3 id="test-6-render-blocking-resource-check" class="text-xl font-semibold mt-8 mb-4">Test 6: Render-Blocking Resource Check</h3> <strong>What it catches</strong>: New JavaScript or CSS files added that block rendering, impacting Core Web Vitals. <strong>Implementation</strong>: <p>Use Lighthouse CI to automate Lighthouse audits in your pipeline.</p> </code>`<code>yaml <h1>.github/workflows/lighthouse.yml</h1> <p>name: Lighthouse CI</p> <p>on: [pull_request]</p> <p>jobs:</p> <p>lighthouse:</p> <p>runs-on: ubuntu-latest</p> <p>steps:</p> <p>- uses: actions/checkout@v3</p> <p>- uses: treosh/lighthouse-ci-action@v9</p> <p>with:</p> <p>urls: |</p> <p>https://staging.example.com/</p> <p>https://staging.example.com/product-page/</p> <p>uploadArtifacts: true</p> <p>temporaryPublicStorage: true</p> <p>budgetPath: ./lighthouse-budget.json</p> </code>`<code> <strong>Budget file</strong> (</code>lighthouse-budget.json<code>): </code>`<code>json <p>[</p> <p>{</p> <p>"path": "/*",</p> <p>"timings": [</p> <p>{ "metric": "first-contentful-paint", "budget": 2000 },</p> <p>{ "metric": "largest-contentful-paint", "budget": 2500 },</p> <p>{ "metric": "cumulative-layout-shift", "budget": 0.1 }</p> <p>]</p> <p>}</p> <p>]</p> </code>`<code> <strong>Speed</strong>: 20-40 seconds per URL. <strong>When to block merge</strong>: Core Web Vitals regressions (LCP increases by >500ms, CLS exceeds 0.1). <h3 id="test-7-indexability-check" class="text-xl font-semibold mt-8 mb-4">Test 7: Indexability Check</h3> <strong>What it catches</strong>: Accidental noindex tags added, pages returning X-Robots-Tag: noindex headers, canonical chains. <strong>Implementation</strong>: </code>`<code>javascript <p>// tests/seo/indexability-check.js</p> <p>async function checkIndexability(url) {</p> <p>const response = await fetch(url);</p> <p>const headers = response.headers;</p> <p>// Check HTTP headers</p> <p>const xRobotsTag = headers.get('x-robots-tag');</p> <p>if (xRobotsTag && xRobotsTag.includes('noindex')) {</p> <p>console.error(</code>ERROR: ${url} has noindex in X-Robots-Tag header<code>);</p> <p>process.exit(1);</p> <p>}</p> <p>// Check HTML meta tags</p> <p>const html = await response.text();</p> <p>if (html.match(/<meta\s+name="robots"\s+content="noindex"/i)) {</p> <p>console.error(</code>ERROR: ${url} has noindex meta tag<code>);</p> <p>process.exit(1);</p> <p>}</p> <p>// Check canonical chain</p> <p>const canonicalMatch = html.match(/<link\s+rel="canonical"\s+href="([^"]+)"/i);</p> <p>if (canonicalMatch && canonicalMatch[1] !== url) {</p> <p>const canonicalUrl = canonicalMatch[1];</p> <p>const canonicalResponse = await fetch(canonicalUrl);</p> <p>const canonicalHtml = await canonicalResponse.text();</p> <p>const nestedCanonical = canonicalHtml.match(/<link\s+rel="canonical"\s+href="([^"]+)"/i);</p> <p>if (nestedCanonical && nestedCanonical[1] !== canonicalUrl) {</p> <p>console.error(</code>ERROR: Canonical chain detected: ${url} → ${canonicalUrl} → ${nestedCanonical[1]}<code>);</p> <p>process.exit(1);</p> <p>}</p> <p>}</p> <p>}</p> </code>`<code> <strong>Speed</strong>: 2-5 seconds per URL. <strong>When to block merge</strong>: Any noindex tag on pages that should be indexed, or canonical chains. <h2 id="layer-3-post-deploy-monitoring" class="text-2xl sm:text-3xl font-bold mb-6">Layer 3: Post-Deploy Monitoring</h2> <p>Even with perfect pre-deploy tests, production issues emerge—CDN misconfigurations, database migrations affecting dynamic content, third-party script changes.</p> <h3 id="monitor-8-index-status-tracking" class="text-xl font-semibold mt-8 mb-4">Monitor 8: Index Status Tracking</h3> <strong>What it monitors</strong>: Sudden drops in indexed pages suggest deindexing events. <strong>Implementation</strong>: <p>Use Google Search Console API to track indexed page count daily.</p> </code>`<code>python <h1>scripts/monitor-index-status.py</h1> <p>from google.oauth2 import service_account</p> <p>from googleapiclient.discovery import build</p> <p>import sys</p> <p>credentials = service<em>account.Credentials.from</em>service<em>account</em>file('service-account.json')</p> <p>service = build('searchconsole', 'v1', credentials=credentials)</p> <p>site_url = 'https://example.com/'</p> <p>response = service.sitemaps().list(siteUrl=site_url).execute()</p> <h1>Get total indexed pages</h1> <p>indexed = sum(sitemap.get('contents', [{}])[0].get('indexed', 0) for sitemap in response.get('sitemap', []))</p> <h1>Alert if drop exceeds 10%</h1> <p>baseline = 10000 # Your expected index count</p> <p>if indexed < baseline * 0.9:</p> <p>print(f"ALERT: Indexed pages dropped to {indexed} (baseline: {baseline})")</p> <p>sys.exit(1)</p> </code>`<code> <strong>Frequency</strong>: Run daily via cron or CI scheduled job. <strong>Alert threshold</strong>: 10% drop in indexed pages triggers investigation. <h3 id="monitor-9-core-web-vitals-regression" class="text-xl font-semibold mt-8 mb-4">Monitor 9: Core Web Vitals Regression</h3> <strong>What it monitors</strong>: Real user experience data from Chrome User Experience Report. <strong>Implementation</strong>: <p>Query CrUX API daily for your origin's Core Web Vitals percentiles.</p> </code>`<code>javascript <p>// scripts/monitor-cwv.js</p> <p>const fetch = require('node-fetch');</p> <p>async function checkCWV(url) {</p> <p>const response = await fetch(</p> <p>'https://chromeuxreport.googleapis.com/v1/records:queryRecord',</p> <p>{</p> <p>method: 'POST',</p> <p>headers: { 'Content-Type': 'application/json' },</p> <p>body: JSON.stringify({</p> <p>origin: url,</p> <p>formFactor: 'PHONE'</p> <p>})</p> <p>}</p> <p>);</p> <p>const data = await response.json();</p> <p>const lcp = data.record.metrics.largest<em>contentful</em>paint.percentiles.p75;</p> <p>const fid = data.record.metrics.first<em>input</em>delay.percentiles.p75;</p> <p>const cls = data.record.metrics.cumulative<em>layout</em>shift.percentiles.p75;</p> <p>if (lcp > 2500 || fid > 100 || cls > 0.1) {</p> <p>console.error(</code>CWV regression detected: LCP=${lcp}ms, FID=${fid}ms, CLS=${cls}<code>);</p> <p>process.exit(1);</p> <p>}</p> <p>}</p> <p>checkCWV('https://example.com');</p> </code>`<code> <strong>Frequency</strong>: Daily. <strong>Alert threshold</strong>: Any metric failing "Good" threshold. <h3 id="monitor-10-organic-traffic-anomaly-detection" class="text-xl font-semibold mt-8 mb-4">Monitor 10: Organic Traffic Anomaly Detection</h3> <strong>What it monitors</strong>: Sudden traffic drops indicate ranking losses or technical issues. <strong>Implementation</strong>: <p>Query Google Analytics 4 API, compare today's traffic to 7-day average.</p> </code>`<code>python <h1>scripts/monitor-traffic.py</h1> <p>from google.analytics.data_v1beta import BetaAnalyticsDataClient</p> <p>from google.analytics.data_v1beta.types import RunReportRequest, DateRange, Metric</p> <p>import sys</p> <p>client = BetaAnalyticsDataClient()</p> <p>property_id = 'properties/123456789'</p> <p>response = client.run_report(</p> <p>request=RunReportRequest(</p> <p>property=property_id,</p> <p>date<em>ranges=[DateRange(start</em>date='7daysAgo', end_date='yesterday')],</p> <p>metrics=[Metric(name='sessions')],</p> <p>dimension_filter={</p> <p>'filter': {</p> <p>'field_name': 'sessionDefaultChannelGroup',</p> <p>'string_filter': {'value': 'Organic Search'}</p> <p>}</p> <p>}</p> <p>)</p> <p>)</p> <p>sessions = int(response.rows[0].metric_values[0].value)</p> <p>baseline = 5000 # 7-day average baseline</p> <p>if sessions < baseline * 0.8:</p> <p>print(f"ALERT: Organic sessions dropped to {sessions} (baseline: {baseline})")</p> <p>sys.exit(1)</p> </code>`<code> <strong>Frequency</strong>: Hourly or daily depending on traffic volume. <strong>Alert threshold</strong>: 20% drop compared to 7-day average. <h2 id="integrating-tests-into-ci-cd" class="text-2xl sm:text-3xl font-bold mb-6">Integrating Tests into CI/CD</h2> <h3 id="github-actions-example" class="text-xl font-semibold mt-8 mb-4">GitHub Actions Example</h3> </code>`<code>yaml <h1>.github/workflows/seo-tests.yml</h1> <p>name: SEO Tests</p> <p>on:</p> <p>pull_request:</p> <p>branches: [main]</p> <p>jobs:</p> <p>seo-validation:</p> <p>runs-on: ubuntu-latest</p> <p>steps:</p> <p>- name: Checkout code</p> <p>uses: actions/checkout@v3</p> <p>- name: Setup Node.js</p> <p>uses: actions/setup-node@v3</p> <p>with:</p> <p>node-version: '18'</p> <p>- name: Install dependencies</p> <p>run: npm ci</p> <p>- name: Run meta tag validation</p> <p>run: npm run test:meta-tags</p> <p>- name: Run canonical check</p> <p>run: npm run test:canonicals</p> <p>- name: Deploy to staging</p> <p>run: npm run deploy:staging</p> <p>env:</p> <p>STAGING<em>KEY: ${{ secrets.STAGING</em>KEY }}</p> <p>- name: Wait for staging deployment</p> <p>run: sleep 30</p> <p>- name: Crawl staging site</p> <p>run: npm run test:crawl</p> <p>env:</p> <p>STAGING_URL: https://staging.example.com</p> <p>- name: Validate schema markup</p> <p>run: npm run test:schema</p> <p>- name: Run Lighthouse CI</p> <p>uses: treosh/lighthouse-ci-action@v9</p> <p>with:</p> <p>urls: |</p> <p>https://staging.example.com/</p> <p>uploadArtifacts: true</p> <p>- name: Check indexability</p> <p>run: npm run test:indexability</p> </code>`<code> <h3 id="gitlab-ci-example" class="text-xl font-semibold mt-8 mb-4">GitLab CI Example</h3> </code>`<code>yaml <h1>.gitlab-ci.yml</h1> <p>stages:</p> <p>- validate</p> <p>- build</p> <p>- test</p> <p>- deploy</p> <p>meta-tag-validation:</p> <p>stage: validate</p> <p>script:</p> <p>- npm run test:meta-tags</p> <p>canonical-validation:</p> <p>stage: validate</p> <p>script:</p> <p>- npm run test:canonicals</p> <p>build-staging:</p> <p>stage: build</p> <p>script:</p> <p>- npm run build</p> <p>artifacts:</p> <p>paths:</p> <p>- dist/</p> <p>deploy-staging:</p> <p>stage: deploy</p> <p>script:</p> <p>- npm run deploy:staging</p> <p>environment:</p> <p>name: staging</p> <p>url: https://staging.example.com</p> <p>crawl-test:</p> <p>stage: test</p> <p>script:</p> <p>- npm run test:crawl</p> <p>dependencies:</p> <p>- deploy-staging</p> <p>schema-validation:</p> <p>stage: test</p> <p>script:</p> <p>- npm run test:schema</p> <p>dependencies:</p> <p>- deploy-staging</p> <p>lighthouse-test:</p> <p>stage: test</p> <p>image: cypress/browsers:node16.14.2-slim-chrome100-ff99-edge</p> <p>script:</p> <p>- npm install -g @lhci/cli</p> <p>- lhci autorun</p> </code>`<code> <h2 id="handling-test-failures" class="text-2xl sm:text-3xl font-bold mb-6">Handling Test Failures</h2> <strong>Philosophy</strong>: SEO tests should fail loudly and block deploys for critical issues, but only warn for minor problems. <h3 id="blocking-failures-exit-code-1" class="text-xl font-semibold mt-8 mb-4">Blocking Failures (Exit Code 1)</h3> <ul class="space-y-2 my-6 text-text-muted"><li>Missing title tags on indexable pages</li> <li>Multiple canonical tags on a single page</li> <li>Pages returning 500 errors</li> <li>Noindex tags on key pages (homepage, product pages)</li> <li>Core Web Vitals regressions exceeding 20%</li> <li>Malformed JSON-LD structured data</li> </ul> <h3 id="non-blocking-warnings-exit-code-0-log-warning" class="text-xl font-semibold mt-8 mb-4">Non-Blocking Warnings (Exit Code 0, Log Warning)</h3> <ul class="space-y-2 my-6 text-text-muted"><li>Meta descriptions exceeding 160 characters</li> <li>Missing alt text on images</li> <li>Redirect chains (if not on critical paths)</li> <li>Minor HTML validation errors</li> <li>PageSpeed score drops (as long as CWV pass)</li> </ul> <strong>Override mechanism</strong>: Allow developers to bypass warnings with a specific commit message flag like </code>[skip-seo-warnings]<code> if they understand the trade-off. <h2 id="custom-tests-for-different-frameworks" class="text-2xl sm:text-3xl font-bold mb-6">Custom Tests for Different Frameworks</h2> <h3 id="react-next-js" class="text-xl font-semibold mt-8 mb-4">React / Next.js</h3> <strong>Challenge</strong>: Server-side rendering and hydration issues can cause content mismatches. <strong>Test</strong>: Compare server-rendered HTML to client-rendered HTML after hydration. </code>`<code>javascript <p>// tests/seo/ssr-test.js</p> <p>const { chromium } = require('playwright');</p> <p>async function testSSR(url) {</p> <p>const browser = await chromium.launch();</p> <p>const page = await browser.newPage();</p> <p>// Capture HTML before JavaScript execution</p> <p>await page.goto(url, { waitUntil: 'domcontentloaded' });</p> <p>const ssrContent = await page.content();</p> <p>// Capture HTML after JavaScript execution</p> <p>await page.goto(url, { waitUntil: 'networkidle' });</p> <p>const csrContent = await page.content();</p> <p>// Compare key SEO elements</p> <p>const ssrTitle = ssrContent.match(/<title>(.*?)<\/title>/)?.[1];</p> <p>const csrTitle = csrContent.match(/<title>(.*?)<\/title>/)?.[1];</p> <p>if (ssrTitle !== csrTitle) {</p> <p>console.error(</code>Title mismatch: SSR="${ssrTitle}" vs CSR="${csrTitle}"<code>);</p> <p>process.exit(1);</p> <p>}</p> <p>await browser.close();</p> <p>}</p> </code>`<code> <h3 id="wordpress" class="text-xl font-semibold mt-8 mb-4">WordPress</h3> <strong>Challenge</strong>: Plugin updates can break SEO settings (Yoast, Rank Math). <strong>Test</strong>: Verify SEO plugin API outputs match expectations. </code>`<code>php <p>// tests/test-seo-plugin.php</p> <p>use PHPUnit\Framework\TestCase;</p> <p>class SEOPluginTest extends TestCase {</p> <p>public function testMetaTags() {</p> <p>$post_id = 123;</p> <p>$meta<em>title = get</em>post<em>meta($post</em>id, '<em>yoast</em>wpseo_title', true);</p> <p>$meta<em>desc = get</em>post<em>meta($post</em>id, '<em>yoast</em>wpseo_metadesc', true);</p> <p>$this->assertNotEmpty($meta_title, 'Meta title should not be empty');</p> <p>$this->assertLessThanOrEqual(60, strlen($meta_title), 'Title exceeds 60 chars');</p> <p>$this->assertLessThanOrEqual(160, strlen($meta_desc), 'Description exceeds 160 chars');</p> <p>}</p> <p>}</p> </code>`<code> <h3 id="shopify" class="text-xl font-semibold mt-8 mb-4">Shopify</h3> <strong>Challenge</strong>: Liquid templates can break SEO when modified. <strong>Test</strong>: Validate that key Liquid variables render correctly. </code>`<code>javascript <p>// tests/seo/liquid-test.js</p> <p>const fetch = require('node-fetch');</p> <p>const cheerio = require('cheerio');</p> <p>async function testLiquidTemplates(productUrl) {</p> <p>const response = await fetch(productUrl);</p> <p>const html = await response.text();</p> <p>const $ = cheerio.load(html);</p> <p>const title = $('title').text();</p> <p>const ogTitle = $('meta[property="og:title"]').attr('content');</p> <p>const productJsonLd = $('script[type="application/ld+json"]').text();</p> <p>if (!title.includes('{{') && !ogTitle.includes('{{')) {</p> <p>console.log('Liquid variables rendered correctly');</p> <p>} else {</p> <p>console.error('ERROR: Unrendered Liquid variables detected');</p> <p>process.exit(1);</p> <p>}</p> <p>try {</p> <p>const json = JSON.parse(productJsonLd);</p> <p>if (!json['@type'] || json['@type'] !== 'Product') {</p> <p>throw new Error('Invalid Product schema');</p> <p>}</p> <p>} catch (error) {</p> <p>console.error('ERROR: Product schema invalid', error);</p> <p>process.exit(1);</p> <p>}</p> <p>}</p> </code>`<code> <h2 id="alerting-and-escalation" class="text-2xl sm:text-3xl font-bold mb-6">Alerting and Escalation</h2> <p>When post-deploy monitors detect issues, route alerts effectively.</p> <strong>Severity 1 (Critical)</strong>: Page on Slack/PagerDuty immediately <ul class="space-y-2 my-6 text-text-muted"><li>Indexed pages drop >20%</li> <li>Homepage returns non-200 status</li> <li>Entire site noindexed</li> </ul> <strong>Severity 2 (High)</strong>: Slack alert, resolve within 4 hours <ul class="space-y-2 my-6 text-text-muted"><li>Indexed pages drop 10-20%</li> <li>Core Web Vitals fail on key pages</li> <li>Product pages return errors</li> </ul> <strong>Severity 3 (Medium)</strong>: Email alert, resolve within 24 hours <ul class="space-y-2 my-6 text-text-muted"><li>Organic traffic drops 15-25%</li> <li>Schema markup errors on subset of pages</li> </ul> <strong>Severity 4 (Low)</strong>: Log warning, resolve in next sprint <ul class="space-y-2 my-6 text-text-muted"><li>Minor meta tag issues</li> <li>Redirect chains on low-traffic pages</li> </ul> <strong>Take Action: Give Your AI a Memory</strong> <p>Everything above gets easier when your AI already knows your business. The <a href="/setup.html" class="text-accent hover:text-accent-light">Claude Code + Obsidian setup</a> builds persistent, file-based memory so context compounds instead of evaporating between sessions.</p> <h2 id="key-recap" class="text-2xl sm:text-3xl font-bold mb-6">Key Recap</h2> <ul class="space-y-2 my-6 text-text-muted"><li><strong>The SEO Testing Stack:</strong> Your pipeline needs three testing layers.</li> <li><strong>Layer 1: Pre-Commit Hooks:</strong> Install these checks in .git/hooks/pre-commit or use a tool like Husky (for JavaScript projects) or pre-commit (for Python projects).</li> <li><strong>Layer 2: Build-Time Tests (CI Pipeline):</strong> Run these in your CI environment (GitHub Actions, CircleCI, Jenkins, etc.) before merging pull requests.</li> <li><strong>Layer 3: Post-Deploy Monitoring:</strong> Even with perfect pre-deploy tests, production issues emerge—CDN misconfigurations, database migrations affecting dynamic content, third-party script changes.</li> <li><strong>Integrating Tests into CI/CD:</strong> jobs:</li> </ul> seo-validation: <p>runs-on: ubuntu-latest</p> <ul class="space-y-2 my-6 text-text-muted"><li><strong>Handling Test Failures:</strong> async function testSSR(url) {</li> </ul> const browser = await chromium.launch(); <p>const page = await browser.newPage();</p> <h2 id="faq" class="text-2xl sm:text-3xl font-bold mb-6">FAQ</h2> <strong>How do I convince engineering to adopt SEO tests?</strong> <p>Frame it as preventing revenue loss, not as bureaucracy. Show historical incidents—"Last quarter, a deploy noindexed 1,000 pages and cost us $50K in lost traffic." Quantify the cost of SEO regressions. Emphasize that tests prevent firefighting, not create work.</p> <strong>What if tests slow down the CI pipeline too much?</strong> <p>Optimize test coverage. Run fast checks (meta tags, canonicals) on every commit. Run slower checks (crawling, Lighthouse) only on staging deploys or nightly. Cache crawl results and only re-test changed pages.</p> <strong>Should SEO tests block production deploys?</strong> <p>Yes for critical issues (noindex, server errors, broken canonicals). No for warnings (meta description length, missing alt text). Use a tiered system: errors block, warnings log.</p> <strong>Can I use these tests with a headless CMS?</strong> <p>Yes. Deploy your frontend to staging, then run crawl and rendering tests against the staging URL. The tests don't care whether content comes from a CMS, static files, or a database—they validate the rendered HTML.</p> <strong>What if false positives block legitimate deploys?</strong> <p>Build override mechanisms. Require a manual approval step or a commit message flag (</code>[override-seo-test: reason]`) that logs the bypass for later review. Track override frequency—if developers bypass tests often, the tests are misconfigured.</p> <strong>How do I test SEO for authenticated pages?</strong> <p>Use Playwright or Puppeteer to log in programmatically before crawling. Store session cookies or tokens. Test that logged-in product pages, dashboards, or account pages have correct meta tags and aren't accidentally noindexed.</p> <strong>What about testing for different locales or languages?</strong> <p>Run separate test suites per locale. Validate hreflang tags, ensure canonical URLs point to the correct language version, check that content isn't duplicated across locales without proper hreflang signals.</p> <strong>How do I test dynamic content (personalization, A/B tests)?</strong> <p>Disable personalization in staging environments for SEO tests. Use a specific user agent or cookie that returns the default, non-personalized version. Google crawls the non-personalized version, so that's what your tests should validate.</p> <p>SEO testing isn't about perfection. It's about preventing catastrophic regressions—the noindex tag pushed to production, the robots.txt that blocks everything, the canonical chain that deindexes your entire catalog.</p> <p>Automate the checks that catch 80% of issues. Invest 20% of your SEO engineering time in test infrastructure, and you'll prevent 80% of ranking drops caused by code changes.</p> <hr class="my-8 border-border"> <h2 id="when-this-approach-isn-t-right" class="text-2xl sm:text-3xl font-bold mb-6">When This Approach Isn't Right</h2> <p>This guidance may not fit if:</p> <ul class="space-y-2 my-6 text-text-muted"><li><strong>You're brand new to SEO.</strong> Some frameworks here assume working knowledge of crawling, indexing, and ranking fundamentals. Start with the basics first — this article builds on them.</li> <li><strong>Your site has fewer than 50 indexed pages.</strong> Some strategies (like cannibalization audits or hub-and-spoke restructuring) require a minimum content base. Focus on content creation before optimization.</li> <li><strong>You're working on a site with active penalties.</strong> Manual actions require a different playbook. Resolve the penalty first, then apply these optimization frameworks.</li> </ul> <hr class="my-8 border-border"> <h2 id="frequently-asked-questions" class="text-2xl sm:text-3xl font-bold mb-6">Frequently Asked Questions</h2> <h3 id="is-this-relevant-to-my-specific-seo-role" class="text-xl font-semibold mt-8 mb-4">Is this relevant to my specific SEO role?</h3> <p>This article addresses patterns that apply across SEO specializations. Whether you manage technical SEO, content strategy, or client-facing audits, the frameworks here adapt to your workflow. Role-specific implementation details are called out where they diverge.</p> <h3 id="how-do-i-prioritize-these-recommendations" class="text-xl font-semibold mt-8 mb-4">How do I prioritize these recommendations?</h3> <p>Start with the diagnostic framework in the first section to identify which recommendations match your current situation. Not everything applies to every site. Prioritize by expected impact relative to implementation effort — the article flags which tactics are quick wins versus long-term investments.</p> <h3 id="can-i-share-this-with-my-team-or-clients" class="text-xl font-semibold mt-8 mb-4">Can I share this with my team or clients?</h3> <p>Yes. The frameworks are designed to be communicable. The comparison tables and checklists work well in client presentations or team documentation. Adapt the specific numbers to your data when presenting recommendations.</p> </div> <!-- CTA Section --> <section class="my-16 p-8 sm:p-10 bg-gradient-to-br from-accent/20 via-surface to-surface-light rounded-2xl border border-accent/20 text-center"> <h2 class="text-2xl sm:text-3xl font-bold mb-4"> Your AI Has Amnesia. Here's the Fix. </h2> <p class="text-lg text-text-muted mb-6 max-w-xl mx-auto"> $997. 90 minutes. One file that gives Claude permanent memory of your business, your clients, and your preferences. </p> <ul class="text-left max-w-md mx-auto mb-8 space-y-3"> <li class="flex items-start gap-3"> <svg class="w-5 h-5 text-accent mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 20 20"> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> </svg> <span class="text-text-muted">Personal CLAUDE.md file built for your specific context</span> </li> <li class="flex items-start gap-3"> <svg class="w-5 h-5 text-accent mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 20 20"> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> </svg> <span class="text-text-muted">Obsidian vault structure optimized for AI retrieval</span> </li> <li class="flex items-start gap-3"> <svg class="w-5 h-5 text-accent mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 20 20"> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> </svg> <span class="text-text-muted">Claude Code configuration and hook scripts</span> </li> <li class="flex items-start gap-3"> <svg class="w-5 h-5 text-accent mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 20 20"> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> </svg> <span class="text-text-muted">Live 90-minute walkthrough of the entire system</span> </li> </ul> <a href="https://scalewithsearch.com/#offer" class="cta-primary relative inline-flex items-center justify-center px-8 py-4 bg-accent hover:bg-accent-light text-white text-lg font-semibold rounded-xl transition-colors"> Get Your Setup - $997 </a> <p class="text-text-dim text-sm mt-4">Pays for itself in the first week.</p> </section> </article> </div> </main> <!-- Footer --> <footer class="bg-void border-t border-border py-12"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6"> <div> <p class="text-lg font-semibold text-text mb-1">Search your thoughts. Scale your output.</p> <p class="text-sm text-text-dim">© 2026 AI First Search. All rights reserved.</p> </div> <nav class="flex flex-wrap gap-6 text-sm text-text-muted"> <a href="/how-to-make-ai-remember-you" class="hover:text-text transition-colors">The Guide</a> <a href="/about" class="hover:text-text transition-colors">About</a> <a href="https://scalewithsearch.com/#offer" class="hover:text-text transition-colors">Get Setup</a> <a href="/pages/privacy" class="hover:text-text transition-colors">Privacy</a> </nav> </div> </div> </footer> <script> // Reading Progress const progressBar = document.getElementById('reading-progress'); window.addEventListener('scroll', () => { const scrollTop = window.scrollY; const docHeight = document.documentElement.scrollHeight - window.innerHeight; const progress = (scrollTop / docHeight) * 100; progressBar.style.width = `${progress}%`; }); // Mobile Menu Toggle const mobileMenuBtn = document.getElementById('mobile-menu-btn'); const mobileMenu = document.getElementById('mobile-menu'); mobileMenuBtn.addEventListener('click', () => { mobileMenu.classList.toggle('hidden'); }); // Copy Code Button function copyCode(button) { const codeBlock = button.closest('.code-block'); const code = codeBlock.querySelector('code').innerText; navigator.clipboard.writeText(code).then(() => { button.textContent = 'Copied!'; button.classList.add('copied'); setTimeout(() => { button.textContent = 'Copy'; button.classList.remove('copied'); }, 2000); }); } </script> </body> </html>