Esta es una traducción libre de Refactoring the Cat API client – Part I
Mattias Noback tenía la idea de hacer una serie de videos sobre «Principles of Package Design book» pero finalmente en vez de videos hizo una serie de post con el material que tenía pensado presentar en los videos.
Para empezar esta es una pequeña pieza de código:
class CatApi
{
public function getRandomImage()
{
if (!file_exists(__DIR__ . '/../../cache/random')
|| time() - filemtime(__DIR__ . '/../../cache/random') > 3) {
$responseXml = @file_get_contents(
'http://thecatapi.com/api/images/get?format=xml&type=jpg'
);
if (!$responseXml) {
// the cat API is down or something
return 'http://cdn.my-cool-website.com/default.jpg';
}
$responseElement = new \SimpleXMLElement($responseXml);
file_put_contents(
__DIR__ . '/../../cache/random',
(string)$responseElement->data->images[0]->image->url
);
return (string)$responseElement->data->images[0]->image->url;
} else {
return file_get_contents(__DIR__ . '/../../cache/random');
}
}
}
Como se puede adivinar, la función getRandomImage
devuelve una URL de la imagen aleatoria (y que existe) de Cat API. Cuando llamamos a la función en dentro de una ventana de tiempo de 3 segundos, se devuelve la misma URL para que el servicio sea más rápido, así como para no abusar de Cat API.
Si este código estuviese en un script para usarlo una vez, para un fin específico,… no pasaría nada. Pero si este código termina en una aplicación en producción, yo no estaría orgulloso.
Estos son algunos de los problemas que hay por aquí: Se están intentando solucionar varios problemas en la misma función, es decir, intentamos traer una nueva URL y almacenar en la caché. Existen gran cantidad de detalles de bajo nivel, como rutas de archivos, URL, tiempo de vida de la caché, nombres de elementos XML,… que no nos dejan ver lo que está pasando realmente. Estos son grandes problemas, porque si intentamos ver los test unitarios tenemos:
class CatApiTest extends \PHPUnit_Framework_TestCase
{
protected function tearDown()
{
@unlink(__DIR__ . '/../../cache/random');
}
/** @test */
public function it_fetches_a_random_url_of_a_cat_gif()
{
$catApi = new CatApi();
$url = $catApi->getRandomImage();
$this->assertTrue(filter_var($url, FILTER_VALIDATE_URL) !== false);
}
/** @test */
public function it_caches_a_random_cat_gif_url_for_3_seconds()
{
$catApi = new CatApi();
$firstUrl = $catApi->getRandomImage();
sleep(2);
$secondUrl = $catApi->getRandomImage();
sleep(2);
$thirdUrl = $catApi->getRandomImage();
$this->assertSame($firstUrl, $secondUrl);
$this->assertNotSame($secondUrl, $thirdUrl);
}
}
¡Necesitamos tener sleep()
en nuestra pruebas para testear la caché! De la misma manera, verificamos únicamente que la URL es válida(no nos aseguramos que sean devueltas por Car API)
Vamos a empezar ha hacer esto un poco mejor.
Separando las preocupaciones – «Cacheado» vs «lo» real
Mirando el código anterior, vemos que las preocupaciones de «cachear cosas» están entrelazadas con las preocupaciones de «traer cosas nuevas/frescas». En realidad la situación no es tan mala como podría parecer. Básicamente la estructura del código podría ser algo como esto:
if (!isCachedRandomImageFresh()) {
$url = fetchFreshRandomImage();
putRandomImageInCache($url);
return $url;
}
return cachedRandomImage();
En cualquier lugar donde tengamos cosas «cargadas con pereza» (lazy load), o cosas nuevas generadas solo cada x segundos, veremos este patrón
Al tener separadas las preocupaciones podemos probar «traer cosas nuevas» separadamente de «cachear cosas». De esta manera, no nos distraeríamos con los detalles de la caché cuando estamos focalizados en verificar si el código trae imágenes aleatorias cada vez que sea necesario.
Por ello podemos mover todo el código relacionado con «cachear» a una nueva clase CachedCatApi
la cual tendrá la misma interfaz pública que teníamos.
class CachedCatApi
{
public function getRandomImage()
{
$cacheFilePath = __DIR__ . '/../../cache/random';
if (!file_exists($cacheFilePath)
|| time() - filemtime($cacheFilePath) > 3) {
// not in cache, so do the real thing
$realCatApi = new CatApi();
$url = $realCatApi->getRandomImage();
file_put_contents($cacheFilePath, $url);
return $url;
}
return file_get_contents($cacheFilePath);
}
}
De camino, me tomé la libertad de cambiar un par de cosas menores, que harán un poco más fácil la legibilidad del código:
- He eliminado todas las menciones duplicadas de
__DIR__ . '/../../cache/random'
- He eliminado
else
– es irrelevante porque if siempre devuelve algo
En este momento la clase CatApi
solo contienen la logica necesaria para llamar a Cat API:
class CatApi
{
public function getRandomImage()
{
$responseXml = @file_get_contents('http://thecatapi.com/api/images/get?format=xml&type=jpg');
if (!$responseXml) {
// the cat API is down or something
return 'http://cdn.my-cool-website.com/default.jpg';
}
$responseElement = new \SimpleXMLElement($responseXml);
return (string)$responseElement->data->images[0]->image->url;
}
}
Dependencia de inversión: introduciendo una abstracción
Llegados a este punto podríamos dividir nuestros tests así, ya que ahora tenemos 2 clases: De hecho, estaríamos probando CatApi
2 veces, debido a que CachedCatApi
esta utilizándola directamente. Nosotros podríamos dar un gran paso e introducir una abstracción para CatApi
– algo que sea reemplazable si no queremos hacer llamadas HTTP, pero en ese caso solo testearemos el comportamiento de CachedCatApi
. Ambas clases tienen exactamente la misma interfaz, así que es fácil definir una interfaz para ellos. lLamaremos a esta interfaz CatApi
y renombraremos la antigua clase CatApi
como RealCatApi
.
interface CatApi
{
/**
* @return string
*/
public function getRandomImage();
}
class RealCatApi implements CatApi
{
...
}
class CachedCatApi implements CatApi
{
...
}
No creo que utilizar nombres propios sea bueno, pero de momento es suficiente.
Como siguiente paso, no deberíamos permitir que CachedCapApi
crease cada vez instancias de CatApi
. En su lugar deberíamos dotarla de una instancia que pueda utilizar para «traer imágenes nuevas/frescas». Esto es llamado dependencia de inyección: Proporcionamos un objeto con todo lo que necesita.
class CachedCatApi implements CatApi
{
private $realCatApi;
public function __construct(CatApi $realCatApi)
{
$this->realCatApi = $realCatApi;
}
public function getRandomImage()
{
...
$url = $this->realCatApi->getRandomImage();
...
}
}
Esto nos permite tener casos de prueba verdaderamente independientes para cada una de las clases.
class CachedCatApiTest extends \PHPUnit_Framework_TestCase
{
protected function tearDown()
{
@unlink(__DIR__ . '/../../cache/random');
}
/** @test */
public function it_caches_a_random_cat_gif_url_for_3_seconds()
{
$realCatApi = $this->getMock('RealCatApi');
$realCatApi
->expects($this->any())
->will($this->returnValue(
// the real API returns a random URl every time
'http://cat-api/random-image/' . uniqid()
);
$cachedCatApi = new CachedCatApi($realCatApi);
$firstUrl = $cachedCatApi->getRandomImage();
sleep(2);
$secondUrl = $cachedCatApi->getRandomImage();
sleep(2);
$thirdUrl = $cachedCatApi->getRandomImage();
$this->assertSame($firstUrl, $secondUrl);
$this->assertNotSame($secondUrl, $thirdUrl);
}
}
class RealCatApiTest extends \PHPUnit_Framework_TestCase
{
/** @test */
public function it_fetches_a_random_url_of_a_cat_gif()
{
$catApi = new RealCatApi();
$url = $catApi->getRandomImage();
$this->assertTrue(filter_var($url, FILTER_VALIDATE_URL) !== false);
}
}
Conclusión
¿Qué hemos ganado hasta ahora? Ahora podemos probar el comportamiento del «cacheo» de manera separada al «comportamiento real». Esto significa que el agrupamiento puede evolucionar por separado, tal vez incluso en diferentes direcciones, siempre y cuando respeten su contrato.
¿Qué queda por hacer? Bien, mucho en realidad. Todavía no podemos probar RealCatApi
sin hacer llamadas HTTP reales (lo cual hace el test poco fiable y bastante indigna del nombre «prueba unitaria»). Lo mismo pasa con CachedCatApi
: intentará escribir en el sistema de archivos, algo que no queremos hacer en una «prueba unitaria», ya que es lento e influye en el comportamiento global