← Voltar ao blog

Testes unitários no Magento 2 com PHPUnit: guia prático

Rodrigo Biazzi

Testes unitários no Magento têm fama de dolorosos. Classes com dez argumentos no construtor, abstract models que tocam o banco no construtor e uma suíte que demora uma eternidade se você a inicia do lugar errado. Quase toda essa dor vem de testar o tipo errado de classe, ou de código que nunca foi pensado para ser testado.

Este artigo mostra como escrever testes unitários que valem a pena manter num projeto Magento 2.4.8: onde eles ficam, como rodar só os seus, como fazer mock das dependências e como moldar suas próprias classes para que testá-las seja fácil.

O que é um teste unitário no Magento

Um teste unitário exercita uma classe isolada. Ele não sobe o Magento, não toca o banco e não lê arquivos de configuração. Toda dependência é trocada por um test double. Por isso um teste unitário roda em milissegundos e falha por exatamente um motivo.

Isso torna os testes unitários adequados para:

  • Cálculos: preços, taxas, limites de frete, datas
  • Mapeamento e transformação, por exemplo montar um payload de ERP a partir de um pedido
  • Lógica de decisão em plugins e observers
  • Validators e parsers

Eles não servem bem para repositories, collections, layout e tudo cujo valor está em como conversa com o framework. Isso é assunto para testes de integração.

Estrutura de pastas e nomes

Os testes ficam dentro do módulo, em Test/Unit, espelhando a estrutura das classes:

app/code/Vendor/Shipping/
  Model/FreeShippingThreshold.php
  Test/Unit/Model/FreeShippingThresholdTest.php

O nome da classe de teste termina em Test, estende PHPUnit\Framework\TestCase, e o namespace segue a pasta: Vendor\Shipping\Test\Unit\Model. O coding standard do Magento e a maioria das ferramentas assumem essa estrutura, então mantenha.

Uma classe que vale testar

Aqui vai um serviço pequeno que diz ao cliente quanto falta para chegar ao frete grátis. Ele lê um limite da configuração e faz uma aritmética simples.

<?php
declare(strict_types=1);

namespace Vendor\Shipping\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;

class FreeShippingThreshold
{
    private const XML_PATH_THRESHOLD = 'vendor_shipping/general/free_threshold';

    public function __construct(
        private readonly ScopeConfigInterface $scopeConfig
    ) {
    }

    public function getRemaining(float $subtotal, ?int $storeId = null): float
    {
        $threshold = (float) $this->scopeConfig->getValue(
            self::XML_PATH_THRESHOLD,
            ScopeInterface::SCOPE_STORE,
            $storeId
        );

        if ($threshold <= 0) {
            return 0.0;
        }

        return max(0.0, round($threshold - $subtotal, 2));
    }
}

Ele depende só de uma interface, recebe pelo construtor e não tem efeitos colaterais. Esse é o formato que facilita os testes unitários.

O teste

<?php
declare(strict_types=1);

namespace Vendor\Shipping\Test\Unit\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Vendor\Shipping\Model\FreeShippingThreshold;

class FreeShippingThresholdTest extends TestCase
{
    private ScopeConfigInterface&MockObject $scopeConfig;
    private FreeShippingThreshold $model;

    protected function setUp(): void
    {
        $this->scopeConfig = $this->createMock(ScopeConfigInterface::class);
        $this->model = new FreeShippingThreshold($this->scopeConfig);
    }

    /**
     * @dataProvider remainingProvider
     */
    public function testGetRemaining(?string $configValue, float $subtotal, float $expected): void
    {
        $this->scopeConfig->expects($this->once())
            ->method('getValue')
            ->with('vendor_shipping/general/free_threshold', ScopeInterface::SCOPE_STORE, 1)
            ->willReturn($configValue);

        $this->assertSame($expected, $this->model->getRemaining($subtotal, 1));
    }

    public static function remainingProvider(): array
    {
        return [
            'below threshold' => ['200', 150.0, 50.0],
            'exactly threshold' => ['200', 200.0, 0.0],
            'above threshold' => ['200', 250.0, 0.0],
            'rounding' => ['99.90', 33.33, 66.57],
            'not configured' => [null, 10.0, 0.0],
            'zero threshold' => ['0', 10.0, 0.0],
        ];
    }
}

Algumas coisas para notar:

  • A classe testada é criada com new. Sem object manager, sem helper.
  • O mock é da interface, não de uma classe de config concreta.
  • O data provider cobre os extremos: igual, acima, arredondamento, configuração ausente. A maioria dos bugs mora aí.
  • O data provider é static. Versões mais novas do PHPUnit exigem isso e as antigas aceitam, então escreva assim desde o começo.
  • Os valores de configuração voltam como string ou null do ScopeConfigInterface, então o teste retorna strings, que é o que acontece em produção.

O PHPUnit 10 e posteriores também suportam attributes como #[DataProvider('remainingProvider')]. Confira no composer.lock qual versão do PHPUnit a sua release do Magento traz antes de escolher entre annotations e attributes.

Rodando só os seus testes

O Magento traz uma configuração do PHPUnit em dev/tests/unit/phpunit.xml.dist. Passe com -c e aponte o PHPUnit para o seu código:

vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist app/code/Vendor/Shipping/Test/Unit

vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist --filter FreeShippingThresholdTest app/code/Vendor

Rodar a suíte unitária inteira do core raramente é útil num projeto. É lenta, e falhas em testes do core não são suas para consertar. Muitos times copiam o arquivo dist para dev/tests/unit/phpunit.xml e mudam os diretórios da test suite para só os seus namespaces. Rode os testes dentro do container PHP do seu ambiente local, para a versão do PHP bater com a de produção, como descrito no artigo sobre o setup com Warden.

Fazendo mock de classes do Magento

Factories

As factories geradas não existem até o Magento gerá-las, mas o PHPUnit ainda consegue fazer mock delas se a pasta generated estiver presente, ou você pode usar getMockBuilder() com disableOriginalConstructor(). A opção mais limpa é depender de uma interface e da sua factory só onde você realmente precisa de novas instâncias, e manter a lógica que usa o objeto criado num método separado e testável.

Getters mágicos

Models que estendem DataObject têm métodos como getCustomAttribute, que existem, e métodos como getSomeField, que só existem via __call. Para fazer mock de um método mágico, use addMethods() no mock builder do PHPUnit 9. Nas versões mais novas o addMethods() está deprecated, mais um motivo para depender de interfaces com getters de verdade, como OrderInterface ou ProductInterface, em vez das classes de model.

Chamadas consecutivas

O withConsecutive() foi removido no PHPUnit 10. Se você precisa de retornos diferentes para argumentos diferentes, use willReturnMap() ou um willReturnCallback() que confere os argumentos. Evite em testes novos, mesmo que sua versão atual ainda tenha.

Projetando para ser testável

A melhor forma de tornar código Magento testável é tirar a lógica das classes do framework. Algumas regras que funcionam bem:

  • Mantenha plugins e observers finos. Um plugin afterGetPrice deve chamar um serviço que faz o cálculo. Teste o serviço. O plugin vira duas linhas.
  • Dependa de interfaces. ScopeConfigInterface, ProductRepositoryInterface e LoggerInterface são fáceis de mockar. Resource models concretos não são.
  • Evite o object manager no seu código. Chamar ObjectManager::getInstance() torna a classe impossível de testar sem subir o Magento.
  • Passe valores, não containers. Um método que recebe um subtotal e um store ID é mais fácil de testar do que um que recebe a quote inteira.
  • Sem lógica em construtores. Construtores só devem atribuir dependências. O DI e os proxies do Magento também dependem disso, como explicado em dependency injection e virtual types.

O que não testar

Não escreva testes unitários que só verificam se um mock foi chamado com os argumentos que você acabou de configurar. Um teste que espelha a implementação linha por linha quebra em todo refactor e não pega nada. Teste comportamento: dadas essas entradas e essas respostas das dependências, o resultado é este. Pule também getters e setters triviais e XML de configuração. Análise estática e testes de integração cobrem isso melhor.

Mantendo a suíte saudável

Rode os testes unitários em todo pull request no CI, falhe o build em qualquer falha e mantenha a suíte rápida. Se um teste é instável, conserte ou apague, porque um build vermelho que todo mundo ignora é pior que nenhum build. Relatórios de cobertura ajudam a achar lógica de negócio sem teste, mas uma meta de cobertura sozinha tende a produzir testes de getters, não testes do código que importa.

FAQ

Onde ficam os testes unitários num módulo Magento 2?

Em Test/Unit dentro do módulo, espelhando o caminho da classe. Um teste para Model/Calculator.php vai em Test/Unit/Model/CalculatorTest.php com o namespace Vendor\Module\Test\Unit\Model.

Testes unitários do Magento precisam de banco de dados?

Não. Testes unitários rodam sem uma instalação do Magento. Tudo de que a classe depende é trocado por mocks. Se um teste precisa do banco, ele é um teste de integração.

Devo fazer mock de classes concretas ou de interfaces?

Prefira interfaces. Fazer mock de uma interface é estável e não roda nenhuma lógica de construtor. Mockar classes concretas do Magento funciona, mas amarra o teste a detalhes de implementação que mudam entre versões.

O test helper ObjectManager do Magento ainda é necessário?

Raramente. Com constructor property promotion e interfaces, você cria a classe testada com new e passa os mocks direto. O helper serve mais para classes antigas com construtores longos.

Atendo como freelancer em Magento e Adobe Commerce, incluindo adicionar testes a módulos existentes. Se você precisa de ajuda com isso, fale comigo.

Artigos relacionados

Erros comuns do Magento 2 e como corrigir

Correções para erros clássicos do Magento 2: Area code is not set, Class does not exist, Invalid Form Key, indexers travados, unserialize e 404 em estáticos.