Usando testes de contrato e colaboração

Publicado por Lucas André de Alencar no dia dev

Durante a vida longa de uma suite de testes, alguns problemas comuns podem começar a surgir e atrapalhar o processo de desenvolvimento do software. Por exemplo, testes que levam uma eternidade para serem executados e o surgimento de diversos falsos positivos (exemplos de testes que falham, porém a funcionalidade testada está funcionando corretamente). Boa parte desses problemas é decorrente do uso excessivo de testes integrados, onde toda a stack da aplicação é executada para testar uma parte pequena do sistema. Essa stack poderia muito bem ser simulada utilizando mocks/stubs.

Estudando sobre tipos de testes e as fronteiras de comunicação entre objetos, me deparei com os testes de contrato e de colaboração. A princípio, eles podem parecer muito simples, porém podem proporcionar um maior conforto ao trabalhar com mocks/stubs em seus exemplos de teste.

É o quê?

Testes de contrato e colaboração são tipos de testes extritamente ligados com técnicas de mocks e stubs, em que objetos dummies simulam o comportamento de objetos reais. Estas técnicas tem como objetivo simular dependências que podem existir ao testar a interação entre dois objetos.

Testes de contrato tem como objetivo descrever a interface de programação disponível em um objeto. Quando digo interface, me refiro aos métodos e seus parâmetros definidos em uma classe. Estes testes garantem que o objeto possua os métodos que estão sendo simulados com mocks/stubs em algum teste.

Já testes de colaboração tem o objetivo de descrever o comportamento esperado de um objeto. Esse tipo de teste garante que uma dependência tenha o comportamento esperado pelo objeto dependente, dado o estado atual da aplicação.

Show me the code

Vamos imaginar que temos uma classe Rocket (foguete) que depende da classe Engine (motor). Dado a altitude do foguete, a classe Engine tem responsabilidade de decidir se é seguro desacoplar os motores do foguete ou não.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Rocket
  def initialize(engine)
    @engine = engine
  end

  def detach
    @engine.detach if @engine.detach?
  end
end

class Engine
  # Assume that a lot of computation and variables decide
  # if an engine can be detached (high time complexity function)
  def detach?
    altitude >= 45 && altitude <= 55
  end

  # Detach engine from Rocket
  def detach
    'detached'
  end

  def altitude
    # Returns current altitude
  end
end

Os testes para a classe Rocket são bem diretos. Caso seja seguro o desacoplamento, o motor deve ser desacoplado, caso contrário não. Focando no isolamento da classe Rocket, nós usamos um mock que simula o comportamento da classe Engine e podemos testar apenas o comportamento esperado pela classe Rocket.

Se assumirmos que a classe Engine possui uma implementação complexa e que envolve diversas outras classes, usar um mock para ela passa a ser interessante pois evitamos executar todo o código complexo existente e realizamos apenas uma chamada simples que retorna o valor esperado.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
describe Rocket do
  subject { Rocket.new(engine) }

  context "when it is safe" do
    let(:engine) { double(:detach? => true) }

    it "detaches engine" do
      expect(subject.detach).to eq 'detached'
    end
  end

  context "when it is not safe" do
    let(:engine) { double(:detach? => false) }

    it "does not detach engine" do
      expect(subject.detach).to be_nil
    end
  end
end

No entanto, temos um problema neste exemplo. E se na classe Engine, a interface do método detach? passar por alguma mudança, como uma troca de nome ou a adição de novos parâmetros? Os testes continuarão passando, porém erroneamente, pois o que foi previsto no teste já não reflete mais o comportamento implementado.

Para resolvermos estes problemas, podemos usar testes de contrato. A ideia é escrever um exemplo em que seja verificado a interface esperada das dependências existentes na classe. No caso da classe Rocket, os testes de contrato ficam da seguinte maneira:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
describe Rocket do
  subject { Rocket.new(engine) }

  describe "contract tests for Engine" do
    it { expect(Engine).to respond_to :detach? }
  end

  context "when it is safe" do
    let(:engine) { double(:detach? => true) }

    it "detaches engine" do
      expect(subject.detach).to eq 'detached'
    end
  end

  context "when it is not safe" do
    let(:engine) { double(:detach? => false) }

    it "does not detach engine" do
      expect(subject.detach).to be_nil
    end
  end
end

Assim caso a classe Engine mude a interface, o teste de contrato vai indicar exatamente qual é o erro.

Ok, resolvemos um lado da equação. Porém existe o outro lado, onde devemos testar que, dado um determinado estado, a dependência retornará a resposta esperada. Neste caso, utilizamos testes de colaboração.

No caso da classe Engine, os testes de colaboração ficam da seguinte maneira (os valores de altitude são simulados em stubs para facilitar nos exemplos):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
describe Engine do

  describe "collaboration tests for Rocket" do

    context "when it is safe" do
      before { allow(Engine).to receive(:altitude).and_return(50) }

      it { expect(Engine.detach?).to be_true }
    end

    context "when it is not safe" do
      before { allow(engine).to receive(:altitude).and_return(40) }

      it { expect(engine.detach?).to be_false }
    end
  end
end

Repare que os contextos de Rocket e Engine são os mesmos, isto é, todas as expectativas assumidas nos testes do Rocket são verificadas no teste do Engine utilizando testes de colaboração. Uma prática importante ao utilizar testes de contrato e colaboração é que, para todo e qualquer caso em que existam expectativas (mocks de algum objeto) em algum teste, deve existir um teste de colaboração correspondente que preveja e verfique esta expectativa. Dessa forma, todas as possibilidades de interação entre os objetos é testada isoladamente.

Concluindo

Um dos maiores receios de desenvolvedores ao trabalhar com mocks e stubs é de não estar testando o código efetivamente. São casos em que, ao utilizar mocks/stubs, os testes passam porém a aplicação ainda contém erros. São estas situações que levam alguns devs a preferirem testes de integração em vez de testes mais isolados. Isso acontece pelo desconhecimento do quadro completo da comunicação entre objetos, em que todas as expectativas sobre algum objeto devem ser testadas em algum momento, para se ter certeza de que tudo se comunica corretamente.

Por serem isolados e não executarem código desnecessário para o objeto testado, testes de contrato e colaboração são bastante rápidos. Eles também auxiliam na percepção de problemas de design na implementação das classes, o que contribui diretamente na qualidade final do código.

Como mostrou o texto, testes de contrato e colaboração são valiosos ao trabalhar com testes unitários. Você possui alguma experiência com eles? Nos conte nos comentários abaixo!

Extras

Lucas André de Alencar

Lucas André de Alencar

Full Stack Developer

Comentários