Detectando Bugs de UI com Testes de Regressão Visual
O visual regression testing com Playwright e Vitest detecta bugs de UI que testes unitários e E2E não veem, com dicas de baselines, flakiness e CI.
Os testes de regressão visual detectam alterações não intencionais na UI ao renderizar seu componente ou página em um navegador controlado, capturando um screenshot e comparando-o pixel a pixel com uma imagem de baseline aprovada — qualquer diferença acima de um limite configurável reprova o teste e expõe a mudança para revisão humana. Eles detectam uma categoria de bug que testes unitários e end-to-end estruturalmente não conseguem enxergar: um botão que dispara seu handler de clique corretamente, mas renderiza na cor errada, desloca doze pixels, ou desaparece atrás de um modal overlay.
Esse é o tipo de falha que escapa em um refactor de CSS e silenciosamente quebra o layout em produção. Seus testes passam, o build está verde, e um usuário descobre que o botão de checkout está obscurecido por um banner de cookies. Este artigo mostra como fechar essa lacuna com ferramentas nativas dos frameworks — as asserções de screenshot integradas do Playwright e os testes visuais no modo browser do Vitest — sem a necessidade de um serviço pago, e também aborda o controle de flakiness e a configuração de CI que separam uma suite útil de uma ruidosa.
Principais Conclusões
- Os testes de regressão visual comparam um screenshot renderizado com um baseline aprovado; qualquer diferença acima de um limite configurável reprova o teste, detectando bugs de cor, posição e contexto de empilhamento que testes funcionais deixam passar.
- O
expect(page).toHaveScreenshot()do Playwright é integrado e gratuito, e já defineanimationscomo'disabled'por padrão, além de fazer auto-retry até que dois screenshots consecutivos sejam idênticos — portanto, não é necessário usarwaitForTimeout. - A maioria dos problemas de flakiness tem quatro origens: animações CSS, fontes web carregadas tardiamente, conteúdo dinâmico e anti-aliasing sub-pixel que varia por GPU e sistema operacional — cada uma tem uma correção específica, não um limite mais permissivo.
- Fixe o CI em uma imagem Docker específica do Playwright (
mcr.microsoft.com/playwright:v1.61.0-noble), pois a renderização de fontes difere entre sistemas operacionais e, caso contrário, geraria falsos positivos. - O Vitest 4.x adicionou regressão visual integrada via
toMatchScreenshot()no Browser Mode estável, oferecendo aos usuários do Vitest um caminho nativo sem precisar abandonar seu runner existente.
O que são testes de regressão visual?
Os testes de regressão visual são um método de teste que compara screenshots de uma página web ou componente de UI com uma imagem de baseline aprovada para detectar alterações visuais não intencionais. A mecânica é a mesma independentemente da ferramenta: capture uma renderização conhecidamente correta uma vez, armazene-a como baseline e, em cada execução subsequente, capture um novo screenshot e compare-o com esse baseline. Uma diferença acima do seu limite reprova o teste e sinaliza a mudança para que um humano aprove ou rejeite.
Isso é diferente de snapshot testing. Um snapshot test serializa o markup renderizado de um componente e compara o texto; um teste de regressão visual compara os pixels reais renderizados. Snapshots em nível de markup não detectam nada que seja puramente visual — uma mudança de z-index, uma troca de token de cor, um fallback de fonte — porque o DOM serializado é idêntico mesmo quando o que o usuário vê não é.
Por que testes unitários e E2E deixam passar bugs visuais?
Discover how at OpenReplay.com.
Testes unitários verificam se um botão dispara seu handler onClick; testes E2E verificam se clicar nele conclui um fluxo; nenhum dos dois consegue dizer se o botão está na cor errada, deslocou doze pixels para a direita ou está oculto atrás de um modal overlay — essa é a lacuna que os testes de regressão visual preenchem. A matriz de cobertura deixa esse limite explícito:
| Cenário | Unitário (Jest/Vitest) | E2E (Playwright/Cypress) | Visual |
|---|---|---|---|
| Botão renderiza no DOM | Sim | Parcial | Sim |
Botão dispara onClick | Sim | Sim | Não |
| Clique conclui o fluxo | Não | Sim | Não |
| Botão está na cor correta | Não | Não | Sim |
| Botão está na posição correta | Não | Não | Sim |
| Botão está obscurecido por um overlay | Não | Não | Sim |
A última linha é a que causa problemas em produção. Considere um <CheckoutButton> que funciona perfeitamente até que alguém adicione um <CookieBanner> cujo contexto de empilhamento o cobre:
// CheckoutButton.tsx
export function CheckoutButton({ onCheckout }: { onCheckout: () => void }) {
return (
<button data-testid="checkout" onClick={onCheckout}>
Complete purchase
</button>
);
}
Um teste unitário que verifica o disparo do handler de clique passa — o JSDOM, a implementação de DOM usada por padrão pelo Jest e Vitest, não computa layout nem contextos de empilhamento, portanto não consegue saber que o botão está coberto. Uma asserção E2E como await expect(page.getByTestId('checkout')).toBeVisible() também passa, porque o Playwright considera um elemento visível quando ele tem um bounding box não vazio e não está com display:none — ser coberto por um elemento com z-index maior não altera nenhum desses critérios. Embora um locator.click() subsequente possa detectar a obstrução por meio das verificações de acionabilidade do Playwright, uma asserção de visibilidade isolada não detectará. Um diff de screenshot mostra o banner sobreposto ao botão.
Como funcionam os testes de regressão visual?
O fluxo de trabalho é um ciclo de cinco etapas: capturar um baseline, executar após uma mudança, comparar o novo screenshot com o baseline, revisar o diff e então aprovar a mudança (atualizar o baseline) ou rejeitá-la (corrigir o código). O Playwright torna isso concreto por meio do fluxo com --update-snapshots.
Na primeira vez que você executa um teste contendo toHaveScreenshot(), nenhum baseline existe, portanto o Playwright grava uma imagem de referência. Assim que esse baseline for revisado e commitado, as execuções subsequentes fazem o diff em relação a ele. Quando uma mudança visual é intencional — um botão redesenhado, um novo token de cor — execute npx playwright test --update-snapshots, revise o diff no relatório HTML gerado, faça commit dos arquivos .png atualizados (Git LFS é uma prática comum para esses baselines binários) e o novo screenshot se torna o baseline; a próxima execução de CI compara em relação a ele. A flag --update-snapshots regenera as imagens de referência no lugar.
A disciplina crítica é tratar uma atualização de baseline como uma revisão de código, não como uma aprovação automática. Um baseline atualizado para “fazer o teste passar” sem inspecionar o diff é como uma regressão real se torna a nova UI aceita.
Técnicas de diff e escopo dos testes
As ferramentas de regressão visual utilizam uma de quatro técnicas de diff — pixel a pixel, layout, baseado em DOM ou assistido por IA — e diferem na região que capturam. O mecanismo de diff:
| Técnica | Como funciona | Trade-off |
|---|---|---|
| Pixel a pixel | Compara cada pixel; sinaliza qualquer diferença | Preciso, mas ruidoso por anti-aliasing e suavização de fontes; requer limites |
| Layout | Compara posição, tamanho e espaçamento dos elementos | Ignora ruído cosmético de pixels; não detecta mudanças de cor/textura |
| Baseado em DOM | Compara markup serializado, não pixels renderizados | Detecta mudanças estruturais; cego a bugs apenas de renderização |
| Assistido por IA | Visão computacional sinaliza apenas mudanças perceptíveis por humanos | Reduz falsos positivos; disponível em ferramentas comerciais específicas |
O escopo de captura é um eixo separado. Testes em nível de componente isolam um único botão, card ou modal — menos ruído, revisão mais rápida, ideal para design systems e Storybook. Testes em nível de página capturam telas completas e detectam problemas de layout em jornadas reais, ao custo de mais conteúdo dinâmico e revisão mais lenta. Uma suite prática usa ambos: nível de componente para o design system, nível de página para fluxos críticos.
Sobre diff com IA especificamente, o campo é frequentemente caracterizado de forma equivocada. Em junho de 2026, o diff visual baseado em IA é a oferta paga principal da Applitools; o Percy adiciona triagem assistida por IA sobre comparação de pixels, enquanto o Chromatic usa diff por pixels e ciente do Git, em vez de IA — e as ferramentas nativas dos frameworks (Playwright, Vitest browser mode) usam comparação de pixels com limites configuráveis, o que é suficiente para a maioria das equipes se o flakiness for controlado na origem.
Como configurar testes de regressão visual com o Playwright?
O Playwright inclui a comparação de screenshots como uma asserção integrada, portanto não é necessária nenhuma dependência adicional além de @playwright/test. A asserção é expect(page).toHaveScreenshot(), documentada na referência da API PageAssertions. Comece pela configuração:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// Organize baselines por navegador para evitar colisões entre screenshots cross-browser.
snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}/{arg}{ext}',
expect: {
toHaveScreenshot: {
// Permita um pequeno orçamento absoluto de pixels para ruído de renderização sub-pixel.
maxDiffPixels: 100,
// E um orçamento relativo — 1% da imagem — para capturas maiores.
maxDiffPixelRatio: 0.01,
},
},
use: {
// Viewport fixo: mudanças de layout responsivo invalidariam os baselines.
viewport: { width: 1280, height: 720 },
},
// Fixe um navegador para baselines determinísticos no CI.
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
Duas opções têm papel fundamental. As opções maxDiffPixels e maxDiffPixelRatio definem quanta diferença é tolerada antes de o teste reprovar. Elas existem porque o anti-aliasing sub-pixel varia entre máquinas — a documentação do Playwright observa diretamente que “diferentes sistemas operacionais podem produzir screenshots diferentes.” Um limite de maxDiffPixelRatio: 0.01 absorve esse ruído sem mascarar regressões reais.
Um padrão vale a pena entender em vez de configurar: para a asserção toHaveScreenshot(), animations tem como padrão 'disabled', o que finaliza animações CSS finitas e congela as infinitas antes da captura. Isso difere de page.screenshot(), onde animations tem como padrão 'allow'. Portanto, frames intermediários de transição já são tratados pela asserção — você não precisa adicionar a opção, apenas confiar nela.
Um teste tem a seguinte aparência:
// checkout.spec.ts
import { test, expect } from '@playwright/test';
test('checkout button is not obscured', async ({ page }) => {
await page.goto('/checkout');
// Asserção web-first: aguarda o elemento em vez de um timeout fixo.
await expect(page.getByTestId('checkout')).toBeVisible();
await expect(page.getByTestId('checkout')).toHaveScreenshot('checkout-button.png');
});
Escopar o screenshot a um locator em vez da página inteira mantém a região capturada pequena e o diff focado — exatamente o que detecta a sobreposição do <CookieBanner> sem precisar refazer o baseline da página inteira a cada mudança não relacionada.
Testes em nível de componente com o modo browser do Vitest
Se sua stack já usa o Vitest, a versão 4.0 promoveu o Browser Mode para estável e adicionou regressão visual integrada via toMatchScreenshot(). Esse é um recurso da versão 4.x — a linha 2.x não possui tal asserção — portanto, defina ^4.0 como versão mínima. A configuração usa o provider do Playwright:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
});
// Button.visual.test.ts
import { expect, test } from 'vitest';
import { page } from 'vitest/browser';
import { render } from 'vitest-browser-react';
import { CheckoutButton } from './CheckoutButton';
test('checkout button matches baseline', async () => {
render(<CheckoutButton onCheckout={() => {}} />);
// Capture um locator de elemento específico — a documentação do Vitest aponta
// a captura de página inteira como um antipadrão.
await expect(page.getByTestId('checkout')).toMatchScreenshot();
});
O provider é a função playwright() de @vitest/browser-playwright, passada como objeto — não a string 'playwright' usada em versões anteriores. O import de contexto é vitest/browser. Consulte o guia de migração do Vitest antes de atualizar uma configuração existente de browser mode, pois esses caminhos de import foram alterados na v4.
Testes em nível de componente com stories do Storybook
Se seus componentes estão no Storybook (atualmente v10.4), você não precisa reescrever nada para adicionar cobertura visual — reutilize as stories que já mantém. O padrão antigo com jest-image-snapshot e postVisit no test-runner é agora um caminho legado via Node: ele não funciona no modo browser baseado em Vitest do Storybook atual, pois jest-image-snapshot depende do Node.js.
O equivalente gratuito e local atual é importar uma story para um teste no modo browser do Vitest usando a API de portable stories do Storybook (composeStories) e fazer a asserção com o mesmo toMatchScreenshot() da configuração do Vitest acima:
// CheckoutButton.visual.test.tsx
import { expect, test } from 'vitest';
import { page } from 'vitest/browser';
import { render } from 'vitest-browser-react';
import { composeStories } from '@storybook/react-vite';
import * as stories from './CheckoutButton.stories';
// Componha a story com seus args, decorators e anotações de projeto,
// para que o teste renderize exatamente o mesmo estado do componente que sua story descreve.
const { Primary } = composeStories(stories);
test('CheckoutButton matches its story baseline', async () => {
render(<Primary />);
// Escope para o elemento, não para a página inteira.
await expect(page.getByTestId('checkout')).toMatchScreenshot();
});
Isso mantém uma única fonte de verdade — a story — tanto para os estados renderizados do componente quanto para seu baseline visual. Dois pontos importantes a saber. A asserção é executada em um teste standalone no modo browser do Vitest, não por meio do addon Vitest do Storybook, que não suporta toMatchScreenshot (ele lança Invalid Chai property: toMatchScreenshot); o addon lida com testes de interação e acessibilidade, não com diffs de pixels. E o produto de teste visual próprio do Storybook é o Chromatic, um serviço cloud pago, que é o caminho a seguir se você quiser baselines cross-browser hospedados e um fluxo de revisão gerenciado em vez de armazenar baselines no seu repositório.
Por que meus testes visuais continuam falhando?
A maioria dos problemas de flakiness em testes visuais tem quatro origens: animações CSS produzindo frames intermediários de transição, fontes web carregando após o disparo do screenshot, conteúdo dinâmico (timestamps, avatares, contadores) que legitimamente difere entre execuções, e anti-aliasing sub-pixel que varia por GPU e sistema operacional — cada um requer uma correção específica, não um limite mais permissivo. Aumentar o maxDiffPixels para silenciar o ruído também torna a suite cega a regressões reais, o que derrota o propósito.
Um helper de estabilização trata as fontes controláveis antes do disparo do screenshot:
// prepare-page.ts
import { Page } from '@playwright/test';
export async function preparePageForScreenshot(page: Page) {
// 1. Force animações e transições para duração zero como medida adicional de segurança
// (toHaveScreenshot já as desativa, mas page.screenshot
// e estados intermediários visíveis também se beneficiam).
await page.addStyleTag({
content: `*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}`,
});
// 2. Aguarde as fontes web. document.fonts.ready resolve quando o carregamento
// de fontes se estabiliza, evitando o reflow de fonte fallback que desloca o texto.
await page.waitForFunction(() => document.fonts.ready.then(() => true));
// 3. Aguarde o carregamento de todas as imagens.
await page.waitForFunction(() =>
Array.from(document.images).every((img) => img.complete)
);
// 4. Neutralize conteúdo dinâmico em vez de mocká-lo em todos os lugares.
await page.locator('[data-dynamic]').evaluateAll((els) =>
els.forEach((el) => ((el as HTMLElement).style.visibility = 'hidden'))
);
}
O passo 2 depende de document.fonts.ready, que retorna uma Promise que resolve assim que as operações de carregamento de fontes e layout do documento são concluídas — o sinal correto para “fontes foram pintadas”, muito mais confiável do que um sleep fixo.
Observe o que esse helper deliberadamente não faz: ele nunca chama page.waitForTimeout(). O guia de boas práticas do Playwright desencoraja timeouts fixos (“Never wait for timeout in production”). A alternativa óbvia, waitForLoadState('networkidle'), também é desaconselhada — a documentação do Playwright diz para “depender de asserções web para avaliar a prontidão.” A abordagem correta é asserções web-first e esperas explícitas por elementos, e toHaveScreenshot() já faz auto-retry até que dois screenshots consecutivos sejam idênticos, o que por si só absorve a maioria dos ruídos de timing residuais.
Para regiões genuinamente dinâmicas que você quer manter visíveis mas ignorar, a opção mask do Playwright aceita um array de locators e pinta cada um com uma caixa sólida antes do diff:
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [page.getByTestId('last-login'), page.locator('img[data-avatar]')],
});
Isso é mais limpo do que hacks de visibilidade CSS porque a região mascarada ainda ocupa espaço no layout, portanto o layout ao redor é comparado exatamente como é renderizado.
Como executar testes visuais no CI sem falsos positivos?
A renderização de fontes difere entre Ubuntu, macOS e Alpine Linux mesmo na mesma versão do navegador, portanto um baseline capturado no MacBook de um desenvolvedor produzirá falsos positivos quando comparado com um screenshot tirado em um runner Ubuntu do GitHub Actions — fixe seu CI em uma imagem Docker oficial específica mcr.microsoft.com/playwright:v1.61.0-noble para eliminar essa variável. Tags flutuantes como :latest não são mais publicadas para essas imagens, portanto uma tag de versão fixada é obrigatória, não opcional.
A regra prática que se segue: gere baselines dentro do mesmo container que o CI usa. Um baseline capturado localmente em um sistema operacional diferente é a causa mais comum de diffs exclusivos do CI. Gere as referências no CI (ou no container fixado localmente) e faça o commit delas.
Para os próprios baselines, o Git LFS é uma prática comum — arquivos PNG são binários e incham o repositório ao longo do tempo. Uma entrada em .gitattributes os direciona para o Git LFS:
tests/**/__screenshots__/** filter=lfs diff=lfs merge=lfs -text
Revise os diffs de baseline em pull requests exatamente como você revisa código. Quando um teste visual falha no CI, faça upload do relatório HTML do Playwright como artefato do build para que os revisores possam abrir o diff lado a lado. Uma mudança aprovada é um commit deliberado de baseline no mesmo PR que o código que a causou — nunca um commit separado de “corrigir os testes” que oculta o que mudou.
O que devo testar e o que devo deixar de fora?
Teste componentes e fluxos onde uma mudança visual teria um custo real — um botão de checkout quebrado, uma navegação colapsada, um componente de design system usado em 40 páginas; pule dashboards altamente dinâmicos e widgets de terceiros onde o conteúdo legitimamente muda a cada renderização. O custo de um teste visual é o tempo de revisão de cada diff, portanto invista onde uma regressão é cara e a renderização é estável.
Bons candidatos: componentes de design system, fluxos críticos de conversão (autenticação, checkout), navegação, estados de erro e vazios, e breakpoints responsivos. Maus candidatos: dashboards em tempo real, conteúdo gerado por usuários, slots de anúncios e embeds de terceiros que você não controla — esses produzem diffs constantes que treinam os revisores a aprovar sem olhar, o pior resultado possível.
Onde os testes visuais param: produção
Os testes de regressão visual validam a UI em relação a baselines controlados no CI, mas a produção renderiza em dispositivos reais, viewports não controlados, fontes que podem falhar ao carregar e scripts de terceiros que refazem o layout da página — o session replay reconstrói e reproduz a sessão gravada, de modo que uma quebra de layout que escapou da sua suite de testes aparece nas gravações das sessões afetadas.
Sua matriz de testes fixa um viewport e um navegador; um usuário em um laptop 1366×768 com uma fonte web que expirou e um locale que alongou todos os rótulos encontra um layout que seus baselines nunca descreveram. Os testes visuais são prevenção na fronteira do PR; o session replay é detecção na fronteira da produção. Eles cobrem modos de falha diferentes e são complementares, não redundantes.
Como escolher uma ferramenta de teste de regressão visual?
As ferramentas de regressão visual se dividem em três categorias — runners nativos dos frameworks, serviços cloud e ferramentas self-hosted — e a escolha mapeia para a necessidade da equipe, não para listas de recursos. Runners nativos dos frameworks — Playwright e Vitest browser mode — são gratuitos, executam no seu CI existente e armazenam baselines no seu repositório; o trade-off é que você gerencia baselines e consistência entre ambientes por conta própria. Serviços cloud como Percy e Chromatic cuidam do armazenamento de baselines e dos fluxos de revisão e oferecem planos gratuitos (Percy inclui 5.000 screenshots por mês; os planos pagos do Chromatic começam em $179/mês), ao custo de uma dependência externa. Opções self-hosted como o BackstopJS mantêm tudo internamente com maior overhead de configuração. Para uma equipe que já executa Playwright ou Vitest no CI, o caminho nativo não tem custo adicional e é suficiente se o flakiness for controlado na origem.
O caminho nativo dos frameworks fecha a categoria de bugs que testes funcionais estruturalmente deixam passar, sem nenhum novo fornecedor e sem custo recorrente. Comece adicionando uma asserção toHaveScreenshot() ao seu componente de maior risco — o botão de checkout, a navegação principal — gere o baseline dentro do mesmo container que seu CI usa, e trate o primeiro diff com falha como uma revisão de código, não como um teste a ser silenciado.
Perguntas Frequentes
Qual é a diferença entre testes de regressão visual e snapshot testing?
O snapshot testing serializa o markup renderizado de um componente em texto e compara essa string, enquanto os testes de regressão visual comparam os pixels reais renderizados do screenshot. Snapshots em nível de markup não detectam nada puramente visual, como uma mudança de z-index, uma troca de token de cor ou um fallback de fonte, porque o DOM serializado permanece idêntico mesmo quando o que o usuário vê não é. A regressão visual detecta os bugs apenas de renderização que os diffs de snapshot estruturalmente não conseguem detectar.
Por que meu teste visual passa localmente mas falha no CI?
A renderização de fontes e o anti-aliasing sub-pixel diferem entre sistemas operacionais, portanto um baseline capturado em um laptop macOS produz falsos positivos quando comparado com um screenshot tirado em um runner Ubuntu do GitHub Actions. A correção é gerar baselines dentro do mesmo container fixado que seu CI usa, como a imagem Docker oficial mcr.microsoft.com/playwright:v1.61.0-noble, em vez de commitar referências capturadas em um sistema operacional diferente. Baselines entre ambientes diferentes são a causa mais comum de diffs exclusivos do CI.
Preciso de um serviço pago como Percy ou Applitools para fazer testes de regressão visual?
Não. O Playwright inclui a comparação de screenshots como uma asserção integrada por meio de expect(page).toHaveScreenshot(), e o Vitest 4.x adicionou regressão visual integrada via toMatchScreenshot() no Browser Mode estável, ambos gratuitos e executando no seu CI existente com baselines armazenados no seu repositório. Serviços pagos como Percy e Chromatic adicionam armazenamento de baselines e fluxos de revisão hospedados, e a Applitools adiciona diff baseado em IA, mas a comparação de pixels nativa dos frameworks com limites configuráveis é suficiente para a maioria das equipes se o flakiness for controlado na origem.
Devo capturar a página inteira ou um único componente em um teste visual?
Escope o screenshot a um locator de elemento específico em vez da página inteira sempre que possível. A captura em nível de componente mantém o diff focado, reduz o ruído, acelera a revisão e evita refazer o baseline da página inteira a cada mudança não relacionada. A própria documentação do Vitest aponta a captura de página inteira como um antipadrão. Reserve a captura em nível de página para fluxos end-to-end críticos como checkout, onde as interações de layout entre elementos importam, e use testes em nível de componente para peças de design system e UI isolada.