QA w technicznym SEO: canonical, hreflang, metadata i redirecty bez regresji

Opublikowano
4 czerwca 2026
Aktualizacja
25 czerwca 2026
Czas czytania
6 min czytania

Dlaczego błędy technicznego SEO przeciekają na produkcję

W aplikacjach mamy testy komponentów, testy API, czasem testy E2E. Metadane zbyt często zostają jednak poza tym systemem, choć nie powinny.

Przykłady z projektów:

  • stagingowy noindex trafił na produkcję,
  • canonical wszystkich filtrów wskazuje bieżący URL z parametrami,
  • wersja EN nie odsyła hreflangiem do PL,
  • migracja zmieniła 301 na 302,
  • nowy layout nie przekazuje description,
  • strona kategorii ma pusty <title>.

Żadna z tych rzeczy nie psuje UI. To negatywne konsekwencje SEO, które mogą pozostać niewidoczne przez jakiś czas, by potem nagle ujawnić się w Google Search Console.

Jedno źródło prawdy dla metadanych SEO

Unikaj sytuacji w której każdy szablon składa metadata, bo to potencjalne źródło problemów. Ktoś doda trailing slash, ktoś inny użyje względnego canonicala, jeszcze inna osoba zapomni o x-default.

Pierwszy krok to helper, który centralizuje reguły.

Code
// src/seo/metadata.ts
type Locale = 'pl' | 'en'
 
type SeoInput = {
  path: string
  locale: Locale
  title: string
  description: string
  index?: boolean
  alternates?: Partial<Record<Locale, string>>
}
 
const siteUrl = 'https://example.com'
 
export function buildSeoMetadata(input: SeoInput) {
  const canonical = new URL(input.path, siteUrl).toString()
  const shouldIndex = input.index !== false
 
  return {
    title: input.title,
    description: input.description,
    robots: shouldIndex ? 'index,follow' : 'noindex,nofollow',
    alternates: {
      canonical,
      languages: buildLanguageAlternates(input.alternates),
    },
  }
}
 
function buildLanguageAlternates(alternates?: SeoInput['alternates']) {
  if (!alternates) return undefined
 
  return Object.fromEntries(
    Object.entries(alternates).map(([locale, path]) => [
      locale,
      new URL(path, siteUrl).toString(),
    ]),
  )
}

To nie musi być dokładnie taki kod, ale liczy się zasada, by meta dane trzymać w jednym miejscu.

Jak testować tagi canonical w QA

Google traktuje canonical jako mocny sygnał, ale nie jako rozkaz. Jeśli wysyłasz sprzeczne sygnały, wyszukiwarka może wybrać inaczej, niż chcesz.

Minimalna reguła powinna być taka: każdy canonical jest bezwzględnym URL-em bez parametrów śledzących, nie wskazuje na inną wersję językową, zgadza się z URL-em w sitemapie i jest jeden na stronie.

Test jednostkowy helpera:

Code
import { describe, expect, it } from 'vitest'
import { buildSeoMetadata } from './metadata'
 
describe('buildSeoMetadata', () => {
  it('builds an absolute canonical URL', () => {
    const metadata = buildSeoMetadata({
      path: '/blog/test/',
      locale: 'pl',
      title: 'Test',
      description: 'Opis testowy',
    })
 
    expect(metadata.alternates.canonical).toBe('https://example.com/blog/test/')
  })
})

Test po renderze w Playwright:

Code
import { expect, test } from '@playwright/test'
 
test('blog post has one absolute canonical URL', async ({ page }) => {
  await page.goto('/blog/przykladowy-wpis/')
 
  const canonicals = page.locator('link[rel="canonical"]')
  await expect(canonicals).toHaveCount(1)
 
  const href = await canonicals.first().getAttribute('href')
  expect(href).toBe('https://example.com/blog/przykladowy-wpis/')
})

Test jednostkowy chroni logikę. Playwright sprawdza, czy HTML naprawdę dostał to, co miał dostać.

Jak testować zgodność sitemap z tagami canonical

Sitemap i canonical opowiadają robotowi tę samą historię różnymi kanałami. Jeśli się rozjadą, wysyłasz sprzeczne sygnały: sitemap mówi „indeksuj ten URL", a strona pod tym URL-em ma canonical wskazujący gdzie indziej albo noindex. To klasyczna, cicha regresja po refaktorze routingu.

W QA pilnuję trzech rzeczy: w sitemapie są wyłącznie URL-e, które naprawdę mają się indeksować, każdy z nich zwraca 200, a jego canonical wskazuje sam siebie. Najtaniej sprawdzić to na danych, z których generujesz sitemap, zanim w ogóle dojdzie do renderu.

Code
import { describe, expect, it } from 'vitest'
import { buildSitemapUrls } from './sitemap'
import { redirects } from './redirects'
 
describe('buildSitemapUrls', () => {
  const urls = buildSitemapUrls()
 
  it('contains only absolute, deduplicated URLs', () => {
    const unique = new Set(urls)
    expect(unique.size).toBe(urls.length)
    for (const url of urls) {
      expect(url.startsWith('https://example.com/')).toBe(true)
    }
  })
 
  it('never lists a URL that is a redirect source', () => {
    const sources = new Set(
      redirects.map((r) => `https://example.com${r.source}`),
    )
    for (const url of urls) {
      expect(sources.has(url)).toBe(false)
    }
  })
})

Test wzajemności z redirectami łapie najczęstszy błąd po migracji: stary URL został w sitemapie, choć już prowadzi przez 301. Robot dostaje wtedy listę adresów, z których część od razu go przekierowuje, co osłabia zaufanie do całej sitemap.

Jak testować hreflang i wzajemność wersji językowych

Hreflang jest szczególnie wrażliwy na brak symetrii. Jeśli PL wskazuje EN, EN musi wskazywać PL. Każda wersja powinna wskazywać też samą siebie. Do tego dochodzi x-default, format kodów językowych i pełne URL-e.

Przy małej stronie można utrzymać to ręcznie. Przy wielu wpisach blogowych albo produktach lepiej generować relacje z danych.

Code
type LocalizedRoute = {
  id: string
  paths: Partial<Record<'pl' | 'en' | 'de', string>>
}
 
export function getHreflangSet(route: LocalizedRoute) {
  const entries = Object.entries(route.paths)
 
  return Object.fromEntries(
    entries.map(([locale, path]) => [locale, `https://example.com${path}`]),
  )
}

Testujemy dane, a nie pojedyncze strony. Najważniejszy jest tutaj test wzajemności. Jeśli zestaw hreflang wymienia dany język, każda strona w tym zestawie musi wskazywać na pełny komplet wersji językowych, włączając w to samą siebie.

Code
import { routes } from './localized-routes'
 
it('keeps hreflang relations reciprocal', () => {
  for (const route of routes) {
    const set = getHreflangSet(route)
    const locales = Object.keys(set)
 
    // każda wersja musi wskazywać pełny komplet, w tym samą siebie
    for (const locale of locales) {
      expect(set[locale]).toBeTruthy()
      const reverse = getHreflangSet(route)
      expect(Object.keys(reverse)).toEqual(locales)
    }
  }
})

To zabezpieczenie przed typową niespójnością, jaką są linki do nieistniejących tłumaczeń albo wersja EN, która nie odsyła z powrotem do PL. Taka asymetria jest gorsza niż całkowity brak hreflang, ponieważ w ten sposób wysyłamy robotowi obietnicę bez pokrycia.

Jak testować metadata: title, description i Open Graph

Tytuł i opis muszą istnieć, być unikalne w skali typu strony i nie wypadać przez refaktor.

W QA sprawdzam zwykle:

  • <title> nie jest pusty.
  • meta[name="description"] istnieje.
  • Description ma sensowną długość.
  • Open Graph używa tych samych danych.
  • Strony z noindex są jawnie oznaczone w danych, a nie przypadkiem.

Przykład testu:

Code
test('service page exposes basic metadata', async ({ page }) => {
  await page.goto('/uslugi/next-js/')
 
  await expect(page).toHaveTitle(/Next\.js/)
 
  const description = await page
    .locator('meta[name="description"]')
    .getAttribute('content')
 
  expect(description).toContain('Next.js')
  expect(description?.length ?? 0).toBeGreaterThan(80)
})

Nie testuj dokładnego zdania, jeśli copy zmienia się często. Testuj zakres, obecność i elementy, które nie powinny zniknąć.

Jak testować redirecty 301 trzymane jako dane

Redirecty lubią ginąć przy migracjach. Najgorszy układ to redirecty zapisane w panelu hostingu, częściowo w middleware, częściowo w configu, a częściowo w pamięci osoby, która robiła migrację.

Lepszy układ:

Code
// src/seo/redirects.ts
export const redirects = [
  {
    source: '/stary-blog/jak-wybrac-framework/',
    destination: '/blog/astro-js-vs-next-js-porownanie-frameworkow/',
    permanent: true,
  },
  {
    source: '/kontakt.html',
    destination: '/kontakt/',
    permanent: true,
  },
] as const

Tę samą listę wykorzystujesz w aplikacji i w testach.

Code
import { expect, test } from '@playwright/test'
import { redirects } from '../../src/seo/redirects'
 
for (const redirect of redirects) {
  test(`${redirect.source} redirects to ${redirect.destination}`, async ({
    request,
  }) => {
    const response = await request.get(redirect.source, { maxRedirects: 0 })
 
    expect(response.status()).toBe(redirect.permanent ? 301 : 302)
    expect(response.headers().location).toBe(redirect.destination)
  })
}

Przy większej migracji możesz mieć setki takich rekordów. To nadal normalne. Dane w tabeli są łatwiejsze do testowania niż ręczne klikanie starych URL-i po deployu.

Checklista QA technicznego SEO w pull requeście

Manualna checklista ma sens tylko wtedy, gdy prowadzi do decyzji. Reszta powinna działać automatycznie.

Dobry podział:

ObszarGdzie sprawdzać
Typy danych SEOTypeScript, Zod, walidacja CMS
Canonicaltest helpera + Playwright
Hreflangtest relacji w danych + Playwright dla szablonu
NoindexPlaywright na stronach, które mają się indeksować
Redirectytesty HTTP bez podążania za redirectem
Sitemaptest generowanej listy URL-i vs. canonical
Dane strukturalnesnapshot JSON-LD albo walidacja schematu
Bramkawymagany job w CI na pull requeście

W pull requeście człowiek powinien sprawdzić intencję: czy ta strona ma się indeksować, czy redirect prowadzi do najlepszego odpowiednika, czy title nie obiecuje czegoś, czego nie ma w treści. Maszyna pilnuje mechaniki.

Bramka QA SEO w CI: testy blokujące merge

Najlepszy zestaw testów SEO nie chroni przed niczym, jeśli da się go ominąć. Zasada braku regresji zaczyna obowiązywać dopiero wtedy, gdy te testy blokują merge tak samo jak każdy inny test. Inaczej pierwszy gorący deploy pod presją czasu przepuści dokładnie ten błąd, który miałeś wyłapywać.

Praktyczny podział wygląda tak: testy jednostkowe helperów i walidacja danych biegną na każdym pushu, bo są szybkie. Testy Playwright po renderze biegną na pull requeście przeciwko buildowi preview. Obie warstwy są wymagane do merge'u.

Code
# .github/workflows/seo-qa.yml
name: SEO QA
on: pull_request
 
jobs:
  seo-qa:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      # szybka warstwa: helpery, hreflang, sitemap na danych
      - run: npm run test:unit -- seo
      # wolniejsza warstwa: HTML po renderze
      - run: npx playwright install --with-deps chromium
      - run: npm run build
      - run: npm run test:e2e -- --grep @seo

Tag @seo pozwala odpalać same testy SEO osobno, kiedy zmienił się tylko routing albo metadata, bez czekania na cały zestaw E2E. Reszta to higiena: build musi przejść przed Playwrightem, a job jest wymagany w regułach ochrony brancha.

Częste błędy: gdzie łatwo przesadzić z QA SEO

Nie ma sensu testować każdej podstrony osobno, jeśli masz tysiące produktów. Wtedy wystarczy przetestować jedną stronę reprezentującą dany typ oraz próbki danych.

Nie popadaj też w przesadę, blokując merge tylko dlatego, że description ma 79 znaków zamiast 80. Najlepszy zestaw jest mały, szybki i bezwzględnie wyłapuje najważniejsze i najbardziej kosztowne błędy: deindeksację, zły canonical, utratę wersji językowych, zerwane redirecty i puste metadane.

Testy automatyczne komponentów i E2E w Cypress.
QA & Automation

Często zadawane pytania

Czym QA technicznego SEO różni się od audytu SEO?

Audyt mówi, co jest aktualnie zepsute, podczas gdy QA ma sprawić, że ten sam błąd nie wróci przy najbliższej okazji. Innymi słowy to różnica między raportem a systemem ochrony w kodzie.

Brak przypadkowego noindex, poprawny canonical, tytuł, meta description, spójne hreflang na stronach wielojęzycznych, statusy 301 dla redirectów i obecność danych strukturalnych tam, gdzie strona ich wymaga.

Nie. Część rzeczy lepiej sprawdzić statycznie w TypeScript albo w walidacji danych. Playwright zostaw dla renderowanego HTML, nagłówków i redirectów. Im bliżej źródła błędu złapiesz problem, tym mniej kosztuje naprawa.

Tak, ale zakres powinien być mały. Dla strony firmowej wystarczy kilka testów na głównych szablonach i redirectach. Rozbudowany system ma sens dopiero przy blogu, e-commerce, wielu językach albo częstych wdrożeniach.

Zakres powinien powstać wspólnie. Developerzy utrzymują testy i helpery w kodzie, a SEO wskazuje reguły biznesowe: które strony mają się indeksować, jakie redirecty muszą działać i które segmenty URL dowożą ruch.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty side-projecty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów