Diga não à instancias nos construtores!

Como escrever um bom software, facilmente testável e com cobertura de testes lá em cima?

Esse texto vem de um rascunho já antigo, que está no meu Medium e já tem pelo menos 2 anos, além da responsabilidade de concluir esse texto, aprendi coisas novas no período e já coloquei algumas bases interessantes aqui no blog para chegar nesse assunto.

Agora que temos class em JS, algumas práticas precisam mudar ou pelo menos entendermos ela melhor, vamos começar pela forma invisível em que escrevíamos algumas classes em JS e nem percebíamos o problema. Vamos começar com um objeto simples de exemplo.

Nosso objeto carro tem uma função de ir em frente (moveFoward) aumentando a velocidade

function Car() {
  this.velocity = 0;
  return this;
}

Car.prototype.moveFoward = function moveFoward() {
  this.velocity++;
}

Agora para o nosso carro funcionar, ele precisa de uma instancia de um objeto bateria para saber se pode aumentar sua velocidade. Então vamos criar um objeto bateria e depois instanciar no nosso objeto carro.

function Battery() {
  this.level = 100;
  return this;
}

Battery.prototype.charge = function charge() {
  this.level++;
}

Battery.prototype.consume = function consume() {
  this.level--;
}
function Car() {
  var battery = new Battery();
  this.battery = battery;
  this.batteryLevel = battery;
  this.velocity = 0;
  return this;
}

Car.prototype.moveFoward = function moveFoward() {
  if (this.batteryLevel) {
    this.velocity++;
    this.battery.consume();
  }
}

module.exports = Car;

Agora implementamos em nosso carro uma bateria que é consumida com a velocidade. Esse código apesar de funcional ele tem alguns problemas, o principal deles é a dificuldade em testar. Fora a sequencia de instancias dele.

E se fossemos usar o nosso objeto Car, seria como o seguinte

const Car = require('./car');

const car = new Car();
car.moveFoward();

console.log('car velocity', car.velocity);

Refatorando para algo moderno

Vamos usar as novas sintaxe sugar que veio junto da ES2015 e depois ir para um caminho mostrando os problemas para escrever os testes.

class Battery {
  constructor() {
     this.level = 100;
     return this;
  }

  charge() {
    this.level++;
  }

  consume() {
    this.level--;
  }
}

module.exports = Battery;
arquivo battery.js
const Battery = require('./battery.js');

class Car {
  constructor() {
    const battery = new Battery();
    this.battery = battery;
    this.velocity = 0;
    return this;
  }

  moveFoward() {
    if (this.battery.level) {
      this.velocity++;
      this.battery.consume();
    }
  }
}
arquivo Car.js

O ganho de ter um código menos verboso já é enorme, mas nosso problema ainda existe e não foram resolvidos e vamos usar testes unitários para mostrar isso. O nosso arquivot index continua igual.

Códigos difíceis de testar

Primeiro vamos lembrar que um dos objetivos dos testes unitários é esclarecer e revelar problemas na arquitetura e existe problema quando o código é dificil de testar.

Vamos escrever o teste unitário para Car e a função moveFoward

const Car = require('./car.js');

describe('Teste da unidade Car', () => {
  const car = new Car();
    
  it('espera que a propriedade velocity não mude quando chamar moveFoward e sem a propriedade batteryLevel', () => {
      const initialVelocity = car.velocity;
      car.moveFoward();
      assert.equal(car.velocity, initialVelocity);
  });
});
Tente criar a classe Car e rodar esse código no Node.

Esse teste leva um tempo para conseguir corrigir ele e é bem provavel que se recorra a ferramentas como rewire, para mockar o Battery e conseguir simular as funcionalidades dele e contornar o problema.

Acoplamento: Esse é um problema do nosso código, se o objeto Battery estiver faltando, nunca conseguiremos realizar instanciar o nosso objeto Car, por isso o teste fica dificil de ser executado. E uma bela forma de resolver o problema é através de alguns patterns já conhecidos e até abordado brevemente aqui.

Side-effects: Quem é adapto da programação funcional, sempre fala que orientação a objetos tem muito side-effects (ou efeitos colaterais) e realmente esses sides-effects podem ter efeitos complicados como acoplamento que discutimos e causar efeitos inesperados.

Injeção de dependência

Já abordamos nesse artigo aqui injeção de dependência e sugiro dar uma revisada. Essa é a forma em que vamos desacoplar nosso código, afinal Battery é uma dependência do nosso objeto Car.

Sabendo disso vamos reescrever o nosso construtor do objeto Car para receber o argumento Battery.

class Car {
  constructor(battery) {
    this.battery = battery;
    this.velocity = 0;
    return this;
  }

  moveFoward() {
    if (this.battery.level) {
      this.velocity++;
      this.battery.consume();
    }
  }
}

module.exports = Car;

Com isso já conseguimos escrever um teste um pouco melhor e que funciona já que nosso anterior nem roda os testes devido ao problema.

E podemos ir além e escrever testes melhores por exemplo, para verificar se o battery.consume é chamado.

const assert = require('assert').strict;

const Car = require('./car.js');

describe('Teste da unidade Car', () => {
  const batteryMock = {};

  const car = new Car(batteryMock);
    
  it('espera que a propriedade velocity não mude quando chamar moveFoward e battery level for 0', () => {
    batteryMock.level = 0;
    const initialVelocity = car.velocity;
    car.moveFoward();
    assert.equal(car.velocity, initialVelocity);
  });

  it('espera que a propriedade velocity mude quando chamar moveFoward e tiver battery level', () => {
    batteryMock.level = 10;
    batteryMock.consume = ()=> {};
    const initialVelocity = car.velocity;
    car.moveFoward();
    assert.notStrictEqual(car.velocity, initialVelocity);
  });

  it('espera que ao chamar moveForward consumeCall mude seu valor para true indicando que foi chamado', () => {
    let consumeCall = false;
    batteryMock.level = 10;
    batteryMock.consume = () => { consumeCall = true};

    car.moveFoward();
    assert.equal(consumeCall, true);
  });
});

Com isso conseguimos até testar e documentar os efeitos colaterais que nossos objetos podem ter.

O nosso código consegue assim testar algumas coisas coisas que podem parecer complicadas e até escrever mais cenários de testes com facilidade documentando realmente o que fazemos.

Um pouco sobre a função do construtor

Construtores de classes são métodos que precisam ser simples somente para adicionar preencher propriedades na instancia.

Além dessa dificuldade de testar, existem ainda outros erros que você talvez encontre nos construtores, como loops e ifs, isso pode trazer problemas, no gerenciamento de memória e até do fluxo de código.

Além disso também é sinal de alerta quando precisa usar uma outra função para inicializar a classe. Ao chamar um construtor a classe deve estar completamente pronta para o uso.

Somente funções

Mas em JS uma das coisas que mais se tem popularizado é a escrita somente de funções e o abandono completo da escrita usando orientação a objetos. Isso é positivo, já que a abordagem funcional facilita testes e reduz efeitos colaterais.

Não gosto de prescrever uma unica abordagem e acredito que julgar a necessidade de cada abordagem pode ajudar a escrever programas mais complexos e perfomáticos, sabendo do impacto que cada abordagem tem.

Recapitulando

Use o construtor somente para preencher propriedades da necessárias a classes, isso vai facilitar seus testes.

Espero que tenha ficado claro, estou devendo falar disso já tem uns 2 anos e espero que finalmente tenha conseguido amarrar alguns assuntos que considero meio soltos.

Sugiro também, baixar o código que está no github, nele deixei algumas coisas que dá para aprender bastante, inclusive no segundo cenário de teste da classe Car, tem uma coisa bem interessante.

https://github.com/flpms/constructor-object-instance


Qualquer dúvida, sugestão e correção só me chamar.


Referências

http://misko.hevery.com/code-reviewers-guide/flaw-constructor-does-real-work/?fbclid=IwAR0Pn1gkbctqzjeZXVW4Xz_eg1quBX8ZT3vQAGmbW-Y1gQAQsmG38_18-Pc

Unit Tests, How to Write Testable Code and Why it Matters
Writing unit tests can be tough, but it shouldn’t be. If your tests are hard to write, you probably have problems elsewhere. Untestable code is a sign of deeper design problems. In this article, Toptal developer Sergey Kolodiy delivers a comprehensive breakdown of what makes code hard to test, and …