O uso de test doubles é uma prática antiga dentro da disciplina de testes automatizados. Antiga, bem disseminada e bastante útil. No entanto, com qual frequência e em quais situações devemos utilizá-los não são pontos tão claros e uniformes nessa disciplina.

Este artigo traz meu ponto de vista sobre o assunto.

(Ao longo do texto, uso o termo “SUT” – System Under Test – para me referir à unidade sendo testada e a invenção “mockar” com o significado de “criar um test double”, seja este um mock verdadeiro ou qualquer outro tipo de double.)

USANDO TEST DOUBLES

No lugar de objetos que acessam recursos externos

Dependências externas são aquelas que fogem da fronteira de memória do software, como bancos de dados, o sistema de arquivos ou uma API REST.

Tais dependências podem fazer com que a suíte de testes fique mais lenta e/ou menos determinística, além de exigirem um setup mais elaborado (colocar o serviço externo em funcionamento e no estado desejado para cada teste). Outro fator importante é que desejamos possuir total controle sobre a dependência – o que não seria praticável usando o objeto real – como simular o retorno de um erro ou valor específico.

No lugar de dependências “incontroláveis”

Mesmo que uma dependência faça parte do espaço de memória do software, muitas vezes é necessária isolá-la para que seja possível escrever o teste que a usa.

O exemplo mais recorrente é o uso da data/hora do sistema. Em um determinado código de produção, existe uma lógica que verifica se “DateTime.now.day == 1″. Neste caso, uma solução seria extrair a responsabilidade de fornecer data/hora para um serviço (um “DateTimeProvider”) e criar um stub no teste.

USANDO COLABORADORES REAIS

Sempre que o colaborador da SUT não for uma das dependências mencionadas acima, não há necessidade alguma de “mocka-lo”.

Quando você usa um test double no lugar do objeto real, você precisa configurá-lo no teste para responder aos métodos que ele chama dentro da SUT. Além de deixar o teste mais sujo (o tanto de “sujeira” vai depender da verbosidade de sua mocking library), você cria mais acoplamento entre seu código de teste e o código de produção. Em outras palavras, o teste conhece detalhes de implementação da SUT, o que significa que – se o código da SUT for alterado – o teste precisará ser corrigido!

Vamos a um exemplo:

# código de produção a ser testado
# os colaboradores são "OrderItem" e "DefaultDiscountPolicy"
class Order
  # .....
  def apply_discount_policy(policy)
    @discount_policy = policy
  end

  def total
    discount = @discount_policy.calculate(self)
    items_total = @items.map(&:total_per_item).inject(:+)
    items_total - discount
  end
end

# usando test doubles para os colaboradores
it 'should calculate order total' do
  discount_policy = instance_double(DefaultDiscountPolicy,
                                    calculate: 10)
  items = [instance_double(OrderItem, total_per_item: 10),
           instance_double(OrderItem, total_per_item: 90)]
  order = Order.new(items)
  order.apply_discount_policy(discount_policy)

  result = order.total

  expected = order.items_total - order.items_total * 0.1
  expect(result).to eq(expected)
end

Olhando o exemplo, percebemos que o teste conhece detalhes de implementação do método “order.total”: ele chama os métodos “calculate” e “total_per_item”, de “DefaultDiscountPolicy” e “OrderItem”, respectivamente.

Além de poluição para o teste – não importam quais métodos são chamados internamente pela SUT e sim o resultado esperado –  criamos um acoplamento desnecessário com os colaboradores. O simples fato de renomear um dos métodos de um dos colaboradores exigiria corrigir o setup de todos os testes semelhantes ao de cima!

Outro ponto é que não podemos garantir com 100% de certeza que o método “total” funciona quando usado com os colaboradores reais!

O mesmo teste poderia ser escrito da seguinte forma:

it 'should calculate order total' do
  discount_policy = DefaultDiscountPolicyBuilder.new.build
  items = [OrderItemBuilder.new.build, OrderItemBuilder.new.build]
  order = Order.new(items)
  order.apply_discount_policy(discount_policy)

  result = order.total

  expected = order.items_total - order.items_total * 0.1
  expect(result).to eq(expected)
end

Embora utilizando os colaboradores reais, percebam que o teste utiliza test data builders para criar os colaboradores, isolando o teste dos construtores das classes, prática popularizada pelo livro “Growing Object-Oriented Software, Guided By Tests”. (Mais sobre builders e suas vantagens, no artigo na seção “Links Úteis”.)

Percebam também que o teste agora está menos acoplado com o código de produção. Ele ainda conhece seus colaboradores, mas não se importa mais com os comportamentos destes, apenas com o resultado esperado.

Se o colaborador é um mero valor (Value Object), é ainda mais sem sentido “mocka-lo”. Passe a instância com o valor desejado para a SUT (por ex, Money.new(‘R$’,  10.5)) ao invés de ter que criar o double e configurar o retorno de cada um de seus atributos.

Obs.: Outra desvantagem relacionada aos test doubles – específica de linguagens estaticamente tipadas – é que para conseguir “mockar” uma entidade/valor (definidos em classes concretas), você será obrigado a (1) abrir seus métodos, isto é, permitir que sejam sobrescritos (em C#, usar o “virtual” nos métodos”); ou (2) extrair uma interface para a classe, proliferando em sua base de código uma série de interfaces sem sentido algum.

MAS NÃO É TESTE DE “UNIDADE”?

Algumas pessoas entendem que um teste de “unidade” testa somente a classe em questão, totalmente isolada de qualquer colaborador. Como, em um modelo OO, fatalmente grande maioria dos objetos terá algum colaborador, seguir essa definição de unidade estritamente levará à criação de test doubles em praticamente todos os testes, acentuando as desvantagens já mencionadas. No pior caso, teremos cenários de testes onde há doubles configurados para retornar doubles, que retornam outros doubles!

Nunca existiu uma distinção clara de nomes quanto às duas formas de testar. Martin Fowler usou os termos “solitary tests” e “sociable tests” para explicar a diferença entre ambos, mas eles continuam sendo considerados “testes de unidade”.

USANDO TEST DOUBLES (O RETORNO)

Com mais base sobre o assunto, podemos chegar agora a outro ponto onde seria interessante (ou, no mínimo, viria a calhar) o uso de um test double no lugar do colaborador real: quando temos vários objetos que implementam o mesmo comportamento (ou mesmo “papel”).

it 'should delegate order processing' do
  #... criação da SUT (order) omitida
  # criando test double (mock)
  order_processor = spy('order_processor')

  order.approve([order_processor])

  expect(order_processor)
    .to have_received(:process_order).with(order)
end

No cenário acima, “approve” recebe um array de “order processors” e estou assumindo que há vários tipos de objetos que implementam esse comportamento de “process_order” (duck types ou objetos que implementam uma interface em linguagens estaticamente tipadas).

Neste caso, “process_order” seria testado pelos testes de unidade de cada tipo concreto e o teste acima apenas se encarrega de testar que ele é chamado. Além disso, o teste deixa explícito que “approve” funciona para qualquer objeto que desempenhe o papel de um “order_processor”.

CONCLUINDO

Usar test doubles é uma técnica importante, mas como toda técnica, deve ser praticada com cuidado.  Mockar tudo acopla demais seu código de teste com o código de produção, pedindo manutenção desnecessária nos testes, além de dar menor segurança sobre o funcionamento do todo, já que tudo está isolado!

(Embora não tenha tentado cobrir todas as situações possíveis sobre o assunto, acredito que este artigo tenha coberto a maioria dos casos.)

E você, quando usa “test doubles”? Tem algum outro exemplo/edge-case?
Comente aí!

Links úteis:
Mockar ou não mockar, eis a questão
When to mock
Loosely Coupled Testing
Refactoring de testes/Test Builders
Unit Test