Esto es una traducción libre de Refactoring the Cat API client – Part II
El mundo un sitio seguro
Cuando estas ejecutando test unitarios, no quieres que el resto del mundo este involucrado en tus tests. Ejecutar consultas en bases de datos reales, hacer peticiones HTPP, escribir en ficheros, nada de esto es deseable: harán que tus tests sean lentos e impredecibles. Si el servidor en el que estas haciendo tu petición está caído, o responde de manera inesperada tus test unitarios fallarán por razones equivocadas. Un test unitario solo debería fallar si tu código no hace lo que se supone que debería hacer.
Como hemos visto en el post anterior de la serie, tanto la clase CachedCatApi
como RealCatApi
de alguna manera dependen del mundo. La primera escribe ficheros en el sistema de ficheros, la segunda hace peticiones HTTP. Además, la creación de fichero y las peticiones HTTP son cosas de bastante bajo nivel, para lo cual las clases no utilizan las mejores herramientas disponibles. De la misma manera, un montón de casos límite no están siendo tenidos en cuenta.
Ambas clases pueden liberarse de sus dependencias con el mundo, simplemente creando algunas nuevas clases que encapsulen los detalles a bajo nivel. Por ejemplo, podemos fácilmente mover la llamada file_get_contents()
a otra clase llamada FileGetContentsHttpClient
:
class FileGetContentsHttpClient
{
public function get($url)
{
return @file_get_contents($url);
}
}
Dependencia de inversión de nuevo
Al igual que en el artículo anterior, extrayendo el código a una nueva clase no será suficiente. Necesitaremos introducir una interfaz para la nueva clase, o no será nada fácil mantener nuestros tests.
interface HttpClient
{
/**
* @return string|false Response body
*/
public function get($url);
}
Ahora podemos proveer un HttpClient
cómo un argumento del constructor de RealCatApi
:
class RealCatApi implements CatAPi
{
private $httpClient;
public function __construct(HttpClient $httpClient)
{
$this->httpClient = $httpClient;
}
public function getRandomImage()
{
$responseXml = $this->httpClient->get('http://thecatapi.com/api/images/get?format=xml&type=jpg');
...
}
}
Un verdadero test unitario
Por primera vez podemos tener un verdadero test unitario para RealCatApi
. Solo necesitamos proporcionar un sustituto de HttpClient
el cual devuelva una respuesta XML predefinida:
class RealCatApiTest extends \PHPUnit_Framework_TestCase
{
/** @test */
public function it_fetches_a_random_url_of_a_cat_gif()
{
$xmlResponse = <<<EOD
<response>
<data>
<images>
<image>
<url>http://24.media.tumblr.com/tumblr_lgg3m9tRY41qgnva2o1_500.jpg</url>
<id>bie</id>
<source_url>http://thecatapi.com/?id=bie</source_url>
</image>
</images>
</data>
</response>
EOD;
$httpClient = $this->getMock('HttpClient');
$httpClient
->expect($this->once())
->method('get')
->with('http://thecatapi.com/api/images/get?format=xml&type=jpg')
->will($this->returnValue($xmlResponse));
$catApi = new RealCatApi($httpClient);
$url = $catApi->getRandomImage();
$this->assertSame(
'http://24.media.tumblr.com/tumblr_lgg3m9tRY41qgnva2o1_500.jpg',
$url
);
}
}
Ahora sí tenemos un test adecuado, el cual verifica correctamente el comportamiento de RealCatApi
: Debe llamar a HttpClient
con una URL especñifica y este devolverá el valor del elemento <url>
del cuerpo de la respuesta XML
Desacoplando nuestra API de file_get_contents()
Aun queda una cuestión por resolver: El contrato de HttpClient::get()
todavía está acoplado al contrato de file_get_contents()
en el que se devuelve false
cuando una petición falla, o el cuerpo de la respuesta cuando la respuesta es correcta. Bien podemos ocultar este detalle de implementación convirtiendo cualquier valor específico de retorno (cuando la función nos devuelve false
) en una excepción personalizada. De esta manera, lo único que tiene el objeto es un argumento de una función, devuelve un string o una excepción. Así pues, hacemos más sencillo a los objetos.
class FileGetContentsHttpClient implements HttpClient
{
public function get($url)
{
$response = @file_get_contents($url);
if ($response === false) {
throw new HttpRequestFailed();
}
return $response;
}
}
interface HttpClient
{
/**
* @return string Response body
* @throws HttpRequestFailed
*/
public function get($url);
}
class HttpRequestFailed extends \RuntimeException
{
}
Solo necesitamos modificar RealCatApi
un poco para capturar la excepción, en vez de devolver false
:
class RealCatApi implements CatAPi
{
public function getRandomImage()
{
try {
$responseXml = $this->httpClient->get('http://thecatapi.com/api/images/get?format=xml&type=jpg');
...
} catch (HttpRequestFailed $exception) {
return 'http://cdn.my-cool-website.com/default.jpg';
}
...
}
}
¿Te diste cuenta de que antes solo teníamos un test para el caso feliz? Solo probamos que el resultado `file_get_contents()_ nos devolvía un cuerpo XML. No estábamos en posición de testar un fallo en la petición Http, bueno, ¿cómo de «fiable» es hacer que una petición Http falle, excepto si desenchufamos el cable de red?
Desde ahora tenemos control total de HttpClient
, podemos simular peticiones Http erróneas, simplemente con la excepción HttpRequestFailed
.
class RealCatApiTest extends \PHPUnit_Framework_TestCase
{
...
/** @test */
public function it_returns_a_default_url_when_the_http_request_fails()
{
$httpClient = $this->getMock('HttpClient');
$httpClient
->expect($this->once())
->method('get')
->with('http://thecatapi.com/api/images/get?format=xml&type=jpg')
->will($this->throwException(new HttpRequestFailed());
$catApi = new RealCatApi($httpClient);
$url = $catApi->getRandomImage();
$this->assertSame(
'http://cdn.my-cool-website.com/default.jpg',
$url
);
}
}
Eliminando las dependencias del sistema de ficheros
Podemos repetir los mismos pasos para las dependencias con el sistema de ficheros de CacherCatApi
interface Cache
{
public function isNotFresh($lifetime);
public function put($url);
public function get();
}
class FileCache implements Cache
{
private $cacheFilePath;
public function __construct()
{
$this->cacheFilePath = __DIR__ . '/../../cache/random';
}
public function isNotFresh($lifetime)
{
return !file_exists($this->cacheFilePath)
|| time() - filemtime($this->cacheFilePath) > $lifetime
}
public function put($url)
{
file_put_contents($this->cacheFilePath, $url);
}
public function get()
{
return file_get_contents($this->cacheFilePath);
}
}
class CachedCatApi implements CatApi
{
...
private $cache;
public function __construct(CatApi $realCatApi, Cache $cache)
{
...
$this->cache = $cache;
}
public function getRandomImage()
{
if ($this->cache->isNotFresh()) {
...
$this->cache->put($url);
return $url;
}
return $this->cache->get();
}
}
Por ultimo, podemos eliminar los horrorosos sleep()
de CachedCatApiTest
utilizando un simple sustituto de Cache
. Dejo esta parte como ejercicio para la imaginación del lector.
Algunas cuestiones que nos quedan por aquí:
- No me gusta la API de
Cache
.Cache::isNotFresh()
es difícil de envolver («wrappear»). Tampoco se corresponde con las abstracciones de caché existentes, como Doctrine lo que hace que sea más difícil de seguir para personas que están familiarizadas con «cachear cosas» en PHP. - La ruta del archivo todavía está «a pincho» en la clase
FileCache
. Esto es mano para el testing ya que no puedes cambiarlo para realizar pruebas.
La primera cuestión puede resolverse renombrando algunos métodos e invirtiendo algo de lógica booleana. La segunda cuestión puede resolverse inyectando la ruta como un argumento del constructor.
Conclusión
En esta parte nosotros hemos ocultado los detalles de bajo nivel como manejar ficheros y peticiones Http. Esto nos permitió convertir nuestros test en test unitarios de verdad.
De acuerdo, el código de FileCache
y FileGetContentsHttpClient
necesita ser testado. Así que todavía tenemos algunos tests lentos y frágiles. Hay una cosa que puede ser muy útil: Empujar estos tests fuera del propio proyecto utilizando algunas de las opciones existentes para la manipulación de ficheros y para hacer peticiones Http. El peso de que estos tests funcionen correctamente recaen en los mantenedores de estas bibliotecas. Básicamente esto te permite centrarte en partes importantes de tu código y en mantener una suite de tests muy rápida.