Seletores estáveis: como deixar sua interface fácil de automatizar

Neste guia você vai ver como pequenas decisões no HTML deixam a interface muito mais fácil de automatizar: atributos data-testid com convenção de nome, seletores por papel e rótulo acessível, o que evitar (classes geradas, XPath posicional, textos dinâmicos, índices) e como lidar com listas, Shadow DOM e iframes. No final há um checklist para colar no template de pull request e exemplos prontos em Cypress e Playwright.

Por que seletor estável é assunto de dev, não só de QA

Quem automatiza testes de interface há tempo — eu trabalho com QA desde 2010 — sabe que a maior parte dos testes “quebrados” não aponta bug nenhum. O fluxo continua funcionando, mas alguém trocou uma classe, reorganizou um div ou mudou o texto de um botão, e o seletor deixou de encontrar o elemento. O resultado é pipeline vermelho, retrabalho e, pior, perda de confiança na suíte.

A boa notícia é que a solução está quase toda nas mãos de quem escreve o componente. Um seletor estável é um contrato: enquanto o comportamento do elemento for o mesmo, o jeito de encontrá-lo também não muda. Isso custa poucos minutos durante o desenvolvimento e economiza horas de manutenção depois.

data-testid e convenções de nome

O atributo data-testid (ou data-test, data-cy, conforme o time preferir) existe só para testes. Ele não interfere em estilo, não depende de texto e sobrevive a refatorações visuais. O segredo é tratá-lo com a mesma disciplina de uma API pública:

  • Escolha um nome de atributo para o projeto inteiro e documente.
  • Use um padrão previsível, por exemplo contexto-elemento-acao: checkout-botao-finalizar, login-campo-senha.
  • Prefira kebab-case, sem acentos e sem espaços.
  • Descreva a função do elemento, não a aparência: carrinho-total em vez de texto-verde-grande.
  • Não gere o valor com hash ou contador aleatório; ele precisa ser igual em toda renderização.
<form data-testid="login-form">
  <label for="email">E-mail</label>
  <input id="email" type="email" data-testid="login-campo-email">
  <label for="senha">Senha</label>
  <input id="senha" type="password" data-testid="login-campo-senha">
  <button type="submit" data-testid="login-botao-entrar">Entrar</button>
</form>

Se o seu build remove atributos data-* em produção, combine isso com o time de QA: alguns times mantêm os atributos em todos os ambientes para permitir testes de fumaça e monitoramento sintético depois do deploy.

Seletores por papel e rótulo acessível

Antes mesmo do data-testid, vale considerar os seletores baseados em acessibilidade: o papel (role) do elemento e seu nome acessível. Um botão “Entrar” com papel button é encontrado do mesmo jeito que um leitor de tela o encontra. Isso traz dois benefícios: o teste fica legível e qualquer regressão de acessibilidade (um botão virar div clicável, um campo perder o label) quebra o teste — o que é exatamente o que você quer.

A documentação do Playwright recomenda priorizar localizadores voltados ao usuário, como getByRole e getByLabel, e usar getByTestId quando não houver alternativa semântica (fonte: https://playwright.dev/docs/locators, consultado em 13/10/2026). No Cypress, a página de boas práticas orienta usar atributos data-* dedicados para isolar os testes de mudanças de CSS ou JS (fonte: https://docs.cypress.io/app/core-concepts/best-practices, consultado em 13/10/2026), e a biblioteca Testing Library adiciona consultas por papel e rótulo.

Para que esses seletores funcionem, o HTML precisa estar correto:

  • Use elementos nativos: <button>, <a href>, <input>, <select>.
  • Todo campo de formulário precisa de <label for> associado ou aria-label.
  • Botões só com ícone precisam de nome acessível (aria-label="Remover item").
  • Regiões importantes podem ter role e nome: <nav aria-label="Principal">, <dialog aria-labelledby="titulo-modal">.

Por que evitar classes geradas (CSS-in-JS)

Bibliotecas de CSS-in-JS e CSS Modules geram nomes como css-1x2y3z ou Button_primary__a8F2k. Eles mudam quando o estilo muda, quando a ordem de importação muda ou até a cada build. Um teste que depende deles quebra sem que nada no comportamento tenha mudado. O mesmo vale para classes utilitárias (mt-4 flex px-2): elas descrevem aparência, e aparência é justamente o que mais muda.

Regra prática: classe é para estilo, data-testid e semântica são para teste. Se um seletor de teste precisa de uma classe, falta um atributo no componente.

XPath frágil, textos dinâmicos e índices

Três padrões aparecem em quase toda suíte instável:

  • XPath posicional, como /html/body/div[2]/div/div[3]/button. Qualquer wrapper novo quebra o caminho. XPath não é proibido, mas se precisar usá-lo, ancore em atributos estáveis, nunca na estrutura.
  • Texto dinâmico, como “Olá, Maria” ou “3 itens no carrinho”. O texto varia por usuário, quantidade, idioma ou teste A/B. Localize o elemento por um atributo estável e só depois verifique o texto como asserção.
  • Índices, como .eq(2) ou nth(2). Funcionam até alguém mudar a ordenação, adicionar um item ou carregar os dados em outra sequência.
// Frágil
cy.xpath('/html/body/div[2]/div/div[3]/button').click()
cy.contains('Olá, Maria').should('be.visible')
cy.get('.produto').eq(2).click()

// Estável
cy.get('[data-testid="checkout-botao-finalizar"]').click()
cy.get('[data-testid="header-saudacao"]').should('contain', 'Maria')
cy.get('[data-testid="produto-card"][data-produto-id="sku-123"]').click()

IDs em listas: identifique o item pelo dado, não pela posição

Listas, tabelas e grids são onde os índices mais aparecem. A solução é expor no HTML um identificador do próprio dado: o SKU do produto, o ID do pedido, o e-mail do usuário. O data-testid continua genérico (o “tipo” do elemento) e um segundo atributo diz qual item é.

<ul data-testid="carrinho-lista">
  <li data-testid="carrinho-item" data-item-id="sku-123">
    <span data-testid="carrinho-item-nome">Caneca QA</span>
    <button data-testid="carrinho-item-remover" aria-label="Remover Caneca QA">×</button>
  </li>
</ul>

Evite usar o índice do array como id ou key: além de atrapalhar o teste, isso causa problemas de renderização em frameworks como React e Vue quando a lista é reordenada. E lembre que id HTML precisa ser único na página; se o mesmo componente aparece várias vezes, use atributos data-* em vez de id fixo.

Shadow DOM e iframes

Web components com Shadow DOM encapsulam a marcação interna, e iframes carregam outro documento. Em ambos, um seletor comum feito a partir da página principal pode não enxergar o elemento.

  • Shadow DOM: todos os localizadores do Playwright atravessam shadow roots abertos por padrão, exceto XPath; shadow roots fechados (mode: "closed") não são suportados (fonte: https://playwright.dev/docs/locators, consultado em 13/10/2026). No Cypress é preciso usar .shadow() ou habilitar a opção includeShadowDom. Para o dev, a dica é: coloque o data-testid no elemento interno que o usuário de fato aciona e, se possível, use shadow root aberto (mode: "open").
  • Iframes: dê ao <iframe> um title descritivo e um data-testid, para que o teste encontre o frame certo sem depender de ordem. Lá dentro, valem as mesmas regras de seletores estáveis.
// Playwright
await page.getByTestId('cupom-input').fill('QA10'); // atravessa shadow root aberto
const pagamento = page.frameLocator('[data-testid="iframe-pagamento"]');
await pagamento.getByLabel('Número do cartão').fill('4111111111111111');

// Cypress
cy.get('loja-cupom').shadow().find('[data-testid="cupom-input"]').type('QA10')
cy.get('[data-testid="iframe-pagamento"]')
  .its('0.contentDocument.body').should('not.be.empty')
  .then(cy.wrap)
  .find('[data-testid="cartao-numero"]').type('4111111111111111')

Quer praticar esses casos? A página de elementos difíceis do Lab reúne Shadow DOM, iframes, listas dinâmicas e outros cenários que costumam derrubar seletores, prontos para você testar suas estratégias.

Exemplos completos em Cypress e Playwright

O mesmo fluxo de login, escrito com seletores estáveis nas duas ferramentas:

// Cypress
describe('Login', () => {
  it('entra com usuário válido', () => {
    cy.visit('/login')
    cy.get('[data-testid="login-campo-email"]').type('standard_user@exemplo.com')
    cy.get('[data-testid="login-campo-senha"]').type(Cypress.env('SENHA'), { log: false })
    cy.get('[data-testid="login-botao-entrar"]').click()
    cy.get('[data-testid="header-saudacao"]').should('be.visible')
  })
})
// Playwright
import { test, expect } from '@playwright/test';

test('entra com usuário válido', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('E-mail').fill('standard_user@exemplo.com');
  await page.getByLabel('Senha').fill(process.env.SENHA!);
  await page.getByRole('button', { name: 'Entrar' }).click();
  await expect(page.getByTestId('header-saudacao')).toBeVisible();
});

No Playwright, se o seu projeto usa outro atributo, dá para configurar testIdAttribute no arquivo de configuração. No Cypress, um comando customizado como cy.getByTestId() evita repetir a string do atributo em todo teste.

Checklist para o pull request

Cole no template de PR do repositório de frontend:

  • Elementos interativos novos têm data-testid seguindo a convenção do projeto.
  • Campos de formulário têm label associado ou aria-label.
  • Botões só com ícone têm nome acessível.
  • Nenhum teste depende de classe gerada, utilitária ou de estilo.
  • Nenhum seletor usa XPath posicional ou índice para escolher item de lista.
  • Itens de lista expõem um identificador do dado (data-item-id ou similar).
  • Textos dinâmicos são verificados como asserção, não usados para localizar.
  • Componentes com Shadow DOM usam shadow root aberto ou expõem o elemento acionável.
  • Iframes têm title e data-testid.
  • data-testid existentes não foram renomeados sem avisar quem mantém os testes.

Próximos passos