TypeScript

O que Todo Dev Deve Saber sobre Mutation Testing com TypeScript: Stryker e Qualidade de Cobertura

15 min de leitura

O que Todo Dev Deve Saber sobre Mutation Testing com TypeScript: Stryker e Qualidade de Cobertura

Entendendo Mutation Testing: Além da Cobertura de Código Quando começamos a trabalhar com testes automatizados, frequentemente nos focamos em uma métrica simples: cobertura de código. Uma cobertura de 80%, 90% ou até 100% nos passa a sensação de segurança, mas será que realmente estamos testando bem? A resposta é não. Cobertura de código apenas mede quantas linhas seu código executa durante os testes, não se seus testes realmente validam o comportamento esperado. Mutation testing é uma técnica que revoluciona essa perspectiva. A ideia é simples, mas poderosa: o framework introduz pequenas alterações (mutações) no seu código-fonte e executa todos os seus testes. Se um teste falhar com a mutação, ele "matou" a mutação. Se todos os testes passarem mesmo com o código alterado, significa que seus testes não estão validando aquele comportamento adequadamente. Essa abordagem nos força a escrever testes de verdade, não apenas testes que tocam o código. Instalação e Configuração do Stryker em um Projeto TypeScript O Stryker

<h2>Entendendo Mutation Testing: Além da Cobertura de Código</h2>

<p>Quando começamos a trabalhar com testes automatizados, frequentemente nos focamos em uma métrica simples: cobertura de código. Uma cobertura de 80%, 90% ou até 100% nos passa a sensação de segurança, mas será que realmente estamos testando bem? A resposta é não. Cobertura de código apenas mede quantas linhas seu código executa durante os testes, não se seus testes realmente validam o comportamento esperado.</p>

<p>Mutation testing é uma técnica que revoluciona essa perspectiva. A ideia é simples, mas poderosa: o framework introduz pequenas alterações (mutações) no seu código-fonte e executa todos os seus testes. Se um teste falhar com a mutação, ele &quot;matou&quot; a mutação. Se todos os testes passarem mesmo com o código alterado, significa que seus testes não estão validando aquele comportamento adequadamente. Essa abordagem nos força a escrever testes de verdade, não apenas testes que tocam o código.</p>

<h2>Instalação e Configuração do Stryker em um Projeto TypeScript</h2>

<p>O Stryker é um mutation testing framework moderno, otimizado e fácil de usar. Vamos começar instalando e configurando em um projeto TypeScript do zero.</p>

<h3>Preparação do Ambiente</h3>

<p>Primeiro, crie um diretório do projeto e inicialize o Node.js:</p>

<pre><code class="language-bash">mkdir mutation-testing-demo

cd mutation-testing-demo

npm init -y

npm install --save-dev typescript ts-node @types/node

npm install --save-dev @stryker-mutator/core @stryker-mutator/typescript-checker</code></pre>

<p>Crie um arquivo <code>tsconfig.json</code>:</p>

<pre><code class="language-json">{

&quot;compilerOptions&quot;: {

&quot;target&quot;: &quot;ES2020&quot;,

&quot;module&quot;: &quot;commonjs&quot;,

&quot;lib&quot;: [&quot;ES2020&quot;],

&quot;outDir&quot;: &quot;./dist&quot;,

&quot;rootDir&quot;: &quot;./src&quot;,

&quot;strict&quot;: true,

&quot;esModuleInterop&quot;: true,

&quot;skipLibCheck&quot;: true,

&quot;forceConsistentCasingInFileNames&quot;: true

},

&quot;include&quot;: [&quot;src/*/&quot;],

&quot;exclude&quot;: [&quot;node_modules&quot;, &quot;dist&quot;]

}</code></pre>

<p>Agora instale um framework de testes. Vamos usar Jest:</p>

<pre><code class="language-bash">npm install --save-dev jest ts-jest @types/jest</code></pre>

<p>Configure o Jest criando <code>jest.config.js</code>:</p>

<pre><code class="language-javascript">module.exports = {

preset: &#039;ts-jest&#039;,

testEnvironment: &#039;node&#039;,

testMatch: [&#039;/__tests__//.test.ts&#039;, &#039;/?(.)+(spec|test).ts&#039;],

rootDir: &#039;src&#039;

};</code></pre>

<h3>Configuração do Stryker</h3>

<p>Execute o configurador interativo:</p>

<pre><code class="language-bash">npx stryker init</code></pre>

<p>Este comando fará perguntas sobre seu setup. Para nosso caso, configure para usar TypeScript e Jest. Ele criará <code>stryker.conf.json</code>. Você pode ajustar manualmente:</p>

<pre><code class="language-json">{

&quot;mutate&quot;: [&quot;src/*/.ts&quot;, &quot;!src/*/.test.ts&quot;],

&quot;testRunner&quot;: &quot;jest&quot;,

&quot;checkers&quot;: [&quot;typescript&quot;],

&quot;tsconfigFile&quot;: &quot;tsconfig.json&quot;,

&quot;reporters&quot;: [&quot;html&quot;, &quot;clear-text&quot;, &quot;progress&quot;],

&quot;timeoutMS&quot;: 5000,

&quot;concurrency&quot;: 4

}</code></pre>

<h2>Escrevendo Testes Fracos vs. Testes Fortes com Exemplos Práticos</h2>

<p>A diferença entre um teste fraco e um teste forte só fica evidente quando usamos mutation testing. Vamos demonstrar com um exemplo real.</p>

<h3>Exemplo 1: Um Teste Fraco que Não Detecta Mutações</h3>

<p>Crie <code>src/calculator.ts</code>:</p>

<pre><code class="language-typescript">export class Calculator {

add(a: number, b: number): number {

return a + b;

}

isPositive(value: number): boolean {

if (value &gt; 0) {

return true;

}

return false;

}

divide(a: number, b: number): number {

if (b === 0) {

throw new Error(&#039;Division by zero&#039;);

}

return a / b;

}

}</code></pre>

<p>Agora crie <code>src/calculator.weak.test.ts</code> — um teste fraco:</p>

<pre><code class="language-typescript">import { Calculator } from &#039;./calculator&#039;;

describe(&#039;Calculator - Testes Fracos&#039;, () =&gt; {

let calc: Calculator;

beforeEach(() =&gt; {

calc = new Calculator();

});

it(&#039;should add two numbers&#039;, () =&gt; {

const result = calc.add(2, 3);

expect(result).toBeTruthy(); // Teste fraco: apenas verifica se é truthy

});

it(&#039;should check if positive&#039;, () =&gt; {

calc.isPositive(5);

// Teste fraco: não verifica o resultado!

});

it(&#039;should divide numbers&#039;, () =&gt; {

const result = calc.divide(10, 2);

expect(result).toBeDefined(); // Teste fraco: apenas verifica se existe

});

});</code></pre>

<p>Estes testes passarão em qualquer circunstância. Se você mutar <code>a + b</code> para <code>a - b</code>, o teste de adição ainda passará porque o resultado será truthy. O Stryker conseguirá sobreviver com essas mutações.</p>

<h3>Exemplo 2: Testes Fortes que Matam Mutações</h3>

<p>Crie <code>src/calculator.strong.test.ts</code> — testes realmente bons:</p>

<pre><code class="language-typescript">import { Calculator } from &#039;./calculator&#039;;

describe(&#039;Calculator - Testes Fortes&#039;, () =&gt; {

let calc: Calculator;

beforeEach(() =&gt; {

calc = new Calculator();

});

it(&#039;should add two positive numbers correctly&#039;, () =&gt; {

expect(calc.add(2, 3)).toBe(5);

expect(calc.add(10, 20)).toBe(30);

expect(calc.add(-5, 5)).toBe(0);

});

it(&#039;should return true only for positive numbers&#039;, () =&gt; {

expect(calc.isPositive(5)).toBe(true);

expect(calc.isPositive(0.1)).toBe(true);

expect(calc.isPositive(0)).toBe(false);

expect(calc.isPositive(-1)).toBe(false);

});

it(&#039;should divide numbers correctly&#039;, () =&gt; {

expect(calc.divide(10, 2)).toBe(5);

expect(calc.divide(15, 3)).toBe(5);

});

it(&#039;should throw error when dividing by zero&#039;, () =&gt; {

expect(() =&gt; calc.divide(10, 0)).toThrow(&#039;Division by zero&#039;);

});

it(&#039;should handle edge cases in division&#039;, () =&gt; {

expect(calc.divide(0, 5)).toBe(0);

expect(calc.divide(-10, 2)).toBe(-5);

});

});</code></pre>

<p>A diferença é clara: verificamos valores específicos, casos extremos e comportamentos esperados. Se o Stryker mutar <code>&gt;</code> para <code>&gt;=</code> em <code>isPositive</code>, o teste falhará porque esperamos que <code>isPositive(0)</code> retorne <code>false</code>. Se mutar <code>+</code> para <code>-</code>, o teste de adição falhará imediatamente.</p>

<h2>Executando Mutation Testing e Interpretando Resultados</h2>

<h3>Rodando o Stryker</h3>

<p>Com a configuração pronta e testes escritos, execute:</p>

<pre><code class="language-bash">npx stryker run</code></pre>

<p>O Stryker analisará seu código, criará múltiplas versões mutantes, executará todos os testes contra cada mutante e gerará relatórios. Isso pode levar tempo dependendo do tamanho do projeto.</p>

<h3>Entendendo o Relatório</h3>

<p>O Stryker gera um relatório HTML em <code>reports/mutation/html/index.html</code>. O relatório mostra:</p>

<ul>

<li><strong>Killed</strong>: Mutações que foram detectadas por seus testes (bom!)</li>

<li><strong>Survived</strong>: Mutações que não foram detectadas (problema!)</li>

<li><strong>Timeout</strong>: Mutações que causaram loop infinito</li>

<li><strong>Compile Error</strong>: Mutações que não compilam</li>

</ul>

<p>A métrica principal é o <strong>Mutation Score</strong>, calculado como: <code>(Killed / (Killed + Survived)) × 100</code>. Um score de 100% significa que seus testes detectam todas as mutações. Na prática, 80% é excelente.</p>

<p>Você também pode usar a saída no terminal:</p>

<pre><code class="language-bash">npx stryker run --reporters clear-text</code></pre>

<p>Isso mostra um resumo direto no console, útil para CI/CD.</p>

<h2>Estratégias Práticas para Melhorar Mutation Score</h2>

<h3>Identificando Pontos Cegos</h3>

<p>Quando o Stryker mostra mutações que sobrevivem, você encontrou pontos fracos em seus testes. Vamos criar um exemplo mais realista:</p>

<pre><code class="language-typescript">// src/userService.ts

export class UserService {

private users: Map&lt;number, { id: number; name: string; active: boolean }&gt; = new Map();

private nextId = 1;

createUser(name: string): number {

if (!name || name.trim() === &#039;&#039;) {

throw new Error(&#039;Name cannot be empty&#039;);

}

const id = this.nextId++;

this.users.set(id, { id, name: name.trim(), active: true });

return id;

}

getUserById(id: number) {

const user = this.users.get(id);

if (!user) {

return null;

}

return user;

}

deactivateUser(id: number): boolean {

const user = this.users.get(id);

if (!user) {

return false;

}

user.active = false;

return true;

}

getActiveUsers() {

return Array.from(this.users.values()).filter(u =&gt; u.active);

}

}</code></pre>

<p>Agora, um teste que parece bom, mas deixa mutações passar:</p>

<pre><code class="language-typescript">// src/userService.weak.test.ts

import { UserService } from &#039;./userService&#039;;

describe(&#039;UserService - Testes Incompletos&#039;, () =&gt; {

let service: UserService;

beforeEach(() =&gt; {

service = new UserService();

});

it(&#039;should create a user&#039;, () =&gt; {

const id = service.createUser(&#039;João&#039;);

expect(id).toBe(1); // Apenas valida o ID retornado

});

it(&#039;should get user by id&#039;, () =&gt; {

service.createUser(&#039;Maria&#039;);

const user = service.getUserById(1);

expect(user).not.toBeNull(); // Valida apenas existência

});

it(&#039;should deactivate user&#039;, () =&gt; {

service.createUser(&#039;Pedro&#039;);

service.deactivateUser(1);

expect(true).toBe(true); // Teste completamente vazio!

});

});</code></pre>

<p>Testes forte que matam mutações:</p>

<pre><code class="language-typescript">// src/userService.strong.test.ts

import { UserService } from &#039;./userService&#039;;

describe(&#039;UserService - Testes Abrangentes&#039;, () =&gt; {

let service: UserService;

beforeEach(() =&gt; {

service = new UserService();

});

describe(&#039;createUser&#039;, () =&gt; {

it(&#039;should create user with unique incremental IDs&#039;, () =&gt; {

const id1 = service.createUser(&#039;Alice&#039;);

const id2 = service.createUser(&#039;Bob&#039;);

expect(id1).toBe(1);

expect(id2).toBe(2);

});

it(&#039;should trim user name before storing&#039;, () =&gt; {

service.createUser(&#039; Charlie &#039;);

const user = service.getUserById(1);

expect(user?.name).toBe(&#039;Charlie&#039;);

});

it(&#039;should throw error for empty names&#039;, () =&gt; {

expect(() =&gt; service.createUser(&#039;&#039;)).toThrow(&#039;Name cannot be empty&#039;);

expect(() =&gt; service.createUser(&#039; &#039;)).toThrow(&#039;Name cannot be empty&#039;);

});

it(&#039;should mark new users as active&#039;, () =&gt; {

service.createUser(&#039;David&#039;);

const user = service.getUserById(1);

expect(user?.active).toBe(true);

});

});

describe(&#039;getUserById&#039;, () =&gt; {

it(&#039;should return null for non-existent user&#039;, () =&gt; {

expect(service.getUserById(999)).toBeNull();

});

it(&#039;should return complete user object&#039;, () =&gt; {

service.createUser(&#039;Eve&#039;);

const user = service.getUserById(1);

expect(user).toEqual({

id: 1,

name: &#039;Eve&#039;,

active: true

});

});

});

describe(&#039;deactivateUser&#039;, () =&gt; {

it(&#039;should deactivate an existing user&#039;, () =&gt; {

service.createUser(&#039;Frank&#039;);

const result = service.deactivateUser(1);

expect(result).toBe(true);

expect(service.getUserById(1)?.active).toBe(false);

});

it(&#039;should return false for non-existent user&#039;, () =&gt; {

const result = service.deactivateUser(999);

expect(result).toBe(false);

});

it(&#039;should not affect other users&#039;, () =&gt; {

service.createUser(&#039;Grace&#039;);

service.createUser(&#039;Henry&#039;);

service.deactivateUser(1);

expect(service.getUserById(2)?.active).toBe(true);

});

});

describe(&#039;getActiveUsers&#039;, () =&gt; {

it(&#039;should return only active users&#039;, () =&gt; {

service.createUser(&#039;Iris&#039;);

service.createUser(&#039;Jack&#039;);

service.createUser(&#039;Karen&#039;);

service.deactivateUser(2);

const activeUsers = service.getActiveUsers();

expect(activeUsers).toHaveLength(2);

expect(activeUsers.map(u =&gt; u.id)).toEqual([1, 3]);

});

it(&#039;should return empty array when no users exist&#039;, () =&gt; {

expect(service.getActiveUsers()).toEqual([]);

});

it(&#039;should return empty array when all users are inactive&#039;, () =&gt; {

service.createUser(&#039;Liam&#039;);

service.deactivateUser(1);

expect(service.getActiveUsers()).toEqual([]);

});

});

});</code></pre>

<p>A diferença é gritante. Os testes fortes:</p>

<ul>

<li>Validam valores específicos, não apenas existência</li>

<li>Testam casos extremos (strings vazias, valores null, etc.)</li>

<li>Verificam múltiplos cenários no mesmo teste quando apropriado</li>

<li>Garantem que o estado muda corretamente</li>

</ul>

<h3>Checklist para Melhorar Mutation Score</h3>

<p>Quando encontrar mutações que sobrevivem, pergunte-se:</p>

<ul>

<li><strong>Estou validando o resultado exato?</strong> Use <code>toBe()</code> ao invés de <code>toBeTruthy()</code></li>

<li><strong>Estou testando todas as branches?</strong> Use ferramentas de cobertura para identificar caminhos não testados</li>

<li><strong>Estou testando casos extremos?</strong> Zero, valores negativos, strings vazias, null</li>

<li><strong>Estou validando mudanças de estado?</strong> Não apenas retornos, mas também como o objeto muda</li>

<li><strong>Estou testando as condições corretamente?</strong> <code>&gt;</code>, <code>&gt;=</code>, <code>&lt;</code>, <code>&lt;=</code>, <code>===</code> são diferentes</li>

</ul>

<h2>Conclusão</h2>

<p>Você aprendeu que <strong>mutation testing não é uma métrica de cobertura melhorada, é uma mudança de filosofia</strong>: testes reais validam comportamento, não apenas linha de código. O Stryker em TypeScript fornece uma ferramenta acessível para implementar essa mentalidade no seu workflow diário.</p>

<p>Em segundo lugar, <strong>testes fracos e testes fortes têm uma diferença fundamental</strong>: testes fracos apenas &quot;tocam&quot; o código, enquanto testes fortes validam o comportamento esperado com precisão. Usar mutation testing força você a escrever o segundo tipo, aumentando a confiabilidade do seu software significativamente.</p>

<p>Por fim, <strong>melhorar mutation score é um processo iterativo</strong>: execute o Stryker regularmente, analise mutações que sobrevivem, e reescreva testes para cobrir esses cenários. A combinação de cobertura de código tradicional com mutation score cria uma rede de segurança praticamente impenetrável contra bugs.</p>

<h2>Referências</h2>

<ul>

<li><a href="https://stryker-mutator.io/" target="_blank" rel="noopener noreferrer">Stryker Mutator - Documentação Oficial</a></li>

<li><a href="https://github.com/testing-library/testing-library-docs" target="_blank" rel="noopener noreferrer">Testing Library TypeScript Best Practices</a></li>

<li><a href="https://pitest.org/" target="_blank" rel="noopener noreferrer">Mutation Testing: Evaluating Test Quality</a> — Referência de implementação similar em Java</li>

<li><a href="https://jestjs.io/docs/getting-started" target="_blank" rel="noopener noreferrer">Jest Documentation - Complete Testing Guide</a></li>

<li><a href="https://www.amazon.com/Art-Software-Testing-Glenford-Myers/dp/1118031962" target="_blank" rel="noopener noreferrer">The Art of Software Testing - Glenford J. Myers</a> — Clássico sobre qualidade de testes</li>

</ul>

<p>&lt;!-- FIM --&gt;</p>

Comentários

Mais em TypeScript

Como Usar Banco de Dados com TypeScript: Prisma, TypeORM e Drizzle Comparados em Produção
Como Usar Banco de Dados com TypeScript: Prisma, TypeORM e Drizzle Comparados em Produção

Introdução ao Ecossistema de Banco de Dados em TypeScript TypeScript revoluci...

Dominando Segurança em TypeScript: Tipos que Previnem Vulnerabilidades Comuns em Projetos Reais
Dominando Segurança em TypeScript: Tipos que Previnem Vulnerabilidades Comuns em Projetos Reais

O Poder do Sistema de Tipos do TypeScript na Prevenção de Vulnerabilidades O...

Dominando NestJS Avançado: Guards, Interceptors, Pipes e Exception Filters em Projetos Reais
Dominando NestJS Avançado: Guards, Interceptors, Pipes e Exception Filters em Projetos Reais

Introdução ao Middleware de NestJS NestJS é um framework robusto construído s...