Fixtures x Object Factories

por Alan Willms


Qualquer tecnologia possui seus prós e contras, nem sempre há uma solução universal em detrimento das demais.

Após conversar com várias pessoas sobre o assunto de fixtures versus object factories, cheguei à conclusão que isso é mais uma questão de adequação ao projeto e gosto pessoal do que “bom ou ruim”.

Nesse texto explico casos onde o uso de uma object factory pode ser uma boa opção. Existem diferentes implementações para PHP, mas aqui falarei especificamente da Phactory.

Problemas com fixtures

Fixtures são rápidas para carregar, fáceis de usar, mas apresentam alguns problemas em projetos complexos. Alguns dos problemas listados abaixo presumem que você não é fã do uso de mocks e stubs.

Um dos principais problemas é que quando você possui uma série de objetos associados, terá de carregar todas as fixtures relacionadas de modo a realizar um teste. Em alguns sistemas o número de relacionamentos é tanto que facilmente dezenas de fixtures precisariam ser carregadas para executar o teste com sucesso. Assumindo que existam diferentes fixtures para diferentes casos de testes, a quantidade de dados que precisa ser carregada e descarregada a cada teste é enorme.

Outro grande problema é que eventualmente você quebra testes existentes ao adicionar novas fixtures de um mesmo tipo. Suponha que você tenha um teste que verifica a soma total de pedidos. Caso você precise testar um novo tipo de pedido, e tenha que adicioná-lo às fixtures de pedidos, esse teste que soma todos os pedidos quebrará porque a soma do total agora será maior. Então ao adicionar uma nova fixture de um tipo existente, você precisaria corrigir testes que funcionavam antes.

Ao testar o projeto, você precisa estar ciente do que os valores que estão definidos na fixture fazem. Você precisa saber que o status 3 é “excluído”, que o tipo 5 é “aguardando aprovação”, etc. Você testa algo pressupondo valores que estão definidos em outro lugar, e não de maneira clara, prejudicando a leitura do teste.

Além de tudo isso, é muito chato criar novas fixtures, apesar de existirem ferramentas como o Faker que auxiliam no processo. Eventualmente você precisa acrescentar um novo atributo ao objeto, e consequentemente terá de alterar todas as fixtures daquela classe, acrescentando um valor para a nova coluna do banco de dados.

Existem várias formas de contornar estes problemas, mas trataremos aqui do uso da Phactory em substituição às fixtures.

Utilizando a biblioteca Phactory

Primeiramente, para saber como configurar a biblioteca Phactory, por favor consulte a documentação oficial em português.

A fábrica de objetos

Definir uma fábrica é muito simples:

<?php
class PostPhactory
{
    public function blueprint()
    {
        return [
            'id' => '#{sn}',
            'titulo' => 'Post #{sn}',
            'texto' => 'Texto',
            'autor' => Phactory::hasMany('usuario'),
        ];
    }
}

class UsuarioPhactory
{
    public function blueprint()
    {
        return [
            'id' => '#{sn}',
            'login' => 'login#{sn}',
            'is_admin' => false,
        ];
    }

    public function admin()
    {
        return [
            'is_admin' => true,
        ];
    }
}
?>

O método blueprint é chamado para alimentar os objetos criados pela fábrica. Desta forma sempre que você chamar Phactory::post(), este método retornará um objeto post contendo os atributos definidos em blueprint. Por exemplo: id “1”, título “Post 1”, texto “Texto” e autor “login 1”.

A segunda fábrica possui um método adicional chamado de admin. Esses métodos adicionais se chamam tipos (types) e servem para criar objetos pré-alimentados de uma maneira ligeiramente diferente. Por exemplo, Phactory::usuario() retornaria um usuário normal, enquanto Phactory::usuario('admin') retorna um usuário do tipo administrador.

Uso básico

Caso eu queira testar um post, eu não preciso preencher todos os campos, como usuário criador, data de criação, título, etc. A Phactory toma conta disso para mim:

<?php
$post = Phactory::post();
$post->texto = 'Teste de contador de palavras';

$this->assertEquals(5, $post->getTotalDePalavras());
?>

Ou de maneira resumida, usando overrides:

<?php
$post = Phactory::post(['texto' => 'Teste de contador de palavras']);

$this->assertEquals(5, $post->getTotalDePalavras());
?>

Criando relacionamentos automaticamente

A Phactory criará automaticamente quaisquer objetos relacionados sem que eu precise defini-los manualmente a cada teste:

<?php
// Ele gera a categoria automaticamente
$produto = Phactory::produto(['nome' => 'iPad 2']);
$produto->categoria->nome = 'Tablet';

$this->assertEquals('Tablet - iPad 2', $produto->getDescricaoCompleta());
?>

Novos atributos do objeto

Caso você precise criar uma nova propriedade na classe, basta alterar a Phactory para que todos os objetos sejam criados automaticamente com o novo atributo.

<?php
class PessoaPhactory
{
    public function blueprint()
    {
        return [
            'id' => '#{sn}',
            'nome' => 'Pessoa #{sn}',
            // Novo atributo
            'idade' => 18,
        ];
    }
}
?>

Isolamento dos testes

Isso depende de uma estratégia de transaction/rollback ou de criacao/truncate do banco de dados, mas a questão é que os objetos criando para um teste não são os mesmos criados para outro:

<?php
public function testTotalPedidos()
{
    Phactory::pedido(['total' => 100.00]);
    Phactory::pedido(['total' => 200.00]);
    Phactory::pedido('cancelado', ['total' => 300.00]);

    $this->assertEquals(300.00, Pedido::getTotal());
}

public function testTotalPedidosCancelados()
{
    Phactory::pedido(['total' => 10.00]);
    Phactory::pedido('cancelado', ['total' => 20.00]);
    Phactory::pedido('cancelado', ['total' => 30.00]);

    $this->assertEquals(50.00, Pedido::getTotalCancelado());
}
?>

Objetos não-persistidos

Nem todos os testes precisam que você salve o objeto no banco de dados. Para esses casos é muito mais rápido apenas criar o objeto na memória. Isso é muito simples:

<?php
// Esse método salva no banco de dados!!!
$postSalvo = Phactory::post();

// Esse só cria o objeto sem persistir
$postMemoria = Phactory::unsavedPost();
?>

Conjuntos complexos de objetos

Eventualmente você precisa testar uma classe que possui uma série de relacionamentos complexos. Considere o caso a seguir:

<?php
public function testTotal()
{
    $pedido = Phactory::pedido();
    $pedido->addItem(Phactory::itemPedido(['total' => 10.00]);
    $pedido->addItem(Phactory::itemPedido(['total' => 20.00]);
    $pedido->addItem(Phactory::itemPedido(['total' => 30.00]);

    $this->assertEquals(60.00, $pedido->getTotal());
}

public function testMedia()
{
    $pedido = Phactory::pedido();
    $pedido->addItem(Phactory::itemPedido(['total' => 10.00]);
    $pedido->addItem(Phactory::itemPedido(['total' => 20.00]);
    $pedido->addItem(Phactory::itemPedido(['total' => 30.00]);

    $this->assertEquals(20.00, $pedido->getMedia());
}
?>

Pode se tornar um pouco repetitivo ter de definir um mesmo cenário várias vezes, e somos tentados a utilizar fixtures para esses casos específicos. No entanto, nestes casos, podemos usar a funcionalidade de cenários da Phactory:

<?php
class PedidoScenario
{
    public $pedido;

    public function blueprint()
    {
        $this->pedido = Phactory::pedido();
        $this->pedido->addItem(Phactory::itemPedido(['total' => 10.00]);
        $this->pedido->addItem(Phactory::itemPedido(['total' => 20.00]);
        $this->pedido->addItem(Phactory::itemPedido(['total' => 30.00]);
    }
}

public function testTotal()
{
    $cenario = Phactory::pedidoScenario();
    $this->assertEquals(60.00, $cenario->pedido->getTotal());
}

public function testMedia()
{
    $cenario = Phactory::pedidoScenario();
    $this->assertEquals(20.00, $cenario->pedido->getMedia());
}
?>

Conclusão

Como a Phactory trabalha com objetos ao invés de comandos de INSERT no banco de dados, sua performance não é tão boa quanto a de fixtures.

Em outras palavras, uma suíte de testes usando Phactory é mais lenta para rodar do que uma que usa fixtures. Por outro lado, uma suíte destas é muito mais rápida para o programador ler, mais rápida para o programador escrever, e como os testes ficam completamente isolados, não é necessário rodar a suíte toda o tempo todo, mas sim somente o caso de teste em desenvolvimento.

Dito isso, é importante ponderar: o que custa mais caro, o tempo de execução de um comando no servidor de integração ou a hora de trabalho de um programador?

Referências

Consulte os seguintes textos que se aprofundam no assunto de fixtures x factories: