TDD Com Phpunit: Um Exemplo Prático

Hoje falaremos sobre testes unitários. Por ser meu primeiro post no assunto, darei uma introdução até chegarmos na maior dificuldade para iniciantes, mockar objetos.
Os exemplos serão em php com phpunit, mas os conceitos podem e devem ser utilizados em qualquer tecnologia. O Post ficou um pouco extenso mas contém o básico pra começar.

O que são e porque testamos software de forma automatizada ?

A frase “A computação veio pra resolver problemas que não existiam antes” pode ser aplicada para nosso dia-a-dia como desenvolvedor de software. A cada novo release, existem chances de quebrarmos funcionalidades antigas. Em softwares monolíticos e pouco distribuídos a chance só aumenta.
As empresas costumam criar times ou dar papéis de testadores a certas pessoas. Esse papel define que a cada lançamento de bugfix ou feature, será preciso cobrir o software de testes para garantir que nada quebrou, o que torna o trabalho repetitivo, caro e cansativo. E como sabemos, precisamos responder a mudanças de mercado cada vez mais rápido. O movimento agile com o pensamento em entregas curtas e contínuas quase nos força a pensar numa maneira de facilitar as vidas dessas “equipes de teste”.
Entram os testes automatizados; Se somos escritores de programas, porque não fazer programas que testam programas? Dessa forma, a cada novo release, apenas rodaríamos o programa testador para ver se o software ainda se mantém consistente (, todos os testes passando).

Testes unitários

Dentro do mundo de testes automatizados, existem várias subdivisões, entre elas temos os testes unitários. Nesse tipo de teste, verificamos se uma unidade mínima do software, um método de classe/instância por exemplo, nos responde com valores esperados em todos os casos cobertos. Criamos uma classe que representa uma bateria com casos de teste, no phpunit da seguinte forma:

1
2
3
4
5
6
7
8
<?php

// arquivo em /meudiretorio/algumacoisaTest.php
class AlgumacoisaTest extends \PHPUnit_Framework_TestCase
{


}

Para rodar o arquivo, usamos o phpunit (que pode ser encontrado via arquivo .phar) :

1
phpunit.phar /meudiretorio/algumacoisaTest

A saída no console será algo como

1
2
3
4
5
6
7
8
9
10
11
12
13
PHPUnit 4.5.0 by Sebastian Bergmann and contributors.

F

Time: 33 ms, Memory: 2.75Mb

There was 1 failure:

1) Warning
No tests found in class "AlgumacoisaTest".

FAILURES!
Tests: 1, Assertions: 0, Failures: 1.

Ou seja, você criou uma bateria de testes mas não definiu nenhum caso de teste.

Definindo um problema

Vamos criar um problema em que uma classe irá nos atender.

1
2
3
4
5
6
7
8
Precisamos de um programa que mande e-mails de newsletter para usuários.
Cada usuário se inscreve em apenas uma de duas opções:  Newsletter tecnologia ou Newsletter filosofia.
Antes de cada envio é preciso perceber se o usuário não se descadastrou da lista,
acessando uma api simples de um serviço chamado minhanews.io.
Basta acessar a url http://minhanews.io/<email>/status e um json do tipo
'{ tecnologia: true, filosofia: false }' será encontrado,
onde as chaves dizem sobre o cadastro na newsletter.
Como saída do programa queremos todos os e-mails que foram enviados. Agrupados por tipo de news.

TDD

Sobre testes temos uma prática chamada TDD (Testing driven development). Com ela os programadores sempre escrevem os testes antes do código. O que faz com que o código só exista para atender os testes. Como um tempo, um ganho muito alto na arquitetura de código vem e isso também nos faz pensar melhor no problema antes de sair codando.
Vamos criar um roteiro para a soluão de nosso problema:

  • Você possui uma coleção de e-mails.
  • Para cada e-mail verificar se o usuário ainda está cadastrado.
  • Se ainda estiver cadastrado envie o e-mail e guarde a conta numa coleção.
  • Se não estiver cadastrado na newsletter, ignore o usuário.
  • Retorne a coleção com os usuários que receberam e-mails.

Conhecendo um pouco as dependências

Coleção de usuários

Vamos receber no método que iremos testar, um array de objetos do seguinte tipo:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

class User
{

  const NEWS_TECHNOLOGY = 1;
    const NEWS_PHILOSOPHY = 2;

  private $email;
  private $news;

  // Setters e Getters ...
}

Enviando e-mail com o SwiftMailer

Usaremos a library SwiftMailer para enviar o nosso e-mail de newsletter e uma introdução simples pode ser vista abaixo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

$transport = Swift_SmtpTransport::newInstance('smtp.example.org', 25)
  ->setUsername('your username')
  ->setPassword('your password')
  ;

// instanciando um mensageiro  
$mailer = Swift_Mailer::newInstance($transport);

// criando uma mensagem
$message = Swift_Message::newInstance('Assunto')
  ->setFrom(array('remetente@doe.com' => 'John Doe'))
  ->setTo(array('destinatario@domain.org', 'outro@domain.org' => 'Um nome'))
  ->setBody('Sua mensagem!')
  ;

// enviando a mensagem
$result = $mailer->send($message);

Requisições http com Guzzle

Como precisaremos acessar uma url para verificarmos o tipo de newsletter dos usuários, usaremos o melhor client http em php, o Guzzle. A ferramenta é manipulada da seguinte forma:

1
2
3
4
5
<?php

$client = new GuzzleHttp\Client();
$response = $client->get('http://seusite.com.br');
echo $response->json();

Autoloading

Não iremos abordar o autoloading de arquivos nesse post, você poderia utilizar o autoload do composer e a opção de bootstraping do phpunit para resolver o problema

Mão na massa, primeiro teste

Vamos começar finalmente a codar. O TDD afirma, como disse, que primeiro escrevemos testes. Vamos começar todo nome de método de teste com o prefixo test, assim o phpunit entederá que precisa rodá-lo.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

class NewsletterTest extends \PHPUnit_Framework_TestCase
{
  public function testDeveriaRetornarGruposVaziosSeRecebeuNenhumEmail()
  {
      // valor esperado
      $expected = [
          'tecnologia' => [],
          'filosofia' => []
      ];
      $emails = array();

      $service = new Newsletter;
      // valor encontrado
      $groups = $service->sendAll($emails);

      // asserção: valor encontrado é o valor esperado ? 
      $this->assertEquals($expected, $groups);
  }
}

O nome que damos para os testes devem ser auto explicativos, nesse caso ele diz que a chamada de “sendAll” deveria não enviar nenhum email e consequentemente ter arrays vazios como retorno.

1
2
3
4
PHPUnit 4.5.0 by Sebastian Bergmann and contributors.


Fatal error: Class 'Newsletter' not found in /meudiretorio/news.php on line 13

Claro que o teste irá falhar, a classe que será coberta pelos testes (Newsletter) ainda não foi criada. Simplesmente iremos escrevê-la.

1
2
3
4
5
6
<?php

class Newsletter
{

}

Outro erro irá ocorrer quando rodarmos os testes:

1
2
3
4
PHPUnit 4.5.0 by Sebastian Bergmann and contributors.


Fatal error: Call to undefined method Newsletter::sendAll() in /meudiretorio/news.php on line 20

Temos que nos preocupar primeiro em passar o teste, codamos o mínimo para que isso aconteça:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

class Newsletter
{
    public function sendAll($users)
    {
        $groups = array(
            'tecnologia' => [],
            'filosofia' => []
        );

        return $groups;
    }
}

O teste passou!

1
2
3
4
5
6
7
PHPUnit 4.5.0 by Sebastian Bergmann and contributors.

.

Time: 33 ms, Memory: 2.75Mb

OK (1 test, 1 assertion)

E sim, o código que produzimos é muito imaturo, mas ele atende ao nosso primeiro passo, e a intenção é justamente não desenvolver todo o algoritmo de uma vez.

Segundo teste

Vamos criar um segundo teste que faça nosso código evoluir, afinal queremos resolver todo o problema proposto.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php

class NewsletterTest extends \PHPUnit_Framework_TestCase
{

  // primeiro teste
  // ...

  public function testDeveriaTerUmEmailNoGrupoDeTecnologia()
  {
      // usuario do tipo tecnologia
      $email = "cloudson@outlook.com";
      $user = new User;
      $user->setEmail($email);
      $user->setNews(User::NEWS_TECHNOLOGY);

      // valor esperado diz que um email de tecnologia foi enviado para cloudson@outlook.com
      $expected = [
          'tecnologia' => [$email],
          'filosofia' => []
      ];

      // passaremos uma coleção com um usuário
      $emails = array($user);

      $service = new Newsletter;
      
      $groups = $service->sendAll($emails);
      $this->assertEquals($expected, $groups);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
PHPUnit 4.5.0 by Sebastian Bergmann and contributors.

.F

Time: 182 ms, Memory: 3.00Mb

There was 1 failure:

1) NewsletterTest::testDeveriaEnviarUmEmailDeTecnologia
Failed asserting that two arrays are equal.
--- Expected
+++ Actual
@@ @@
 Array (
     'tecnologia' => Array (
-        0 => 'cloudson@outlook.com'
     )
     'filosofia' => Array ()
 )

/meudiretorio/news.php:83

FAILURES!
Tests: 2, Assertions: 2, Failures: 1.

Novamente iremos nos concentrar em fazer o teste passar, mas nos atentando ao fato de não quebrar o primeiro teste.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Newsletter
{
    public function sendAll($users)
    {
        $groups = array(
            'tecnologia' => [],
            'filosofia' => []
        );
        // recebe a coleção de usuários, e assume que todos são do tipo tecnologia
        // perceba que se a coleção for vazia, continuamos com o comportamento anterior
        foreach ($users as $user) {
            $groups['tecnologia'][] = $user->getEmail();
        }

        return $groups;
    }
}
1
2
3
4
5
6
7
PHPUnit 4.5.0 by Sebastian Bergmann and contributors.

..

Time: 42 ms, Memory: 2.75Mb

OK (2 tests, 2 assertions)

Refatorando código

Uma das mais importantes etapas do desenvolvimento orientado a testes é a refatoração, nela modificamos o código sem alterar o sentido do mesmo, e consequentemente, sem quebrar os testes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Newsletter
{
    public function sendAll($users)
    {
        $groups = array(
            'tecnologia' => [],
            'filosofia' => []
        );

        foreach ($users as $user) {
          // apenas alteramos a forma como se descobre em que grupo de  newsletter o
          // usuário está.
            $key = ($user->getNews() == User::NEWS_TECHNOLOGY) ? 'tecnologia' : 'filosofia';
            $groups[$key][] = $user->getEmail();
        }

        return $groups;
    }

}

Se você rodar os testes, eles continuam passando.

Injeção de dependência

Como sabemos vamos precisar que nosso método sendAll mande uma request para a api minhanews.io usando o Guzzle e envie um e-mail com o SwiftMailer. Com essa relação de dependência entre nossa classe e as duas ferramentas, podemos usar um padrão chamado injeção de dependência (DI) onde simplesmente recebemos as dependências de fora da classe, como argumentos no construtor, por exemplo.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Newsletter
{
    private $http;
    private $mailer;

    public function __construct(\Guzzle\Client $http, \Swift_Mailer $mailer)
    {
        $this->http = $http;
        $this->mailer = $mailer;
    }


    public function sendAll($users)
    {
        $groups = array(
            'tecnologia' => [],
            'filosofia' => []
        );

        foreach ($users as $user) {
            $key = ($user->getNews() == User::NEWS_TECHNOLOGY) ? 'tecnologia' : 'filosofia';
            $groups[$key][] = $user->getEmail();
        }

        return $groups;
    }
}

Se rodarmos os testes, veremos que quebramos tudo, ou seja, os dois testes não correspondem ao estado atual do código.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
PHPUnit 4.5.0 by Sebastian Bergmann and contributors.

EE

Time: 37 ms, Memory: 2.75Mb

There were 2 errors:

1) NewsletterTest::testDeveriaRetornarGruposVaziosSeRecebeuNenhumEmail
Argument 1 passed to Newsletter::__construct() must be an instance of Guzzle\Client, none given, called in /home/cloudson/Desktop/news.php on line 72 and defined

/home/cloudson/Desktop/news.php:38
/home/cloudson/Desktop/news.php:72

2) NewsletterTest::testDeveriaEnviarUmEmailDeTecnologia
Argument 1 passed to Newsletter::__construct() must be an instance of Guzzle\Client, none given, called in /home/cloudson/Desktop/news.php on line 95 and defined

/home/cloudson/Desktop/news.php:38
/home/cloudson/Desktop/news.php:95

FAILURES!
Tests: 2, Assertions: 0, Errors: 2.

Mockando classes

Poderíamos simplesmente instanciar e passar as dependências necessárias para nossa classe Newsletter. Mas pensando em testes que verificam a integridade de uma porção pequena de código isso pode ser complicado. Pense que estamos falando de dependências que irão enviar e-mail e requisições http, recursos externos que podem estar fora do ar (quem garante que teremos internet? Que a api irá responder corretamente? Que a máquina não bloqueia as portas de smtp? ). Para evitar que o sucesso de nossos testes dependam de outros fatores, além do código propriamente dito, criamos instâncias fakes que simulam o comportamento de uma dependência real.
Há várias formas de simular esse comportamento como você pode ver num artigo do Martin Fowler, a forma que iremos tratar é a de Mocking. É importante dizer que essa técnica só é possível graças a injeção de dependência, se instanciássemos classes dentro de nossa Newsletter, não teríamos a possibilidade de substituir uma real por uma fake. Veja abaixo como criar um mock da classe Swift_Mailer usando o phpunit

1
2
3
4
<?php
// ... dentro de uma class \PHPUnit_Framework_TestCase ... 

$mailer = $this->getMockBuilder('\Swift_Mailer')->disableOriginalConstructor()->getMock();

Vamos agora reescrever a classe de teste e utilizar os mocks nos momentos certos, atente-se aos comentários:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<?php

class NewsletterTest extends \PHPUnit_Framework_TestCase
{
  private $mailer;
    private $client;
    private $service;

    // esse método é chamado antes de cada teste :) 
    public function setUp()
    {
      // mockamos as duas dependencias
        $this->mailer = $this->getMockBuilder('\Swift_Mailer')->disableOriginalConstructor()->getMock();
        $this->client = $this->getMockBuilder('\Guzzle\Client')->disableOriginalConstructor()->getMock();

        // utilizamos um atributo de instância com as dependências
        $this->service = new Newsletter($this->client, $this->mailer);
    }

    public function testDeveriaRetornarGruposVaziosSeRecebeuNenhumEmail()
    {
        $expected = [
            'tecnologia' => [],
            'filosofia' => []
        ];
        $emails = array();

        // utilzamos o atributo de instância ao inves de instanciar a classe Newsletter
        // a todo momento.
        $groups = $this->service->sendAll($emails);

        $this->assertEquals($expected, $groups);
    }

    public function testDeveriaEnviarUmEmailDeTecnologia()
    {
        $email = "cloudson@outlook.com";
        $user = new User;
        $user->setEmail($email);
        $user->setNews(User::NEWS_TECHNOLOGY);

        $expected = [
            'tecnologia' => [$email],
            'filosofia' => []
        ];

        $emails = array($user);

        $groups = $this->service->sendAll($emails);
        $this->assertEquals($expected, $groups);
    }

}

Os testes voltam a passar e agora vamos novamente evoluir nosso código a partir de um novo teste, em que afirmaremos que “o switfmail irá enviar um email para o usuario”.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php

class NewsletterTest extends \PHPUnit_Framework_TestCase
{
    // ...

  public function testDeveriaEnviarEmail()
    {
        $email = "cloudson@outlook.com";
        $user = new user;
        $user->setemail($email);
        $user->setnews(User::NEWS_TECHNOLOGY);

        $expected = [
            'tecnologia' => [$email],
            'filosofia' => []
        ];

        $emails = array($user);

        // ordenamos que o mock de swiftmailer espera rodar uma vez o método send.
        // Isso funciona como uma asserção. 
        $this->mailer->expects($this->once())->method('send');

        $groups = $this->service->sendall($emails);
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PHPUnit 4.5.0 by Sebastian Bergmann and contributors.

..F

Time: 40 ms, Memory: 3.25Mb

There was 1 failure:

1) NewsletterTest::testDeveriaEnviarEmail
Expectation failed for method name is equal to <string:send> when invoked 1 time(s).
Method was expected to be called 1 times, actually called 0 times.

FAILURES!
Tests: 3, Assertions: 3, Failures: 1.

Ao rodar os testes, vemos que o phpunit nos disse que send() era esperado 1 vez e não foi invocado nenhuma vez, em nossa classe Newsletter, iremos usá-lo.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php

class Newsletter
{

  public function sendAll($users)
    {
        $groups = array(
            'tecnologia' => [],
            'filosofia' => []
        );

        foreach ($users as $user) {
            $key = ($user->getNews() == User::NEWS_TECHNOLOGY) ? 'tecnologia' : 'filosofia';
            $message = Swift_Message::newInstance('Wonderful Subject')
                  ->setFrom(array('cloudson@claudson.com.br'))
                  ->setTo(array($user->getEmail()))
                  ->setBody($key)
                  ;

            $this->mailer->send($message);
            $groups[$key][] = $user->getEmail();
        }

        return $groups;
    }
}

Testes passando.

1
2
3
4
5
6
7
PHPUnit 4.5.0 by Sebastian Bergmann and contributors.

...

Time: 237 ms, Memory: 3.25Mb

OK (3 tests, 3 assertions)

Mockando o Guzzle

Para este post estamos chegando no último passo. Estamos enviando e-mail para todos os usuários, mas a especificação dizia que era preciso verificar se eles não se descadastraram. Vamos usar nosso mock de \Guzzle\Client para simular uma chamada get e um retorno para ela. Veja abaixo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

class ResponseSubscribed
{
    public function json()
    {
      return json_encode([
      'tecnologia' => true, 'filosofia' => false
      ]);
    }
}

class ResponseUnSubscribed
{
    public function json()
    {
      return json_encode([
      'tecnologia' => false, 'filosofia' => false
      ]);
    }
}

// ... dentro do metodo setup da classe de testes

$this->client->expects($this->any())
  ->method('get')
  ->will($this->returnCallback(function($url){
  // Perceba, estamos usando any() pra definir que o método get pode ser chamado várias 
  //vezes, e que pra cada uma delas ele irá retornar um valor que é decidido por uma função 
  //anônima. 
  // Nessa função estamos usando as classes definidas acima que também simulam
  // comportamentos do Guzzle. Nesse caso estamos usando stubs, mas não
  // entraremos em detalhe nesse post. 

  $response = new ResponseSubscribed;
  if (strpos($url, "cloudson") === FALSE) {
      $response = new ResponseUnSubscribed;
  }

  return $response;

}));

Da ultima vez que afirmamos algo sobre um método mockado, o fizemos dentro do método de teste, pois ele era restrito para o teste. Nesse caso vamos colocar esse código no setup() dos testes pois imaginamos que esse comportamento será padrão.

Agora, evoluindo nosso código, já podemos chamar o guzzle para verificar o status dos usuários e definir se eles se descadastraram das newsletters.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

class Newsletter
{
    private $http;
    private $mailer;

    public function __construct(\Guzzle\Client $http, \Swift_Mailer $mailer)
    {
        $this->http = $http;
        $this->mailer = $mailer;
    }


    public function sendAll($users)
    {
        $groups = array(
            'tecnologia' => [],
            'filosofia' => []
        );

        foreach ($users as $user) {
            $response = $this->http->get('http://minhanews.io/'.$user->getEmail().'/status');
            $data = json_decode($response->json(), true);
            if (!$data['tecnologia'] && !$data['filosofia']) {
                continue;
            }

            $key = ($user->getNews() == User::NEWS_TECHNOLOGY) ? 'tecnologia' : 'filosofia';
            $message = Swift_Message::newInstance('Wonderful Subject')
                  ->setFrom(array('cloudson@claudson.com.br'))
                  ->setTo(array($user->getEmail()))
                  ->setBody($key)
                  ;

            $this->mailer->send($message);
            $groups[$key][] = $user->getEmail();
        }

        return $groups;
    }
}

Último teste

Fugindo um pouco do TDD e escrevendo testes depois do código, vamos assegurar que quando a api diz que um usuario está descadastrado, ele não recebe e-mails

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php

class NewsletterTest extends \PHPUnit_Framework_TestCase
{
  // ...
  public function testDeveriaEnviarAPenasUmEmail()
    {
      $user = new user;
      $user->setemail("cloudson@outlook.com");
      $user->setnews(User::NEWS_TECHNOLOGY);

      $user2 = clone $user;
      $user->setEmail("joao@ti.com");

      $user3 = clone $user;
      $user->setEmail("maria@sensio.com");

      $expected = [
          'tecnologia' => ["cloudson@outlook.com"],
          'filosofia' => []
      ];

      $emails = array($user, $user2, $user3);
      // apesar de passarmos 3 usuarios, 
      //o metodo send foi chamado apenas uma vez

      $this->mailer->expects($this->once())->method('send');

      $groups = $this->service->sendall($emails);
    }
}

Os testes passam e provam que nosso código está pronto :)

1
2
3
4
5
6
7
PHPUnit 4.5.0 by Sebastian Bergmann and contributors.

....

Time: 42 ms, Memory: 3.25Mb

OK (4 tests, 4 assertions)

Novos testes?

Quando falamos em testes unitários, podemos verificar a cobertura do mesmo, que seria; baseado nos seus testes, quais partes de seu código são atingido por eles? Isso poderia dar um indício de bons locais onde possíveis bugs podem aparecer. Boas perguntas para novos casos de teste poderiam ser:

  • Quando um usuario é do tipo filosofia, ele recebe email filosofia?
  • Quando um usuario recebe um status diferente de seu tipo, o que acontece?
  • Quando a api não retorna um json esperado, o que a classe Newsletter faz?
  • E se eu quisesse personalizar mais o corpo dos e-mails?

Conclusão

O TDD é um bom método para pensarmos melhor no problema, a velocidade de desenvolvimento nos fala sobre nosso entendimento e a cobertura pode ser uma métrica interessante para novos desenvolvedores no projeto. Ufa! Deu trabalho, mas nossa classe está coberta de testes, e caso alguem a altere, os testes podem alertar problemas.

Comments