Crear el proyecto¶
Vamos a implementar el proyecto que implemente las características descritas en el apartado anterior. Vamos a hacer uso de un esqueleto que ya tiene configurado:
composer create aulasoftwarelibre/bdd-by-example
Se nos creará un directorio con todo lo que necesitamos para empezar a trabajar. Si analizamos el fichero composer.json
veremos las dependencias de nuestro proyecto:
{ "require-dev": { "behat/behat": "^3.4", "phpspec/phpspec": "^5.0", "phpunit/phpunit": "^7.0" } }
Creación de características¶
Las características (ficheros .feature
) deben ir dentro del directorio features/
de nuestro proyecto.
Tip
Las cajas de ejemplo tienen un icono que, si lo pulsas, permiten copiar el contenido al portapapeles. Úsalo para ir más rápido al copiar el código.
Crearemos dentro de dicho directorio un fichero llamado menu.feature
con el contenido que describimos en el capítulo anterior.
#language: es 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: Dados los siguientes menús: | número | precio | | 1 | 10 | | 2 | 12 | | 3 | 8 | 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 Escenario: Pagar con dinero y puntos Dado que he comprado 5 menús del número 1 Cuando pido la cuenta recibo una factura de 55 euros Y pago con 10 puntos y 54 euros Entonces la factura está pagada Y he obtenido 0 puntos Escenario: Pagar con puntos Dado que he comprado 5 menús del número 1 Cuando pido la cuenta recibo una factura de 55 euros Y pago con 500 puntos y 5 euros Entonces la factura está pagada Y he obtenido 0 puntos Escenario: Intentar pagar el IVA con puntos Dado que he comprado 5 menús del número 1 Cuando pido la cuenta recibo una factura de 55 euros Y pago con 550 puntos y 0 euros Entonces quedan 5 euros por pagar Escenario: Comprar menús de varios tipos Dado que he comprado 1 menú del número 1 Y que he comprado 2 menús del número 2 Y que he comprado 2 menús del número 3 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
Ejecución de behat¶
Ahora que tenemos las pruebas definidas vamos a ejecutar behat:
vendor/bin/behat --snippets-for FeatureContext
Obtendremos el resumen de la ejecución de pruebas que contiene la siguiente información:
5 scenarios (5 undefined) 32 steps (32 undefined) 0m0.02s (9.49Mb)
Lo que significa es que behat no reconoce ninguno de los step o pasos de los que se compone cada escenario. Esa parte debemos programarla nosotros. Para ello behat nos facilita el trabajo con una serie de snippets. Veamos uno:
<?php /** * @Given que he comprado :arg1 menús del número :arg2 */ public function queHeCompradoMenusDelNumero($arg1, $arg2) { throw new PendingException(); }
El step se compone de una cabecera con las palabras @Given
, @When
o @Then
y una frase que coincide con la que hayamos determinado en el paso. Los números y las cadenas que se pongan entre comillas se convierten en parámetros del paso. También es posible usar expresiones regulares, pero en esos casos debemos hacerlo a mano. El objetivo es meter todos estos snippets en el archivo de contexto que usa Behat.
Si editamos el archivo features/bootstrap/FeatureContext.php, veremos el archivo de contexto por defecto que usar Behat. Podemos tener los que necesitemos, para separar los steps de forma conveniente, pero eso es configuración avanzada del entorno y no nos vamos a meter en eso. Editamos el archivo y copiamos el siguiente contenido en él:
<?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 { /** * 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() { } /** * @Given los siguientes menús: */ public function losSiguientesMenus(TableNode $table) { throw new PendingException(); } /** * @Given que he comprado :arg1 menús del número :arg2 */ public function queHeCompradoMenusDelNumero($arg1, $arg2) { throw new PendingException(); } /** * @When pido la cuenta recibo una factura de :arg1 euros */ public function pidoLaCuentaReciboUnaFacturaDeEuros($arg1) { throw new PendingException(); } /** * @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(); } /** * @Given que he comprado :arg1 menú del número :arg2 */ public function queHeCompradoMenuDelNumero($arg1, $arg2) { throw new PendingException(); } }
Si volvemos a ejecutar behat:
vendor/bin/behat
Obtenemos algo distinto:
5 scenarios (5 pending) 32 steps (5 pending, 27 skipped) 0m0.03s (9.54Mb)
Ya los escenarios no están como undefined sino como pending.
Implementando el primer step¶
El primer step es el que corresponde con la parte de antecedentes:
#language: es 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: Dados los siguientes menús: | número | precio | | 1 | 10 | | 2 | 12 | | 3 | 8 |
Que corresponde al siguiente snippet
<?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 { /** * @Given los siguientes menús: */ public function losSiguientesMenus(TableNode $table) { throw new PendingException(); } }
Y aquí nos surge la primera necesidad, necesitamos una clase para almacenar menús.
PhpSpec¶
PhpSpec es una herramienta para el diseño de clases. Se usa, especialmente, para el diseño de un modelo de dominio limpio, desacoplado y aislado sin involucrarse demasiado en la infraestructura. Principalmente lo que vamos a indicar con PhpSpec es la API de nuestra clase con el resto del dominio.
Por el momento, para poder pasar la prueba que falla "Dados los siguientes menús", necesitamos una clase que nos ofrezca información del número de menú y del precio. Así que vamos a empezar a describir nuestra clase con la ayuda de _PhpSpec:
vendor/bin/phpspec desc Restaurant/Menu
Warning
Debido a que la barra invertida '\' sirve como secuencia de escape en la consola, usamos la barra normal '/' para separar el espacio de nombres de la clase. Otra opción es usar doble barra invertida:
vendor/bin/phpspec desc Restaurant\\Menu
Obtendremos un archivo en spec/Restaurant/MenuSpec.php
:
<?php namespace spec\Restaurant; use Restaurant\Menu; use PhpSpec\ObjectBehavior; use Prophecy\Argument; class MenuSpec extends ObjectBehavior { function it_is_initializable() { $this->shouldHaveType(Menu::class); } }
La especificación indica que existe una clase que debe ser iniciable y de tipo Restaurant\Menu
. Si ejecutamos las pruebas de PhpSpec:
vendor/bin/phpspec run
Obtendremos la siguiente salida:
Restaurant\Menu 11 ! is initializable class Restaurant\Menu does not exist. ---- broken examples Restaurant/Menu 11 ! is initializable class Restaurant\Menu does not exist. 1 specs 1 examples (1 broken) 32ms Do you want me to create `Restaurant\Menu` for you? [Y/n]
Nos avisa que la clase que se quiere probar no existe y si quiere probarla por nosotros. Para sucesivas veces, en este tutorial responderemos siempre sí a esta circunstancia aunque no se indique.
Class Restaurant\Menu created in .../bdd-by-example/src/Restaurant/Menu.php. Restaurant\Menu 11 ✔ is initializable 1 specs 1 examples (1 passed) 34ms
Obteniendo una clase Menu en nuestro proyecto en src/Restaurant/Menu.php
:
<?php namespace Restaurant; class Menu { }
Vamos a seguir especificando los requisitos de nuestra clase para pasar la prueba. Concretamente necesitamos que nuestra clase sea capaz de indicar el número de menú y el precio. Vamos a escribir la especificación y la comentamos a continuación. Modificamos nuestro MenuSpec.php
así:
<?php namespace spec\Restaurant; use Restaurant\Menu; use PhpSpec\ObjectBehavior; use Prophecy\Argument; class MenuSpec extends ObjectBehavior { const NUMBER = 10; const PRICE = 2500; function let() { $this->beConstructedWith(self::NUMBER, self::PRICE); } function it_is_initializable() { $this->shouldHaveType(Menu::class); } function it_has_a_menu_number() { $this->number()->shouldBe(self::NUMBER); } function it_has_a_price() { $this->price()->shouldBe(self::PRICE); } }
Las líneas 24 y 29 especifican los dos comportamientos que esperamos de nuestra clase, devolver el número y devolver el precio. Pero antes de devolver nada esa información debe incorporarse a través del constructor. Para ello usamos la función _ let_ (línea 14), que sirve para configurar la prueba en su comienzo. En este caso, la línea 16 construye la clase con el número y el precio del menú. El uso de constantes es para ser más descriptivo a la hora de leer la prueba. Ya que hemos especificado como se construye la clase, especificamos los otros dos comportamientos.
Info
Estamos usando euros para los ejemplos. En realidad, y dado que PHP no tiene un tipo de datos para datos financieros, deberíamos usar alguna clase Moneda o guardar los datos en céntimos para evitar el uso de decimales. Para simplificar el tutorial vamos a usar céntimos. Así que aunque en los test usemos euros en la clase Menu vamos a almacenar el valor en céntimos.
Para indicar el número de menú, indicamos que llamamos a un método number()
(línea 26) que debe devolver el mismo valor que se pasó al constructor. Para indicar el precio, lo mismo pero llamando a un método price()
(línea 31).
Ejecutamos otra vez PhpSpec, respondiendo afirmativamente a todas las preguntas:
vendor/bin/phpspec run
Obteniendo la siguiente salida:
Restaurant\Menu 19 ! is initializable method Restaurant\Menu::__construct not found. 24 ! has a menu number method Restaurant\Menu::__construct not found. 29 ! has a price method Restaurant\Menu::__construct not found. ---- broken examples Restaurant/Menu 19 ! is initializable method Restaurant\Menu::__construct not found. Restaurant/Menu 24 ! has a menu number method Restaurant\Menu::__construct not found. Restaurant/Menu 29 ! has a price method Restaurant\Menu::__construct not found. 1 specs 3 examples (3 broken) 59ms Do you want me to create `Restaurant\Menu::__construct()` for you? [Y/n] Do you want me to create `Restaurant\Menu::__construct()` for you? [Y/n] Do you want me to create `Restaurant\Menu::__construct()` for you? [Y/n] Method Restaurant\Menu::__construct() has been created. Restaurant\Menu 19 ✔ is initializable 24 ! has a menu number method Restaurant\Menu::number not found. 29 ! has a price method Restaurant\Menu::price not found. ---- broken examples Restaurant/Menu 24 ! has a menu number method Restaurant\Menu::number not found. Restaurant/Menu 29 ! has a price method Restaurant\Menu::price not found. 1 specs 3 examples (1 passed, 2 broken) 62ms Do you want me to create `Restaurant\Menu::number()` for you? [Y/n] Do you want me to create `Restaurant\Menu::number()` for you? [Y/n] Do you want me to create `Restaurant\Menu::number()` for you? [Y/n] Method Restaurant\Menu::number() has been created. Do you want me to create `Restaurant\Menu::price()` for you? [Y/n] Do you want me to create `Restaurant\Menu::price()` for you? [Y/n] Do you want me to create `Restaurant\Menu::price()` for you? [Y/n] Method Restaurant\Menu::price() has been created. Restaurant\Menu 19 ✔ is initializable 24 ✘ has a menu number expected [integer:10], but got null. 29 ✘ has a price expected [integer:2500], but got null. ---- failed examples Restaurant/Menu 24 ✘ has a menu number expected [integer:10], but got null. Restaurant/Menu 29 ✘ has a price expected [integer:2500], but got null. 1 specs 3 examples (1 passed, 2 failed) 79ms
Evidentemente las pruebas fallarán al final, pero es el proceso normal en desarrollo orientado a pruebas/comportamiento. Analicemos que ha ocurrido en nuestra clase Menu
:
<?php namespace Restaurant; class Menu { public function __construct($argument1, $argument2) { // TODO: write logic here } public function number() { // TODO: write logic here } public function price() { // TODO: write logic here } }
La especificación de la prueba ha creado el esqueleto de nuestra clase, ahora solo queda implementar la funcionalidad para pasar la especificación:
<?php declare(strict_types=1); namespace Restaurant; class Menu { 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; } }
Ejecutamos las pruebas y comprobamos que pasan:
bash$ vendor/bin/phpspec run Restaurant\Menu 19 ✔ is initializable 24 ✔ has a menu number 29 ✔ has a price 1 specs 3 examples (3 passed) 60ms
Ya tenemos nuestra primera clase completada que pasa la especificación.
Terminar de implementar los antecedentes¶
Ahora podemos programar el paso para ir progresando en nuestros casos de uso. Editamos el archivo FeatureContext:
<?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; /** * 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 = []; } /** * @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($arg1, $arg2) { throw new PendingException(); } /** * @When pido la cuenta recibo una factura de :arg1 euros */ public function pidoLaCuentaReciboUnaFacturaDeEuros($arg1) { throw new PendingException(); } /** * @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(); } /** * @Given que he comprado :arg1 menú del número :arg2 */ public function queHeCompradoMenuDelNumero($arg1, $arg2) { throw new PendingException(); } }
Ejecutamos behat y vemos que ya hay pruebas que pasan:
5 scenarios (5 pending) 32 steps (5 passed, 5 pending, 22 skipped) 0m0.03s (9.54Mb)