Saltar a contenido

Implementando el primer escenario

El primer escenario es el siguiente:

Escenario: Ganar puntos al pagar en efectivo
    Dado que he comprado 5 menús del número 1
    Cuando pido la cuenta recibo una factura de 55 euros
    Y pago en efectivo con 55 euros
    Entonces la factura está pagada
    Y he obtenido 50 puntos

Para implementar esta escenario, que es realmente el primero de nuestro proyecto que vamos a implementar, necesitamos una clase cuenta (Bill) que guarde los menús que se han consumido e informe del coste total, de la cantidad que se ha ingresado (en dinero o puntos), de lo que resta por pagar y de los puntos obtenidos.

Describir la clase Bill

Usamos PhpSpec para crear la especificación, siguiendo los pasos que anteriormente hicimos con la clase Menu.

vendor/bin/phpspec desc Restaurant/Bill

Y como resultado tendremos nuestra nueva clase Bill:

bash$ vendor/bin/phpspec desc Restaurant/Bill
Specification for Restaurant\Bill created in /home/sergio/Developer/curso-web/bdd-by-example/bdd-by-example/spec/Restaurant/BillSpec.php.

bash$ vendor/bin/phpspec run

      Restaurant\Bill

  11  ! is initializable
        class Restaurant\Bill does not exist.

      Restaurant\Menu

  19  ✔ is initializable
  24  ✔ has a menu number
  29  ✔ has a price

----  broken examples

        Restaurant/Bill
  11  ! is initializable
        class Restaurant\Bill does not exist.


2 specs
4 examples (3 passed, 1 broken)
71ms

  Do you want me to create `Restaurant\Bill` for you?
                                                                         [Y/n]

Class Restaurant\Bill created in /home/sergio/Developer/curso-web/bdd-by-example/bdd-by-example/src/Restaurant/Bill.php.


      Restaurant\Bill

  11  ✔ is initializable

      Restaurant\Menu

  19  ✔ is initializable
  24  ✔ has a menu number
  29  ✔ has a price


2 specs
4 examples (4 passed)
71ms

Ya tenemos nuestra especificación spec/Restaurant/BillSpec.php y nuestra clase Restaurant/Bill.php

Ahora tenemos que describir la API de nuestra clase. Concretamente para este escenario nuestra clase debe proporcionar una API para:

  • Añadir un menú a la cuenta
  • Obtener el total de la cuenta
  • Permitir pagar una cantidad de dinero
  • Determinar cuánto resta por pagar
  • Determinar cuántos puntos se han ganado

Para esta prueba vamos a suponer que tenemos ya una instancia de Menu que cuesta 10€.

Añadiendo elementos a la cuenta

Ahora ya podemos añadir elementos a la cuenta, sin importarnos si es un menú o cualquier otra cosa, solo los importa que tenga precio. Vamos a hacer las pruebas con un solo elemento que cueste 10€.

Vamos a crear el método let para configurar los datos de ejemplo:

<?php

namespace spec\Restaurant;

use Restaurant\Bill;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Restaurant\Menu;

class BillSpec extends ObjectBehavior
{
    function let(Menu $item)
    {
        $item->price()->willReturn(1000);
    }

    function it_is_initializable()
    {
        $this->shouldHaveType(Bill::class);
    }
}

En esta ocasión no estamos usando let para configurar el constructor de la clase, que por ahora no hemos determinado que vayamos a necesitar, sino para configurar una instancia de la clase Menu y que cuando se llamen a la función price() devolverá 1000. Hay que tener en cuenta que Menu no es algo que hayamos instanciado nosotros. Lo que ha ocurrido es que phpspec ha creado un doble, una clase que simula las respuestas a los métodos con los valores que se le indican con las cláusulas willReturn.

<?php

namespace spec\Restaurant;

use Restaurant\Bill;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Restaurant\Menu;

class BillSpec extends ObjectBehavior
{
    function let(Menu $item)
    {
        $item->price()->willReturn(1000);
    }

    function it_is_initializable()
    {
        $this->shouldHaveType(Bill::class);
    }

    function it_has_no_items_by_default()
    {
        $this->getTotal()->shouldBe(0);
    }

    function it_adds_an_item(Menu $item)
    {
        $this->addItem($item);
        $this->getTotal()->shouldBe(1100);
    }
}

Estamos describiendo que nuestra cuenta, cuando se crea, no debe tener ningún elemento, y que los elementos que se añaden incrementan la cuenta (con IVA). Debido al incremento del IVA el valor de retorno será siempre flotante. Ejecutamos las pruebas, que fallarán, y pasamos a implementar el código. Pasamos a completar el código de nuestra clase Bill:

<?php
declare(strict_types=1);

namespace Restaurant;

class Bill
{
    const VAT = '1.10';
    private $items;

    public function __construct()
    {
        $this->items = [];
    }

    public function getTotal(): int
    {
        return (int) (array_reduce($this->items, function ($carry, Menu $menu) {
            return $carry + $menu->price();
        }, 0) * self::VAT);
    }

    public function addItem(Menu $item): void
    {
        $this->items[] = $item;
    }
}

Y ejecutamos las pruebas:

vendor/bin/phpspec run Restaurant\\Bill

    Restaurant\Bill

17  ✔ is initializable
22  ✔ has no items by default
27  ✔ adds an item
33  ✔ adds multiple items


1 specs
4 examples (4 passed)
112ms

Refactorizar Menu

A la clase Bill, ¿le importa el número del menú? No, en realidad no. En más, ¿podríamos añadir elementos a la cuenta que no fueran menús? Lo lógico es que sí. Entonces, ¿cómo hacemos nuestra clase compatible con cualquier clase que tenga un precio? Pues utilizando interfaces. Vamos a crear una interfaz Priced que obligue a las clases que lo implementen a devolver price(). De esa manera, a Bill solo le interesa que elemento que añadimos a la cuenta tenga un método price.

Añadimos esta especificación a MenuSpec:

<?php
class MenuSpec extends ObjectBehavior
{
    function it_implements_price_interface()
    {
        $this->shouldImplement(\Restaurant\Priced::class);
    }
}

Info

Ponemos el namespace completo de \Restaurant\Price, pero podemos importarla con use Restaurant\Price; en la cabecera de nuestro archivo.

En este caso Priced aún no existe y la prueba fallará, como es normal. Pero en esta ocasión phpspec no creará la clase, simplemente se limitará a fallar. Vamos a crear nuestra interfaz en Restaurant/Priced.php:

<?php

namespace Restaurant;


interface Priced
{
    public function price(): int;
}

Ahora necesitamos que nuestra clase _Menu_ implemente la interfaz _Priced_:

```php hl_lines="5"
<?php

namespace Restaurant;

class Menu implements Priced
{
    private $number;
    private $price;

    public function __construct(int $number, int $price)
    {
        $this->number = $number;
        $this->price = $price;
    }

    public function number(): int
    {
        return $this->number;
    }

    public function price(): int
    {
        return $this->price;
    }
}

Y ya hemos conseguido que la prueba pase:

vendor/bin/phpspec run

      Restaurant\Bill

  11  ✔ is initializable

      Restaurant\Menu

  19  ✔ implements price interface
  24  ✔ is initializable
  29  ✔ has a menu number
  34  ✔ has a price


2 specs
5 examples (5 passed)
82ms

Refactorizamos la especificación de Bill:

<?php

namespace spec\Restaurant;

use Restaurant\Bill;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Restaurant\Priced;

class BillSpec extends ObjectBehavior
{
    function let(Priced $item)
    {
        $item->price()->willReturn(1000);
    }

    function it_is_initializable()
    {
        $this->shouldHaveType(Bill::class);
    }

    function it_has_no_items_by_default()
    {
        $this->getTotal()->shouldBe(0);
    }

    function it_adds_an_item(Priced $item)
    {
        $this->addItem($item);
        $this->getTotal()->shouldBe(1100);
    }

    function it_adds_multiple_items(Priced $item, Priced $anotherItem)
    {
        $anotherItem->price()->willReturn(2000);
        $this->addItem($item);
        $this->addItem($anotherItem);
        $this->getTotal()->shouldBe(3300);
    }
}

Y refactorizamos la clase Bill:

<?php
declare(strict_types=1);

namespace Restaurant;

class Bill
{
    const VAT = '1.10';
    private $items;

    public function __construct()
    {
        $this->items = [];
    }

    public function getTotal(): int
    {
        return (int) (array_reduce($this->items, function ($carry, Priced $menu) {
            return $carry + $menu->price();
        }, 0) * self::VAT);
    }

    public function addItem(Priced $item): void
    {
        $this->items[] = $item;
    }
}

Implementando los primeros steps

Ahora estamos en posición de implementar los primeros steps:

Escenario: Ganar puntos al pagar en efectivo
    Dado que he comprado 5 menús del número 1
    Cuando pido la cuenta recibo una factura de 55 euros
    Y pago en efectivo con 55 euros
    Entonces la factura está pagada
    Y he obtenido 50 puntos

Quedando el código en el archivo FeatureContext como sigue:

<?php

use Behat\Behat\Context\Context;
use Behat\Behat\Tester\Exception\PendingException;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;

/**
* Defines application features from the specific context.
*/
class FeatureContext implements Context
{
    private $menus;
    private $bill;

    /**
    * Initializes context.
    *
    * Every scenario gets its own context instance.
    * You can also pass arbitrary arguments to the
    * context constructor through behat.yml.
    */
    public function __construct()
    {
        $this->menus = [];
        $this->bill = new \Restaurant\Bill();
    }

    /**
    * @Given los siguientes menús:
    */
    public function losSiguientesMenus(TableNode $table)
    {
        foreach ($table->getHash() as $menu) {
            $this->menus[$menu['número']] = new \Restaurant\Menu($menu['número'], $menu['precio'] * 100);
        }
    }

    /**
    * @Given que he comprado :arg1 menús del número :arg2
    */
    public function queHeCompradoMenusDelNumero($count, $menuNumber)
    {
        $menu = $this->menus[$menuNumber];

        for($i = 0; $i < $count; $i++) {
            $this->bill->addItem($menu);
        }
    }

    /**
    * @When pido la cuenta recibo una factura de :arg1 euros
    */
    public function pidoLaCuentaReciboUnaFacturaDeEuros($total)
    {
        \PHPUnit\Framework\Assert::assertEquals($total * 100, $this->bill->getTotal());
    }

    /**
    * @When pago en efectivo con :arg1 euros
    */
    public function pagoEnEfectivoConEuros($arg1)
    {
        throw new PendingException();
    }

    /**
    * @Then la factura está pagada
    */
    public function laFacturaEstaPagada()
    {
        throw new PendingException();
    }

    /**
    * @Then he obtenido :arg1 puntos
    */
    public function heObtenidoPuntos($arg1)
    {
        throw new PendingException();
    }

    /**
    * @When pago con :arg1 puntos y :arg2 euros
    */
    public function pagoConPuntosYEuros($arg1, $arg2)
    {
        throw new PendingException();
    }

    /**
    * @Then quedan :arg1 euros por pagar
    */
    public function quedanEurosPorPagar($arg1)
    {
        throw new PendingException();
    }
}

Y comprobamos que, efectivamente, el código funciona:

bash$ vendor/bin/behat features/menu.feature:16
Característica: Pagar un menú
  Reglas:

  - 1 punto por cada euro.
  - 10 puntos equivalen a un descuento de 1 euros.
  - El IVA es del 10%

  Antecedentes:                 # features/menu.feature:9
    Dados los siguientes menús: # FeatureContext::losSiguientesMenus()
      | número | precio |
      | 1      | 10     |
      | 2      | 12     |
      | 3      | 8      |

  Escenario: Ganar puntos al pagar en efectivo           # features/menu.feature:16
    Dado que he comprado 5 menús del número 1            # FeatureContext::queHeCompradoMenusDelNumero()
    Cuando pido la cuenta recibo una factura de 55 euros # FeatureContext::pidoLaCuentaReciboUnaFacturaDeEuros()
    Y pago en efectivo con 55 euros                      # FeatureContext::pagoEnEfectivoConEuros()
      TODO: write pending definition
    Entonces la factura está pagada                      # FeatureContext::laFacturaEstaPagada()
    Y he obtenido 50 puntos                              # FeatureContext::heObtenidoPuntos()

1 scenario (1 pending)
6 steps (3 passed, 1 pending, 2 skipped)
0m0.02s (10.09Mb)

Implementando el pago

Para implementar el pago debemos ser capaces de indicar una cantidad pagada en metálico, ver cuánto queda por pagar y ver cuántos puntos hemos obtenido.

Esta sería la especificación:

<?php

namespace spec\Restaurant;

use Restaurant\Bill;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Restaurant\Priced;

class BillSpec extends ObjectBehavior
{
    function let(Priced $item)
    {
        $item->price()->willReturn(1000);
    }

    function it_is_initializable()
    {
        $this->shouldHaveType(Bill::class);
    }

    function it_has_no_items_by_default()
    {
        $this->getTotal()->shouldBe(0);
    }

    function it_adds_an_item(Priced $item)
    {
        $this->addItem($item);
        $this->getTotal()->shouldBe(1100);
    }

    function it_adds_multiple_items(Priced $item, Priced $anotherItem)
    {
        $anotherItem->price()->willReturn(2000);
        $this->addItem($item);
        $this->addItem($anotherItem);
        $this->getTotal()->shouldBe(3300);
    }

    function it_can_be_paid_with_money(Priced $item)
    {
        $this->addItem($item);
        $this->payWithMoney(1100);
        $this->restToPay()->shouldBe(0);
    }

    function it_can_give_points_when_is_payed_with_money(Priced $item)
    {
        $this->addItem($item);
        $this->payWithMoney(1100);
        $this->getPoints()->shouldBe(10);
    }

    function it_can_not_give_points_when_total_is_not_enough(Priced $anotherItem)
    {
        $anotherItem->price()->willReturn(99);

        $this->addItem($anotherItem);
        $this->payWithMoney(109);
        $this->getPoints()->shouldBe(0);
    }
}

Estamos describiendo distintos casos:

  • Cuando se paga exacto y no queda nada por pagar
  • Cuando se pagan justo 10 euros
  • Cuando se pagan menos de 1 euro

Podríamos ser más exhaustivos, como determinar que no se den puntos hasta que no se pague, pero lo dejamos para los casos siguientes.

Ejecutamos las pruebas para que phpspec genere los métodos en nuestra clase y completamos el código en la clase Bill:

<?php
declare(strict_types=1);

namespace Restaurant;

class Bill
{
    const VAT = '1.10';
    private $items;
    private $amount;

    public function __construct()
    {
        $this->items = [];
    }

    public function getTotal(): int
    {
        return (int) round($this->totalWithoutVAT() * self::VAT);
    }

    public function addItem(Priced $item): void
    {
        $this->items[] = $item;
    }

    public function payWithMoney(int $amount): void
    {
        $this->amount = $amount;
    }

    public function restToPay(): int
    {
        return $this->getTotal() - $this->amount;
    }

    public function getPoints(): int
    {
        return (int) floor($this->totalWithoutVAT() / 100);
    }

    private function totalWithoutVAT(): int
    {
        return array_reduce($this->items, function ($carry, Priced $priced) {
            return $carry + $priced->price();
        }, 0);
    }
}

Y comprobamos que pasamos las pruebas:

bash$ vendor/bin/phpspec run Restaurant\\Bill

    Restaurant\Bill

17  ✔ is initializable
22  ✔ has no items by default
27  ✔ adds an item
33  ✔ adds multiple items
41  ✔ can be paid with money
48  ✔ can give points when is payed with money
55  ✔ can not give points when total is not enough


1 specs
7 examples (7 passed)
172ms

Ya nos resta terminar de implementar la historia de usuario. Nuestra clase FeatureContext queda así:

<?php

use Behat\Behat\Context\Context;
use Behat\Behat\Tester\Exception\PendingException;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;

/**
* Defines application features from the specific context.
*/
class FeatureContext implements Context
{
    private $menus;
    private $bill;

    /**
    * Initializes context.
    *
    * Every scenario gets its own context instance.
    * You can also pass arbitrary arguments to the
    * context constructor through behat.yml.
    */
    public function __construct()
    {
        $this->menus = [];
        $this->bill = new \Restaurant\Bill();
    }
    /**
    * @Given los siguientes menús:
    */
    public function losSiguientesMenus(TableNode $table)
    {
        foreach ($table->getHash() as $menu) {
            $this->menus[$menu['número']] = new \Restaurant\Menu($menu['número'], $menu['precio'] * 100);
        }
    }

    /**
    * @Given que he comprado :arg1 menús del número :arg2
    */
    public function queHeCompradoMenusDelNumero($count, $menuNumber)
    {
        $menu = $this->menus[$menuNumber];

        for($i = 0; $i < $count; $i++) {
            $this->bill->addItem($menu);
        }
    }

    /**
    * @When pido la cuenta recibo una factura de :arg1 euros
    */
    public function pidoLaCuentaReciboUnaFacturaDeEuros($total)
    {
        \PHPUnit\Framework\Assert::assertEquals($total * 100, $this->bill->getTotal());
    }

    /**
    * @When pago en efectivo con :arg1 euros
    */
    public function pagoEnEfectivoConEuros($amount)
    {
        $this->bill->payWithMoney($amount * 100);
    }

    /**
    * @Then la factura está pagada
    */
    public function laFacturaEstaPagada()
    {
        \PHPUnit\Framework\Assert::assertEquals(0, $this->bill->restToPay());
    }

    /**
    * @Then he obtenido :arg1 puntos
    */
    public function heObtenidoPuntos($points)
    {
        \PHPUnit\Framework\Assert::assertEquals($points, $this->bill->getPoints());
    }

    /**
    * @When pago con :arg1 puntos y :arg2 euros
    */
    public function pagoConPuntosYEuros($arg1, $arg2)
    {
        throw new PendingException();
    }

    /**
    * @Then quedan :arg1 euros por pagar
    */
    public function quedanEurosPorPagar($arg1)
    {
        throw new PendingException();
    }
}

Si ejecutamos este primer escenario debemos comprobar que se ha completado con éxito:

bash$ vendor/bin/behat features/menu.feature:16
Característica: Pagar un menú
Reglas:

- 1 punto por cada euro.
- 10 puntos equivalen a un descuento de 1 euros.
- El IVA es del 10%

Antecedentes:                 # features/menu.feature:9
    Dados los siguientes menús: # FeatureContext::losSiguientesMenus()
    | número | precio |
    | 1      | 10     |
    | 2      | 12     |
    | 3      | 8      |

Escenario: Ganar puntos al pagar en efectivo           # features/menu.feature:16
    Dado que he comprado 5 menús del número 1            # FeatureContext::queHeCompradoMenusDelNumero()
    Cuando pido la cuenta recibo una factura de 55 euros # FeatureContext::pidoLaCuentaReciboUnaFacturaDeEuros()
    Y pago en efectivo con 55 euros                      # FeatureContext::pagoEnEfectivoConEuros()
    Entonces la factura está pagada                      # FeatureContext::laFacturaEstaPagada()
    Y he obtenido 50 puntos                              # FeatureContext::heObtenidoPuntos()

1 scenario (1 passed)
6 steps (6 passed)
0m0.02s (10.08Mb)