Test Double com Mock e Stub

Lembro-me bem da primeira vez que ouvi o termo. Era meados de 2009, quando li em algum lugar do GUJ sobre um "recurso" muito valioso nos Testes Unitários que era de fundamental entendimento para seguir adiante. Depois de ler algumas coisas na Internet, eu havia encontrado pelo menos três diferentes definições para Mock e Stub. Naquele momento eu descobri de que precisava de bibliografias-referência no assunto para acabar com aquelas meias verdades que pairavam sobre minha cabeça.

A primeira definição que encontrei era mais ou menos assim: "Mock você utiliza quando o valor de retorno importar; Stub nos demais casos." - ah, quantas vezes não "testei" software com esse mindset. Mesmo não entendendo bem o que era o tal valor de retorno, eu me aventurava e me forçava a fazer. Segui assim até ler pela primeira vez o livro Growing Object-Oriented Software, Guided by Tests, de Steve Freeman e Nat Price. Naquele momento minha cabeça explodiu e tudo fez mais sentido. Desde então, costumo defini-los da seguinte forma:

"Stub é uma dependência da sua classe-alvo (Objeto em Teste), agindo como um substituto, evitando que a implementação real seja executada."

Explicação longa:

class Authenticator
  def login(user)
    return user.password == "123456"
  end
end

describe Authenticator do
  it "will login with valid credentials" do
    user = double('User', password: '123456')
    expect(subject.login(user)).to be_true
  end
end

Repare no teste o user = double('User', password: '123456') e repare que isto permite que eu simule um usuário "válido" (no exemplo é só o password bater com 123456) - ou seja, eu configurei minha dependência (User) para que o objeto Authenticator pudesse ter um usuário válido. Um exemplo mais elaborado seria:

class Authenticator
  def login(user)
    if (user.admin? and user.has_confirmed_account?)
      self.grant_permissions_to(user)
    else
      false
    end
  end
  
  private
  def grant_permissions_to(user)
    # do something nice with our lovely user
  end
end

class User
  def initialize(sms_api: SMSApi.get_instance)
    @sms_api = sms_api
  end
  
  def admin?
    type == 'admin' # type could be an Database field mapped
  end
  
  def has_confirmed_account?
    user.documentation_already_approved? and sms_api.cellphone_confirmed_for(self)
  end
  
  private
  
  def sms_api
   @sms_api # SMS Wrapper Injected via initialize (constructor) method
  end
end

describe Authenticator do
  it "will login with valid credentials" do
    user = double('User', :admin? => true, :has_confirmed_account? => true)
    expect(subject.login(user)).to be_true
  end
end

Deixei o exemplo mais elaborado para mostrar o poder e a importância do Test Double: note que stubando o método User#has_confirmed_account? eu simplesmente evito ter que lidar com o SMSApi e com o documentation_already_approved?, bastando eu ter feito meu double retornar true no has_confirmed_account?. Imagina o trabalho que eu teria para configurar o SMSApi e o método de documentação aprovada em todo teste que eu precisasse chamar o método has_confirmed_account?. Insano, né?

Graças ao double eu consigo focar no meu problema que é: o Authenticator#login está, dado um usuário aceito por ele, conseguindo autenticar este user object?

Repare que o método grant_permissions_to(user) não é stubado. Ele precisa ser chamado de verdade pois é um colaborador interno da classe-algo (Authenticator class).

Mock é ligeiramente diferente, precisa estar atento para entender as diferenças.

"O Mock irá criar a expectativa de que aquilo que você definiu irá de fato acontecer. Se não acontecer, o teste falhará."

describe Authenticator do
  it "will login with valid credentials" do
    user = double
    expect(user).to receive(:admin?).once.and_return(true)
    expect(user).to receive(:has_confirmed_account?).once.and_return(true)
    expect(subject.login(user)).to be_true
  end
end

Assumindo o exemplo anterior, modifiquei apenas o teste, trocando Stub por Mock. Trocando quando e se, encontrar user.admin? e/ou user.has_confirmed_account?, substitua por true e true respectivamente para você (Authenticator#login) deverá chamar user.admin? e user.has_confirmed_account? (em qualquer ordem no método) apenas uma vez (once), e terá true e true respectivamente como resposta. Saímos de algo simples, para algo assertivo. Se por um acaso eu trocar o código de produção para:

class Authenticator
  def login(user)
    if (user.has_confirmed_account?)
      self.grant_permissions_to(user)
    else
      false
    end
  end
end

O teste neste caso começará a falhar, reclamando a falta do user.admin?.
Naturalmente, há regras e boas práticas para quando testar expectativas via Mock's e quando não. Sandi Metz abordou o tema neste Lunch 'n Learn.

Mock/Stub parciais (Partial Mocks)

Um recurso suportado pelo RSpec são os Partial Mocks. Há quem defenda o não uso deles (Prophecy @ PHPSpec, estou olhando para você!). Mock parcial, permite que você utilize um objeto real como dependência e mock apenas determinados métodos dela. Ainda continuando com o exemplo do autenticador, teríamos:

describe Authenticator do
  let(:admin_user) { User.new(...) # faz alguma coisa para construir um Usuário admin? == true}
  
  it "will login with valid credentials" do
    expect(admin_user).to receive(:has_confirmed_account?).once.and_return(true)
    expect(subject.login(admin_user)).to be_true
  end
end

As diferenças aqui são:

  1. Não utilizamos um double do RSpec. Preferimos utilizar o User object de verdade, fazendo o que for necessário para criar/retornar um usuário administrador, ou seja, um objeto de User cujo o método #admin? retornará true sem a necessidade de mudar o valor de retorno do método com stub.
  2. Com isto, não precisamos definir no teste it... o retorno de #admin? (pude remover a linha)

Com isto, ainda assim, eu mockei o #has_confirmed_account? para continuar retornando true. Com isto, acabei fazendo um Mock Parcial: o método #admin? é chamado de verdade e o #has_confirmed_account? é mockado para retornar sempre true naquele teste.

Concluindo

Mocks e Stubs são fundamentais para a construção do seu design e para seguir em frente com Test-Driven Development. Neste post, busquei mostrar, com definições mais simples, o que são ambos e dar foco nas suas diferenças. Mas não pense que sair mokando/stubando tudo é boa prática. Há situações onde você não deve mockar; há situações onde o mock aponta um possível problema de design - e então você precisa refatorar seu código e talvez criar uma nova layer na aplicação. Em todo caso, pratique muito o assunto e torne seu código mais legível, testável e plugável.

Happy Mocking ;)

Hélio Costa

Desenvolvedor de software há um tempinho. Interessou-se por Object-Oriented Design e Test-first antes do Eddy Merckx ser removido na organização da UCI Road World Championships.

Sao Paulo, Brazil