O melhor metódo para testar sistemas legados
Quando se fala em testes de software os principais que vêm a cabeça normalmente são testes de unidade, integração e ponta-a-ponta, conhecido também como testes E2E. Contudo quando se trata de sistemas legados, a implementação dessas abordagens pode não ser tão simples.
É bem comum em sistemas legados encontrar casos em que a estrutura original do projeto não foi planejada com a testabilidade em mente, o que pode tornar a tarefa de escrever testes automatizados bastante complexa devido a alta complexidade do código e seu alto nível acoplamento entre seus componentes.
Neste momento você deve esta pensando: basta refatorar o código para que ele se torne mais fácil de ser testado. Entretanto essa tarefa não é tão simples de ser feita em um sistema legado, já que muito provavelmente este código já esta em produção e qualquer alteração por menor que seja, pode interroper todo o funcionamento do sistema.
Com isso acabamos nos deparando com um paradoxo: para conseguirmos testar nosso sistema precisamos refatorações para tornar o código mais testável, porém para conseguirmos refatorar com seguranças precisamos de testes para garantir que não vamos introduzir novos bugs no sistema. E agora, como sair desse ciclo aparentemente interminável?
Com essa questão em mente, Michael Feathers autor do livro “Trabalho Eficaz com Código Legado” propós uma nova metodologia de testes criada especificamente para sistemas legados: testes caracterização, conhecido também Golden Master Testing. Segundo ele mais importante do que encontrar bugs em um sistema legado é garantir que as alterações não irão afetar o comportamento atual sistema.
O que são testes de caracterização?
Ao contrário das abordagens tradicionais, os testes de caracterização não tem o objetivo de checar se o código esta correto, mas sim de descrever o comportamento atual da aplicação sem precisar de especificações e prever todos os cenários e combinações possíveis. Desta maneira é possível garantir mais segurança na hora de modficiar o código já existente, uma vez que basta o desenvovledor rodar esses testes para garantir que tudo está funcionando como inicialmente.
Esta abordagem consiste em invocar um método várias vezes com combinações de entrada diferente e armazenar o resultado em um arquivo externo, criando assim uma snapshot. E depois executar os mesmos testes comparado o resultado com os executados anteriormente. Normalmente a criação do arquivo de snapshot consite em chamar repetidamente um método com uma variedade de parâmetros, cada um gerado aleatoriamente, e então salvar os resultados em um arquivo. O arquivo pode ser em qualquer formato, texto, csv, SQLite etc. Para fins de simplificação irei utilizar o formato JSON no exemplo, mas fique a vontade para usar o que quiser ja que o conceito é o mesmo.
Aqui está um pequeno algoritmo para escrever testes de caracterização:
- Identifique pontos de alteração;
- Escreva um teste que você sabe que irá falhar;
- Deixe que a falha diga qual é o comportamento;
- Altere o teste para que ele espere o comportamento que o código; produz;
- Repita.
Gilded Rose Kata
Para exemplificar os conceitos do Golden Master Testing, irei utilizar um problema clássico da programação: o Gilded Rose. Este desafio consiste em melhorar a estrutura e legibilidade de um código já existente sem alterar seu comportamento. Primeiramente vou explicar como criar um teste manualmente e no fim como automizar esse processo para criar uma grande suite de testes.
A versão original foi postada por Bobby Johnson e posteriomente expandido por Emily Bache, A proposta inicial foi escrita em C#, porém com o aumento de sua popularidade o desafio ganhou versões nas mais variadas linguagens de programação, como pode ser visto no repositório oficial. Neste artigo irei utilizar o TypeScript, já que é a linguagem que trabalho atualmente, mas fique a vontade para utilizar sua lingaugem de prefêrencia.
A história fictícia por trás do desafio é que você é um desenvolvedor que herdou um sistema de inventário chamado Gilded Rose, no qual o desenvolvedor original saiu em busca de novas aventuras. Entretanto o código existente é confuso e difícil de modificar. Sua tarefa é melhorar a qualidade do sistema existente. As intruções do repositório original são as seguintes:
Todos os itens (classe Item
) possuem uma propriedade chamada SellIn
que informa o número de dias que temos para vende-lo.
- Todos os itens possuem uma propriedade chamada
quality
que informa o quão valioso é o item. - No final do dia, nosso sistema decrementa os valores das propriedades
SellIn
equality
de cada um dos itens do estoque através do método updateQuality.
Bastante simples, não é? Bem, agora que as coisas ficam interessantes:
- Quando a data de venda do item tiver passado, a qualidade (
quality
) do item diminui duas vezes mais rapido. - A qualidade (
quality
) do item não pode ser negativa - O “Queijo Brie envelhecido” (
Aged Brie
), aumenta sua qualidade (quality
) em 1 unidade a medida que envelhece. - A qualidade (
quality
) de um item não pode ser maior que 50. - O item “Sulfuras” (
Sulfuras
), por ser um item lendário, não precisa ter uma data de venda (SellIn
) e sua qualidade (quality
) não precisa ser diminuida. - O item “Entrada para os Bastidores” (
Backstage Passes
), assim como o "Queijo Brie envelhecido", aumenta sua qualidade (quality
) a medida que o dia da venda (SellIn
) se aproxima; - A qualidade (
quality
) aumenta em 2 unidades quando a data de venda (SellIn
) é igual ou menor que 10. - A qualidade (
quality
) aumenta em 3 unidades quando a data de venda (SellIn
) é igual ou menor que 5. - A qualidade (
quality
) do item vai direto à 0 quando a data de venda (SellIn
) tiver passado.
Talk is cheap, show me the code
Com o desafio introduzido chegou a hora de colocarmos a mão na massa. Após realizar o clone do desafio, você irá notar que projeto é composto por duas classes principais: a primeira é denominada Item, servindo apenas de um DTO simples, já a segunda classe denominada GildedRose é onde toda a regra de negócio esta centralizada.
A classe GildedRose é composta por apenas um método chamado updateQuality, que será nosso ponto de partida para a criação dos testes. O código fonte pode ser visualizado abaixo:
export class Item {
name: string;
sellIn: number;
quality: number;
constructor(name: string, sellIn: number, quality: number) {
this.name = name;
this.sellIn = sellIn;
this.quality = quality;
}
}
export class GildedRose {
items: Array<Item>;
constructor(items = [] as Array<Item>) {
this.items = items;
}
updateQuality() {
for (let i = 0; i < this.items.length; i++) {
if (this.items[i].name != 'Aged Brie' && this.items[i].name != 'Backstage passes to a TAFKAL80ETC concert') {
if (this.items[i].quality > 0) {
if (this.items[i].name != 'Sulfuras, Hand of Ragnaros') {
this.items[i].quality = this.items[i].quality - 1
}
}
} else {
if (this.items[i].quality < 50) {
this.items[i].quality = this.items[i].quality + 1
if (this.items[i].name == 'Backstage passes to a TAFKAL80ETC concert') {
if (this.items[i].sellIn < 11) {
if (this.items[i].quality < 50) {
this.items[i].quality = this.items[i].quality + 1
}
}
if (this.items[i].sellIn < 6) {
if (this.items[i].quality < 50) {
this.items[i].quality = this.items[i].quality + 1
}
}
}
}
}
if (this.items[i].name != 'Sulfuras, Hand of Ragnaros') {
this.items[i].sellIn = this.items[i].sellIn - 1;
}
if (this.items[i].sellIn < 0) {
if (this.items[i].name != 'Aged Brie') {
if (this.items[i].name != 'Backstage passes to a TAFKAL80ETC concert') {
if (this.items[i].quality > 0) {
if (this.items[i].name != 'Sulfuras, Hand of Ragnaros') {
this.items[i].quality = this.items[i].quality - 1
}
}
} else {
this.items[i].quality = this.items[i].quality - this.items[i].quality
}
} else {
if (this.items[i].quality < 50) {
this.items[i].quality = this.items[i].quality + 1
}
}
}
}
return this.items;
}
}
Como podemos observar acima, este é um bom exemplo de como NÃO se deve escrever um código. Qualquer alteração por mais trivial que seja não é nem pouco simples de realizar neste código, já que a grande quantidade de condições aninhadas e ramificações diferentes acaba dificultando a legibilidade do código.
Agora que já conseguimos identifcar o ponto de alteração, partiremos para os próximos passos. Primeiramente iremos escrever um teste que sabemos que irá falhar, e utilizar sua saída para saber qual o comportamento original do metódo.
Preparando os testes
Nosso primeiro passo é gerar o arquivo de snapshot. Como não consegui encontrar nenhuma biblioteca para gerar este arquivo, eu mesmo criei um utilitário bem simples para fazer isso. O script abaixo tem a função de gerar uma lista com a quantidade desejada de registros, remover duplicatas e salvar o resultado em um arquivo JSON.
import { GildedRose } from "../app";
import fs from 'node:fs';
import { Item } from '../Item';
// Função para criar o arquivo JSON
export function persistRecords(json: Record<string, any>) {
const fileExists = fs.existsSync('./out/records.json');
if (fileExists) {
fs.rmSync('./out/records.json');
}
fs.writeFile(
'./out/records.json',
JSON.stringify(json),
{ flag: 'wx' },
err => {
if (err) console.error(err);
else console.log("Records created successed!");
}
);
}
const names = [
"+5 Dexterity Vest",
"Aged Brie",
"Elixir of the Mongoose",
"Sulfuras, Hand of Ragnaros",
"Backstage passes to a TAFKAL80ETC concert",
"Conjured Mana Cake"
];
function getRandomNumber(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function getRandomName() {
const index = Math.floor(Math.random() * names.length)
return names[index];
}
// Função gerar gerar uma lista de itens aleatórios
export function generateItems(count: number = 10): Item[] {
const set = new Set();
Array.from(Array(count).keys()).forEach(() => {
const name = getRandomName();
const isSulfuras = name.includes("Sulfuras");
const sellIn = getRandomNumber(0, 10);
const quality = getRandomNumber(0, isSulfuras ? 80 : 50);
const item = new Item(name, sellIn, quality);
set.add(item);
});
return Array.from(set) as unknown as Item[];
}
export function generateRecords() {
const [count = 100] = process.argv.slice(2)
const itemsList = generateItems(Number(count));
const json: Record<string, any> = {};
for (let item of itemsList) {
const gildedRose = new GildedRose([item]);
const result = gildedRose.updateQuality()[0];
json[`${item.name};${item.sellIn};${item.quality}`] = result;
}
persistRecords(json);
}
generateRecords();
Para simplificar a sua chamada podemos adiciona-lo como script em nosso package.json. Caso queira alterar a quantidade de registro basta passar quantidade desejada como paramêtro.
{
...
"scripts": {
"start": "tsx src/app.ts",
"test": "jest",
"golden-master:generate": "tsx src/golden-master/create-records.ts "
}
...
}
O arquivo gerado será salvo no caminho out/records.json
e terá o seguinte formato: os parâmetros da chamada são armazenados na chave e são separados por ponto e vírgula. O objeto de resposta é salvo no campo value. Neste exemplo, como o retorno é um objeto, cada propriedade do objeto é salva em um campo separado.
{
"Conjured Mana Cake;6;41": {
"name": "Conjured Mana Cake",
"sellIn": 6,
"quality": 41
},
"+5 Dexterity Vest;2;47": {
"name": "+5 Dexterity Vest",
"sellIn": 2,
"quality": 47
}
}
Por exemplo, no primeiro item, a classe GildedRose
foi iniciada com os valores "Conjured Mana Cake", 6 e 41, onde o primeiro valor corresponde a name, o segundo a sellIn
e o terceiro a quality
.
Criando casos de teste
Agora que já criamos os registros de teste, é hora de implementar o teste que os validará. O teste a seguir percorre todos os registros gerados anteriormente, criando um teste para cada caso. Em cada teste, ele compara se o resultado obtido pela versão atual do código é igual ao resultado previamente registrado. Dessa forma, garantimos que qualquer alteração no código não introduza regressões, mantendo a consistência e a confiabilidade do sistema.
import { GildedRose } from '../src/app';
import { Item } from '../src/Item';
import records from '../out/records.json';
describe('Gilded Rose', () => {
// [params, expected-result]
const entries = Object.entries(records);
for (let [key, value] of entries) {
const [name, sellIn, quality] = key.split(';');
const gildedRose = new GildedRose([
new Item(name, Number(sellIn), Number(quality))
]);
it(`${[name, sellIn, quality]}`, () => {
expect(gildedRose.updateQuality()[0]).toEqual(value);
});
}
});
Depois de todo esse trabalho finalmente chegou a hora de executar nossos testes. Antes de rodar nossos testes primeiro precisamos criar nosso snapshot, podemos fazer isso usando o script que criamos, para isso basta executar yarn golden-master:generate 100
. Feito isso teremos um arquivo com 100 entradas diferentes.
Com nossa snapshot já criada agora é so rodarmos os testes usando o comando yarn test
.
E voilà, você acaba de garantir que seu código esta seguro contra introdudação de bugs para 100 entradas diferentes. Não acredita em mim? Então veja só: irei realizar uma pequena alteração no método updateQuantity da classe GildedRose, irei apenas adicionar um espaço em branco e rodar os testes novamente.
Vejam só, essa pequena o código parou de funcionar para 22 entradas diferentes! Agora imagina só o tempo que você gastaria debugando para descobrir qual a combinação de paramêtros que estava quebrando o sistema. Ou pior, se você só descobrisse quando já estivesse em produção.
Como podemos vver testes de caracterização é uma ferramenta poderosa para garantir a estabilidade de seu código. Como podemos perceber uma pequena alteração no código pode causar falhas inesperadas em seu sistema, tornando essencial a identificação e correção antecipada desses problemas. Utilizar esse tipo de teste economiza tempo e esforço significativos no processo de depuração. O código fonte deste artigo está disponível em meu Github e caso tenha ficado alguma dúvida sinta-se à vontade em deixar um comentário ou se preferir, me enviar uma mensagem no meu Linkedin.
Referências
FEATHERS, Michael. Working Effectively with Legacy Code. Upper Saddle River: Prentice Hall, 2005. Capítulo 13.
Gilded Rose Kata. Disponível em: https://kata-log.rocks/gilded-rose-kata. Acesso em: 18 jul. 2024.
The key points of Working Effectively with Legacy Code. Understand Legacy Code, 2021. Disponível em: https://understandlegacycode.com/blog/key-points-of-working-effectively-with-legacy-code/. Acesso em: 18 jul. 2024.