PHP: Pra que mockar?

Se você já escreve testes, em algum momento, deve ter recebido orientações para mockar uma classe com o objetivo de fazer um teste mais simples. Se você nunca passou por isso ou se ainda não entendeu o motivo de usar, esse artigo é pra você.

E antes de começar, quero destacar um fato importante: mock não é para ser usado em tudo. Em algumas situações, o que você vai precisar é de um spy, que é bem menos "agressivo" que um mock. Então eu não estou dizendo que você deve mockar tudo em todos os testes, por favor. Se seu teste pode ser escrito de forma simples e clara sem ele, escreva sem!

Mas Kiko, o que é um mock?

O mock é um clone de alguma classe ou objeto, que serve para substituir a classe original de modo a te dar o controle sobre o que é esperado de acontecer durante o fluxo de testes.

Por exemplo, se você tem uma classe Panela que tem um método cozinhar(), em testes onde é preciso chamar esse método sem se importar com o que acontece dentro, você pode mockar a classe e criar a expectativa de que o método deve ser acionado pelo menos uma vez. Assim, se após a checagem isso não acontecer, uma falha será detectada.

Além desse cenário, o mock também serve para retirar dependências desnecessárias para o seu teste. Isso fica claro em situações onde uma classe tem acúmulo de responsabilidade e você quer testar somente uma delas. Para ignorar todas as outras, você vai mockar algumas classes chamadas ali dentro para criar retornos falsos, de modo que o teste não dependa do sucesso delas.

Interessante... Mas como funciona um mock, Kiko?

Via injeção de dependência. Isso é uma estratégia que depende inteiramente de como o seu projeto foi desenvolvido. Por exemplo, se você usa um framework com gerenciamento de container como o Laravel, o mock pode ser atrelado diretamente a uma classe. Assim, quando você for instanciar essa classe usando o gerenciador, o Laravel instanciará o mock no lugar da classe.

Outra forma de injeção é quando você não instancia classe alguma no seu código, recebendo isso como entrada via construtor ou algum método em específico, que exemplifiquei no artigo anterior.

Hum... Acho que entendi... Acho...

Então vamos ver na prática, continuando o exemplo da classe Panela:

class Panela
{
    public function __construct(
        private array $ingredientes
    ) { }

    public function cozinhar(): void
    {
        foreach($this->ingredientes as $ingrediente) {
            $ingrediente->aumentarTemperatura();
        }
    }
}

Para complementar, farei uma classe Ingrediente:

class Ingrediente
{
    protected float $congelado = 10.0;
    protected float $cru = 30.0;
    protected float $malCozido = 55.0;
    protected float $cozido = 65.0;
    protected float $bemCozido = 75.0;

    public function __construct(
        public readonly string $nome,
        protected float $temperatura = 25.0
    ) {
    }

    public function temperaturaAtual(): float
    {
        return $this->temperatura;
    }

    public function aumentarTemperatura(): void
    {
         $this->temperatura += 0.01;
    }

    public function estado(): string
    {
        if ($this->temperatura <= $this->congelado) {
            return "congelado";
        }

        if ($this->temperatura <= $this->cru) {
            return "cru";
        }

        if ($this->temperatura <= $this->malCozido) {
            return "mal cozido";
        }

        if ($this->temperatura <= $this->cozido) {
            return "cozido";
        }

        if ($this->temperatura <= $this->bemCozido) {
            return "bem cozido";
        }

        return "queimado";
    }
}

Nota: eu não manjo nada de cozinha, apenas chutei alguns valores.

Ué, Kiko, como vai funcionar isso tudo?

Eu vou explorar um pouco de Design Patterns aqui. Cada ingrediente será uma classe que herda essa base de ingredientes, precisando configurar somente o nome do ingrediente e sua temperatura inicial (vindo via construtor). Já a temperatura de cada estado, é uma configuração opcional da hierarquia.

Também podemos fazer um override no método aumentarTemperatura, já que cada ingrediente tem um tempo de cozimento diferente.

Isso nos permitirá configurar ingredientes completamente diferentes. Por exemplo:

class PeitoDeFrango extends Ingrediente
{ 
    public function __construct()
    {
        parent::__construct(
            nome: 'Peito de frango',
            temperatura: 20.0
        );
    }
}
class CoxaoMole extends Ingrediente
{ 
    public function __construct()
    {
        parent::__construct(
            nome: 'Coxão Mole',
            temperatura: 28.0
        );
    }
}

Com isso, você pode ter a seguinte usabilidade:

$coxaoMole = new CoxaoMole();
$panela = new Panela(ingredientes: [$coxaoMole]);

$repeticoes = 0;
while ($coxaoMole->temperaturaAtual() < 67.0) {
    $panela->cozinhar();
    $repeticoes++;
}

echo "Levou $repeticoes aumentos de temperatura para deixar o {$coxaoMole->nome} {$coxaoMole->estado()}!";

Brinque com o código: https://paiza.io/projects/MmMfRYUKATVnyER0NLw5oQ?language=php

Agora que montamos o nosso projetinho, vamos refletir sobre como podemos mockar alguma das classes citadas a fim de comprovar alguma coisa. Dada que a principal classe é a Panela e ela é a única que recebe outra em algum trecho do código, faz muito sentido que ela não seja mockada, somente suas dependências. No nosso experimento, ela só tem dependência com a classe Ingrediente.

E se você pensou dessa forma, você está plenamente certo(a). Ingrediente é o nosso alvo aqui. Se eu quero testar se a panela funciona, eu não preciso colocar um ingrediente de verdade, concorda? É só cozinhar qualquer coisa.

Nesse caso, nós vamos cozinhar um ingrediente falso. Para isso, nós precisamos criar essa classe falsa, por exemplo:

class IngredienteMock extends Ingrediente
{
    public function __construct()
    {
        parent::__construct(
            name: "Mock",
            temperatura: -50.0
        );
    }
}

Essa é uma classe falsa, que tem como único objetivo validar interações entre os ingredientes e a panela. Digamos que eu queira comprovar que a classe panela aciona o método aumentarTemperatura do ingrediente... Há várias formas de se fazer isso.

class IngredienteMock extends Ingrediente
{
    public bool $acionou = false;

    public function __construct()
    {
        parent::__construct(
            nome: "Mock",
            temperatura: -50.0
        );
    }

    public function aumentarTemperatura(): void
    {
        $this->acionou = true;
    }
}

E aí, no seu teste, você teria algo assim:

$ingrediente = new IngredienteMock();
$panela = new Panela(ingredientes: [$ingrediente]);
$panela->cozinhar();
if ($ingrediente->acionou) {
    echo "Acionou o método aumentarTemperatura()";
} else {
    echo "Não acionou o método aumentarTemperatura()";
}

Veja essa brincadeira aqui: https://paiza.io/projects/ppWVYgKRKjOxztCMzGIFPQ?language=php

Ah, então o Mock não necessariamente precisa fazer o que o código original faz, apenas registrar os métodos acionados, Kiko?

Exatamente. No geral, nós usamos o Mock para simular coisas. Então muitas vezes você vai ter um Mock que retorna algum resultado fictício para testar se esse resultado está influenciando no resto do código, etc.

A forma como montamos esse mock não é a ideal, na verdade, está bem longe! Afinal, nós temos diversas bibliotecas que nos fornecem várias classes auxiliadoras para clonar classes e tornar as verificações ainda mais assertivas.

Uma das bibliotecas mais usadas é a Mockery.io, que já vem integrada no PHPUnit e é bem simples de se usar. Por exemplo, aquele teste que fizemos poderia ser bem mais compacto:


$ingrediente = Mockery::mock(Ingrediente::class);
$ingrediente->shouldReceive('aumentarTemperatura')->once();

$panela = new Panela(ingredientes: [$ingrediente]);
$panela->cozinhar();

Se o método não for chamado, durante o processo de desconstrução do objeto, o Mockery lançará um erro. Seu teste não terá nenhum problema se for chamado conforme esperado (somente uma vez).

Conclusão

Embora o ato de mockar possa ser feito do zero, trabalhar com bibliotecas mais robustas e com um bom tempo de mercado pode ser uma escolha melhor. Até porque seu código fica mais legível e você perde menos tempo escrevendo os seus testes.

Outro detalhe é o fato de que Mocks estão aí para deixar seus testes mais focados, unitariamente falando. Se eu estou testando a panela, pouco importa o ingrediente, confere? Agora se eu estivesse testando o ingrediente, não teríamos o que mockar.

Se a classe que estiver mockando tiver algum método essencial para o teste (que faz parte do que está testando), não faça esse mock! No máximo, faça um spy, como mostra o Adam Wathan nesse artigo sobre Mockery mock vs spy. Não deixe brechas no seu teste!

E por hoje é só! Curtiu?? Comenta e compartilha! Acabei ficando até tarde escrevendo esse artigo só pra dar mais uma variada no blog. Prometo que no próximo eu voltarei com a série de PHP para Iniciantes, hahaha. Não deixa de acompanhar!

Inté!