02Engenharia5 min

O teste que dizia verde e mentia

Seis SCRIPT ERROR dentro de um teste que imprimia TODOS LOS TESTS OK e saía com código 0. Como se descobre que um verde é falso e o que faço agora antes de confiar em um.

Por Salva Castells

Imagem de capa: Roland Tanglao, CC BY 2.0.
Neste post

Um teste de um jogo multijogador em Godot imprimia "TODOS LOS TESTS OK" e saía com código 0. Dentro dessa mesma rodada havia seis SCRIPT ERROR. Dois deles diziam Compilation failed e outro Trying to assign value of type 'CanvasLayer' to a variable of type 'character_select_panel.gd'. O runner olhava o exit code, o exit code era 0, e ninguém olhava mais nada.

Um teste que não pode falhar é pior do que não ter teste. Sem teste você sabe que não sabe. Com um verde falso você acha que sabe.

Seis erros dentro de um verde

O bloco que falhava instanciava a cena completa da fase para contar os objetos coletáveis que ela declara. Essa cena arrasta duas zonas de UI cujos scripts usam autoloads que, iniciando o motor com --script, não existem. Os scripts não compilavam, o nó saía como CanvasLayer em vez do tipo esperado, e o Godot cuspia os erros por stderr sem derrubar o processo.

A parte feia não é o erro, é que o teste seguia em frente e as asserções seguintes eram avaliadas sobre uma árvore construída pela metade.

O conserto foi parar de instanciar. A cena é lida com PackedScene.get_state(), que dá os nomes de nó, o instance= e as propriedades sobrescritas sem executar nenhum _ready nem carregar os scripts das zonas. A pré-condição continua ancorada ao arquivo de produção: se alguém tirar um objeto da fase, o teste fica vermelho.

A asserção que era uma tautologia

Esse mesmo teste tinha outro problema, pior, porque não faz barulho no stderr. A pré-condição dizia "a fase traz N templates" com N derivado da própria cena, e depois verificava que a conversão devolvia N. Isso sempre passa. Não verifica que ele converta os templates que existem, verifica que o número que você acabou de ler é igual a si mesmo.

Agora esse número está escrito na mão:

const PLAYGROUND_TEMPLATE_COUNT := 7  # os 7 pickups colocados no editor

E cada payload convertido é casado um a um com sua declaração do .tscn por posição, com tolerância de 1 px porque o host já integrou um frame de gravidade antes de convertê-los, mais seu tipo e sua quantidade. Fixar o 7 na mão é o que distingue "converte todos os que houver" de "converte os 7 que há".

Como se verifica que um vermelho é possível

A forma de saber se um teste serve é quebrar o código de propósito e ver se ele percebe. Nesse conserto deixei escritas as três mutações que testei: tirar o remove_child da função que descola os templates dava vermelho em três asserções; mandar a quantidade para 1 no payload dava vermelho em duas; apagar um objeto da cena dava vermelho na pré-condição.

Trinta segundos por mutação, e é a única evidência real de que um teste protege algo. Um teste que você nunca viu vermelho é uma hipótese.

A corrida que fazia o teste de duas instâncias passar

Um script subia duas instâncias do jogo, host e cliente por ENet em localhost, e capturava o estado das duas. Saía verde. Tinha três verdes falsos ao mesmo tempo, e reproduzi os três antes de mexer em qualquer coisa:

  • O host morria no meio e dava 0. O cliente já tinha capturado seus frames com jogador local, então o script se dava por satisfeito. Agora se exige também que o host morra pelo SIGTERM do próprio script; o código medido é 143.
  • O pkill -f metia caminhos sem escape numa ERE. Com um + no caminho não casava e o host vivia até o timeout: três minutos em vez de vinte segundos. Com um . podia matar o Godot de outra rodada. Agora se mata pelo PID do filho direto.
  • O cliente adivinhava por número de frame quando o host existia. E não dá para adivinhar, porque antes de hospedar o Godot coloca um OfflineMultiplayerPeer: get_unique_id() devolve 1 e is_server() devolve true no menu, inclusive na instância que vai ser cliente. O único marcador real de "está hospedando" é o JSON dele ter um jogador com is_local.

De quebra descobri que os at do autopilot contam frames desenhados, não segundos: com vsync, uma janela coberta ou sem foco espera o compositor e cai para 1-3 fps. Medido, 85 frames em 90 segundos. Com --disable-vsync --max-fps 60, 51 fps estáveis.

Testar o artefato que você distribui, não o que você compila

O caso que mais me custou foi num app de desktop de transcrição. O CI estava verde e publicava um .deb com o microfone morto, porque nada nunca executava o que era construído.

O PyInstaller empacotava o PortAudio do wheel do sounddevice, compilado sem backend de PulseAudio, mais um libasound.so.2 sem seus módulos de alsa-lib. Dentro do bundle isso apaga os PCM pulse, pipewire e default e só deixa os hw: em acesso exclusivo. O PipeWire já está com o microfone, então gravar falhava com Invalid sample rate [PaErrorCode -9997].

Só se reproduzia no .deb instalado: em desenvolvimento o link é contra o PortAudio do sistema, que tem Pulse. O conserto foi excluir libportaudio, libasound e libjack do bundle, que já vinham exigidos em Depends.

O que mudou no CI importa mais do que o conserto. Agora, antes de empacotar, ele verifica que não há bibliotecas de áudio dentro do bundle e que o binário sobe sob xvfb. E o .deb declarava libc6 (>= 2.43), um piso que não instala em nenhum Ubuntu: vinha de pegar a maior tag de GLIBC do bundle, ou seja, de bibliotecas copiadas da máquina que compila. Descrevia o construtor, não o app. Agora está declarado na mão como o que é, uma decisão de release: 2.35, a do runner.

Também tirei o build de Windows do CI: comentei ele inteiro e tirei build-windows dos needs do release. Manter verde um artefato que ninguém testava custava mais do que rendia.

O que me pergunto hoje diante de um teste novo

Duas coisas, e basta uma falhar para eu não confiar. A primeira: já o vi vermelho por uma mudança que quebra o comportamento de verdade, não por uma que quebra a forma? A segunda: o valor esperado vem de outro lugar que não o código julgado, ou eu o estou derivando dele?

E uma terceira para o CI: o que você testa tem que ser o arquivo que alguém vai baixar, não o que sai do seu run.