DEV Community

Thali Marra
Thali Marra

Posted on • Updated on

Test doubles


Olá olá!

Hoje eu quero falar de algo que me confundiu muito quando comecei a aplicar testes: como eu faço pra substituir coisas desnecessárias ao objetivo do teste? Quais conceitos preciso entender para escrever os testes de forma que meus pares também entendam?

Imagine que você está desenvolvendo uma integração com algum meio de pagamento (PayPal, Mercado Pago, etc.) e precisa testar essa classe.

Podemos criar os testes sem alterar nada no código e continuar chamando a integração. Fazer isso vem com vários problemas como criar uma dependência do nosso teste no serviço externo, aumentar o tempo de execução da rotina de testes, pode afetar algum limite de requisição que o serviço imponha, e por ai vai...

Podemos criar nossos testes ignorando todas as etapas que utilizam o serviço externo, mas assim vamos acabar ignorando parte da lógica interna. Como testar se o banco de dados está sendo atualizado depois que o pagamento foi feito com sucesso? Como confirmar que os tratamentos de erro estão funcionando conforme o esperado?

Recorrendo a Test Doubles

O termo Test Doubles foi descrito no livro XUnit Test Patterns: Refactoring Test Code, de Gerard Meszaros e representa qualquer objeto que simula um objeto real para fins de teste.

Quando escrevemos nossos testes e nos deparamos com casos onde não podemos usar um componente real ou casos onde não seria prático usá-lo, podemos substituí-lo por um Test Double. Os test doubles não precisam ser exatamente como o componente real, precisam apenas se comportar de tal forma que o sistema sobre teste (System Under Test ou SUT) pense que ele é real.

Vamos para um exemplo prático?

Precisamos fazer um sistema de assinatura que pode ter vários meios de pagamento.

Uma forma de executar isso é criando uma classe de integração para cada meio de pagamento e padronizá-las através de uma interface, chamada Gateway, para garantir que todas as classes respeitem o mesmo contrato.

Esse contrato é dividido em três funções: register, que cria um novo registro do nosso usuário na plataforma de pagamento, activate, que ativa a assinatura, e deactivate, que inativa a assinatura quando o usuário quiser cancelar seu plano:

<?php

namespace App;

interface Gateway
{
    public function register(string $email): void;

    public function activate(): string;

    public function deactivate(): void;
}
Enter fullscreen mode Exit fullscreen mode

Com a interface Gateway padronizamos como será feita a integração com o meio de pagamento, mas agora precisamos resolver as nossas regras de negócio.

Para isso temos a classe Subscription que será responsável por chamar a integração, o gerenciamento do usuário e enviar um email de confirmação da assinatura. Isso tudo será feito dentro de uma função chamada create:

<?php

namespace App;

class Subscription
{
    protected Gateway $gateway;
    protected Mailer $mailer;

    public function __construct(Gateway $gateway, Mailer $mailer)
    {
        $this->gateway = $gateway;
        $this->mailer = $mailer;
    }

    public function create(User $user): void
    {
        $this->gateway->register($user->email);

        $receipt = $this->gateway->activate();

        $user->subscribe($receipt);

        $this->mailer->send($user,'Your receipt number is: ' . $receipt);
    }
}
Enter fullscreen mode Exit fullscreen mode

Temos também a classe User com todos os métodos responsáveis por gerir os dados do usuário do nosso sistema. Essa classe possui o método subscribe, que é responsável por vincular o usuário ao ponteiro do meio de pagamento, e o método isSubscribed, que retorna se aquele objeto possui ou não uma assinatura vinculada.

<?php

namespace App;

class User
{
    public string $email;
    public ?string $subscription;

    public function __construct(string $email)
    {
        $this->email = $email;
        $this->subscription = null;
    }

    public function subscribe(string $subscriptionId): void
    {
        $this->subscription = $subscriptionId;
    }

    public function isSubscribed(): bool
    {
        return is_string($this->subscription);
    }
}
Enter fullscreen mode Exit fullscreen mode

Por fim, criamos uma integração com algum serviço de envio de email chamada Mailer para enviar uma mensagem ao usuário:

<?php

namespace App;

class Mailer
{
    public function send(User $user, string $message): void
    {
        // logica
    }
}
Enter fullscreen mode Exit fullscreen mode

Pronto, esse é o nosso SUT, o sistema que vamos testar. E sem mais delongas, vamos aos test doubles!

Quais Test Doubles existem?

Segundo Gerard Meszaros eles podem ser divididos em cinco categorias: Fakes, Dummies, Stubs, Mocks e Spies.

Fake

Fakes são objetos com implementações funcionais que normalmente usam atalhos para executar ações.

Para não precisar usar a implementação de produção no nosso teste, podemos criar uma classe Fake que respeita o mesmo contrato da classe de produção e usar ela nos nossos testes. Para o nosso exemplo poderia ser uma classe assim:

<?php

namespace Tests;

use App\Gateway;

class GatewayFake implements Gateway
{
    public function register(string $email): void
    {
        //
    }

    public function activate(): string
    {
        return 'some-uuid';
    }

    public function deactivate(): void
    {
        //
    }
}
Enter fullscreen mode Exit fullscreen mode

Ela usa a mesma interface de produção (Gateway), possui os mesmos métodos que são chamados pela classe Subscription, mas não executam nenhuma ação real, possui apenas o suficiente para rodarmos nossos testes.

Dummy

Dummy é um objeto sem nenhuma funcionalidade específica; é usado apenas para preencher parâmetros.

Vamos para o primeiro caso de teste: precisamos validar se o usuário foi atualizado após a ativação da assinatura.

Usando a biblioteca PHPUnit é possível escrever o teste da seguinte forma:

public function testValidaUsuarioComAssinatura(): void
{
    $gatewayFake = new GatewayFake();
    $mailerDummy = $this->createMock(Mailer::class);

    $subscription = new Subscription($gatewayFake, $mailerDummy);
    $user = new User('Maria');

    self::assertFalse($user->isSubscribed());

    $subscription->create($user);

    self::assertTrue($user->isSubscribed());
}
Enter fullscreen mode Exit fullscreen mode

Para não chamar a integração com o meio de pagamento usamos a classe GatewayFake que criamos anteriormente. Como também não precisamos validar nenhum comportamento do envio de email substituímos a classe Mailer por um Dummy criado através do PHPUnit apenas para preencher os requisitos da construção da classe Subscription.

O uso de Fake e Dummy aqui foi apenas uma forma de exemplificar esses dois conceitos. Poderíamos criar um Fake para o envio de email ou um Dummy para a integração com o meio de pagamentos sem problema algum.

💡 Note que o nome da função chamada para criar o Dummy é createMock(...). Não é incomum o uso do termo Mock como sinônimo de qualquer um dos outros test doubles mas ele também tem outra definição, que veremos logo a seguir.

Stub

Stubs são objetos que usamos para simular interações de chegada de alguma dependência externa ao SUT. Eles são usados apenas para fornecer uma resposta programada para aquele teste.

No nosso exemplo a classe de Subscription solicita a ativação da assinatura através da função activate e atualiza o UUID que retornou da integração nos dados do usuário interno.

Para garantir a integridade dos dados do nosso usuário precisamos ter certeza que o valor retornado pela função activate é o mesmo que está sendo salvo no usuário:

public function testValidaValorCorretoAssinatura(): void
{
    $gatewayStub = $this->createStub(Gateway::class);
    $gatewayStub->method('activate')->willReturn('some-uuid');

    $mailerDummy = $this->createMock(Mailer::class);

    $subscription = new Subscription($gatewayStub, $mailerDummy);
    $user = new User('Maria');

    // valida que o usuario foi criado sem assinatura
    self::assertNull($user->subscription);

    $subscription->create($user);

    // valida que o valor salvo no banco é o mesmo retornado pela integração
    self::assertEquals('some-uuid', $user->subscription);
}
Enter fullscreen mode Exit fullscreen mode

Para validar esse cenário precisamos que o método activate retorne uma string correspondente ao UUID externo, portanto criamos um Stub para simular essa interação de chegada.

Como não precisamos validar o envio de email, permanecemos com o Dummy para a classe Mailer.

Mock

Mocks, por sua vez, são objetos que usamos para simular interações de saída. Eles possuem expectativas sobre seu comportamento, ou seja, verificam se um ou mais métodos foram chamados, a ordem e quantidade de vezes que foram chamados, etc, lançando exceções quando o teste não se comporta conforme o esperado.

Vamos testar se o SUT está enviando o email quando a assinatura é criada:

public function testValidaEnvioDeEmail(): void
{
    $this->expectNotToPerformAssertions();

    $user = new User('Maria');

    $gatewayStub = $this->createStub(Gateway::class);
    $gatewayStub->method('activate')->willReturn('some-uuid');

    $mailerMock = Mockery::mock(Mailer::class);
    $mailerMock
        ->shouldReceive('send')
        ->once()
        ->withArgs([$user, 'Your receipt number is: some-uuid']);

    $subscription = new Subscription($gatewayStub, $mailerMock);
    $subscription->create($user);
}
Enter fullscreen mode Exit fullscreen mode

Aqui acabei usando a biblioteca Mockery junto ao PHPUnit para melhorar a legibilidade do código.

Podemos ver no teste acima que logo no começo foi explicito que não haveria nenhum afirmação (assert). Ao invés disso apenas validamos as expectativas da interação de saída do SUT em relação ao serviço externo.

Ou seja, descrevemos nossa expectativa (shouldReceive()) que o serviço de envio de email chamaria o método send, que essa chamada aconteceria apenas uma vez (once()) e que os argumentos passados para o método (withArgs()) seriam o usuário e aquela exata mensagem.

Caso a mensagem ou o objeto $user estejam diferentes do definido no teste, o mesmo iria falhar.

Spy

Spy pode ser definido como uma combinação do Stub com o Mock. Spies validam informações com base em como foram chamados e também permitem testar as interações tanto de chegada quanto de saída.

Diferem dos Stubs e Mocks no fato de que os Spies registram qualquer interação entre ele e o SUT e nos permitem fazer afirmações contra essas interações após o fato.

Usaremos um Spy para garantir que a assinatura foi ativada e não houve nenhuma chamada do fluxo de inativação:

public function testConfirmaAssinaturaAtiva(): void
{
    $this->expectNotToPerformAssertions();

    $gatewaySpy = Mockery::spy(Gateway::class);
    $mailerDummy = $this->createMock(Mailer::class);

    $subscription = new Subscription($gatewaySpy, $mailerDummy);
    $subscription->create(new User('Maria'));

    $gatewaySpy->shouldHaveReceived('activate');
    $gatewaySpy->shouldNotHaveReceived('deactivate');
}
Enter fullscreen mode Exit fullscreen mode

Logo no começo foi definido que não haveria nenhuma afirmação, já que não faz parte do objetivo do teste. E como não precisamos testar nada do fluxo de envio de email, voltamos a usar um Dummy para a classe Mailer.

O Spy que substituiu o gateway de pagamento verifica depois de todo o fluxo executado quais funções foram chamadas e quais não foram, validando que houve uma chamada para a função activate e nenhuma para a função deactivate.

Test Doubles e Refatoração

Usar um test double requer que saibamos de um certo nível de detalhe sobre a implementação do que estamos testando.

Nos cenários que testamos aqui foi preciso saber, por exemplo, que para um usuário ser considerado assinante ele deveria salvar o UUID que retornou da integração com o meio de pagamento. O teste fica vinculado à implementação.

Quando refatoramos o código é possível que o teste também precise ser refatorado. Então cabe ao desenvolvedor saber quando é necessário usar o test double ou quando podemos deixar para que a classe resolva o que precisa ser resolvido usando objetos reais.

Cuidado com o SUT

Quando executamos os testes estamos validando algum aspécto do SUT.

Os test doubles estão ai para nos ajudar a lidar com dependências de um ou mais componentes, isolar o objeto de teste, simplificar comportamentos e nos dar mais controle sobre a situação.

Às vezes se torna tentador criar um test double para substituir parte do SUT e essa é uma ideia ruim.

Se você tiver vontade de substituir o comportamento de determinada função da classe que está testando, por exemplo, veja como um indicativo de que aquele código possui uma complexidade alta e talvez faça sentido ser encapsulada em uma classe própria.


Bom, esse post acabou ficando maior do que eu imaginei, então vou parar por aqui.

Caso tenha ficado alguma dúvida pode deixar nos comentários que eu respondo assim que possível. E vou deixar aqui também alguns recursos que me ajudaram enquanto montava esse post:

Repositório com o código

Test Double - Martin Fowler

Test Doubles Swift - Matheus de Vasconcelos

Test Doubles - Pablo Rodrigo Darde

Don't Stub the System Under Test - Joe Ferris

PHP Testing Jargon - Laracasts

Até a próxima!

Discussion (1)

Collapse
albersonfbarbosa profile image
Alberson Barbosa

Que demais, parabéns pelo texto.