IlmHamroh
JavaScript Full-stack/22-qism. Frontend testlash13/16-dars24 daqiqa
Mundarija (33)

Visual regression testlar: Playwright toHaveScreenshot, oltin rasmlar va piksel farqi

Qisqacha: Visual regression test sahifaning suratini oladi va uni oldin saqlangan oltin rasm (baseline) bilan piksel bo'yicha solishtiradi. Farq chegaradan oshsa — test yiqiladi va farq rasmini beradi. Playwright'da bu bitta qator: await expect(page).toHaveScreenshot("royxat.png"). Birinchi ishga tushirishda rasm yoziladi, ataylab o'zgarishdan keyin --update-snapshots bilan yangilanadi. Bunday test kod, atribut va matnni emas, ko'zga ko'rinadigan natijani tekshiradi. vazifalar da u 20-qismdan beri yashirin turgan xatoni topdi: «Qayta urinish» tugmasi internet bor paytda ham ko'rinib turgan.

Bu darsda

  • Visual regression qanday ishlashini va boshqa testlar nimani ko'rmasligini tushuntira olasiz.
  • toHaveScreenshot bilan test yozasiz, oltin rasmlarni yaratasiz va yangilaysiz.
  • threshold, maxDiffPixels, maxDiffPixelRatio, mask, animations sozlamalarini tanlaysiz.
  • Beqaror piksel farqlarining sabablarini (shrift, animatsiya, kursor, sana, platforma) bilasiz.
  • Vitest toMatchScreenshot, Chromatic, Percy, Argos, Lost Pixel o'rnini taqqoslaysiz.

Oldin bilishingiz kerak: Storybook II, Playwright II, Kaskadni boshqarish: @layer, Ikonkalar.

1. Nega bu kerak?

vazifalar da ro'yxat ustida aloqa qatori bor. Internet yo'q yoki server javob bermasa — "Serverga ulanib bo'lmadi…" matni va «Qayta urinish» tugmasi chiqadi. Internet bor paytda tugma yashirin bo'lishi kerak. Kodda hammasi to'g'ri:

tsx
      <Tugma
        id="qayta-tugma"
        hidden={tarmoq !== "xato"}
        onClick={onQaytaUrin}
      >
        Qayta urinish
      </Tugma>

Ilovada esa tugma Ikonkalar darsidan (20-qism) beri doim ko'rinib turgan. Internet bor, ro'yxat yuklangan — tepada esa ma'nosiz «Qayta urinish». Shu vaqt ichida 348 ta Vitest testi, 10 ta Playwright e2e testi va kursning 159 ta brauzer tekshiruvi har holatda yashil bo'lgan. Hech biri xatoni ko'rmagan.

Nega? Ular atributni tekshirgan: "hidden bormi?" — bor edi. Foydalanuvchi esa atributni emas, ekranni ko'radi. Xatoni ekranning suratiga qaraydigan test topdi — bu darsning mavzusi.

Hayotiy o'xshatish: kitob bosmaxonasida har yangi nashrdan oldin bitta sahifani oldingi nashrning sahifasi ustiga qo'yib, yorug'likka tutib qarashadi. Harf siljigan, rasm xira chiqqan bo'lsa — darhol ko'rinadi. Matnni o'qib chiqish bunday xatoni ko'rmaydi.

2. Qanday ishlaydi

2.1 Oltin rasm va solishtirish

Visual regression testi (visual regression test — ko'rinish regressiyasi testi) — sahifa yoki komponent suratini oldingi tasdiqlangan surat bilan solishtirish. Regressiya — avval ishlagan narsaning buzilishi (regressiya testi). Tasdiqlangan surat — oltin rasm (golden image, baseline, reference — uchala nom ham uchraydi).

sequenceDiagram
  participant T as Test
  participant B as Brauzer
  participant D as Disk (oltin rasm)
  T->>B: sahifani och, tayyor bo'lishini kut
  B-->>T: surat (PNG)
  T->>D: oltin rasm bormi?
  alt yo'q
    T->>D: yoz (birinchi marta)
  else bor
    T->>T: piksel bo'yicha solishtir
    T-->>T: farq > chegara → yiqil + farq rasmi
  end

Rasmda: birinchi ishga tushirishda oltin rasm yoziladi, keyingilarida yangi surat u bilan solishtiriladi. Yiqilganda uchta rasm qoladi: kutilgan, haqiqiy va farq rasmi (diff) — farqli piksellar qizil bilan.

2.2 Solishtirish — o'quv modeli

Ikki rasmni qanday solishtirish mumkin? Rasm — piksellar massivi, har piksel — rang ([r, g, b]). Kichik model: 4 pikselli "rasm", ikki chegara bilan.

js
// Har piksel — [r, g, b] (0–255). 4 pikselli "rasm"
const golden = [
  [255, 255, 255], [255, 255, 255], [20, 23, 38], [255, 255, 255],
];
// Shrift chetidagi yumshatish: 1 piksel ozgina boshqa rang
const antialias = [
  [255, 255, 255], [250, 250, 250], [20, 23, 38], [255, 255, 255],
];
// Xato: 2 pikselda tugma paydo bo'ldi
const bug = [
  [255, 255, 255], [139, 130, 245], [139, 130, 245], [255, 255, 255],
];

// Ikki rang qanchalik farq qiladi: 0 (bir xil) … 1 (oq va qora)
function colorDistance(a, b) {
  const diffs = a.map((v, i) => Math.abs(v - b[i]));
  return Math.max(...diffs) / 255;
}

function compare(expected, actual, options = {}) {
  const { threshold = 0.2, maxDiffPixelRatio = 0 } = options;
  let diff = 0;
  for (let i = 0; i < expected.length; i++) {
    if (colorDistance(expected[i], actual[i]) > threshold) diff++;
  }
  const ratio = diff / expected.length;
  return { diff, ratio, pass: ratio <= maxDiffPixelRatio };
}

console.log(compare(golden, antialias));
console.log(compare(golden, bug));
console.log(compare(golden, bug, { maxDiffPixelRatio: 0.5 }));

Konsolda:

text
{ diff: 0, ratio: 0, pass: true }
{ diff: 2, ratio: 0.5, pass: false }
{ diff: 2, ratio: 0.5, pass: true }

Ikki xil chegara bor va ular har xil savolga javob beradi:

  • threshold — bitta piksel uchun: "bu ikki rang bir xilmi?" 250 va 255 — 0.02 farq, 0.2 dan kichik: bir xil deb hisoblandi. Shrift chetlaridagi mayda farqlar shunday o'tib ketadi.
  • maxDiffPixelRatio — butun rasm uchun: "nechta piksel farq qilsa ham mayli?" 0 — bitta ham. 0.5 — yarmi (bu juda yumshoq: xato ham o'tib ketdi, uchinchi qatorga qarang).

Playwright ham shunday ishlaydi, faqat rangni aqlliroq solishtiradi: pixelmatch kutubxonasi YIQ rang fazosida (ko'z sezadigan farq) hisoblaydi va yumshatilgan (anti-aliased) piksellarni alohida taniydi. Playwright'da threshold sukut bo'yicha 0.2, piksellar soni chegarasi esa sukut bo'yicha yo'q — bitta farqli piksel ham testni yiqitadi.

Tekshirib ko'ring: threshold: 0 va maxDiffPixelRatio: 0.01 — bu qanday test? threshold: 0.9 va maxDiffPixelRatio: 0 chi?

Javob

Birinchisi: har qanday rang farqini "farq" deb sanaydi, lekin 1 % gacha shunday pikselga ruxsat beradi — mayda shovqinni o'tkazadi, katta o'zgarishni ushlaydi. Ikkinchisi: deyarli har qanday rang o'zgarishini "bir xil" deb hisoblaydi (oq va kulrang ham), keyin bitta ham farqga ruxsat bermaydi — qattiq ko'rinadi, aslida juda yumshoq: rangi o'zgargan tugmani sezmaydi.

3. Playwright: toHaveScreenshot

3.1 vazifalar dagi test

ts
import { expect, test } from "@playwright/test";
import { soxtaApi } from "./soxta-api.ts";

// tayyor — sahifa ma'lumot bilan chizilganini bildiradigan sarlavha
const SAHIFALAR = [
  { nomi: "royxat", yol: "/", tayyor: "Non olish #bozor" },
  { nomi: "sozlamalar", yol: "/sozlamalar", tayyor: "Sozlamalar" },
  { nomi: "vazifa", yol: "/vazifa/1", tayyor: "Non olish #bozor" },
] as const;

for (const tema of ["light", "dark"] as const) {
  test.describe(`${tema} tema`, () => {
    // Ilova "tizim" temasida — brauzerning prefers-color-scheme i
    test.use({ colorScheme: tema });

    for (const { nomi, yol, tayyor } of SAHIFALAR) {
      test(`${nomi} sahifasi`, async ({ page }) => {
        await soxtaApi(page);
        await page.goto(yol);
        // Server javobi kelib, sahifa ma'lumot bilan
        // chizilguncha kutamiz
        await expect(
          page.getByText(tayyor, { exact: true }).first(),
        ).toBeVisible();
        await expect(page).toHaveScreenshot(`${nomi}-${tema}.png`, {
          fullPage: true,
          // Motion (JS) animatsiyalari ham — "disabled" CSS'ni
          // to'xtatadi, qolganini sahifa tinchlanguncha
          // Playwright o'zi kutadi
          animations: "disabled",
          // Kursor (caret) miltillashi — har suratda boshqacha
          caret: "hide",
        });
      });
    }
  });
}

Kanon izohlari telefonga sig'ishi uchun bo'lingan. Nima bo'layapti:

  • Ikki sikl: 2 tema × 3 sahifa = 6 test, har biri bitta surat. colorScheme — brauzer prefers-color-scheme ni shunday deb aytadi; ilova "tizim" temasida, shuning uchun qorong'i rejim ham suratga tushadi.
  • soxtaApi(page) — Playwright I dagi page.route soxta serveri: ma'lumot har safar bir xil. Haqiqiy server bilan surat har kuni boshqacha bo'lardi.
  • Tayyorlikni kutish — eng muhim qator. Surat sahifa ma'lumot bilan chizilgandan keyin olinsin. Birinchi variantda kutish faqat #aloqa elementiga qarardi, u esa faqat ro'yxat sahifasida bor — 6 testdan 4 tasi "element topilmadi" bilan yiqildi. Har sahifaga o'z "tayyor" matni berildi.
  • fullPage: true — faqat ekran (1280×720) emas, butun uzun sahifa.

toHaveScreenshot suratni darhol olmaydi: u ketma-ket ikki surat bir xil chiqguncha qayta suratga oladi, keyin oxirgisini oltin rasm bilan solishtiradi. Yarim chizilgan sahifa shunday o'tkazib yuboriladi.

3.2 Alohida loyiha

playwright.config.ts da suratli testlar alohida loyiha — vizual:

ts
  expect: {
    // 22/#13: farqli piksellar ulushi — 1 % gacha. Bir mashinada
    // 0 bilan ham barqaror (o'lchandi: 30/30), zaxira — Chrome/shrift
    // yangilanishi antialiasing'ni ozgina o'zgartirsa.
    // «Qayta urinish» tugmasi xatosi 3–5 %
    toHaveScreenshot: { maxDiffPixelRatio: 0.01 },
  },
ts
    {
      // 22/#13: visual regression. Oltin rasmlar platformaga
      // bog'liq — CI (Linux) uchun Linux'da yaratiladi;
      // lokal Windows'da — o'ziniki
      name: "vizual",
      testMatch: /vizual.spec.ts/,
      use: {
        ...devices["Desktop Chrome"],
        storageState: "e2e/.auth/foydalanuvchi.json",
      },
      dependencies: ["kirish"],
    },

chrome loyihasiga testIgnore: /vizual.spec.ts/ qo'shildi, skriptlar: "test:e2e": "playwright test --project=chrome", "test:vizual": "playwright test --project=vizual". Nega ajratildi: suratli testlar mashinaga bog'liq (keyingi bo'limlar) va ataylab dizayn o'zgarishida hammasi birga qizarishi mumkin — oddiy e2e testlar bundan aziyat chekmasin.

3.3 Oltin rasmlarni yaratish va yangilash

bash
npx playwright test --project=vizual --update-snapshots

O'lchov: 15.9 s, 6 ta PNG, jami 312 KB. Ular test fayli yonidagi papkaga tushadi:

text
e2e/vizual.spec.ts-snapshots/
  royxat-light-vizual-win32.png
  royxat-dark-vizual-win32.png
  sozlamalar-light-vizual-win32.png
  sozlamalar-dark-vizual-win32.png
  vazifa-light-vizual-win32.png
  vazifa-dark-vizual-win32.png

Nom shabloni: <siz bergan nom>-<loyiha>-<platforma>.png. Platforma (win32, linux, darwin) nomda — chunki bir sahifa har tizimda boshqacha chiziladi ("Windows va Linux" bo'limi). Rasmlar git'ga kiradi: ular test natijasi emas, testning "kutilgan qiymati".

--update-snapshots (qisqasi -u) to'rt rejimda ishlaydi:

Rejim Nima qiladi
bayroqsiz (missing) faqat yo'q rasmni yozadi; test baribir yiqiladi ("snapshot doesn't exist")
-u yoki changed farq qilganlarini qayta yozadi
all hammasini qayta yozadi
none hech narsa yozmaydi

Qoida: -u ni faqat ataylab o'zgarishdan keyin bosing — va yangi rasmlarni commit'dan oldin ko'zingiz bilan ko'ring. "Test qizil — -u bosaman" degani xatoni oltin rasmga aylantirish degani. Snapshot darsidagi ogohlantirish bu yerda ikki barobar kuchli: rasm farqini kod ko'rib chiqishda o'qish matndan ham qiyin.

Tekshiruv uch marta: 17.1 / 14.2 / 13.7 s devor, 7 passed (6 surat + kirish).

4. Topilma: «Qayta urinish» tugmasi

4.1 Rasm nima ko'rsatdi

Birinchi oltin rasmlarni ko'zdan kechirganda ro'yxat suratida g'alati narsa ko'rindi: soxta server ishlayapti, ro'yxat chizilgan, aloqa matni bo'sh — tugma esa turibdi. Bu — oltin rasmni tasdiqlashdan oldin ko'rish qoidasining amaldagi foydasi: rasm "kutilgan qiymat" bo'lishidan oldin u to'g'rimi, deb so'rash kerak.

4.2 Sabab: hidden va display

hidden atributi o'zi hech narsani yashirmaydi. Uni brauzerning o'z CSS qoidasi yashiradi — brauzer standart stillari (UA stylesheet) ichida: [hidden] { display: none }. Bu qoida kaskadda eng kuchsiz — har qanday muallif CSS'i uni bosadi (kaskad).

Tugma ning sinflari orasida inline-flex bor — Ikonkalar darsida ikonka va matnni bir qatorga qo'yish uchun qo'shilgan. Tailwind'da u display: inline-flex beradi. Muallif CSS'i brauzer qoidasidan ustun — tugma ko'rinadi. Tailwind'ning o'z reset'i (preflight) bu holatni tuzatadigan qoida bilan keladi, lekin shadcn/ui I darsida preflight'ni ataylab ulamagan edik.

Bu holatni natija oynasida ko'ramiz — u haqiqiy Chrome, CSS haqiqatan qo'llanadi:

tsx
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";

// Tailwind'dagidek: utilita sinfi qatlam (layer) ichida
const css = `
@layer base, utilities;
@layer utilities {
  .inline-flex { display: inline-flex; }
}
`;

function Aloqa({ tarmoq }: { tarmoq: string }) {
  return (
    <div>
      <style>{css}</style>
      <p>Internet bor.</p>
      <button className="inline-flex" hidden={tarmoq !== "xato"}>
        Qayta urinish
      </button>
    </div>
  );
}

test("hidden atributi bor", () => {
  render(<Aloqa tarmoq="tayyor" />);
  const btn = screen.getByText("Qayta urinish");
  expect(btn).toHaveAttribute("hidden");
});

test("jest-dom: ko'rinmaydi", () => {
  render(<Aloqa tarmoq="tayyor" />);
  const btn = screen.getByText("Qayta urinish");
  expect(btn).not.toBeVisible();
});

test("ekranda: brauzer chizdimi?", () => {
  render(<Aloqa tarmoq="tayyor" />);
  const btn = screen.getByText("Qayta urinish");
  console.log(getComputedStyle(btn).display);
  expect(btn.checkVisibility()).toBe(false);
});

Natija oynasida (Chrome 155) konsol:

text
  ✓ hidden atributi bor
  ✓ jest-dom: ko'rinmaydi
inline-flex
  × ekranda: brauzer chizdimi?
    → expected true to be false // Object.is equality
Testlar: 1 yiqildi, 2 o'tdi (jami 3)

Uch test — uch xil javob:

  1. Atribut — bor. E2e testlar va kursning brauzer tekshiruvlari shuni so'ragan.
  2. jest-dom toBeVisible — "ko'rinmaydi" deydi va adashadi. Biz jest-dom 7.0.1 manba kodiga qaradik: toBeVisible display ni ham tekshiradi, lekin hidden atributi bor elementni CSS'dan qat'i nazar "ko'rinmas" deb hisoblaydi. jsdom'dagi komponent testlari bu xatoni hech qachon ko'rmas edi.
  3. Brauzerning o'zi — getComputedStyle inline-flex deydi, checkVisibility() — true (brauzer elementni chizgan). Faqat shu test haqiqatni aytdi.

Yana bir qiziq farq: shu blokni jsdom'da (kursning tekshiruvchisi) ishga tushirsak, getComputedStyle none qaytaradi — jsdom qatlamli CSS'ni Chrome kabi hisoblamaydi. "jsdom — brauzer emas" (Browser Mode) bu yerda yana bir bor tasdiqlandi. Shuning uchun blok sayt bayrog'i bilan — natijasi faqat Chrome'da to'g'ri.

4.3 Tuzatish: [hidden] qoidasi !important bilan

asos.css ga — Tailwind preflight'idagi o'sha qoida:

css
[hidden]:where(:not([hidden="until-found"])) {
  display: none !important;
}

Qatorma-qator:

  • [hidden] — hidden atributli har element.
  • :where(:not([hidden="until-found"])) — hidden="until-found" dan tashqari (bu qiymat sahifada qidiruv topguncha yashiradi, lekin display: none emas — uni buzmaslik kerak). :where specificity'ni 0 qiladi (specificity).
  • !important — eng muhimi. asos.css @layer base ichida, utilitalar esa @layer utilities da. Oddiy qoidalarda keyingi qatlam ustun — utilita yutadi. Muhim (!important) qoidalarda esa tartib teskari: oldingi qatlam ustun (@layer). Shunday qilib base'dagi !important utilitadan kuchli.

Natija oynasida — o'sha blok, CSS'ga shu qoida qo'shilgan:

tsx
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";

const css = `
@layer base, utilities;
@layer utilities {
  .inline-flex { display: inline-flex; }
}
@layer base {
  [hidden]:where(:not([hidden="until-found"])) {
    display: none !important;
  }
}
`;

function Aloqa({ tarmoq }: { tarmoq: string }) {
  return (
    <div>
      <style>{css}</style>
      <p>Internet bor.</p>
      <button className="inline-flex" hidden={tarmoq !== "xato"}>
        Qayta urinish
      </button>
    </div>
  );
}

test("ekranda: brauzer chizdimi?", () => {
  render(<Aloqa tarmoq="tayyor" />);
  const btn = screen.getByText("Qayta urinish");
  console.log(getComputedStyle(btn).display);
  expect(btn.checkVisibility()).toBe(false);
});

test("xato holatida — ko'rinadi", () => {
  render(<Aloqa tarmoq="xato" />);
  const btn = screen.getByText("Qayta urinish");
  expect(btn.checkVisibility()).toBe(true);
});

Natija oynasida (Chrome 155) konsol:

text
none
  ✓ ekranda: brauzer chizdimi?
  ✓ xato holatida — ko'rinadi
Testlar: 2 o'tdi (jami 2)

Ikkinchi test muhim: tuzatish tugmani "har doim yashiradigan" bo'lib qolmasin — xato holatida u kerak.

Tekshirib ko'ring: !important ni olib tashlab, qoidani @layer base dan tashqariga (qatlamsiz) qo'ysak ham ishlaydimi?

Javob

Ha — qatlamsiz (unlayered) oddiy qoidalar har qanday qatlamdagi oddiy qoidalardan ustun. Lekin u holda hidden li elementga yozilgan har qanday qatlamsiz display qoidasi uni yana bosishi mumkin, specificity'ga qarab. Tailwind preflight'i va kanon !important ni tanlagan: "atribut bor — demak yashirin" qoidasi kaskadning boshqa joylariga bog'liq bo'lmasin.

4.4 E2e testi tasdiqlaydi

Xato topildi — endi uni qayta kelmaydigan qilish kerak. tarmoq.spec.ts dagi "server 500" testi oxiriga — Playwright'ning toBeHidden i:

ts
  // 22/#13: tugma hidden atributi bilan yashirinadi — Tailwind
  // inline-flex uni bosib ketardi (visual regression topdi,
  // asos.css da tuzatildi)
  await expect(
    page.getByRole("button", { name: "Qayta urinish" }),
  ).toBeHidden();

Playwright jest-dom'dan farqli: u atributga emas, chizilganiga qaraydi (element bo'sh o'lchamli yoki visibility: hidden bo'lsa — yashirin). Tekshirdik: tuzatishsiz bu qator Received: visible bilan yiqildi, tuzatish bilan 4 passed. Endi xatoni ikkita test qo'riqlaydi: surat va aniq tekshiruv. Surat "nimadir o'zgardi" deydi, toBeHidden — "aynan nima".

Eski oltin rasm bilan solishtirganda farq katta chiqdi: kutilgan rasm 1280×1961, yangisi 1280×1917 (tugma yo'qolib, sahifa 44 px qisqardi), 61 501 farqli piksel (light) va 120 627 (dark):

Tugma yo'qolgandagi piksel farqi va chegara
  • light (61 501 px)Playwright hisoboti3 %
  • dark (120 627 px)Playwright hisoboti5 %
  • chegaramaxDiffPixelRatio 0.011 %

Manba: O'lchandi: vazifalar kanoni (holat 13), Playwright 1.64.0, Chrome, 1280px fullPage, i5-12500H, Windows 11, 2026-10-20

Bitta tugma 3–5 % piksel farqi beradi: u o'zi kichik, lekin uning ostidagi hamma narsa 44 px yuqoriga siljidi — siljigan har piksel ham "farq". Dark'da farq ko'p, chunki fon va matn kontrasti boshqacha. Ikkalasi 1 % chegaradan ancha yuqori. Rasmlar yangilandi.

Narx: CSS 5.10 → 5.13 KB gzip. JS kodi baytma-bayt o'zgarmadi (634 255 B); gzip 206.73 → 206.74 KB — CSS fayl nomidagi hash o'zgargani uchun siqish natijasi ozgina boshqacha.

5. Beqaror piksellar: sabablar va davolar

Visual test eng injiq test turi: sahifada hech narsa buzilmagan, surat esa boshqacha. Ko'p uchraydigan sabablar:

Sabab Belgisi Davosi
Animatsiya, transition har safar boshqa kadr animations: "disabled"
Kursor miltillashi maydonda chiziq bor/yo'q caret: "hide"
Sana, vaqt, tasodifiy ID matn har kuni boshqa soxta soat, mask
Tashqi ma'lumot ro'yxat o'zgaradi soxta API (page.route)
Shrift yuklanishi matn kengligi o'zgaradi shriftni lokal qilish, yuklanishni kutish
Boshqa OT yoki brauzer butun sahifa siljigan har platformaga o'z rasmi, bitta muhit (Docker)
Video, karta, reklama doim har xil mask yoki stylePath bilan yashirish

animations va caret uchun Playwright hujjatiga ko'ra toHaveScreenshot da sukut ham "disabled" va "hide" — kanon ularni aniqlik uchun yozgan (page.screenshot() da sukut boshqacha).

mask — elementlar ustiga rangli to'rtburchak chizadi:

ts
await expect(page).toHaveScreenshot("buyurtma.png", {
  // Vaqt va buyurtma raqami har safar boshqa
  mask: [page.getByTestId("vaqt"), page.locator(".buyurtma-raqami")],
  maskColor: "#FF00FF",
});

stylePath — surat oldidan qo'shimcha CSS fayl: video, .reklama { visibility: hidden }. Bu misollar vazifalar da yo'q — bizda soxta API yetarli bo'ldi.

Chegarani qanday tanlash kerak? Kanon o'lchadi: maxDiffPixelRatio: 0 bilan ham --repeat-each=5 (har test 5 marta) — 30/30 surat bir xil. Bitta mashinada, bitta Chrome'da shovqin yo'q. 0.01 — zaxira: Chrome yoki shrift yangilanganda yumshatish (antialiasing) ozgina o'zgarsa, testlar to'satdan qizarmasin. Xato 3–5 % edi — 1 % chegara uni bemalol ushlaydi. Qoida: chegarani "o'tib ketsin" deb emas, o'lchab tanlang — haqiqiy xato chegaradan bir necha baravar katta bo'lsin.

Tekshirib ko'ring: Bosh sahifada "Bugun: 20-oktabr" yozuvi bor. Visual test ertaga yiqiladi. Ikki yechim ayting.

Javob

(1) Vaqtni soxtalash — Playwright'da page.clock.setFixedTime(…) (Vitest II dagi soxta soat g'oyasi): har kuni bir xil sana. (2) O'sha elementni mask bilan yopish. Birinchisi yaxshiroq — sana joyi va uzunligi ham tekshiriladi; mask faqat chiqarib tashlaydi.

6. Windows va Linux: oltin rasm kimniki?

6.1 Muammo

Oltin rasmlar Windows'da yaratildi: nomida win32. GitHub Actions'dagi Linux runner *-vizual-linux.png ni qidiradi va topmaydi — test snapshot doesn't exist bilan yiqiladi. Linux'da rasm yaratib qo'yganda ham farq qoladi: Windows matnni Segoe UI bilan, Linux esa boshqa shriftlar (DejaVu, Liberation) bilan chizadi. Harf kengligi boshqacha — butun sahifa siljiydi.

Shuning uchun vazifalar da visual testlar CI'ga kirmagan. test.yml da izoh qoldirildi:

yaml
      # Visual regression (npm run test:vizual) bu yerda
      # YO'Q: oltin rasmlar Windows'da yaratilgan
      # (*-vizual-win32.png), Linux runner ularni topmaydi
      # va shriftni boshqacha chizadi. CI'ga qo'shish uchun
      # avval Linux rasmlari: Playwright Docker obrazida
      # --update-snapshots (#13)

Halol holat: bizda visual testlar faqat lokal, bitta Windows mashinasida ishlaydi. Bu ham foyda (xatoni topdi), lekin jamoada har kim o'z mashinasida yurgizishi kerak.

6.2 Yo'l: Playwright Docker obrazi (hujjat bo'yicha)

Playwright rasmiy Docker obrazlarini chiqaradi: mcr.microsoft.com/playwright:v1.64.0-noble (Ubuntu 24.04, brauzerlar va shriftlar o'rnatilgan). G'oya: oltin rasmlarni ham, CI'dagi tekshiruvni ham bitta obrazda yurgizish — shunda Windows'dagi dasturchi ham, Linux runner ham bir xil piksellarni oladi. Hujjatga ko'ra taxminan shunday:

bash
docker run --rm -it -v "$(pwd)":/work -w /work \
  mcr.microsoft.com/playwright:v1.64.0-noble \
  /bin/bash -c "npm ci && npx playwright test --project=vizual -u"

Bu yo'l vazifalar da sinalmagan va o'lchanmagan — shu kompyuterda Docker bilan tekshirilmadi. Sinashdan oldin ikki narsani biling. Birinchisi hujjatda yozilgan: obrazdagi Playwright versiyasi package.json dagi bilan aynan bir xil bo'lsin, aks holda brauzer topilmaydi. Ikkinchisi — bizning sozlama: obrazda Playwright'ning o'z brauzerlari bor, vazifalar esa channel: "chrome" (Google Chrome) ishlatadi. Obrazda bu qanday ishlashini ham tekshirish kerak. O'z repongizda shu yo'lni sinab, natijani o'zingiz tekshirasiz.

7. Boshqa vositalar

7.1 Vitest: toMatchScreenshot

Browser Mode da Vitest'ning o'z visual tekshiruvi bor (Vitest 4 dan):

tsx
import { page } from "vitest/browser";

test("tugma — xavfli variant", async () => {
  chiz(<Tugma variant="xavfli">O'chirish</Tugma>);
  const tugma = page.getByRole("button", { name: "O'chirish" });
  await expect.element(tugma).toMatchScreenshot("tugma-xavfli", {
    comparatorName: "pixelmatch",
    comparatorOptions: { allowedMismatchedPixelRatio: 0.01 },
  });
});

Hujjatga ko'ra: rasmlar __screenshots__/<test fayli>/ da, nomida brauzer va platforma (…-chromium-win32.png); threshold sukut 0.1; yangilash — vitest -u. Playwright'dan farqi: butun sahifa emas, odatda bitta komponent suratga olinadi. vazifalar da ishlatilmagan — bitta vosita (Playwright) yetarli bo'ldi, ikkinchi to'plam oltin rasm ikki barobar ishlov talab qiladi.

7.2 Bulut xizmatlari

Xizmat Nima qiladi vazifalar da
Chromatic Storybook story'larining suratlari, ko'rib chiqish ("qabul") yo'q — hisob kerak
Percy (BrowserStack) Playwright/Cypress/Storybook suratlari bulutda yo'q — hisob kerak
Argos Playwright suratlarini yig'ib solishtiradi, ochiq kodli yo'q — hisob kerak
Lost Pixel Storybook va sahifa suratlari, ochiq kodli rejimi bor yo'q — sinalmagan

Hammasi hujjat bo'yicha; kanonda birortasi ishlatilmagan. Ularning umumiy foydasi — bizdagi ikki muammoni hal qilish: suratlar bitta muhitda olinadi (platforma muammosi yo'q) va farqni jamoa veb-sahifada ko'rib, tugma bilan "qabul" qiladi (-u va commit o'rniga). Narxi — hisob, tashqi xizmatga kod suratlari yuboriladi, bepul rejalarida cheklovlar. npm'da (2026-10 holatiga): chromatic 18.13.0, @argos-ci/playwright 7.7.1, lost-pixel 3.22.0.

8. Vazifalar qadami: visual regression (holat 13)

8.1 Nima o'zgardi

Fayl O'zgarish
e2e/vizual.spec.ts 3 sahifa × light/dark = 6 surat
e2e/vizual.spec.ts-snapshots/ 6 PNG, 312 KB (git'da)
playwright.config.ts expect.toHaveScreenshot, vizual loyihasi, testIgnore
package.json test:e2e (--project=chrome), test:vizual
src/asos.css [hidden] qoidasi
e2e/tarmoq.spec.ts toBeHidden
test.yml visual testlar nega yo'qligi haqida izoh

Yangi paket yo'q — toHaveScreenshot @playwright/test ichida.

8.2 Natija va commit

Testlar: Vitest 348, brauzer 6, storybook 12, e2e 10, vizual 6 (+ kirish). npm run test:vizual — 7 passed (12.1s). Lint, format:check, tip, knip — toza. JS 206.7 KB.

bash
git switch -c test/vizual
git add -A
git commit

Commit (kanon): test: visual regression (toHaveScreenshot) — 3 sahifa light/dark; fix: hidden atributi Tailwind'dan ustun.

Commit xabari ikki ishni aytadi: test qo'shildi va xato tuzatildi. Bitta commit'da — chunki tuzatish suratlarga ta'sir qiladi: tuzatishsiz oltin rasm xatoni "kutilgan" deb saqlab qo'yardi.

9. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
A snapshot doesn't exist … writing actual birinchi ishga tushirish rasmni ko'rib, commit qiling
CI'da snapshot doesn't exist rasm boshqa platformada o'sha platformada (Docker) yarating
Expected an image 1280px by 1961px, received … sahifa balandligi o'zgardi haqiqiy o'zgarishmi — tekshiring
har ishga tushirishda boshqa piksellar animatsiya, sana, ma'lumot animations, soxta soat, mask, soxta API
yarim chizilgan sahifa surati tayyorlik kutilmagan aniq "tayyor" elementni kuting
xato o'tib ketdi chegara juda katta o'lchab tanlang (xato chegaradan bir necha baravar katta)
-u dan keyin xato oltin bo'ldi rasm ko'rilmagan yangi rasmni commit'dan oldin oching

10. Mashqlar

Mashqlar kurs/mashqlar/22/13-vizual/ da. Yechimlarni «Ishga tushir» bilan sinang.

1-mashq (oson): bo'sh joylarni to'ldiring

  • Tasdiqlangan surat — rasm
  • Oltin rasmlarni qayta yozish — npx playwright test
  • Bitta piksel ranglari "bir xilmi" chegarasi —
  • Elementni suratda yopish — : [page.getByTestId("vaqt")]
  • Rasm nomidagi Windows belgisi —
Yechim
  • oltin (golden, baseline) — u testning kutilgan qiymati, git'da saqlanadi.
  • --update-snapshots (-u) — faqat ataylab o'zgarishdan keyin.
  • threshold — piksel darajasi (sukut 0.2); butun rasm uchun — maxDiffPixels / maxDiffPixelRatio.
  • mask — locator'lar massivi, ustiga rangli to'rtburchak.
  • win32 — Linux'da linux, macOS'da darwin.

2-mashq (o'rta): maxDiffPixels va o'lcham

«Solishtirish — o'quv modeli» dagi compare ni kengaytiring: (1) maxDiffPixels sozlamasi (farqli piksellar soni chegarasi) — ikkala chegara berilsa, ikkalasi ham bajarilishi kerak; (2) rasmlar uzunligi har xil bo'lsa, solishtirmasdan { pass: false, reason: "o'lcham: 4 ≠ 3" } qaytarsin. maxDiffPixels berilmasa — cheklov yo'q (Infinity).

Ishora: standart qiymatlar destrukturizatsiyada (destrukturizatsiya); && bilan ikki shart.

Yechim
js
function colorDistance(a, b) {
  const diffs = a.map((v, i) => Math.abs(v - b[i]));
  return Math.max(...diffs) / 255;
}

function compare(expected, actual, options = {}) {
  const {
    threshold = 0.2,
    maxDiffPixelRatio = 0,
    maxDiffPixels = Infinity,
  } = options;
  if (expected.length !== actual.length) {
    const reason = `o'lcham: ${expected.length} ≠ ${actual.length}`;
    return { pass: false, reason };
  }
  let diff = 0;
  for (let i = 0; i < expected.length; i++) {
    if (colorDistance(expected[i], actual[i]) > threshold) diff++;
  }
  const ratio = diff / expected.length;
  const pass = ratio <= maxDiffPixelRatio && diff <= maxDiffPixels;
  return { diff, ratio, pass };
}

const white = [255, 255, 255];
const violet = [139, 130, 245];
const golden = [white, white, white, white];
const oneDot = [white, violet, white, white];

console.log(compare(golden, oneDot, { maxDiffPixelRatio: 1 }));
console.log(
  compare(golden, oneDot, { maxDiffPixelRatio: 1, maxDiffPixels: 0 }),
);
console.log(compare(golden, [white, white, white]));

Konsolda:

text
{ diff: 1, ratio: 0.25, pass: true }
{ diff: 1, ratio: 0.25, pass: false }
{ pass: false, reason: "o'lcham: 4 ≠ 3" }

Ikkinchi chaqiruvda ulush bo'yicha hammasiga ruxsat bor, lekin son bo'yicha birortasiga ham yo'q — qattiqrog'i yutadi (Vitest toMatchScreenshot hujjati ham shunday deydi). Playwright o'lcham boshqa bo'lganda Expected an image … received … deb yozadi va farqni ham hisoblaydi — bizning model soddaroq: darhol to'xtaydi.

3-mashq (qiyin): niqob (mask)

compare ga mask sozlamasini qo'shing: indekslar massivi ([1, 2]) — bu piksellar solishtirilmaydi va ulush ham faqat qolgan piksellar bo'yicha hisoblanadi. Sinov: 6 pikselli "sahifa", 4- va 5-pikselda soat (12:00 → 12:01 — rangi o'zgaradi). Niqobsiz — yiqilsin, niqob bilan — o'tsin. Uchinchi holat: niqob ostida bo'lmagan 1-pikselda xato — niqob bo'lsa ham yiqilsin.

Ishora: new Set(mask) va has(i) (Set); ulushning maxraji — expected.length - mask.length.

Yechim
js
function colorDistance(a, b) {
  const diffs = a.map((v, i) => Math.abs(v - b[i]));
  return Math.max(...diffs) / 255;
}

function compare(expected, actual, options = {}) {
  const { threshold = 0.2, maxDiffPixelRatio = 0 } = options;
  const { mask = [] } = options;
  const hidden = new Set(mask);
  let diff = 0;
  for (let i = 0; i < expected.length; i++) {
    if (hidden.has(i)) continue;
    if (colorDistance(expected[i], actual[i]) > threshold) diff++;
  }
  const ratio = diff / (expected.length - hidden.size);
  return { diff, ratio, pass: ratio <= maxDiffPixelRatio };
}

const W = [255, 255, 255];
const D = [20, 23, 38];
const R = [220, 38, 38];
const golden = [W, D, W, W, D, D];
const nextMinute = [W, D, W, W, W, D];
const brokenButton = [W, R, W, W, W, D];

console.log(compare(golden, nextMinute).pass);
console.log(compare(golden, nextMinute, { mask: [4, 5] }).pass);
console.log(compare(golden, brokenButton, { mask: [4, 5] }));

Konsolda:

text
false
true
{ diff: 1, ratio: 0.25, pass: false }

Niqob soatni "ko'rmaslik" ga aylantirdi, lekin qolgan sahifani himoyada qoldirdi — 1-pikseldagi xato baribir ushlandi. Ulushning maxrajidan niqob piksellari olib tashlangani uchun 1 farq — 4 dan 1, ya'ni 0.25. Playwright'da niqob boshqacha ishlaydi: u surat olishda o'sha joyni bir xil rang bilan bo'yaydi (oltin rasm ham shunday olingan), maxraj o'zgarmaydi — natija bir xil, hisob boshqa.

4-mashq: Vazifalar qadami

test/vizual branch'ida «Vazifalar qadami» dagi o'zgarishlarni qiling.

Yechim

Tekshiruv ro'yxati:

  • Avval tuzatishsiz: vizual.spec.ts va playwright.config.ts ni yozib, npx playwright test --project=vizual -u — 6 rasm. royxat-light-…png ni oching: «Qayta urinish» ko'rinadimi?
  • asos.css ga [hidden] qoidasini qo'shing; npm run test:vizual — 2 ta (royxat light/dark) yiqiladi, farq rasmlarini playwright-report da ko'ring (o'lcham 1961 → 1917).
  • Farq ataylab — -u bilan yangilang, rasmlarni yana oching.
  • tarmoq.spec.ts ga toBeHidden; tuzatishni vaqtincha olib tashlab, Received: visible ni ko'ring. Qaytaring.
  • npx playwright test --project=vizual --repeat-each=5 — hammasi yashil (bitta mashinada shovqin yo'q).

11. Real ishda

  • Dizayn tizimlari va katta saytlar visual testni komponent (Storybook story) va sahifa darajasida ishlatadi; farqni dizayner ko'rib "qabul" qiladi.
  • CSS refaktoringi (Tailwind versiyasini ko'tarish, reset almashtirish) — visual testlarsiz xavfli: yuzlab sahifani qo'lda ko'rib chiqib bo'lmaydi. Bizning topilma aynan shunday: reset (preflight) yo'qligining oqibati.
  • Narx: oltin rasmlar repoda joy egallaydi, platforma muammosi bor, beqaror bo'lishi oson. Shuning uchun visual testlar ko'pincha bir nechta muhim sahifa uchun yoziladi, hamma narsa uchun emas.
  • Intervyu savollari: "visual test nimani ushlaydi, unit test nimani?", "oltin rasm CI'da nega yiqiladi?", "beqaror suratni qanday barqaror qilasiz?".

Xulosa

  • Visual regression testi suratni oltin rasm bilan piksel bo'yicha solishtiradi; atribut va matn emas, ko'rinadigan natija tekshiriladi.
  • Playwright: toHaveScreenshot — ketma-ket ikki bir xil suratni kutadi; -u — faqat ataylab o'zgarishdan keyin va rasmni ko'rib.
  • Chegaralar: threshold (piksel), maxDiffPixels/maxDiffPixelRatio (rasm); o'lchab tanlang. Beqarorlikka — animations, caret, mask, soxta ma'lumot va soat.
  • Rasm platformaga bog'liq (win32/linux): vazifalar da visual testlar CI'siz; Linux yo'li — Playwright Docker obrazi (hujjat bo'yicha, sinalmagan).
  • Biz topdik: Tailwind inline-flex hidden ni bosgan — e2e (atribut) va jest-dom toBeVisible ko'rmagan, surat ko'rgan; [hidden] qoidasi !important bilan base qatlamida.

Keyingi dars: Accessibility testlari — axe bilan sahifalarni avtomatik tekshirish, uning chegarasi va klaviatura oqimini qo'lda va testda tekshirish.

Manbalar

  • Playwright: Visual comparisons (playwright.dev/docs/test-snapshots), PageAssertions.toHaveScreenshot, TestProject.expect, CLI --update-snapshots, Docker
  • Vitest: Visual regression testing, toMatchScreenshot (vitest.dev/guide/browser/visual-regression-testing)
  • MDN: hidden atributi (until-found), Element.checkVisibility(), @layer va !important
  • Tailwind CSS v4 preflight ([hidden]:where(:not([hidden="until-found"])))
  • jest-dom 7.0.1 manba kodi (toBeVisible — hidden atributi)
  • Haqiqiy chiqishlar: Playwright 1.64.0, Chrome 155, Node 24.21.0, Windows 11, i5-12500H (2026-10-20)
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Visual regression testlar: Playwright toHaveScreenshot, oltin rasmlar va piksel farqi — IlmHamroh