Testes React Native
Como manter E2E com Playwright útil quando React Native Skia renderiza canvas
Quando o Skia transforma um tabuleiro React Native em um canvas, o Playwright perde as células DOM que inspecionava. A correção útil não é desligar Skia. É testar comportamento por uma superfície mais estreita.
A parte irritante de testar um tabuleiro React Native Skia não foi desenhar a grade. Foi descobrir que a suíte E2E ainda esperava o formato DOM do renderer antigo.
O app parecia correto no aparelho. TypeScript estava verde. Testes unitários estavam verdes. Um APK release instalava. Então
o Playwright tentou clicar em #sudoku-cell-1, e o seletor veio vazio porque o tabuleiro tinha virado canvas. Essa falha não
era só incômodo de teste. Era um relatório honesto: nossa camada E2E lia mais o formato antigo do DOM do que exercitava o
comportamento do Sudoku.
Passamos por isso no Daily Sudoku: Offline Puzzle depois de voltar o ambiente de teste para o renderer Skia do tabuleiro.
A escolha anterior de testar o grid React Native era conveniente, mas escondia exatamente o risco que precisávamos exercitar.
Se produção vai confiar em um tabuleiro em canvas, o E2E precisa aprender a interagir com esse tabuleiro sem fingir que ele
ainda é composto por 81 elementos DOM.
Então o trabalho não era só “fazer Skia passar”. Era manter Skia ativo em teste, dar ao Playwright uma forma honesta de interagir com um tabuleiro em canvas, tornar movimentos determinísticos e persistir progresso sem sleeps. Um conflito de pnpm/GitHub Actions apareceu no mesmo pacote de trabalho, mas a lição durável era sobre observabilidade em canvas.
Mantenha Skia ligado em teste
O atalho com cara de eficiência era testar o renderer antigo porque ele expõe nós DOM bonitos. O E2E voltaria a ficar quieto. Também deixaríamos de testar o renderer que queríamos confiar.
No tabuleiro de Sudoku, o renderer React Native web antigo expunha células individuais com IDs e texto estáveis. O Playwright conseguia ler uma célula, clicar nela, checar sua classe e afirmar que o número mudou. Quando o tabuleiro foi para Skia, o navegador passou a enxergar uma única superfície. O estado visual ainda existia, mas não como 81 nós DOM.
A correção começou com uma pergunta menor: quais checks descrevem comportamento de Sudoku, e quais descrevem a implementação web antiga?
Continuavam sendo comportamento de produto:
- iniciar um jogo com seed;
- selecionar uma célula editável;
- inserir um número válido ou inválido;
- contar erros;
- vencer pelo último movimento;
- ver fluxos de derrota, vitória e resultado;
- preservar e apagar progresso salvo.
Eram detalhes de implementação do renderer:
- encontrar
#sudoku-cell-1; - ler
innerTextde uma célula; - checar classes Tailwind no elemento da célula;
- usar
MutationObserverpara contar texto em 81 células.
Depois que essa separação ficou explícita, a correção perdeu drama. Não precisávamos de um segundo app, de um renderer falso ou de um framework amplo de teste. Precisávamos de uma ponte estreita, só em teste, para o que o navegador não consegue mais inspecionar quando a UI é desenhada em Skia.
Adicione uma ponte de teste, não um segundo app
A ponte adicionada é propositalmente sem graça. Na tela do jogo, apenas quando o ambiente é test e a plataforma é web, a
tela registra um pequeno objeto em globalThis. Ele seleciona célula, lê valor de célula, lista células vazias, encontra
células vazias cuja solução bate com um número alvo e persiste o snapshot atual para testes determinísticos. Só isso.
Isso parece suspeito até comparar com o setup antigo. O teste anterior também não usava visão humana. Ele lia texto e IDs de DOM. O Skia só removeu essa janela privada de inspeção. A ponte mantém o teste no mesmo nível comportamental: escolher uma célula, pressionar um número e observar o jogo pela tela que é dona daquele estado.
As restrições importantes são:
- A ponte só existe em
EXPO_PUBLIC_APP_ENV=test. - Ela não cria estados de jogo impossíveis para usuários.
- Ela passa pelos handlers existentes da tela quando a interação importa.
- Ela persiste snapshots pelo mesmo caminho de persistência de jogo ativo.
- Ela não substitui testes unitários das regras do jogo.
O último ponto é essencial. Uma ponte não deve virar backdoor para provar qualquer coisa. Ela é uma forma de um teste de navegador interagir com uma UI em canvas. Correção de regra continua nos testes de domínio. Navegação, modais, anúncios, fluxos de derrota e vitória, e progresso salvo continuam em BDD ou E2E.
Torne movimentos determinísticos
Uma falha sutil veio de um step que parecia inocente: “colocar o número 1 em uma célula vazia”. No board DOM antigo, o teste percorria células vazias até uma jogada funcionar. Não era elegante, mas geralmente caía em algum lugar útil. Com Skia, o caminho de seleção mudou e ficou mais fácil correr na frente do autosave. O teste podia fazer uma jogada, sair do jogo e esperar um fluxo de progresso salvo que nunca tinha sido duravelmente criado.
A correção não foi aumentar sleep. O app já conhece a solução. Em modo de teste, a ponte consegue devolver células vazias cuja solução é o dígito pedido. O step E2E então seleciona um alvo válido e pressiona o botão do teclado normalmente.
Isso mantém o caminho de usuário honesto e remove aleatoriedade do teste:
- o board continua renderizando em Skia;
- o teclado numérico continua tratando a entrada;
- o estado do jogo continua atualizando pela lógica normal de movimento;
- o jogo ativo continua persistindo pelo código real de persistência.
A única parte especial é a escolha do alvo. Para E2E, isso é aceitável. O teste não está tentando provar que o gerador de puzzle sempre cria uma célula vazia para um dígito. Ele quer provar que uma jogada real aceita cria progresso salvo e libera o fluxo de continuar ou começar novo jogo.
Persista explicitamente em vez de dormir
Autosave é onde testes E2E costumam mentir para si mesmos. Eles ficam verdes porque o sleep foi longo o bastante em uma máquina, não porque a promessa do produto ficou explícita.
A tela de Sudoku usa debounce antes de salvar snapshots de jogo ativo. Isso faz sentido para o app. Evita escrita em storage
a cada transição pequena de estado. Mas um teste E2E que digita um número e toca Back imediatamente pode correr mais rápido
que o timer. Adicionar waitForTimeout(600) parece fácil, mas transforma o teste em palpite.
O hook melhor foi persistCurrentSnapshot(). Ele monta o snapshot atual pelo mesmo caminho da tela de produção, checa que o
jogo não terminou e tem progresso, e faz flush pelo caminho real de autosave.
Isso torna o teste determinístico sem mudar o comportamento de produção. Também diz a parte silenciosa em voz alta: o teste precisa que o snapshot de progresso esteja durável antes de sair da tela. Ele não precisa de 600 milissegundos de esperança.
Quando um teste precisa de um efeito colateral durável, dê nome a esse efeito. Não esconda atrás de sleep.
Separe assertion visual de assertion de estado
Algumas assertions antigas ainda eram úteis no renderer DOM e viravam nonsense em Skia. Uma classe CSS como border-danger
não existe quando o destaque de célula errada é desenhado em canvas. Um teste de foco visível não consegue forçar
pseudo-classe em uma célula que deixou de ser botão DOM.
Esses testes não devem fingir o contrário.
Para suporte misto de renderer, o helper pode perguntar primeiro se existem células DOM. Se existem, roda o check específico de DOM. Se não existem, cai para assertions de estado ou de fluxo. Por exemplo, um teste de game over não precisa checar a classe CSS da célula errada depois de três erros se o modal de derrota e o contador de erros provam o comportamento.
Isso não é baixar a régua. É alinhar cada assertion com a superfície real do renderer.
A regra prática que passamos a usar:
- Assert DOM apenas no renderer DOM.
- Assert estado de gameplay pela app quando o renderer é canvas.
- Assert fluxo visível por texto, roles e modais sempre que possível.
- Mantenha uma camada pequena manual ou de screenshot para detalhes visuais do canvas.
Canvas muda o que é observável. Isso não é motivo para parar de testar comportamento. É motivo para parar de fingir que o DOM é o produto.
Recarregue estado da Home antes do drawer
O trabalho em Skia também revelou uma corrida real de produto que poderia passar batida. Depois de resetar progresso, o banco era limpo, mas a Home ainda mantinha em memória a lista de jogos ativos. O usuário podia abrir o drawer de Play antes do efeito de foco recarregar essa lista, e a UI oferecia um jogo em andamento que não existia mais.
Essa falha não era só espera frágil. Era estado velho disfarçado de problema de timing.
A correção foi recarregar os resumos de jogos ativos antes de abrir o drawer de Play. Se o refresh passa, o drawer abre com dados atuais. Se falha, o drawer ainda abre e o erro é reportado. Isso é melhor comportamento de produto e melhor comportamento de teste.
É por isso que E2E lento ainda vale quando é focado. Ele pega fronteiras entre telas, persistência e navegação que testes unitários raramente exercitam.
Nota operacional: mantenha o CI no mesmo gerenciador
As correções do renderer também expuseram um problema operacional menor. Ele não é a principal lição de Skia, mas entra no mesmo checklist porque um install quebrado pode esconder a falha de renderer que você está tentando depurar.
O repositório agora declara:
{
"packageManager": "pnpm@11.9.0"
}
Mas os workflows ainda tinham:
- uses: pnpm/action-setup@v4
with:
version: 9.15.4
O pnpm/action-setup recusou continuar porque duas versões estavam especificadas. Isso protege o projeto de diferenças sutis
de lockfile e instalação. A correção foi remover a versão explícita do workflow e deixar a action ler packageManager.
Uma fonte de verdade é sem graça. Essa é a principal virtude dela.
Depois de atualizar o gerenciador de pacotes, procure a versão antiga em workflows, docs, Dockerfiles e scripts de setup. É
fácil atualizar package.json e esquecer que o CI tem sua própria cópia pinada. O log do build vai avisar eventualmente. Uma
busca no repositório é mais barata.
Tire a suíte full do loop padrão
O último ajuste foi de disciplina. A suíte web E2E completa do Sudoku é propositalmente lenta. Ela inclui fluxos que jogam muitas partidas pela UI. Rodá-la casualmente em cada iteração local desperdiça tempo e ensina as pessoas a interromper o comando pela metade.
O loop padrão deve usar subsets tagueados ou filtrados:
- smoke tests para CI e confiança diária;
- tags de regressão para o fluxo afetado;
- alvos explícitos com
--grepao depurar um cenário; - shards completos agendados ou autorizados manualmente para cobertura ampla.
Essa diferença mantém a suíte cara valiosa. Uma suíte lenta que todo mundo evita é pior do que uma suíte focada que as pessoas realmente rodam.
O que fica
Uma migração para Skia não é só uma mudança de renderização. Ela muda o formato do que testes de navegador conseguem observar. Se a cobertura E2E realmente afirmava comportamento de usuário, ela sobrevive com pequenas pontes de teste. Se ela afirmava detalhes de DOM, a migração revela isso.
As correções que importaram não foram espertas. Manter Skia ligado em teste. Expor uma ponte mínima só em test. Escolher movimentos válidos de forma determinística. Persistir pelo caminho real em vez de dormir. Recarregar estado da tela antes de abrir UI dependente de storage. Manter o CI sem surpresas para ele não roubar o foco do comportamento de renderer que você quer provar.
Nada disso é glamouroso. É o trabalho que transforma um experimento de renderer em algo que dá para continuar entregando depois que a demo acaba.