Seguro que hemos escuchado hablar de técnicas de desarrollo como BDD (Behavior Driven Development), DDD (Domain Driven Development) o TDD (Test Driven Development). En este post haremos una pequeña introducción a la más genérica de las 3: TDD o Desarrollo Dirigido mediante Tests.
¿Por qué realizar tests?
La principal razón como desarrolladores es que los tests nos ayudan a crear código de calidad y sencillo de mantener. Aunque es cierto que puede ralentizar en cierto modo el desarrollo, nos aseguramos un código de producción donde los posibles bugs se habrán reducido a prácticamente 0.
Estos tests también nos ayudan cuando trabajamos en equipo, ya que si disponemos de una buena batería de test unitarios y funcionales, será infinitamente más fácil explicar el proyecto a un compañero: solo tendrá que leer los test para entender realmente lo que está haciendo la aplicación.
Otra característica importante es que nos permiten evolucionar nuestras aplicaciones de forma segura. Al modificar o añadir algo nuevo, nos ofrecen la certeza de no haber roto ninguna funcionalidad o, en caso contrario, nos avisan de que nuestros cambios han alterado el funcionamiento de la aplicación de manera inesperada.
No todo son ventajas, sobre todo si estamos empezando. Los tests pueden:
- Hacer que se pierda tiempo escribiendo tests
- Hacer que se pierda tiempo manteniendo tests
- Pueden crear una falsa sensación de seguridad (ya que los test se pueden forzar a que pasen y no por ello significa que nuestro desarrollo cumpla con las expectativas)
Con todo esto debemos valorar si el tiempo que debemos dedicar a hacer los tests merece la pena. Por ejemplo, en aplicaciones muy pequeñas que no van a escalar, o que su vida será muy corta, es posible que no merezca la pena el tiempo de dedicación que requiere generar una buena colección de tests.
En cambio, en aplicaciones vivas, no hay duda que nos ahorrarán cantidad de tiempo, porque la mayor parte de los posibles bugs serán interceptados antes de las puestas en producción.
Tipos de tests
Test unitarios
Se encargan de probar pequeñas piezas de software de manera aislada, por ejemplo un método, o una clase en concreto. Para que podamos considerar un test como unitario debe cumplir los siguientes requisitos:*
- No debe hablar con ninguna base de datos
- No debe utilizar la red
- No debe utilizar el sistema de ficheros
- Se debe poder ejecutar en paralelo con otros tests
- No debe requerir ninguna modificación del entorno para ser ejecutado
Si tus tests no cumplen con alguno de estos casos, simplemente significa que no es un test unitario, podría ser un test funcional o de integración.
Mocks, Stubs, Doubles…
Seguro que hemos escuchado estas palabras y es posible que no sepamos muy bien lo que significan. Cuando realizamos tests unitarios, el objeto que queremos testear (SUT o Subject Under Test), puede tener objetos colaboradores necesarios para su funcionalidad. En estos casos, utilizaremos clases de mentira que actuarán como dobles de las clases originales: a estas clases les llamaremos «Mocks».
Tests de integración
Prueban la integración entre dos o más componentes. Por ejemplo, podrían probar el uso de una librería externa dentro de uno de nuestros métodos.
Test funcionales
Sirven para probar la aplicación desde el punto de vista del usuario final. Se pueden diferenciar de los unitarios por las siguientes razones:
- Contiene peticiones de las que comprobamos las respuestas (Request y Responses)
- Puede probar aspectos de navegación, como hacer click sobre un botón y comprobar el resultado
TDD
El desarrollo dirigido por tests es una técnica basada en realizar pequeños tests que describen la funcionalidad antes de desarrollarla. De esta manera, el código final debe ir consiguiendo pasar los test y avanzar mediante refactorización.
En la práctica, el TDD no se basa en realizar una enorme batería de tests y después escribir el código, sino que es preferible ir realizando pequeños ciclos de testing e ir escribiendo a su vez el código necesario para superarlos.
Cuando hacemos TDD debemos respetar las siguientes normas:
- No debemos escribir código de producción, a menos que tengamos ya implementado su correspondiente test unitario
- No se deben escribir más tests de los necesarios para que falle (esto significa ir uno por uno)
- No se debe escribir más código de producción que el necesario para superar el último test fallido
Sobre estas premisas las aplicaciones que construyamos serán sólidas y escalables por su propia naturaleza. Además, nunca tendrán más código que el estrictamente necesario para cumplir con las expectativas acordadas.
Para llevarlo a cabo seguiremos la teoría descrita por James Shore en Noviembre de 2005.
Red, Green , Refactor.
Esta técnica se basa en 5 pasos:
- Pensar: Tomarnos tiempo para pensar qué test es más adecuado para dirigirnos a la finalidad del código (este paso puede ser el más complicado mientras aprendemos).
- Red: Escribe unas pocas líneas de código de prueba y ejecuta el test. Veremos que el test falla. Prestamos atención a los mensajes de error.
- Green: Escribe unas pocas líneas de código de producción. En este punto no debemos preocuparnos por la pureza del diseño o la elegancia. En ocasiones, es posible «Hardcodear», esto es correcto ya que refactorizaremos en un momento. Ejecutamos los test y veremos que son correctos.
- Refactor: Una vez nuestros tests pasan, podemos hacer cambios sin preocuparnos de romper nada. Debemos parar un momento, mirar el código que hemos escrito y preguntarnos a nosotros mismos si podemos optimizarlo. Revisamos el código duplicado y demás «smells». Si vemos algo que no es correcto, pero no estamos seguros de cómo corregirlo, no debemos preocuparnos: podemos revisarlo de nuevo tras finalizar el ciclo varias veces más. En esta etapa debemos tomarnos nuestro tiempo para tomar las mejores decisiones posibles. Pasamos los test y nos aseguramos de que todos sigan siendo correctos.
- Repeat: Hagámoslo otra vez, deberíamos repetir este ciclo tantas veces sea necesario. Normalmente lo repetiremos de tres a cinco veces tan rápido como sea posible, entonces veremos que necesitamos dedicar más tiempo a refactorizar. Es importante lograr agilidad repitiendo este ciclo: con práctica podemos llegar a repetirlo en 20 ocasiones por hora aproximadamente.
En palabras de James Shore, este proceso funciona bien por dos razones. La primera es que vamos trabajando sobre pequeños pasos, formulando y comprobando hipótesis constantemente (los tests deberían estar en rojo…, ahora deberían cambiar a verde…, rojo…, verde…). En el momento que cometamos un error será muy sencillo identificarlo, ya que tan solo hemos escrito unas pocas líneas desde el estado anterior. Todos sabemos que localizar y arreglar fallos es una de las partes de nuestro trabajo que más tiempo requiere.
La otra razón que hace que este proceso funcione, es porque nos obliga a estar siempre pendientes del diseño, ya sea decidiendo el siguiente test, la estructura a seguir o cómo refactorizar. De modo que al probarlo inmediatamente, veremos rápidamente si nuestro diseño es bueno o no.
Manos a la obra
Después de un poquito de teoría, pasemos a la práctica. Crearemos un entorno de desarrollo mediante testing en PHP, configuraremos nuestro proyecto mediante composer y le añadiremos PHPUnit*. Después decidiremos qué framework utilizar para desarrollar una mini app de presentación de libros.
Configurando el entorno
Lo primero que necesitamos para nuestro entorno es Composer. Como ya explicó nuestro compañero Dani en «Primeros pasos con Composer», es el gestor de paquetes que utilizamos en php y daremos por hecho que ya lo tenemos instalado como binario en nuestra máquina.
Composer
Para ello abrimos una consola y nos situamos en nuestro workspace donde crearemos el directorio donde se alojará nuestra aplicación.
mkdir tdd-intro && cd tdd-intro
Lo primero que necesitamos para nuestro proyecto será el archivo composer.json
vim composer.json
Con el siguiente contenido como mínimo
{
"autoload": {
"psr-4": {
"TDDIntro\\": ["core/"]
}
}
}
Simplemente estamos dando de alta el namespace dentro del directorio core/
. Para generar el autoloader ejecutaremos el siguiente comando:
composer dump-autoload
Este genera la carpeta vendor dentro de nuestra raíz donde se alojarán todas la librerías que requiramos mediante composer. Es recomendable excluir de git este directorio.
git init
vim .gitignore
Añadimos la siguiente línea:
/vendor
y realizamos nuestro primer commit
git add .gitignore composer.json
git commit -m 'Step 1 - Setting up composer'
Podemos seguir el tutorial desde el propio repositorio de Github paso a paso , siguiendo los tags.
git clone [email protected]:irontec/TDDIntro.git
git checkout step-1
PHPUnit
El siguiente paso será configurar phpunit para utilizarlo en el desarrollo de nuestra aplicación, primero lo requerimos con composer
composer require phpunit/phpunit --dev
Una vez instalado, necesitamos crear el archivo de configuración phpunit.xml.dist
vim phpunit.xml.dist
con el siguiente contenido como mínimo
<?xml version="1.0" encoding="UTF-8"?> <!-- http://phpunit.de/manual/4.1/en/appendixes.configuration.html --> <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.1/phpunit.xsd" backupGlobals="false" colors="true" bootstrap="./vendor/autoload.php" > <testsuites> <testsuite name="Project Test Suite"> <directory>./tests</directory> </testsuite> </testsuites> <filter> <whitelist> <directory>../src</directory> <exclude> </exclude> </whitelist> </filter> </phpunit>
Comprobamos:
phpunit
PHPUnit 5.4.6 by Sebastian Bergmann and contributors.
Time: 115 ms, Memory: 11.50MB
No tests executed!
Commiteamos de nuevo nuestra tarea.
git add composer.json phpunit.xml.dist tests/
Test mínimo viable
Como podemos ver en la primera ejecución de phpunit recibimos un mensaje de respuesta, que nos informa que no hay ningún test listo para ejecutar, lo solucionamos rápidamemte creando el directorio tests
en la raíz del proyecto y una nueva Clase con el sufijo «Test», por ejemplo, InitialPageTest
.
mkdir tests
vim tests/InitialPageTest.php
Con el siguiente contenido
<?php // tests/InitialPageTest.php namespace Tests\TDDIntro; class InitialPageTest extends \PHPUnit_Framework_TestCase { public function testInitialList() { $this->assertTrue(true); } }
Si ejecutamos de nuevo nuestros tests veremos que ahora si disponemos de un test, y que además es válido.
phpunit
PHPUnit 5.4.6 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 114 ms, Memory: 12.00MB
OK (1 test, 1 assertion)
La aplicación
Crearemos una simple aplicación en la que mostraremos un listado de libros acorde a ciertas categorías, los user storys serían los siguientes:
- Al acceder veremos un listado de libros de todas las categorías
- Los libros se pueden filtrar por su categoría, el listado solo debe mostrar los libros que dispongan de dicha categoría.
Modelo
Para empezar necesitaremos crear nuestras entidades, esta parte podríamos no testearla, ya que tan solo dispondrá de Getters y Setters de sus propiedades, a los que daremos cobertura mediante el testing de los demás componentes. Aun así crearemos un simple test para cada entidad.
<?php // tests/Entity/BookEntityTest.php namespace Tests\TDDIntro\Entity; use TDDIntro\Domain\Domain\Entity\Book; class BookEntityTest extends \PHPUnit_Framework_TestCase { public function testBookEntity() { $book = new Book('Clean code', 'Martin Fowler', new \DateTime()); $this->assertEquals('Clean code', $book->getTitle()); $this->assertEquals('Martin Fowler', $book->getAuthor()); $this->asserttrue($book->getDatePublished() instanceof \DateTime); } }
Si ejecutamos de nuevo los tests, obtendremos un mensaje muy explícito de respuesta.
PHPUnit 5.4.6 by Sebastian Bergmann and contributors. PHP Fatal error: Class 'TDDIntro\Domain\Entity\Book' not found in /.../tdd-intro/tests/Entity/BookEntityTest.php on line 11 PHP Stack trace: PHP 1. {main}() /usr/local/bin/phpunit:0 PHP 2. PHPUnit_TextUI_Command::main() /usr/local/bin/phpunit:569 PHP 3. PHPUnit_TextUI_Command->run() phar:///usr/local/bin/phpunit/phpunit/TextUI/Command.php:113 PHP 4. PHPUnit_TextUI_TestRunner->doRun() phar:///usr/local/bin/phpunit/phpunit/TextUI/Command.php:162 PHP 5. PHPUnit_Framework_TestSuite->run() phar:///usr/local/bin/phpunit/phpunit/TextUI/TestRunner.php:465 PHP 6. PHPUnit_Framework_TestSuite->run() phar:///usr/local/bin/phpunit/phpunit/Framework/TestSuite.php:753 PHP 7. PHPUnit_Framework_TestCase->run() phar:///usr/local/bin/phpunit/phpunit/Framework/TestSuite.php:753 PHP 8. PHPUnit_Framework_TestResult->run() phar:///usr/local/bin/phpunit/phpunit/Framework/TestCase.php:888 PHP 9. PHPUnit_Framework_TestCase->runBare() phar:///usr/local/bin/phpunit/phpunit/Framework/TestResult.php:701 PHP 10. PHPUnit_Framework_TestCase->runTest() phar:///usr/local/bin/phpunit/phpunit/Framework/TestCase.php:932 PHP 11. ReflectionMethod->invokeArgs() phar:///usr/local/bin/phpunit/phpunit/Framework/TestCase.php:1081 PHP 12. Tests\TDDIntro\Domain\Entity\BookEntityTest->testBookEntity() phar:///usr/local/bin/phpunit/phpunit/Framework/TestCase.php:1081
Esta respuesta no da la información necesaria para continuar con el desarrollo; La clase TDDIntro\Domain\Entity\Book
no existe, por lo tanto vamos a crearla.
<?php // core/Domain/Entity/Book.php namespace TDDIntro\Domain\Entity; class Book { protected $title; protected $author; protected $datePublished; public function __construct($title, $author, $datePublished) { $this->title = $title; $this->author = $author; $this->datePublished = $datePublished; } /** * @return mixed */ public function getTitle() { return $this->title; } /** * @return mixed */ public function getAuthor() { return $this->author; } /** * @return mixed */ public function getDatePublished() { return $this->datePublished; } }
si volvemos a ejecutar los tests veremos todo en verde, continuamos.
PHPUnit 5.4.6 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 260 ms, Memory: 19.50MB
OK (2 tests, 4 assertions)
Ahora pasaremos a probar las categorías
<?php // tests/Entity/CategoryEntityTest.php namespace Tests\TDDIntro\Entity; use TDDIntro\Domain\Entity\Category; class CategoryEntityTest extends \PHPUnit_Framework_TestCase { public function testCategoryEntity() { $book = new Category('programming'); $this->assertEquals('programming', $book->getName()); } }
Es obvio que en la siguiente ejecución, los tests no pasarán, por lo que vamos directamente a crear la entidad Category.
<?php // core/Entity/Category.php namespace TDDIntro\Entity; class Category { protected $name; public function __construct($name) { $this->name = $name; } /** * @return mixed */ public function getName() { return $this->name; } }
Con la siguiente clase creada, volvemos al verde. Ahora pasaremos a crear la relación entre Libros y categorías. para ello modificaremos el archivo BookEntityTest.php
, nuestro método testBookEntity
, debe quedar de la siguiente manera.
//tests/Entity/BookEntityTest.php public function testBookEntity() { $mockCategory = $this->createMock('TDDIntro\Domain\Entity\Category'); $mockCategory ->expects($this->atLeastOnce()) ->method('getName') ->willReturn('programming'); $book = new Book('Clean code', 'Martin Fowler', new \DateTime(), $mockCategory); $this->assertEquals('Clean code', $book->getTitle()); $this->assertEquals('Robert C. Martin', $book->getAuthor()); $this->asserttrue($book->getDatePublished() instanceof \DateTime); $this->assertEquals('programming', $book->getCategory()->getName()); }
Actualizamos la entidad:
// core/Domain/Entity/Book.php protected $categories; public function __construct($title, $author, $datePublished, $categories) { $this->title = $title; $this->author = $author; $this->datePublished = $datePublished; $this->category = $category; } ... /** * @return mixed */ public function getCategory() { return $this->category; }
y pasamos de nuevo los tests y si todo ha ido bien volvemos a nuestro deseado verde. Este podría ser un buen momento para commitear nuestra tarea.
Lógica de negocio
Para evitar el acoplamiento de nuestra aplicación con la capa de persistencia, implementaremos el patrón repositorio.
Este repositorio debe tener como mínimo dos métodos findAll
y findBy
, y debemos decidir el motor de persistencia a utilizar, en este caso utilizaremos Doctrine ORM, que nos facilita la utilización de motores de BBDD tipo SQL, como mysql o sqlite.
Antes de nada definiremos los contratos «Interfaces» que nos ayudarán con nuestra tarea y nos permitirán implementar diferentes tipos de persistencia en caso de ser necesario.
Primero creamos una interfaz genérica de la que extenderán las demás.
<?php // core/Repository/RepositoryInterface.php namespace TDDIntro\Repository; interface RepositoryInterface { public function findAll(); public function findBy(array $criteria = array(), array $orderBy = null, $limit = null, $offset = null); }
Ahora creamos el BookRepository y el categoryRepository.
<?php // core/Repository/BookRepositoryInterface.php namespace TDDIntro\Repository; interface BookRepositoryInterface extends RepositoryInterface { }
y
<?php // core/Repository/CategoryRepositoryInterface.php namespace TDDIntro\Repository; interface CategoryRepositoryInterface extends RepositoryInterface { }
Este es un buen momento para commitear.
Creando la aplicación
En este punto, disponemos de un sólido modelo de datos (con 100% de cobertura), por lo que es el momento para tomar varias decisiones:
- Qué framework utilizaremos
- Qué patrones de diseño utilizaremos
- Qué componentes utilizaremos
Uno de nuestros objetivos como programadores de código de calidad es lograr la abstracción posible de del framework que decidamos utilizar, es decir, que nuestro framework no sea quien condicione nuestro desarrollo, sino que lo utilicemos como una herramienta de ayuda, que en un momento dado en el tiempo pueda ser intercambiable sin necesidad de modificar la lógica negocio.
A la hora de seleccionar un framework, nos centraremos en las funcionalidades que sabemos que necesitamos, por ejemplo, el componente principal que utilizaremos(independientemente del framework) será la inyección de dependencias, Pimple puede ser una buena opción, Doctrine para la capa de persistencia, twig en las plantillas y los componentes «HttpFoundation», «Router» y «Console» de Symfony.
El micro framework Silex cumple con todos los requisitos, por lo que es perfecto para nuestra aplicación.
Pongámoslo a punto:
composer require silex/silex
Creamos el archivo index.php
que actuará como controlador frontal de nuestra aplicación:
<?php // fw/silex/web/index.php $app = require_once __DIR__ . '/../app/app.php'; $app->run();
El archivo index.php
requiere del archivo app.php,
donde cargaremos la configuración rutas, etc.
<?php // fw/silex/app/app.php require_once __DIR__.'/../../../vendor/autoload.php'; $app = new Silex\Application(); require_once __DIR__ . '/config/parameters.php'; require_once __DIR__ . '/config/providers.php'; require_once __DIR__ . '/config/services.php'; require_once __DIR__ . '/config/routes.php'; $app['debug'] = true; return $app;
Creamos los archivos requeridos:
mkdir fw/silex/app/config && cd fw/silex/app/config
touch {parameters.php,providers.php,services.php,routes.php}
Más adelante entraremos en detalle.
Actualizamos el composer.json
{
"autoload": {
"psr-4": {
"TDDIntro\\": ["core/"],
"TDDIntro\\App\\": ["fw/silex/src/"]
}
},
"autoload-dev": {
"psr-4": {
"Tests\\TDDIntro\\": ["tests/"]
}
},
"require": {
"silex/silex": "^2.0"
},
"require-dev": {
"phpunit/phpunit": "^5.4"
}
Para seguir con TDD crearemos un entorno de testing.
<?php // fw/silex/app/app_test.php require_once __DIR__.'/../../../vendor/autoload.php'; $app = new Silex\Application(); require_once __DIR__ . '/config/parameters_test.php'; require_once __DIR__ . '/config/providers.php'; require_once __DIR__ . '/config/services.php'; require_once __DIR__ . '/config/routes.php'; $app['debug'] = true; return $app;
Como podemos ver es prácticamente idéntico al entorno de producción, a diferencia del archivo parameters.php, que lo hemos cambiado por parameters_test.php.
<?php // fw/silex/app/config/parameters_test.php require_once __DIR__ . '/parameters.php';
Ahora creamos un test base para nuestros controladores:
<?php // tests/Controller/WebTestCase.php namespace Tests\TDDIntro\Controller; use Silex\WebTestCase as BaseTestCase; use Symfony\Component\HttpKernel\HttpKernelInterface; class WebTestCase extends BaseTestCase { public function setUp() { parent::setUp(); } /** * Creates the application. * * @return HttpKernelInterface */ public function createApplication() { $app = require __DIR__ . '/../../fw/silex/app/app_test.php'; unset($app['exception_handler']); return $app; } public function getApplicationDir() { return $_SERVER['APP_DIR']; } }
Actualizamos el archivo phpunit.xml.dist
para ejecutar nuestro entorno de test:
<?xml version="1.0" encoding="UTF-8"?> <!-- https://phpunit.de/manual/current/en/appendixes.configuration.html --> <phpunit backupGlobals="false" backupStaticAttributes="false" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="true" stopOnFailure="false" syntaxCheck="false" bootstrap="./vendor/autoload.php" > <testsuites> <testsuite name="YourApp Test Suite"> <directory>./tests/</directory> </testsuite> </testsuites> <filter> <whitelist> <directory>core</directory> <directory>fw/silex/src</directory> <exclude> </exclude> </whitelist> </filter> <php> <server name="APP_DIR" value="./fw/silex/app"/> <env name="env" value="test"/> </php> </phpunit>
Por último, para habilitar los tests funcionales en Silex añadimos el componente «Browser-Kit» de Symfony.
composer require symfony/browser-kit symfony/css-selector ''--dev
Regeneramos el autoloader:
composer dump-autoload
y comprobamos que todos los test siguen funcionando correctamente.
phpunit
Si es así, commiteamos y continuamos.
Primer user story
1. Al acceder veremos un listado de libros de todas las categorías
Creamos un nuevo archivo con un único test funcional (de momento), este describirá el comportamiento que queremos para el controlador de nuestra página principal.
<?php // tests/Controller/BookControllerTest.php namespace Tests\TDDIntro\Controller; class BookControllerTest extends WebTestCase { protected $client; public function setUp() { parent::setUp(); $this->client = $this->createClient(); } public function testIndexAction() { $crawler = $this->client->request('GET', '/'); $this->assertTrue($this->client->getResponse()->getStatusCode() === 200); $this->assertCount(2, $crawler->filter('.book')); $this->assertEquals('Clean Code', $crawler->filter('.name')->text()); $this->assertEquals('Robert C. Martin', $crawler->filter('.author')->text()); $this->assertEquals('Programming', $crawler->filter('.category')->text()); } }
Si pasamos los test encontramos un error 404, no existe la ruta «/», vamos a crearla.
<?php // fw/silex/app/config/routes.php $app->get('/', 'TDDIntro\App\Controller\BookController::indexAction');
Para mantener el código ordenado, los controladores los situaremos en archivos separados, para hacer esto en Silex necesitamos incluir el componente «ControllerServiceProvider».
<?php // fw/silex/app/config/providers.php $app->register(new Silex\Provider\ServiceControllerServiceProvider());
Creamos el controlador con lo mínimo para pasar el test.
<?php // fw/silex/src/Controller/BookController.php namespace TDDIntro\App\Controller; use Symfony\Component\HttpFoundation\Response; class BookController { public function indexAction() { $books = [ [ 'title' => 'Clean Code', 'author' => 'Robert C. Martin', 'datePublished' => new \DateTime(), 'category' => ['Programming']], [ 'title' => 'Pro PHP Refatoring', 'author' => 'Iacopo Romei', 'datePublished' => new \DateTime(), 'category' => ['Programming'] ], ]; $content = '<section class="books">' . '<h2>Books</h2>'; foreach ($books as $book) { $content .= '<div class="book">' . '<div class="title">' . $book['title'] . '</div>' . '<div class="author">' . $book['author'] . '</div>' . '<div class="published-date">' . $book['datePublished']->format('Y-m-d') . '</div>' . '<div class="category">' . $book['category'] . '</div>' . '<hr>'; } $content .= '</section>'; return new Response($content, 200); } }
Si los test son correctos podemos commitear y continuar.
En el controlador hemos generado la estructura de datos que esperamos recibir y la vista, ya que de momento no tenemos ningún motor de plantillas. Como hemos previsto al principio utilizaremos Twig, vamos a instalarlo y refactorizar nuestro controlador.
Añadimos el provider de Twig a nuestro providers.php
<?php // fw/silex/app/config/providers.php ... $app->register(new Silex\Provider\TwigServiceProvider(), array( 'twig.path' => __DIR__ . '/../resources/views', ));
Añadimos «Twig» como dependencia.
composer require twig/twig symfony/twig-bridge
Creamos la nueva vista:
{# fw/silex/app/resources/views/books/index.html.twig #} <section class="books"> <h2>Books</h2> {% for book in books %} <div class="book"> <div class="name">{{ book.title }}</div> <div class="author">{{ book.author }}</div> <div class="published-date">{{ book.datePublished.format('Y-m-d') }}</div> <div class="category">{{ book.category }}</div> <hr> </div> {% endfor %} </section>
Actualizamos el controlador para renderizar la vista mediante Twig.
<?php // fw/silex/src/Controller/BookController.php namespace TDDIntro\App\Controller; class BookController { protected $view; public function __construct(\Twig_Environment $view) { $this->view = $view; } public function indexAction() { $books = [...]; return $this->view->render('/books/index.html.twig', ['books' => $books]); } }
Necesitamos inyectar Twig en el controlador mediante pimple.
<?php // fw/silex/app/config/services.php $app['book.controller'] = function () use ($app) { return new \TDDIntro\App\Controller\BookController($app['twig']); };
Y actualizamos el routes.php
.
<?php // fw/silex/app/config/routes.php $app->get('/', 'book.controller:indexAction');
pasamos de nuevo los test y deberíamos seguir en verde, commiteamos.
Para terminar de refactorizar el controlador nos falta comunicar el framework con el modelo. Hora de retomar nuestro repositorio.
Para conectar nuestro el controlador con las entidades utilizaremos el método findAll()
del repositorio BookRepository
, volvamos a los tests.
<?php // tests/Persistence/Doctrine/CategoryRepositoryTest.php namespace Tests\TDDIntro\Persistence\Doctrine; use Doctrine\ORM\EntityRepository; use TDDIntro\Persistence\Doctrine\Repository\BookRepository; class CategoryRepositoryTest extends \PHPUnit_Framework_TestCase { protected function getMockBook() { $mockBook = $this->createMock('TDDIntro\Domain\Entity\Book'); $mockBook ->expects($this->atLeastOnce()) ->method('getTitle') ->willReturn('Clean Code'); $mockBook ->expects($this->atLeastOnce()) ->method('getAuthor') ->willReturn('Robert C. Martin'); return $mockBook; } public function testFindAll() { $books = [$this->getMockBook()]; $er = $this->prophesize(EntityRepository::class); $er->findAll()->willReturn($books); $repository = new BookRepository($er->reveal()); $fakeBooks = $repository->findAll(); $this->assertTrue($books[0]->getTitle() === $fakeBooks[0]->getTitle()); $this->assertTrue($books[0]->getAuthor() === $fakeBooks[0]->getAuthor()); } }
Si ejecutamos los test veremos que estamos intentado instanciar métodos de las clases EntityRepository
y TDDIntro\Persistence\Doctrine\BookRepository
, que todavía no existen en el proyecto. Primero instalaremos doctrine, después crearemos una clase abstracta de la que extenderán los demás repositorios para evitar duplicar código.
Doctrine
Para integrar Doctrine con Silex utilizaremos el proveedor oficial de Silex y «Doctrine ORM Service Provider» de «dflydev», instalamos las dependencias
composer require doctrine/dbal doctrine/orm dflydev/doctrine-orm-service-provider
Abrimos el archivo de configuración creamos la clave «doctrine» dentro del array $config
, en la clave «dbal» seteamos los datos de conexión a la base datos y el driver para las consultas, por otro lado en la clave «orm» ponemos las opciones de mapeo de nuestras entidades; el tipo de mappeo, el namespace donde se encuentran las entidades y la ruta al directorio donde pondremos los archivos de mappeo.
<?php // fw/silex/app/config/parameters.php $config = []; // Twig Configs. $config['twig'] = [ 'twig.path' => __DIR__ . '/../resources/views', ]; // Doctrine Configs. $config['doctrine'] = [ 'dbal' => [ 'db.options' => [ 'driver' => 'pdo_mysql', 'host' => 'HOST', 'dbname' => 'DB_NAME', 'user' => 'DB_USER', 'password' => 'PASS', 'charset' => 'utf8', 'driverOptions' => [1002 => 'SET NAMES utf8'], ], ], 'orm' => [ "orm.em.options" => [ "mappings" => [ [ "type" => "yml", "namespace" => "TDDIntro\\Domain\\Entity", "path" => realpath(__DIR__ . "/../../../../core/Persistence/Doctrine/Mapping"), ], ], ], ] ];
Para seguir con los tests funcionales puede que utilicemos una bbdd sqlite con datos fijos, así que abrimos el archivo de parameters_test.php
y lo actualizamos
<?php // fw/silex/app/config/parameters_test.php require_once __DIR__ . '/parameters.php'; $config['doctrine']['dbal']['db.options'] = [ 'driver' => 'pdo_sqlite', 'path' => __DIR__ . '/../../../../tests/app.db', ];
Y añadimos los «Providers»
<?php // fw/silex/app/config/providers.php $app->register(new Silex\Provider\ServiceControllerServiceProvider()); $app->register(new Silex\Provider\TwigServiceProvider(), $config['twig']); $app->register(new Silex\Provider\DoctrineServiceProvider(), $config['doctrine']['dbal']); // 3rd party Providers. $app->register(new \Dflydev\Provider\DoctrineOrm\DoctrineOrmServiceProvider(), $config['doctrine']['orm']);
En este punto, podemos commitear.
Seguimos con los archivos de mapeo en la ruta que hemos puesto en la configuración.
La entidad Category
.
# core/Persistence/Doctrine/Mapping/TDDIntro.Domain.Entity.Category.dcm.yml TDDIntro\Domain\Entity\Category: type: entity table: Categories id: id: type: bigint fields: name: type: string length: 140
Y la entidad Book
que tendrá una relación «N a M» entre sí misma y categorías
# core/Persistence/Doctrine/Mapping/TDDIntro.Domain.Entity.Book.dcm.yml TDDIntro\Domain\Entity\Book: type: entity table: Books id: id: type: bigint fields: name: type: string length: 140 author: type: string length: 259 published_date: type: datetime manyToOne: category: targetEntity: TDDIntro\Domain\Entity\Category joinColumn: name: category_id referencedColumnName: id
No hemos definido ninguna estrategia de generación de identificadores para facilitar el testeo, esto significa que debemos conocer los «ids» de antemano.
Actualizamos las entidades para que soporten identificadores, para no repetir código podemos definir una clase abstracta y extender nuestras entidades de ella. Modificamos los tests para añadir el nuevo requisito.
<?php // tests/Entity/Book.php namespace Tests\TDDIntro\Entity; use TDDIntro\Domain\Entity\Book; class BookEntityTest extends \PHPUnit_Framework_TestCase { public function testBookEntity() { ... $book = new Book(1, 'Clean code', 'Robert C. Martin', new \DateTime(), $mockCategory); $this->assertEquals(1, $book->getId()); ... <?php // tests/Entity/Category.php namespace Tests\TDDIntro\Entity; use TDDIntro\Entity\Category; class CategoryEntityTest extends \PHPUnit_Framework_TestCase { public function testCategoryEntity() { $category = new Category(1, 'programming'); $this->assertEquals(1, $category->getId()); ...
Y modificamos las respectivas entidades.
<?php // core/Domain/Entity/Entity.php namespace TDDIntro\Domain\Entity; abstract class Entity { protected $id; public function getId() { return $this->id; } }
La entidad Book
<?php // core/Domain/Entity/Book.php namespace TDDIntro\Entity; class Book extends Entity { ... public function __construct($id, $title, $author, $datePublished, $categories = null) { $this->id = $id; $this->title = $title; $this->author = $author; $this->datePublished = $datePublished; $this->categories = $categories; } ...
La entidad Category
<?php // core/Entity/Category.php namespace TDDIntro\Entity; class Category extends Entity { ... public function __construct($id, $name) { $this->id = $id; $this->name = $name; } ...
Si pasamos los tests, vemos que seguimos en rojo, nos falta crear la clase BookRepository
, esta a su vez extenderá de la clase EntityRepository
de doctrine y será una implementación de la clase BookRepositoryInterface
. Primero crearemos la clase AbstractRepository
donde situaremos los métodos compartidos entre todas las entidades.
<?php // core/Persistence/Doctrine/Repository/AbstractRepository.php namespace TDDIntro\Persistence\Doctrine\Repository; use Doctrine\ORM\EntityRepository; use TDDIntro\Domain\Repository\RepositoryInterface; abstract class AbstractRepository implements RepositoryInterface { protected $repository; public function __construct(EntityRepository $repository) { $this->repository = $repository; } public function findAll() { return $this->repository->findAll(); } public function findBy(array $criteria = array(), array $orderBy = null, $limit = null, $offset = null) { // TODO: Implement findBy() method. } }
El método findBy
lo añadimos para complir con las espectativas de nuestros interfaces, creamos también la clase BookRepository
.
<?php // core/Persistence/Doctrine/Repository/BookRepository.php namespace TDDIntro\Persistence\Doctrine\Repository; use TDDIntro\Domain\Repository\BookRepositoryInterface; class BookRepository extends AbstractRepository implements BookRepositoryInterface { }
Pasamos los test… y vemos que volvemos al verde, hacemos un commit y continuamos.
Symfony Console
Para sacar mayor provecho de Doctrine ORM implementaremos nuestra propia herramienta de consola como no con el componente «Console» de Symfony.
composer require symfony/console knplabs/console-service-provider
Es muy sencillo solo necesitamos el ejecutable y notificarle los comandos que tiene disponibles.
; fw/silex/bin/console #!/usr/bin/env php <?php use Symfony\Component\Console\Input\ArgvInput; set_time_limit(0); $loader = require __DIR__ . '/../../../vendor/autoload.php'; $input = new ArgvInput(); $env = $input->getParameterOption(['--env', '-e'], getenv('SILEX_ENV') ?: 'test'); $app = require_once sprintf('%s/app/app%s.php', dirname(__DIR__), 'prod' === $env ? '' : '_' . $env); $console = $app["console"]; require_once __DIR__ . '/../app/config/commands.php'; $console->run($input);
y por último el archivo commands.php
donde indicamos los comandos que tendremos disponibles
<?php // fw/silex/app/config/commands.php use Symfony\Component\Console\Input\InputOption; // Console enviroment default "test" $console->getDefinition()->addOption(new InputOption('--env', '-e', InputOption::VALUE_REQUIRED, 'The Environment name.', 'test')); $console->setHelperSet( new Symfony\Component\Console\Helper\HelperSet([ 'db' => new \Doctrine\DBAL\Tools\Console\Helper\ConnectionHelper($app["db"]), 'em' => new \Doctrine\ORM\Tools\Console\Helper\EntityManagerHelper($app["orm.em"]) ]) ); $console->addCommands([ new \Doctrine\ORM\Tools\Console\Command\ClearCache\MetadataCommand, new \Doctrine\ORM\Tools\Console\Command\ClearCache\QueryCommand, new \Doctrine\ORM\Tools\Console\Command\ClearCache\ResultCommand, new \Doctrine\ORM\Tools\Console\Command\SchemaTool\CreateCommand, new \Doctrine\ORM\Tools\Console\Command\SchemaTool\DropCommand, new \Doctrine\ORM\Tools\Console\Command\SchemaTool\UpdateCommand, new \Doctrine\ORM\Tools\Console\Command\ConvertDoctrine1SchemaCommand, new \Doctrine\ORM\Tools\Console\Command\ConvertMappingCommand, new \Doctrine\ORM\Tools\Console\Command\EnsureProductionSettingsCommand, new \Doctrine\ORM\Tools\Console\Command\GenerateEntitiesCommand, new \Doctrine\ORM\Tools\Console\Command\GenerateProxiesCommand, new \Doctrine\ORM\Tools\Console\Command\GenerateRepositoriesCommand, new \Doctrine\ORM\Tools\Console\Command\InfoCommand, new \Doctrine\ORM\Tools\Console\Command\RunDqlCommand, new \Doctrine\ORM\Tools\Console\Command\ValidateSchemaCommand, new \Doctrine\DBAL\Tools\Console\Command\ImportCommand, new \Doctrine\DBAL\Tools\Console\Command\ReservedWordsCommand, new \Doctrine\DBAL\Tools\Console\Command\RunSqlCommand ]);
Configuramos el nombre y la ruta del CLI a Silex
<?php // fw/silex/app/config/parameters.php ... // Console config. $config['console'] = [ 'console.name' => 'TDD Intro Console Tool', 'console.version' => '0.0.1', 'console.project_directory' => __DIR__ . "/.." ];
Volvemos al archivo de proveedores
// fw/silex/app/config/providers.php ... $app->register(new \Knp\Provider\ConsoleServiceProvider(), $config['console']);
Ahora ya tenemos el CLI operativo con todos los comandos que trae Doctrine DBal y Doctrine ORM, además podemos ejecutar diferentes entornos gracias a la opción «-e», con el entorno de test por defecto. Comprobamos que la consola funciona correctamente ejecutándola con la opción «–help».
php fw/silex/bin/console --help
Veremos todos los comandos disponibles y las opciones globales. Un momento, es demasiado largo, crearemos un symlink para tenerla en sitio más estandarizado, modificamos «composer.json»
{ ... "require-dev": {...}, "scripts": { "post-install-cmd": [ "mkdir -p ./bin ", "chmod +x fw/silex/bin/console", "ln -sf ../fw/silex/bin/console ./bin/console" ] } }
Hacemos «composer install» y volvemos a ejecutar el comando de ayudan pero desde «bin/console»
composer install php bin/console --help
Podemos comprobar que nuestros archivos de mapeo son corrector con el comando «orm:validate-schema»
php bin/console orm:validate-schema
En el entorno de test podemos crear la Base de datos y sus tablas con el comando «orm:schema-tool:create»
php bin/console orm:schema-tool:create # En producción no podemos crear la base de datos dede la consola, pero si su schema una exista y los credenciales dados en la configuración sean correctos. php bin/console orm:schema-tool:update --force
Debemos seguir viendo todo correctamente, así que es momento de commitear.
Conectando los puntos
Ahora tenemos por un lado el repositorio, por otro el controlador usaremos la inyección de dependencias para unirlos. Modificamos el controlador:
<?php // fw/silex/src/Controller/BookController.php ... class BookController { protected $view; protected $repository; public function __construct(\Twig_Environment $view, BookRepositoryInterface $repository) { $this->view = $view; $this->repository = $repository; } public function indexAction() { $books = $this->repository->findAll(); ...
Modificamos el archivo services.php
<?php // fw/silex/app/config/services.php $app['book.gateway'] = function () use ($app) { return $app['orm.em']->getRepository('\TDDIntro\Domain\Entity\Book'); }; $app['book.repository'] = function () use ($app) { return new \TDDIntro\Persistence\Doctrine\Repository\BookRepository( $app['book.gateway'] ); }; $app['book.controller'] = function () use ($app) { return new \TDDIntro\App\Controller\BookController($app['twig'], $app['book.repository']); };
Si pasamos los tests de nuevo veremos que nuestro controlador no cumple las espectativas, donde esperaba dos elemento no ha encontrado ninguno. Esto es porque o tenemos nada en nuestra base de datos de test. Podemos insertar los los registros a mano en sqlite, o crear un FakeBookRepository
que nos entregue valores predefinidos por nosotros.
Para esto crearemos dos nuevos archivos BookFixtures
que imitará los datos de la bbdd y FakeBookrepository
que imitará el Gateway
.
<?php // tests/Repository/BookFixtures.php namespace Tests\TDDIntro\Repository; class BookFixtures { public function getBooks() { return [ [ 'title' => 'Clean Code', 'author' => 'Robert C. Martin', 'datePublished' => new \DateTime(), 'category' => 'Programming' ], [ 'title' => 'The Geology of Central Europe: Mesozioc and cenozoic', 'author' => 'Jorge McCann', 'datePublished' => new \DateTime(), 'category' => 'Geology' ], ]; } }
nuestro repositorio «fake».
<?php // tests/Repository/FakeBookRepository.php namespace Tests\TDDIntro\Repository; use TDDIntro\Domain\Repository\BookRepositoryInterface; class FakeBookRepository implements BookRepositoryInterface { public function findAll() { return (new BookFixtures())->getBooks(); } public function findBy(array $criteria = array(), array $orderBy = null, $limit = null, $offset = null) { // TODO: Implement findBy() method. } }
Creamos un archivo services_test.php
que sobrescribirá el services.php
original.
<?php // fw/silex/app/config/services_test.php require_once __DIR__ . '/services.php'; $app['book.repository'] = function () use ($app) { return new \Tests\TDDIntro\Repository\FakeBookRepository(); };
y por último modificamos el archivo app_test.php
<?php // fw/silex/app/app_test.php require_once __DIR__.'/../../../vendor/autoload.php'; $app = new Silex\Application(); require_once __DIR__ . '/config/parameters_test.php'; require_once __DIR__ . '/config/providers.php'; require_once __DIR__ . '/config/services_test.php'; require_once __DIR__ . '/config/routes.php'; $app['debug'] = true; return $app;
Pasamos los test y volvemos al esperado verde, esto último paso añade mucho mayor valor a nuestros test, que insertar los datos directamente en la BBDD, ya que demuestra la versatilidad de nuestro código, cambiar el sistema de persistencia sería tan sencillo como añadir una nueva implementación de nuestro BookRepositoryInterface
. Es momento de commitear y pasar a nuestro segundo user story.
En este post hemos visto los principios del TDD y como aplicarlo, además hemos implementado una sólida arquitectura, que podría ser el principio de cualquier proyecto a largo plazo, gracias a la cobertura de test. En la segunda parte terminaremos el segundo user story, por el momento podemos seguir paso a paso el tutorial siguiendo el repositorio de git
1 Comentario
¿Por qué no comentas tú también?
[…] Para conocer sobre cómo trabajar con TDD, dirigirse a Primeros pasos con TDD […]
TDD: Aprende sobre Test-Driven Development | CodeHoven Hace 5 años
Queremos tu opinión :)