Test dobles
6.1 Definición de Double tests
En determinadas circunstancias, el código que estamos testeando tiene dependencias. Esta circunstancia lo hace complejo o imposible testear de forma aislada. Estas situaciones son llamadas test dobles, del inglés Double Tests. Para crear tests independientes y aislados hay que realizar algún tipo de tratamiento especial, generando elementos que simulen el comportamiento de las partes que rompen la ejecución aislada de esos tests.
De una forma gráfica, si queremos testear el método doSomething del siguiente fragmento de código:
class ClassToTest {
public function doSomething() {
$otraClase = new ClassNoToTest();
$otraClase->doSomethingNotToTest();
/**
* Code
* ...
*/
}
}
Viendo este sencillo ejemplo podemos entender que testear el comportamiento aislado del método doSomething, no es posible al tener la dependencia del comportamiento del método doSomethingNotToTest de la clase ClassNoToTest. Para solventar este tipo de situaciones se utilizan distintos formas de simular o controlar el comportamiento de la clase ClassNoToTest. Para tener una cobertura de test completa hay que testear cada clase de forma aislada, por lo tanto necesitaremos crear los mecanismos necesarios para la clase ClassNoToTest de nuestro ejemplo.
6.2 Dummy, Fake, Stubs y Mocks
Los tests dobles se pueden clasificar en función de las capacidades que tienen los objetos creados en nuestro SUT y, como se indica en capítulo 8 del libro "PHPUnit Essentials" [3], pueden ser alguno de estos tipos: dummy, fake, stub y mock. A nivel práctico y como veremos en los próximos ejemplos, las distintas librerías que existen para ayudar a crear este tipo de objetos no tienen una forma distinta de crearlos. Por ejemplo, para Prophecy todos estos objetos son creados con el método prophesize e instanciados con el método reveal. Aunque los mecanismos son comunes es importante entender la diferencia entre los distintos tipos de tests dobles, para comprender qué es lo que estamos testeando realmente.
Aunque en este mismo capítulo explicaremos con más detalle Prophecy, en esta sección utilizaremos esta librería de PHP dedicada a crear este tipo de objetos.
6.2.1 Dummy
Dummy son un tipo de objetos utilizados en test dobles que no tienen comportamiento alguno, dado que no queremos tener efectos laterales. Son usados para cumplir las restricciones de argumentos de parámetros tipados. Un ejemplo de uso de dummy sería el siguiente, utilizando Prophecy:
public function testwithDummy() {
$dummy = $this->prophesize("DummyClass");
$classForTesting = new MyClass($dummy->reveal());
}
El método prophesize de PHPUnit 4.6 nos devuelve un objeto de tipo Prophet sin ningún tipo de configuración, que es lo que nos interesa en este tipo de testeo. Como veremos cuando hablemos de Prophecy, el objeto reveal devuelve la instancia de DummyClass, que es necesaria para instanciar un objeto de la clase MyClass. El código del constructor de la clase MyClass, por el que es necesario pasar un objeto del tipo DummyClass sería el siguiente:
class MyClass {
protected $obj;
public function __construct( DummyClass $var) {
$this->obj = $var;
}
}
6.2.2 Fake
En determinadas circunstancias, necesitamos controlar las llamadas a los objetos con dependencias dentro de nuestro SUT. Para ello, es necesario otro tipo de objetos, los cuales, además de especificar la clase, se le especifica una configuración determinada para llamadas a métodos concretos. Para este tipo de situaciones tampoco tenemos dependencia con el resultado de los métodos declarados, pero sí necesitamos tenerlos especificados dado que el SUT hará llamada a dichos métodos. Un ejemplo de este tipo de tests objetos sería:
public function testWithFakeObject() {
$fakeLogger = $this->prophesize("Logger");
$fakeLogger->log(Argument::any());
$fakeLogger->info(Argument::any());
$controler = new Controller($fakeLogger->reveal());
}
En este ejemplo también hemos usado Prophecy. La instancia fakeLogger es un objeto que en la aplicación en sí no devuelve resultado a cada llamada a los métodos log o info, pero sabemos que nuestra clase Controller ejecuta en determinadas circunstancias. Al querer romper la dependencia con la clase Logger real, hemos creado un objeto Fake el cual no hará nada, pero permitirá que la ejecución del SUT (en este caso Controller) sea posible.
6.2.3 Stubs
La práctica de reemplazar un objeto con un test doble que devuelve una salida configurada se llama stubbing. Introduciendo en el SUT este objeto con usa salida predeterminada, tendremos control sobre la pieza de código que producía la indirección y por lo tanto podremos considerar que estamos realizando testeo de forma aislada.
Un ejemplo de objetos Stubs sería el siguiente, dada la clase de ejemplo Controller:
class Controller {
protected $response;
public function __construct(\SolrResponse $response) {
$this->response = $response;
}
public function doController() {
if($this->response->getHttpStatus==200) {
// Do somehing to test
return true;
}
return false;
}
}
Podríamos crear un test en el que rompanmos la dependencia con el objeto response inicializando la clase Controller con un objeto stub de la siguiente forma:
public function testWithStub() {
$stub = $this->prophesize("\SolrResponse");
$stub->getHttpStatus()->willReturn(200);
$controller = new Controller($stub->reveal());
$this->assertTrue($controller->doController());
}
Como se puede ver en la implementación de nuestra clase de ejemplo Controller, la dependencia del método "getHttpStatus" del objeto this->response la configuramos y dejamos bajo control con nuestro objeto stub, al cual le hemos indicado que devuelva el código de estado 200, con la configuración willReturn(200). Así el objeto colaborador SolrResponse queda en una situación conocida, lo que nos permitirá predecir el comportamiento del método doController.
6.2.4 Mocks
La práctica de reemplazar un objeto con un test doble que verifica expectativas, por ejemplo, que un método concreto del objeto colaborador ha sido llamado, se llama mock.
Utilizar mocks tiene sentido cuando hay una dependencia conocida con el comportamiento de los elementos que crean la indirección. En la documentación de PHPUnit [1] podemos ver un ejemplo ilustrativo al testear el patrón de diseños observador. El código de las clases Subject y Observer sería el siguiente:
class Subject
{
protected $observers = array();
protected $name;
public function __construct($name) {
$this->name = $name;
}
public function getName() {
return $this->name;
}
public function attach(Observer $observer) {
$this->observers[] = $observer;
}
public function doSomething() {
// Do something.
// Notify observers that we did something.
$this->notify('something');
}
public function doSomethingBad() {
foreach ($this->observers as $observer) {
$observer->reportError(42, 'Something bad happened', $this);
}
}
protected function notify($argument) {
foreach ($this->observers as $observer) {
$observer->update($argument);
}
}
// Other methods.
}
class Observer
{
public function update($argument) {
// Do something.
}
public function reportError($errorCode, $errorMessage, Subject $subject) {
// Do something
}
// Other methods.
}
El código para testear la clase Subject tendría la siguiente forma:
class SubjectTests extends \PHPUnit_Framework_TestCase {
public function testObserversAreUpdated()
{
$observer = $this->prophesize('Observer');
$observer->update("somethig")->shouldBeCalledTimes(1);
// Create a Subject object and attach the mocked
// Observer object to it.
$subject = new Subject('My subject');
$subject->attach($observer->reveal());
$subject->doSomething();
}
}
El test del ejemplo anterior pasará dado que hemos definido que el método update será llamado una vez y es exactamente lo que ocurrirá si seguimos la traza de ejecución de la llamada doSomething() del objeto subject.
Si en una refactorización futura de la clase Subject eliminamos la llamada update de la clase Observer el test anterior fallaría, dado que no se cumpliría la expectativa de llamar una vez el método *update".
6.2.5 Mocks no son stubs
Aunque en todos los frameworks de tests utilizamos la misma nomenclatura para construir stubs y mocks, los dos tipos de objetos son muy distintos. Por un lado, los stubs son objetos que se controlan la devolución de los objetos colaboradores de nuestro SUT, mientras que los mocks testean el comportamiento de dichos colaboradores. Por otro lado, los dos tipos de objetos se utilizan con una filosofía totalmente distinta de diseño y testeo. Esto puede llevar a debate sobre cuando es necesario utilizar mocks y stubs, y cuando podemos utilizar instancias de las clases colaboradoras dentro del SUT concreto. Este debate, con muchos más matices y detalles lo expone Martin Fowler en su artículo "Mocks Aren't Stubs" [19] . Es un debate que, en nuestra experiencia profesional, se repite constantemente.
De la misma forma que existe el debate sobre testear todo con mocks, o no usar nada, un punto intermedio parece razonable. Además de que el uso de estos frameworks de testeo ayudan a romper dependencias entre el SUT y los colaboradores que interactúan con sistemas externos, como llamadas a API por el protocolo HTTP, llamadas a disco, etc...
6.3 Mocks parciales (Partials Mocks)
En algunos de los frameworks que estudiaremos con más detalles existe la posibilidad de crear mocks parciales. Algunos de estos frameworks son Mockery y Phake. Estos mocks parciales son, como su nombre dan a pensar, una especificación parcial de la clase falseada, llamando a la implementación original para el resto de métodos no especificados. Este concepto no está extendido en toda la literatura sobre testeo.
En la documentación de Phake, cuando explican la definición de mocks parciales y la forma de uso recomiendan no usar este tipo de mocks para código nuevo, recomendando solo utilizarlo cuando existe una base de código antiguo.
6.4 ¿Mockear un método dentro de la clase testeada?
Mockear o modificar el comportamiento de la clase a testear suele ser un indicador de que estamos violando el principio de responsabilidad simple, es decir, una clase debería tener una única responsabilidad. Sin embargo, hay situaciones en las que testear un método puede llegar a ser difícil debido a que hay una dependencia con un sistema externo, por ejemplo, que es fácil de extraer.
Pese a ser un indicador de mala práctica, este problema se puede resolver mediante mocks parciales. Estos objetos, como comentábamos anteriormente, heredan directamente de la clase mockeada, pero lo que el comportamiento de todos los métodos públicos pueden ser configurados después de la creación. Vamos a explicar este concepto con un ejemplo. Supongamos que tenemos la clase DoSearch que realiza una búsqueda en alguna página en internet, cuyo código sería:
class DoSearch {
protected static $url = 'http://exampledomain.com?q=';
protected $searchTerm;
public function __construct($searchTerm)
{
$this-> searchTerm = $searchTerm;
}
public function compile() { /* ... */ }
public function fetch()
{
$url = urlencode($this->url . $this->searchTerm);
return file_get_contents($url);
}
}
Dado que queremos hacer el test unitario de esta clase, nos encontramos con el problema de que testear el método fetch crea una dependencia exterior no deseada. Utilizando Mockery podemos resolver este problema mediante la creación de un mock parcial. Con Mockery lo podemos hacer de dos formas:
- Mediante un mock parcial normal:
$mock = Mockery::mock('DoSearch[fetch]', ['text']);
$mock->shouldReceive('fetch')->once()->andReturn('stub');
Este ejemplo creará un objeto que hereda directamente de DoSearch solo con el método "fetch" con el comportamiento establecido.
- Mediante un mock parcial pasivo:
$mock = Mockery::mock('DoSearch')->makePartial();
$mock->fetch(); // llama al método real
$mock = Mockery::mock('TwitterSearch')->makePartial();
$mock->shouldReceive('fetch')->once()->andReturn('foo');
$mock->fetch(); // devuelve foo.
En el ejemplo mostramos como creando un mock llamando al método makePartial el objeto se comporta como lo haría mediante la sentencia new DoSearch, sin embargo, después de configurarlo el comportamiento quedaría sobreescrito.
Para resolver el problema de mockear el método fetch en los tests de la clase DoSearch sólo bastaría con crear un objeto mockeado parcialmente en el método setUp de nuestro tests, para PHPUnit.
6.5 Alternativas: PHPUnit, Prophecy, Mockery, Phake, AspectMock
Para gestionar la creación de test doble existen bastantes alternativas. Aunque en secciones anteriores hemos visto ejemplos con Prophecy [6] y Mockery [16], en esta sección vamos a ver algunos aspectos de estas y otras opciones. Las librerías de creación y gestión de tests dobles que vamos a abordar son: los propios mecanismos de PHPUnit y otras librerías especializadas a este efecto como son Prophecy, Mockery, Phake [17] y AspectMock [18].
Para ver la expresividad y las principales diferencias que existen entre estas librerías hemos creado una extensión de Monolog [21] para poder almacenar logs en el sistema de indexación Apache Solr. Este ejemplo es solo ilustrativo y no está pensado para utilizarlo en un entorno en producción.
Monolog es un una librería especializada en el envío de logs a distintos sistemas de almacenamiento con distintos formatos. Por defecto Monolog tiene implementando una serie de, lo que en el vocabulario de la propia librería, llaman Handlers", que son clases que gestionan el envío a estos sitemas de almacenamiento. Algunos de estos sistemas de almacenamiento son bases de datos (CouchDB, DynamoDB, MongoDB), sistema de ficheros, email, streams, sistemas de índices (ElasticSearch*), etc.
Dado que de forma original Monolog no soporta Apache Solr, nos parece un ejercicio interesante implementar la lógica necesaria para enviar logs a este sistema de búsquedas y testearlo utilizando test dobles.
En las siguientes secciones mostraremos como quedaría un tests unitario para las clases SolrHandler y SolrFormatter de nuestra extensión de Monolog. El código de las clases a testear sería:
namespace MonologExtended\Handler;
use MonologExtended\Formatter\SolrFormatter;
use Monolog\Logger;
use Solarium\Client;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Formatter\FormatterInterface;
use Solarium\QueryType\Update\Query\Document\DocumentInterface;
use Solarium\Core\Query\QueryInterface;
use Solarium\QueryType\Update\Query\Document\Document;
/**
* SolrHandler.
*/
class SolrHandler extends AbstractProcessingHandler
{
/**
* Client.
*
* @var client.
*/
protected $client;
/**
* Options.
*
* @var options.
*/
protected $options;
/**
* Constructor
*
* @param array $options Options for configure the Solr connection.
* @param integer $level Level of logging.
* @param boolean $bubble Bubble option.
*
* @return void
*/
public function __construct(array $options, $level = Logger::DEBUG, $bubble = false)
{
$this->client = new \Solarium\Client($options);
parent::__construct($level, $bubble);
$this->options = $options;
}
/**
* Setter for Client property.
*
* @param Client $client Client to make the request.
*
* @return void
*/
public function setClient(Client $client)
{
$this->client = $client;
}
/**
* Set formmater.
*
* @param FormatterInterface $formatter Formater object.
*
* @return FormatterInterface
* @throws \InvalidArgumentException Throws the exception
* if the SolrHandler is not compatible with SolrFormatter.
*/
public function setFormatter(FormatterInterface $formatter)
{
if ($formatter instanceof SolrFormatter) {
return parent::setFormatter($formatter);
}
$msg = 'SolrHandler is only compatible with SolrFormatter';
throw new \InvalidArgumentException($msg);
}
/**
* Getter options.
*
* @return array
*/
public function getOptions()
{
return $this->options;
}
/**
* Getter for default formatter.
*
* @return FormatterInterface
*/
protected function getDefaultFormatter()
{
return new SolrFormatter($this->client);
}
/**
* Handle a collection of records.
*
* @param array $records Array of records.
*
* @return void
*/
public function handleBatch(array $records)
{
$documents = $this->getFormatter()->formatBatch($records);
$this->bulkSend($documents);
}
/**
* Write record
*
* @param array $record Record.
*
* @return void
*/
protected function write(array $record)
{
$this->bulkSend(array($record));
}
/**
* Send a bulk of documents to Solr.
*
* @param array $documents Array of documents.
*
* @return void
* @throws \RuntimeException Something wrong happen sending data to solr.
*/
protected function bulkSend(array $documents)
{
try {
$update = $this->client->createUpdate();
foreach ($documents as $document) {
$update->addDocument($document['formatted']);
$update->addCommit();
}
$result = $this->client->update($update);
} catch (\Exception $e) {
if (empty($this->options['ignore_errors'])) {
throw new \RuntimeException('Error sending messages to Solr', 0, $e);
}
}
}
}
namespace MonologExtended\Formatter;
use Monolog\Formatter\NormalizerFormatter;
/**
* SolrFormatter. Extension of Monolog.
*/
class SolrFormatter extends NormalizerFormatter
{
/**
* Index for sending the messages.
*
* @var index
*/
protected $index;
/**
* Type.
*
* @var type
*/
protected $type;
/**
* Client.
*
* @var client
*/
protected $client;
/**
* Constructor.
*
* @param mixed $client Client.
*/
public function __construct($client)
{
parent::__construct(\DateTime::ISO8601);
$this->client = $client;
}
/**
* format function
*
* @param array $record Record to be formatted.
*
* @return Document
*/
public function format(array $record)
{
$record = parent::format($record);
return $this->getDocument($record);
}
/**
* Format a collection of records
*
* @param array $records Records to be formatted.
*
* @return array
*/
public function formatBatch(array $records)
{
$docs = array();
foreach ($records as $key => $record) {
$records[$key]['formatted'] = $this->getDocument($record);
}
return $records;
}
/**
* Getter index.
*
* @return string
*/
public function getIndex()
{
return $this->index;
}
/**
* Getter type.
*
* @return string
*/
public function getType()
{
return $this->type;
}
/**
* Get document function
*
* @param mixed $record Record to make a SolrDocument.
*
* @return Document
* @throws \RuntimeException The SolrDocument couldn't be created.
**/
protected function getDocument($record)
{
try {
$update = $this->client->createUpdate();
$doc = $update->createDocument();
$doc->id = uniqid();
$doc->description = $record['message'];
$doc->title = $record['level_name'];
return $doc;
} catch (\Exception $e) {
throw new \RuntimeException("Can't create an Solr document", 0, $e);
}
}
}
Dado que este ejemplo lo vamos a basar en PHPUnit, el método setUp que compartirán todos los tests con un mensaje de error de ejemplo para registrar en Apache Solr sería:
/**
* setUp method.
*
* @return void
*/
public function setUp()
{
$this->msg = array(
'level' => Logger::ERROR,
'level_name' => 'ERROR',
'channel' => 'meh',
'context' => ['foo' => 7, 'bar',
'class' => new \stdClass()],
'datetime' => new \DateTime('@0'),
'extra' => array(),
'message' => 'Logging an error',
);
}
6.5.1 PHPUnit
PHPUnit contiene herramientas suficientes para generar los distintos tipos de objetos colaboradores (Dummy, Fake, Stub y Mocks), pero con algunas desventajas respecto a las otras librerías. Entra las ventajas a destacar es que es la más flexible, dado que se puede crear objetos y métodos no implementados en las clases a testear. Dentro de las desventajas habría que destacar la sintaxis. Como veremos en el siguiente fragmento de código, en el que testeamos las distintas formas de enviar un log con nuestra extensión de Monolog, las funciones para gestionar mocks de PHPUnit son incómodas de escribir:
/**
* test creating a Log using Mocks of PHPUnit
*
* @return void
*/
public function testAddLogWithPHPUnitMock()
{
$client = $this->getMockBuilder('Solarium\Client')
->setMethods(array('addDocuments', 'createUpdate', 'update'))
->disableOriginalConstructor()
->getMock();
$updateMock = $this->getMockBuilder("Solarium\QueryType\Update\Query\Query")
->setMethods(array('addDocument', 'addCommit', 'createDocument'))
->disableOriginalConstructor()
->getMock();
$updateMock->expects($this->any())
->method('addDocument');
$className = "Solarium\QueryType\Update\Query\Document\Document";
$documentMock = $this->getMockBuilder($className)
->disableOriginalConstructor()
->getMock();
$updateMock->expects($this->any())
->method('createDocument')
->willReturn($documentMock);
$client->expects($this->any())
->method('createUpdate')
->willReturn($updateMock);
$solrHandler = new SolrHandler(array());
$solrHandler->setClient($client);
$solrHandler->handle($this->msg);
$solrHandler->handleBatch(array($this->msg));
}
En PHPUnit, una vez creado un objeto colaborador es necesario indicarle los métodos que van a ser sobreescritos mediante el método setMethods. Además, es necesario indicar al framework que no utilice el constructor propio de la clase colaboradora.
6.5.2 Prophecy
Desde la versión 4.5 de PHPUnit, Prophecy [6] está incluido por defecto. Prophecy es un flexible y completo framework para la creación y gestión de mocks en PHP. Una de las características que más llama la atención de Prophecy es la forma de expresar los tests. Como se puede ver en la tabla 6.1, "Diferencias entre distintos framewors de mocks", Prophecy es estricto en el sentido de que no se puede falsear nada que no esté implementado en la clase a configurar. Esto se debe a que sigue la filosofía de RSpec (framework de testeo en el lenguaje de programación Ruby). Otra de las ventajas es la posibilidad de uso de espías.
Los espías son manipulaciones de tests dobles que permiten almacenar los comportamientos de los objetos colaboradores y ser consultados sin necesidad de ser especificados en la creación de los tests. Prophecy graba todas las llamadas de los objetos falseados, por lo cual realizar comprobaciones tras la ejecución del objeto bajo test, es relativamente sencillo.
Extraído de la documentación de Prophecy podemos ver un ejemplo de código:
$em = $prophet->prophesize('Doctrine\ORM\EntityManager');
$controller->createUser($em->reveal());
$em->flush()->shouldHaveBeenCalled();
El tests que verificaría el funcionamiento de nuestra extensión de Monolog podría tener la siguiente forma:
/**
* test creating a Log using Mocks of Prophecy
*
* @return void
*/
public function testAddLogWithProphecy()
{
$documentProphecy = $this->prophesize(Document::class);
$updateProphecy = $this->prophesize(Query::class);
$updateProphecy->addDocument(Argument::any())->shouldBeCalled();
$updateProphecy->addCommit()->shouldBeCalled();
$updateProphecy->createDocument()->shouldBeCalled();
$updateProphecy->createDocument()->willReturn($documentProphecy->reveal());
$clientProphecy = $this->prophesize("Solarium\Client");
$clientProphecy->createUpdate()->willReturn($updateProphecy->reveal());
$clientProphecy->createUpdate()->shouldBeCalled();
$clientProphecy->update($updateProphecy)->shouldBeCalled();
$solrHandler = new SolrHandler(array());
$solrHandler->setClient($clientProphecy->reveal());
$solrHandler->handle($this->msg);
$solrHandler->handleBatch(array($this->msg));
}
Como podemos ver, la creación de los objetos colaboradores se realizan con el método prophesize, sin necesidad de especificarle ningún método ni otro tipo de configuraciones. El comportamiento viene definido por las expectativas que creamos con la llamada a cada método existente en la clase. Por ejemplo, la llamada al método shouldBeCalled indica que el método precedente debería ejecutarse para que ese test sea considerado como válido. De la misma forma, willReturn especificará la salida que tendrá el método que le precede.
6.5.3 Mockery
Otra alternativa para trabajar con tests dobles es Mockery. Dentro de la comunidad PHP es una opción bastante utilizada y extendida, pese a que el hecho de que Prophecy esté incluido dentro de PHPUnit, como indicábamos anteriormente.
La sintaxis de Mockery es tan cómoda como la de Prophecy y es más flexible, pero el comportamiento es distinto. El autor de Behat, Konstantin Kudryashov, explica su blog [20] la diferencia conceptual entre Mockery y Prophecy. Resumiendo, la principal diferencia es que Mockery asocia a la estructura del test el comportamiento del mismo, mientras que Prophecy lo asocia al paso de mensaje.
Siguiendo con el mismo ejemplo de nuestra extensión de Monolog, el código que validaría nuestra el envío de mensajes a Apache Solr por parte de Monolog podría ser el siguiente:
/**
* test creating a Log using Mocks of Mockery
*
* @return void
*/
public function testAddLogWithMockery()
{
$mockStrDocument = "Solarium\QueryType\Update\Query\Document\Document[addField]";
$documentMockery = \Mockery::mock($mockStr);
$methods = "addDocument,addCommit,createDocument";
$mockStrUpdate = "Solarium\QueryType\Update\Query\Query[$methods]";
$updateMockery = \Mockery::mock($mockStrUpdate );
$updateMockery->shouldReceive('addDocument')->atLeast(1);
$updateMockery->shouldReceive('addCommit')->atLeast(1);
$updateMockery->shouldReceive('createDocument')
->atLeast(1)
->andReturn($documentMockery);
$clientMockery = \Mockery::mock(Client::class);
$clientMockery->shouldReceive('createUpdate')->andReturn($updateMockery);
$clientMockery->shouldReceive('update')->with($updateMockery)->atLeast(1);
$solrHandler = new SolrHandler(array());
$solrHandler->setClient($clientMockery);
$solrHandler->handle($this->msg);
$solrHandler->handleBatch(array($this->msg));
}
Mockery, de forma similar a PHPUnit necesita que le indiquen los métodos a configurar como vemos en la creación del mock asignado a la variable updateMockery, por ejemplo.
6.5.4 Phake
Phake es otro framework que trata de proveer mocks y stubs para la ejecución de test dobles. Está inspirado en Mockito (framework de test para Java). En la documentación oficial matiza que la ventaja de Phake frente a PHPUnit, PHPMock y SimpleTest es que de forma nativa implementa el concepto de espía. La verdad es que esta no es una ventaja significativa, dado que tanto Prophecy como Mockery también lo implementan y en PHPUnit se puede llegar a conseguir.
Nuestro ejemplo de para las clases SolrHandler y SolrFormatter tendría la forma:
/**
* test creating a Log using Mocks of Phake
*
* @return void
*/
public function testAddLogWithPhake() {
$document = \Phake::mock(Document::class);
$update = \Phake::mock(Query::class);
\Phake::when($update)->createDocument()->thenReturn($document);
$update->addDocument($document);
$update->addCommit();
\Phake::verify($update)->addDocument($document);
\Phake::verify($update)->addCommit();
$client = \Phake::mock(Client::class);
\Phake::when($client)->createUpdate()->thenReturn($update);
$client->update($update);
$solrHandler = new SolrHandler(array());
$solrHandler->setClient($client);
$solrHandler->handle($this->msg);
$solrHandler->handleBatch(array($this->msg));
}
6.5.5 AspectMock
AspectMock es otra herramienta para generar mocks en PHP, desarrollada por la comunidad detrás de Codeception.
A diferencia de las otras librerías analizadas, AspectMock permite la redefinición de funciones y clases de forma dinámica, es decir, en tiempo de ejecución. Las funcionales que mencionan en la documentación son:
- Creación de test dobles para métodos estáticos.
- Creación de test dobles para métodos de clases llamadas desde cualquier sitio.
- Redefinición de métodos en tiempo de ejecución.
- Sintaxis simple y fácil de recordar.
De estas funcionalidades vamos a destacar el punto de redefinición de métodos y funciones en tiempo de ejecución. En la documentación encontramos un ejemplo para redefinir la función "time" de PHP, la cual devuelve la hora del sistema. El ejemplo sería el siguiente:
namespace demo;
test::func('demo', 'time', 'now');
$this->assertEquals('now', time());
Testear funcionalidades que dependan de la función time suele tener una complejidad adicional, dado el valor devuelto por esta función siempre es distinto. Aunque el ejemplo aprovecha la forma en la que funcionan los namespaces en PHP, con este framework de test dobles tenemos la posibilidad de alterar en tiempo de ejecución el comportamiento de la función time.
Características | PHPUnit | Prophecy | Mockery | Phake |
---|---|---|---|---|
Instancias de clases no existentes. | X | |||
Posibilidad de testear métodos mágicos | X | X | X | |
Interfaz legible (Fluent Interface) | X | X | X | |
Mocks Parciales | X | X | ||
Espías | X | X | X |
: Diferencias entre distintos frameworks de mocks
6.6 Mocks especiales
Algunas de las dependencias que podemos encontrar en nuestro SUT van más alla de otras piezas de código, como son elementos a más bajo nivel, por ejemplo el sistema de fichero, o el propio comportamiento de las funciones del lenguajes. Para trabajar con estas dependencias en los SUT existen herramientas que nos ayudan a simular o alterar el comportamiento, permitiéndonos dejar en un estado conocido y repetible.
Para resolver las dependencias con el sistema de fichero hay librerías como vfsStream [22].
6.6.1 Testeando el sistema de ficheros: vfsStream
Algunas funcionalidades que necesitamos cumplir cuando desarrollamos software tiene dependencias de otras partes del sistema, como del sistema de ficheros. Esto genera otro punto en el que testear un fragmento de código aislado es complicado por dicha dependencia. Un ejemplo clásico podría ser el de una clase que necesita crear una carpeta para almacenar ficheros. Una posible solución podría ser la de asignar en los métodos setUp y tearDown dicha necesidad. En el caso de setUp, podríamos verificar que no existe la carpeta y borrarla en el caso de que existiera. En el caso de tearDown, el efecto contrario, borrar la carpeta. La idea principal es que cada test deje el sistema sin alteraciones tras su ejecución. El problema de esta solución es que se sigue accediendo a disco y dejando cierta incertidumbre en la ejecución del test. Además, podemos encontrar problemas adicionales si el disco tiene algún problema, hay modificaciones no deseadas en el disco, etc...
Para solventar este tipo de situaciones existen librerías como vfsStream. Esta herramienta es un simulador del sistema de ficheros, el cual ejecuta en memoria todo lo relativo al disco.
Un ejemplo sería, dada la clase FileSystemCache:
class FileSystemCache {
private $dir;
public function __construct($dir) {
$this->dir = $dir;
}
public function store($key, $data) {
if (!file_exists($this->dir)) {
mkdir($this->dir, 0700, true);
}
file_put_contents($this->dir . '/' . $key, serialize($data));
}
}
El test unitario utilizando vfsStream sería:
use org\bovigo\vfs\vfsStream;
class FileSystemCacheWithVfsStreamTest extends \PHPUnit_Framework_TestCase
{
private $root;
public function setUp() {
$this->root = vfsStream::setup();
}
/**
* @test
*/
public function createsDirectoryIfNotExists() {
$cache = new FileSystemCache($this->root->url() . '/cache');
$cache->store('example', ['bar' => 303]);
$this->assertTrue($this->root->hasChild('cache'));
}
/**
* @test
*/
public function storesDataInFile() {
$cache = new FileSystemCache($this->root->url() . '/cache');
$cache->store('example', ['bar' => 303]);
$this->assertTrue($this->root->hasChild('cache/example'));
$this->assertEquals(
['bar' => 303],
unserialize($this->root->getChild('cache/example')->getContent())
);
}
}
Frente al test unitario utilizando únicamente PHPUnit:
class FileSystemCacheWithoutVfsStreamTest extends \PHPUnit_Framework_TestCase
{
/**
* ensure that the directory and file are not present from previous run
*/
private function clean() {
if (file_exists(__DIR__ . '/cache/example')) {
unlink(__DIR__ . '/cache/example');
}
if (file_exists(__DIR__ . '/cache')) {
rmdir(__DIR__ . '/cache');
}
}
public function setUp() {
$this->clean();
}
public function tearDown() {
$this->clean();
}
/**
* @test
*/
public function createsDirectoryIfNotExists() {
$cache = new FileSystemCache(__DIR__ . '/cache');
$cache->store('example', ['bar' => 303]);
$this->assertFileExists(__DIR__ . '/cache');
}
/**
* @test
*/
public function storesDataInFile() {
$cache = new FileSystemCache(__DIR__ . '/cache');
$cache->store('example', ['bar' => 303]);
$this->assertEquals(
['bar' => 303],
unserialize(file_get_contents(__DIR__ . '/cache/example'))
);
}
}
En el ejemplo, obtenido de la documentación de vfsStream hemos creado la clase FileSystemCache, que no es más que una sencilla caché en disco. Para documentar la diferencia del uso de vfsStream hemos creado dos clases de testeo, FileSystemCacheWithVfsStreamTest y FileSystemCacheWithoutVfsStreamTest. En estas clases queda reflejada lo que comentábamos cuando hacíamos referencia a la necesidad de implementar los métodos setUp y tearDown en los tests sin vfsStream, y como no es necesario en la clase de test con vfsStream.