Dev (Back & Front)ARTIGO

Teste de mutação para Angular com Stryker: Quando 100% de cobertura ainda não testa nada

Teste de mutação para Angular com Stryker: Quando 100% de cobertura ainda não testa nada
Imagem: Loiane Groner

Algumas semanas atrás, escrevi sobre testes de mutação para Java com PIT : por que 100% de cobertura de linhas ainda pode não detectar nada e como injetar pequenos bugs deliberadamente, ou mutantes , mede se seus testes realmente os notariam. Se você quiser entender completamente por que a cobertura mente, comece por lá. Este post é a outra metade da solução: o mesmo sensor, aplicado ao Angular com o Stryker Mutator .

É no frontend que isso mais importa. O teste de mutação expect(component).toBeTruthy()é praticamente um gênero próprio de teste do Angular. Ele instancia um componente, executa seu construtor e metade do código ngOnInit, eleva a cobertura para mais de 60% e não verifica nada sobre o que o componente realmente faz. Um conjunto completo desses testes indica se o código funciona ou não, e a cobertura de linhas o considerará bem testado. O teste de mutação é o sensor que enxerga através deles.

É a mesma estratégia usada no restante desta série: partimos de uma premissa, “nossos testes detectariam uma regressão”, e a transformamos em um número que o build pode usar como critério de verificação. Aqui, executamos o Stryker no projeto CRUD real que uso neste blog, lemos uma mutação genuína encontrada e integramos a pontuação da mutação ao CI como um critério de verificação.

Quer o código? Todos os exemplos neste post são direcionados ao projeto CRUD full-stack que uso neste blog e nos meus vídeos: loiane/crud-angular-spring

Neste post, abordaremos:

  • Uma breve recapitulação sobre o que os testes de mutação medem: mutantes, mortos versus sobreviventes e a pontuação da mutação (a explicação completa está no post do PIT).
  • Configurando o Stryker em um projeto Angular moderno e independente.
  • A decisão sobre o executor de testes que realmente importa: o executor de comandos da Stryker versus o executor dedicado Vitest, e por que ng testa nova @angular/build:unit-testversão do construtor muda tudo.
  • Ao ler um relatório real da Stryker e um mutante genuíno sobrevivente desse código-fonte, percebe-se que ele expõe uma cobertura de código redundante completamente oculta.
  • Método de eliminação e mutações de URL em um serviço HTTP, e por que HttpTestingControllerele é bom nisso.
  • Por que os testes gerados por IA sobrevivem a mutações em uma taxa alarmante, com sugestões de exemplos para corrigir isso.
  • Executando o Stryker como um gate no GitHub Actions
  • Garantir que os testes de mutação sejam rápidos o suficiente para serem incorporados ao seu pipeline de forma efetiva é essencial.

Uma breve revisão

postagem do PIT explica tudo detalhadamente, então aqui está a versão resumida em um parágrafo. A cobertura de linhas informa qual linha foi executada durante o teste; ela nunca questiona se uma asserção teria falhado caso essa linha apresentasse comportamento inesperado. O teste de mutação questiona exatamente isso: ele cria centenas de pequenas cópias defeituosas do seu código ( mutantes , onde ` <T>` return 'code'se torna `<T>` return ''ou ` if (record._id)<T>` se torna `<T> if (true)`), executa seu conjunto de testes em cada uma delas e relata quais foram eliminadas pelos testes (um teste falhou) e quais sobreviveram (todos os testes ainda passaram). A porcentagem de mutantes eliminados é a sua pontuação de mutação e, diferentemente da cobertura de linhas, ela não pode ser inflada por testes que executam o código sem verificá-lo. Um mutante sobrevivente é uma mensagem precisa: aqui está uma alteração no seu código de produção que nenhum dos seus testes detectaria.

Preparando o Stryker

Stryker é o framework de testes de mutação para o ecossistema JavaScript e TypeScript, sendo o equivalente em Angular ao PIT na JVM. Instale o pacote principal como uma dependência de desenvolvimento:

aspnet
npm install --save-dev @stryker-mutator/core

Em seguida, crie um arquivo de configuração com `configure` npx stryker initou escreva-o manualmente. Aqui está a configuração que este projeto usa, em stryker.config.json`configure`:

aspnet
{
  "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
  "mutate": [
    "src/app/**/*.ts",
    "!src/**/*.spec.ts"
  ],
  "testRunner": "command",
  "commandRunner": {
    "command": "npm run test:ci"
  },
  "coverageAnalysis": "off",
  "thresholds": { "high": 80, "low": 60, "break": 50 },
  "concurrency": 2,
  "reporters": ["html", "clear-text", "progress"],
  "htmlReporter": {
    "fileName": "reports/mutation/mutation.html"
  },
  "tempDirName": ".stryker-tmp",
  "cleanTempDir": true
}

Conecte-o a um script package.jsone você terá uma mutação executada com um único comando:

aspnet
{
  "scripts": {
    "test:ci": "ng test --watch=false --progress=false",
    "test:mutation": "stryker run"
  }
}

Alguns campos têm o maior peso. mutateDefine o que será sabotado: todo o código do aplicativo, nunca os arquivos de especificação. thresholds.breakÉ o ponto de controle: uma pontuação de mutação abaixo de 50 resulta em um valor diferente de zero e falha na integração contínua, da mesma forma que um teste com falha. highlowapenas influencia o relatório. E testRunnervale a pena entender essa decisão, porque no mundo Angular ela não é óbvia.

A decisão do piloto de testes

O Stryker precisa executar seus testes centenas de vezes, uma vez para cada mutante, então a forma como ele os invoca é mais importante aqui do que em qualquer outro lugar. Ele oferece dois caminhos para um projeto Angular, e este repositório usa deliberadamente o mais simples.

O executor de comandos , usado na configuração acima, trata seu comando de teste como uma caixa preta. O Stryker o substitui por um mutante, executa o comando npm run test:cie observa o código de saída: um código diferente de zero significa que o teste foi encerrado, e zero significa que o teste sobreviveu. Esse comando é `test` ng test --watch=false, que no Angular moderno (este projeto está na versão 22) é executado pelo novo @angular/build:unit-testconstrutor, o executor de testes baseado em Vitest do Angular, configurado em angular.json:

aspnet
"test": {
  "builder": "@angular/build:unit-test"
}

A vantagem do executor de comandos é que ele não se importa com o que está por baixo ng test. Karma, o novo construtor Vitest, qualquer que seja a próxima atualização do Angular: o Stryker simplesmente executa o comando e lê o resultado. É a opção mais robusta enquanto a estratégia de testes do Angular ainda está em desenvolvimento, e é por isso que atualmente é a escolha padrão sensata para um projeto real.

O custo é a velocidade. Como o Stryker não consegue ver o conteúdo do comando, ele precisa executar o conjunto completo de testes para cada mutante, e é exatamente por isso que a configuração define o valor coverageAnalysis: "off". Não há como mapear qual teste abrange qual mutante por meio de um comando de shell opaco, então cada mutante paga pelo conjunto completo de testes.

O executor dedicado do Vitest ( @stryker-mutator/vitest-runner) é a alternativa mais rápida. Ao se integrar diretamente ao Vitest, o Stryker pode habilitar o recurso de execução de testes coverageAnalysis: "perTest": para cada mutante, ele executa apenas os testes que realmente exercitam a linha mutada, o que pode reduzir drasticamente o tempo de execução de mutações. A desvantagem é um acoplamento mais estreito: você configura o Stryker diretamente com o Vitest, em vez de deixar que ng testele o abstraia. Essa é a atualização natural quando o tempo de execução de mutações começa a impactar negativamente o desempenho, e vale a pena adotá-la no momento em que o custo de um conjunto completo de testes por mutante supera a simplicidade.

Este projeto mantém @stryker-mutator/vitest-runnersuas dependências de desenvolvimento exatamente por esse motivo: o executor de comandos é o que a configuração utiliza hoje, mas o executor mais rápido já está instalado e pronto para ser ativado no momento em que o ambiente de execução justificar. Comece com o executor de comandos pela sua robustez; migre para o executor Vitest quando a velocidade exigir.

Como ler um relatório real

Ao executar o comando npm run test:mutation, o Stryker imprime um resumo em texto simples e gera um relatório HTML reports/mutation/mutation.html. O resumo tem a seguinte aparência (os números são meramente ilustrativos):

------------------------|---------|----------|------------|----------|
File                    | % score | # killed | # survived | # no cov |
------------------------|---------|----------|------------|----------|
All files               |   88.24 |       45 |          6 |        0 |
 courses/services       |         |          |            |          |
  courses.ts            |  100.00 |       12 |          0 |        0 |
 shared/pipes           |         |          |            |          |
  category-pipe.ts      |   62.50 |        5 |          3 |        0 |
------------------------|---------|----------|------------|----------|

O número total está correto, mas o valor não está no agregado. O valor está nos sobreviventes, e este código-fonte possui um exemplo verdadeiramente instrutivo. Observe o exemplo CategoryIconPipeque mapeia uma categoria de curso para um ícone do Material Design:

typescript
@Pipe({
  name: 'categoryIcon',
})
export class CategoryIconPipe implements PipeTransform {
  transform(value: string): string {
    switch (value?.toLowerCase()) {
      case 'front-end':
        return 'code';
      case 'back-end':
        return 'computer';
    }
    return 'code';
  }
}

Suas especificações são completas em todos os sentidos convencionais: abrangem todos os casos, variações de maiúsculas e minúsculas, uma categoria desconhecida e uma string vazia:

typescript
it('should return "code" for "front-end"', () => {
  expect(pipe.transform('front-end')).toBe('code');
});
it('should return "computer" for "back-end"', () => {
  expect(pipe.transform('back-end')).toBe('computer');
});
it('should match categories regardless of casing', () => {
  expect(pipe.transform('Front-end')).toBe('code');
  expect(pipe.transform('Back-end')).toBe('computer');
});
it('should return "code" as default for unknown category', () => {
  expect(pipe.transform('full-stack')).toBe('code');
});
it('should return "code" for empty string', () => {
  expect(pipe.transform('')).toBe('code');
});

Isso representa 100% de cobertura de linha e 100% de cobertura de ramificação. A Stryker ainda encontra sobreviventes, e cada um deles revela algo que a cobertura não conseguiu detectar.

Sobrevivente 1: O Ramo Redundante

Stryker transforma a string literal 'front-end'em algo ''e relata que ela sobreviveu:

aspnet
#1. [Survived] StringLiteral
src/app/shared/pipes/category-pipe.ts:9:12
-       case 'front-end':
+       case '':

Pense por que nenhum teste detecta isso. Com a mutação presente, transform('front-end')o valor não corresponde mais ao esperado case, então o teste passa para o valor esperado return 'code'e retorna 'code'exatamente o que o teste espera. O teste é aprovado. O mutante sobrevive.

Esse sobrevivente não é ruído; é um problema de design que a pontuação de mutação revelou. O case 'front-end'branch retorna `true` 'code', e o padrão também retorna `false` 'code'. O branch é redundante: você poderia excluí-lo completamente e todos os testes ainda passariam, porque ele faz a mesma coisa que o fallback. A cobertura marcou essa linha como testada. O teste de mutação revelou que ela é impossível de testar como está escrita , porque não tem nenhum comportamento observável distinto para testar. A solução honesta é excluir o caso redundante, não adicionar um teste para um branch que não faz nada.

Sobrevivente 2: A Corrente Opcional Desprotegida

Stryker também modifica o operador de encadeamento opcional, removendo o ?:

aspnet
#2. [Survived] OptionalChaining
src/app/shared/pipes/category-pipe.ts:8:20
-     switch (value?.toLowerCase()) {
+     switch (value.toLowerCase()) {

Este caso sobrevive porque nenhum teste chama `.` transform(null)ou transform(undefined)`.`. Com ?.o operador de segurança, `.` retorna o valor padrão para uma entrada nula; sem ele, value.toLowerCase()lança uma exceção. Nenhum teste explora esse caminho, portanto, remover o operador de segurança não altera nada do que qualquer asserção observa. Este é o tipo de sobrevivente mais valioso: um caso real e sem proteção em código de produção. A solução aqui é um novo teste:

typescript
it('should return "code" for a nullish category', () => {
  expect(pipe.transform(null as unknown as string)).toBe('code');
});

Adicione isso, e o mutante de encadeamento opcional morre, porque agora remover o ` ?.if` exige um teste. A pontuação da mutação aumentou porque seu conjunto de habilidades realmente ficou mais forte, não porque um número mudou.

Eliminando mutantes em um serviço HTTP

Nem todas as partes do código-fonte estão repletas de sobreviventes, e observar como é uma boa cobertura sob mutação é igualmente instrutivo. O CoursesServicecódigo decide entre criar e atualizar com base na presença de um ID:

typescript
save(record: Partial<Course>): Observable<Course> {
  if (record._id) {
    return this.update(record); // PUT
  }
  return this.create(record);   // POST
}

Stryker ataca isso ifcom dois mutantes condicionais: forçando a condição para true(sempre atualizar) e para false(sempre criar). Ambos falham, porque a especificação verifica o método HTTP real para cada caminho usando HttpTestingController:

typescript
it('should POST to create a course when _id is absent', () => {
  service.save({ name: 'New', category: 'back-end' }).subscribe();
  const req = httpMock.expectOne('/api/courses');
  expect(req.request.method).toBe('POST');
  req.flush({ _id: '2', name: 'New', category: 'back-end' });
});

it('should PUT to update a course when _id is present', () => {
  service.save(mockCourse).subscribe();
  const req = httpMock.expectOne('/api/courses/1');
  expect(req.request.method).toBe('PUT');
  req.flush(mockCourse);
});

É por isso que o ` HttpTestingControllermutation` é um aliado tão forte para a pontuação de mutação: expectOne('/api/courses/1')ele fixa a URL exata e expect(req.request.method).toBe('PUT')fixa o verbo exato. Altere a string da URL e o teste expectOnefalhará em uma requisição inesperada. Altere o método e a asserção falhará. Force a condicional truee o teste “create” de repente verá um PUT em /api/courses/undefinedvez de um POST: mutante morto. Asserções que verificam o que realmente foi transmitido pela rede eliminam mutantes que asserções como `mutation` toBeTruthy()jamais conseguiriam. A lição se generaliza: quanto mais especificamente um teste descreve o comportamento esperado, mais mutantes ele elimina.

Por que os testes gerados por IA sobrevivem a mutantes?

Já apresentei esse argumento para todos os sensores desta série e, para testes de mutação no front-end, demonstrá-lo é quase fácil demais, pois o modo de falha é visível no próprio formato do teste.

Quando você pede a um agente para “adicionar testes para este componente”, ele otimiza para o que consegue ver: fazer o arquivo existir, fazer com que ele seja executado, aumentar a cobertura de código. O caminho de menor resistência para os três objetivos é um teste que instancia o componente e verifica se ele é verdadeiro, ou um que chama um método e verifica se o resultado está definido . Esses testes executam muitas linhas de código e não verificam quase nada; eles maximizam a cobertura de código e são transparentes a mutações. Pior ainda, um agente que escreve a implementação e o teste simultaneamente tende a escrever testes que espelham a implementação em vez de especificar o comportamento: se o código retornar um valor verdadeiro 'code', o teste verifica se 'code'o resultado está definido, e se o código estiver errado, o teste verificará o valor errado com a mesma confiança.

A estrutura é a mesma de sempre. Uma linha copilot-instructions.mdque diz “escreva testes significativos, assegure-se sobre o comportamento, não sobre a existência” é um guia inferencial de feedforward : ele influencia o modelo, e o modelo o ignora no momento em que a cobertura fica verde. A pontuação de mutação é um sensor de feedback computacional : ela falha na compilação quando os testes não testam de fato, independentemente de terem sido escritos por alguém correndo contra o tempo ou por um agente correndo para obter resultados positivos.

A estratégia prática é fornecer os sobreviventes ao agente como trabalho concreto. Um toBeTruthyteste é uma instrução vaga; um mutante sobrevivente é uma instrução precisa. Exemplos de instruções que eu uso:

aspnet
Run `npm run test:mutation` on the Angular project and open the Stryker report.
For every SURVIVED mutant, add or strengthen a test that kills it.
Assert on the specific behavior the mutant changes, not on existence or
truthiness. Do not weaken the code to make a mutant equivalent. If a mutant
is genuinely equivalent (no observable difference), tell me and explain why
instead of adding a test.

E uma sugestão que transforma a pontuação em um critério de aceitação:

aspnet
Treat the mutation score as acceptance criteria, not coverage. The task is
not done until `npm run test:mutation` passes the break threshold (50) and
you have shown me the surviving-mutant list is either empty or contains only
mutants you have justified as equivalent. A green line-coverage report is
not sufficient evidence that the tests work.

Essa segunda instrução reformula todo o exercício. “Escrever bons testes” é uma esperança; “eliminar esses mutantes” é uma lista de verificação que o agente precisa seguir, usando o sensor exato que o pipeline irá executar.

Executando o Stryker no GitHub Actions

O teste de mutação é uma tarefa comum de CI e funciona junto com os outros sensores desta série no mesmo pipeline. Como é mais lento que uma execução de teste normal (cada mutação executa o conjunto de testes sob o comando do runner), muitas equipes o executam de forma agendada ou apenas em pull requests que modificam o código do aplicativo, em vez de a cada push. Como uma tarefa de controle de alterações direta, ele se parece com isto:

aspnet
mutation-angular:
  name: Mutation Testing (Angular / Stryker)
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v7
    - uses: actions/setup-node@v6
      with:
        node-version: 22
        cache: 'npm'
        cache-dependency-path: crud-angular/package-lock.json
    - name: Install dependencies
      run: npm ci
      working-directory: crud-angular
    - name: Run Stryker
      run: npm run test:mutation
      working-directory: crud-angular

thresholds.breakvalor na configuração controla o processo: se a pontuação de mutação cair abaixo de 50, stryker runo resultado será diferente de zero, a tarefa falha e a solicitação de pull request fica vermelha, o mesmo mecanismo de todas as outras verificações na compilação. Ao fazer o upload reports/mutation/mutation.htmlcomo um artefato, você obtém um relatório navegável de cada execução, com cada sobrevivente vinculado à sua linha.

Mantendo os testes de mutação práticos

O teste de mutação tem a reputação de ser lento e ruidoso, e ambos os problemas são reais se você o ativar de forma ingênua. Algumas práticas o mantêm no fluxo de trabalho em vez de desativá-lo:

Não busque 100%. Uma taxa de mutação entre 70% e 85% em código significativo é excelente; a última parte é dominada por mutantes equivalentes e retornos decrescentes. O breaklimite de 50% nesta configuração é um piso que detecta conjuntos de testes realmente fracos, não uma meta a ser alcançada. Defina o piso onde uma regressão seria realmente perigosa e aumente-o à medida que o conjunto de testes se fortalece, a mesma abordagem gradual usada na publicação sobre testes de arquitetura para suas regras.

Aprenda a reconhecer mutantes equivalentes. Às vezes, um mutante realmente não pode ser eliminado porque não produz nenhuma diferença observável: a redundância case 'front-end'acima é muito próxima de um. Esses não são problemas de teste; geralmente são um sinal para simplificar o código. Não force um teste para eliminar um mutante equivalente. Elimine a redundância ou documente por que ele é equivalente e siga em frente.

Defina o escopo da execução. O Stryker suporta o modo incremental e --sincea alteração apenas dos arquivos modificados, o que mantém as execuções de pull requests rápidas, enquanto uma execução completa agendada protege toda a base de código. Exclua o código gerado e as funcionalidades de “cola fina” da execução mutate: modificar um arquivo de barril não ensina nada.

Corrija o código, não apenas os testes. Os sobreviventes mais valiosos, como o ramo redundante, revelam informações sobre o código de produção , e não apenas sobre os testes. Metade do retorno do teste de mutação está nos ramos mortos e caminhos desprotegidos que ele revela ao longo do processo.

Conclusão

Um relatório de cobertura informa quais linhas seus testes executaram. Ele não pode dizer se uma única asserção teria falhado caso essas linhas apresentassem comportamento inesperado, e é por isso que um frontend pode apresentar 100% de cobertura e ainda assim entregar um componente cujos testes passariam por qualquer regressão sem problemas. O teste de mutação preenche essa lacuna, quebrando seu código propositalmente e contabilizando quantas versões sabotadas seus testes realmente detectam. Um mutante sobrevivente não é uma métrica; é uma alteração específica e localizada no seu código de produção que nenhum teste detectaria, e frequentemente um trecho de código morto ou desprotegido cuja existência você desconhecia.

Um caminho razoável para a adoção:

  1. Instale o Stryker e comece com o executor de comandos que envolve o código ng test. Essa é a configuração mais robusta enquanto o executor de testes do Angular continua evoluindo.
  2. Execute o programa uma vez em um alvo pequeno e com muita lógica (um cano, um serviço, um guarda) e analise os sobreviventes antes de mexer em qualquer outra coisa. Deixe a ferramenta mostrar o que estava faltando no seu sistema de segurança.
  3. Elimine os sobreviventes que representam lacunas reais, aplicando restrições a comportamentos específicos. Exclua ou documente os que são equivalentes.
  4. Defina um breaklimite mínimo que envergonharia uma suíte verdadeiramente fraca e conecte-a stryker runao CI como uma tarefa de controle.
  5. Mude para o Vitest Runner dedicado coverageAnalysis: "perTest"quando o custo do conjunto completo por mutante começar a pesar no bolso.
  6. Execute-o incrementalmente em solicitações de pull e completamente de acordo com um cronograma. Aumente a escalabilidade à medida que o conjunto de testes se torna mais robusto.

Enquanto o PIT fazia essa pergunta ao seu backend Java, o Stryker a faz ao seu frontend Angular: não “o código foi executado nos testes?”, mas “um teste teria detectado se o código estivesse errado?”. É o mesmo sensor de comportamento, agora abrangendo a metade da pilha onde toBeTruthy()causou os danos mais silenciosos. Em um mundo onde um agente pode gerar um componente e um conjunto de testes com cobertura perfeita no mesmo prompt, a pontuação de mutação é a diferença entre “os testes passam” e “os testes detectariam um bug”.

Referências

Boa programação!

Loiane Groner possui mais de 10 anos de experiência em TI, trabalha como Analista de Negócios e Desenvolvedora em uma instituição financeira americana, é Microsoft MVP e Google Developer Expert em Web/Angular e ama compartilhar conhecimento! É palestrante internacional e autora de livros publicados mundialmente pela editora Packt. Entusiasta Java, Sencha, Angular, PWAs, programação reativa e desenvolvimento mobile híbrido.

Ver perfil