Meus mocks não conseguiam falhar: injeção de erro e um crash de isolamento no Swift 6
Esse app foi renomeado depois para Deep Cut Atlas. Aqui embaixo ele é chamado de “Discoverer” o tempo todo, porque era esse o nome dele no dia em que isso aconteceu.
Uma revisão de código do meu projeto paralelo em iOS revelou uma descoberta desconfortável: todo branch de tratamento de erro do app tinha cobertura de testes zero. Não “fraca”. Zero.
A causa era simples. Meu mock service, aquilo contra o qual todos os testes de view model rodam, só conseguia ter sucesso. Todo método devolvia dados de exemplo prontos. Então todo branch catch { state = .failed(...) } de cada view model era código morto nos testes. Uma regressão que tratasse erros errado passaria pela CI verdinha.
A costura
Eu não queria um interruptor global “falhe tudo”. Testes de falha de verdade precisam de precisão: a primeira página carrega bem, aí a segunda página falha. O fetch dá certo, o write falha. Então a costura é um conjunto de identificadores de método:
enum MockMethod: Hashable {
case fetchLibraryArtists, fetchPlaylistContents, fetchRecentlyPlayed,
createPlaylist, addTracksToLibrary, removeTracksFromPlaylist // ...
}
var failingMethods: Set<MockMethod> = []
var injectedError: any Error = SimulatedError()
private func throwIfFailing(_ method: MockMethod) throws {
if failingMethods.contains(method) { throw injectedError }
}
Todo método do protocolo ganha uma linha no topo: try throwIfFailing(.fetchRecentlyPlayed). Um teste que quer que a paginação quebre no meio do caminho faz isto:
await vm.load() // first page: fine
service.failingMethods = [.fetchRecentlyPlayed] // now the network "dies"
await vm.loadMore()
#expect(vm.state == .loaded) // the visible list survives
#expect(!vm.hasMore) // pagination ends quietly
A propriedade injectedError também se justifica: troque por CancellationError() e você consegue garantir que um carregamento cancelado não muda a UI para um estado de falha nem guarda em cache um feed pela metade. Esse caminho veio do trabalho de cancelamento do issue anterior e era impossível de testar até agora.
Vinte e seis testes novos depois, os estados de falha, os toasts de falha e o comportamento de manter o conteúdo em cache numa falha em segundo plano estão todos garantidos.
Aí o test runner explodiu
Um dos novos arquivos de teste cobria um helper minúsculo, Array.chunked(into:). Função pura, sem estado, sem actors. Então, diferente de toda outra suíte do projeto, eu não marquei ele com @MainActor. Por que marcaria?
Primeira rodada completa: 20 falhas de teste, todas em exatamente 0.000 segundos, espalhadas por arquivos que eu nem tinha tocado. Esse padrão cheirava menos a 20 bugs e mais a um crash derrubando o runner, então fui procurar em ~/Library/Logs/DiagnosticReports/. O relatório de crash foi direto:
EXC_BREAKPOINT (SIGTRAP)
_dispatch_assert_queue_fail
closure #1 in Array.chunked(into:)
O projeto compila com o isolamento padrão de actor do Swift 6 definido como MainActor. Nessa configuração, minha extensão “pura” de Array é implicitamente @MainActor - o isolamento vem de uma configuração de build, não de nada visível no código. O Swift Testing roda suítes que não são MainActor em executors em segundo plano, então minha única suíte sem anotação chamou uma função MainActor a partir do executor errado e o runtime deu trap. O processo inteiro morreu, e todo teste em andamento foi reportado como falha.
A correção foi uma anotação: marcar a suíte com @MainActor como todas as outras. Aquela convenção do projeto, no fim das contas, era estrutural, não estética.
Lições
- Se seus mocks não conseguem falhar, seu tratamento de erro está sem testes por construção. Construa a costura de falha cedo, são 15 linhas.
- Um conjunto indexado por método é melhor que uma flag de falha global. A maioria dos testes de falha interessantes precisa que algumas chamadas deem certo antes.
- Falhas em massa de teste em 0.000 segundos significam um runner que deu crash, não regressões em massa. Vá ler o relatório de crash antes de “consertar” vinte testes.
- Num projeto com MainActor como padrão, “esse código parece puro” não te diz nada sobre o isolamento dele. Confira a configuração de build antes de pular a anotação.
Leitura relacionada
Um segfault que não era um bug, e a API que eu finalmente apaguei
Uma limpeza da camada de serviço do MusicKit em que apagar métodos mortos travou todos os testes de uma vez, e a solução foi um build limpo, não um debugger.
Fechando as lacunas de teste: lógica pura, um branch de mock inalcançável, e uma virada para Swift 6
Extraindo algoritmos para fora do alcance do MusicKit, um parâmetro de mock que era silenciosamente ignorado, e dois testes que vinham passando pelo motivo errado o tempo todo.
Correndo contra a main actor: quando o await é a condição de corrida
@MainActor elimina data races, não races de interleaving. Um token de geração, uma guarda contra falha desatualizada, e um teste de race determinístico sem sleeps.
Você também pode achar útil
NordPass
Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.
Como afiliado da NordPass, ganho com compras qualificadas.
Saiba maisProton Mail
E-mail criptografado de ponta a ponta, com arquitetura de acesso zero.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maiseSIM Airalo
eSIM de dados local para viagens - sem necessidade de trocar um SIM físico.
Este é meu link de indicação da Airalo. Você recebe um desconto no seu primeiro eSIM e eu ganho crédito da Airalo para o meu.
Saiba mais