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-totalem vez detexto-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 ouaria-label. - Botões só com ícone precisam de nome acessível (
aria-label="Remover item"). - Regiões importantes podem ter
rolee 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)ounth(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çãoincludeShadowDom. Para o dev, a dica é: coloque odata-testidno elemento interno que o usuário de fato aciona e, se possível, use shadow root aberto (mode: "open"). - Iframes: dê ao
<iframe>umtitledescritivo e umdata-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-testidseguindo a convenção do projeto. - Campos de formulário têm
labelassociado ouaria-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-idou 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
titleedata-testid. data-testidexistentes não foram renomeados sem avisar quem mantém os testes.
Próximos passos
- Pratique seletores em cenários difíceis na página de elementos do Lab e compare os fronts HTML, React, Vue, legado e Shadow DOM em /lab/.
- Combine testes de UI com chamadas diretas à API de prática para preparar dados mais rápido.
- Gere dados de entrada para seus formulários com o gerador de massa de testes.
- Valide expressões usadas em asserções com o testador de regex.
- Crie identificadores únicos para dados de teste com o gerador de UUID.
