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

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

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 é um mutation testing framework moderno, otimizado e fácil de usar. Vamos começar instalando e configurando em um projeto TypeScript do zero.

Preparação do Ambiente

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

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

Crie um arquivo tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Agora instale um framework de testes. Vamos usar Jest:

npm install --save-dev jest ts-jest @types/jest

Configure o Jest criando jest.config.js:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/__tests__/**/*.test.ts', '**/?(*.)+(spec|test).ts'],
  rootDir: 'src'
};

Configuração do Stryker

Execute o configurador interativo:

npx stryker init

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

{
  "mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
  "testRunner": "jest",
  "checkers": ["typescript"],
  "tsconfigFile": "tsconfig.json",
  "reporters": ["html", "clear-text", "progress"],
  "timeoutMS": 5000,
  "concurrency": 4
}

Escrevendo Testes Fracos vs. Testes Fortes com Exemplos Práticos

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

Exemplo 1: Um Teste Fraco que Não Detecta Mutações

Crie src/calculator.ts:

export class Calculator {
  add(a: number, b: number): number {
    return a + b;
  }

  isPositive(value: number): boolean {
    if (value > 0) {
      return true;
    }
    return false;
  }

  divide(a: number, b: number): number {
    if (b === 0) {
      throw new Error('Division by zero');
    }
    return a / b;
  }
}

Agora crie src/calculator.weak.test.ts — um teste fraco:

import { Calculator } from './calculator';

describe('Calculator - Testes Fracos', () => {
  let calc: Calculator;

  beforeEach(() => {
    calc = new Calculator();
  });

  it('should add two numbers', () => {
    const result = calc.add(2, 3);
    expect(result).toBeTruthy(); // Teste fraco: apenas verifica se é truthy
  });

  it('should check if positive', () => {
    calc.isPositive(5);
    // Teste fraco: não verifica o resultado!
  });

  it('should divide numbers', () => {
    const result = calc.divide(10, 2);
    expect(result).toBeDefined(); // Teste fraco: apenas verifica se existe
  });
});

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

Exemplo 2: Testes Fortes que Matam Mutações

Crie src/calculator.strong.test.ts — testes realmente bons:

import { Calculator } from './calculator';

describe('Calculator - Testes Fortes', () => {
  let calc: Calculator;

  beforeEach(() => {
    calc = new Calculator();
  });

  it('should add two positive numbers correctly', () => {
    expect(calc.add(2, 3)).toBe(5);
    expect(calc.add(10, 20)).toBe(30);
    expect(calc.add(-5, 5)).toBe(0);
  });

  it('should return true only for positive numbers', () => {
    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('should divide numbers correctly', () => {
    expect(calc.divide(10, 2)).toBe(5);
    expect(calc.divide(15, 3)).toBe(5);
  });

  it('should throw error when dividing by zero', () => {
    expect(() => calc.divide(10, 0)).toThrow('Division by zero');
  });

  it('should handle edge cases in division', () => {
    expect(calc.divide(0, 5)).toBe(0);
    expect(calc.divide(-10, 2)).toBe(-5);
  });
});

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

Executando Mutation Testing e Interpretando Resultados

Rodando o Stryker

Com a configuração pronta e testes escritos, execute:

npx stryker run

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.

Entendendo o Relatório

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

  • Killed: Mutações que foram detectadas por seus testes (bom!)
  • Survived: Mutações que não foram detectadas (problema!)
  • Timeout: Mutações que causaram loop infinito
  • Compile Error: Mutações que não compilam

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

Você também pode usar a saída no terminal:

npx stryker run --reporters clear-text

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

Estratégias Práticas para Melhorar Mutation Score

Identificando Pontos Cegos

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

// src/userService.ts
export class UserService {
  private users: Map<number, { id: number; name: string; active: boolean }> = new Map();
  private nextId = 1;

  createUser(name: string): number {
    if (!name || name.trim() === '') {
      throw new Error('Name cannot be empty');
    }
    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 => u.active);
  }
}

Agora, um teste que parece bom, mas deixa mutações passar:

// src/userService.weak.test.ts
import { UserService } from './userService';

describe('UserService - Testes Incompletos', () => {
  let service: UserService;

  beforeEach(() => {
    service = new UserService();
  });

  it('should create a user', () => {
    const id = service.createUser('João');
    expect(id).toBe(1); // Apenas valida o ID retornado
  });

  it('should get user by id', () => {
    service.createUser('Maria');
    const user = service.getUserById(1);
    expect(user).not.toBeNull(); // Valida apenas existência
  });

  it('should deactivate user', () => {
    service.createUser('Pedro');
    service.deactivateUser(1);
    expect(true).toBe(true); // Teste completamente vazio!
  });
});

Testes forte que matam mutações:

// src/userService.strong.test.ts
import { UserService } from './userService';

describe('UserService - Testes Abrangentes', () => {
  let service: UserService;

  beforeEach(() => {
    service = new UserService();
  });

  describe('createUser', () => {
    it('should create user with unique incremental IDs', () => {
      const id1 = service.createUser('Alice');
      const id2 = service.createUser('Bob');
      expect(id1).toBe(1);
      expect(id2).toBe(2);
    });

    it('should trim user name before storing', () => {
      service.createUser('  Charlie  ');
      const user = service.getUserById(1);
      expect(user?.name).toBe('Charlie');
    });

    it('should throw error for empty names', () => {
      expect(() => service.createUser('')).toThrow('Name cannot be empty');
      expect(() => service.createUser('   ')).toThrow('Name cannot be empty');
    });

    it('should mark new users as active', () => {
      service.createUser('David');
      const user = service.getUserById(1);
      expect(user?.active).toBe(true);
    });
  });

  describe('getUserById', () => {
    it('should return null for non-existent user', () => {
      expect(service.getUserById(999)).toBeNull();
    });

    it('should return complete user object', () => {
      service.createUser('Eve');
      const user = service.getUserById(1);
      expect(user).toEqual({
        id: 1,
        name: 'Eve',
        active: true
      });
    });
  });

  describe('deactivateUser', () => {
    it('should deactivate an existing user', () => {
      service.createUser('Frank');
      const result = service.deactivateUser(1);
      expect(result).toBe(true);
      expect(service.getUserById(1)?.active).toBe(false);
    });

    it('should return false for non-existent user', () => {
      const result = service.deactivateUser(999);
      expect(result).toBe(false);
    });

    it('should not affect other users', () => {
      service.createUser('Grace');
      service.createUser('Henry');
      service.deactivateUser(1);
      expect(service.getUserById(2)?.active).toBe(true);
    });
  });

  describe('getActiveUsers', () => {
    it('should return only active users', () => {
      service.createUser('Iris');
      service.createUser('Jack');
      service.createUser('Karen');
      service.deactivateUser(2);
      const activeUsers = service.getActiveUsers();
      expect(activeUsers).toHaveLength(2);
      expect(activeUsers.map(u => u.id)).toEqual([1, 3]);
    });

    it('should return empty array when no users exist', () => {
      expect(service.getActiveUsers()).toEqual([]);
    });

    it('should return empty array when all users are inactive', () => {
      service.createUser('Liam');
      service.deactivateUser(1);
      expect(service.getActiveUsers()).toEqual([]);
    });
  });
});

A diferença é gritante. Os testes fortes:
- Validam valores específicos, não apenas existência
- Testam casos extremos (strings vazias, valores null, etc.)
- Verificam múltiplos cenários no mesmo teste quando apropriado
- Garantem que o estado muda corretamente

Checklist para Melhorar Mutation Score

Quando encontrar mutações que sobrevivem, pergunte-se:

  • Estou validando o resultado exato? Use toBe() ao invés de toBeTruthy()
  • Estou testando todas as branches? Use ferramentas de cobertura para identificar caminhos não testados
  • Estou testando casos extremos? Zero, valores negativos, strings vazias, null
  • Estou validando mudanças de estado? Não apenas retornos, mas também como o objeto muda
  • Estou testando as condições corretamente? >, >=, <, <=, === são diferentes

Conclusão

Você aprendeu que mutation testing não é uma métrica de cobertura melhorada, é uma mudança de filosofia: 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.

Em segundo lugar, testes fracos e testes fortes têm uma diferença fundamental: testes fracos apenas "tocam" 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.

Por fim, melhorar mutation score é um processo iterativo: 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.

Referências


Artigos relacionados