Cómo testear una aplicación que depende de una API sin conexión a Internet

Venís tirando código como un campeón.

Las integraciones con las APIs de Twitter y Google no esconden ningún secreto.

Sólo te falta probar el último feature.

Le das a correr y…

Timeout.

Lo qué??

Revisás el código de arriba abajo… ¿Cómo es posible?

Si hace 5 minutos funcionaba de 10…

Levantás la vista y ves como el router te sonríe con sorna:

Como diciéndote: A ver cuándo vas a aprender quién manda acá.

¿Otra vez se cayó Internet?

¿De nuevo vas a tener que llamar al proveedor para que lo reparen de una buena vez?

Y… otra no queda… si hay que testear la API… se necesita conectividad…

¿O no?

Y de pronto… muy bajito en la distancia se escucha la voz del maestro Yoda susurrando «Tests escribir debes».

Exacto. Si querés tener un entorno sólido de desarrollo tenés que aislarte de las dependencias de terceros.

Cómo escribir tests que no dependan de terceros

Las soluciones son múltiples y mucho dependerá del contexto pero todas siguen la misma lógica: en vez de depender de un servicio poco confiable (O al menos, uno que está fuera de tu control), usá un doble del cual tengas control total.

Usar un doble de test a nivel unitario

Empecemos por un ejemplo simple a nivel de test unitario.

Imaginemos que tenés un código que usa una librería como esta y es algo del estilo de:

<?php

declare(strict_types=1);

namespace App;
use Exception;
use GuzzleHttpExceptionGuzzleException;
use NowehTwitterApiClient;

readonly class TwitterMonitor
{
    public function __construct(private Client $client)
    {
    }

    /**
     * @return array<Mention>
     * @throws Exception
     * @throws GuzzleException
     */
    public function getRecentMentions(string $uid): array
    {
        $mentions = $this->client
            ->timeline()
            ->getRecentMentions($uid)
            ->performRequest()
        ;

        foreach ($mentions->data as $mention) {
            $return[] = new Mention($mention);
        }

        return $return;
    }
}

Está claro que, si $client->performRequest() falla, por ejemplo por falta de conectividad, todo lo que sigue va a fallar.

Algo que podrías hacer en tu test es usar un mock. Sería algo así como:

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use App\Mention;
use App\TwitterMonitor;
use GuzzleHttp\Exception\GuzzleException;
use Noweh\TwitterApi\Timeline;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Noweh\TwitterApi\Client;

class TwitterMonitorShould extends TestCase
{
    const string UID = "1346889436626259968";

    private static function a2stdClass(array $array): stdClass
    {
        return json_decode(json_encode($array));
    }

    /**
     * @throws Exception|GuzzleException
     */
    #[Test]
    public function returnUsersLastMentions(): void
    {
        $timeline = $this
            ->getMockBuilder(Timeline::class)
            ->disableOriginalConstructor()
            ->getMock();

        $timeline
            ->expects($this->once())
            ->method('getRecentMentions')
            ->with(self::UID)
            ->willReturn($timeline);

        $mentionsData = self::a2stdClass([
            'data' => [
                [
                    "author_id" => "2244994945",
                    "created_at" => "Wed Jan 06 18:40:40 +0000 2021",
                    "id" => "1346889436626259968",
                    "text" => "Learn how to use the user Tweet timeline and user mention timeline endpoints in the X API v2 to explore Tweet\\u2026 https:\\/\\/t.co\\/56a0vZUx7i",
                    "username" => "XDevelopers"
                ]
            ]
        ]);
        $timeline
            ->expects($this->once())
            ->method('performRequest')
            ->willReturn($mentionsData);

        $client = $this
            ->getMockBuilder(Client::class)
            ->disableOriginalConstructor()
            ->getMock();

        $client
            ->expects($this->once())
            ->method('timeline')
            ->willReturn($timeline);

        $userMonitor = new TwitterMonitor($client);

        $this->assertEquals([new Mention($mentionsData->data[0]),], $userMonitor->getRecentMentions(self::UID));
    }
}

De esta forma, cuando ejecutes tus tests con phpUnit la llamada a Twitter no se realizará, si no que será el doble quien responda con la respuesta pre-armada.

Usar un servicio mockeado

Esta técnica puede ser algo más compleja de implementar pero probablemente sea mejor si lo que buscás es hacer algún tipo de test de caja negra.

La idea aquí es «engañar» a tu aplicación para que crea que está hablando con el servicio real cuando, en realidad, está interactuando con uno local.

Si no querés montarte tu propio servicio, podés usar una herramienta como mockoon, la idea es que puedas hacerle peticiones como si estuvieras yendo contra el servicio real.

Es importante comprender que lo que podés testear es aquello que controlás, para todo lo demás tenés que asumir que el comportamiento esperado es el real. En otras palabras: dado que los terceros hacen su parte como se espera, tu aplicación debe comportarse como se espera, en caso de que no se cumpla la premisa no estás obligado a dar garantías.

Obviamente, te conviene dar garantías de que tu aplicación nunca va a generar daños pero esa es otra historia.

Veamos cómo sería en este caso.

Ante todo, tenemos que averiguar a qué URL estamos haciendo los requests. Esta parte no es muy compleja: se trata de seguir la cadena de llamadas hacia atrás:

Empezamos por Client::timeline() donde nos encontramos con que la respuesta es un objeto Timeline, el cual será el que eventualmente ejecutará el método performRequest.

Entramos un poco más y nos encontramos con:

if ($this->auth_mode === 0) { // Bearer Token
                // Inject the Bearer token header
                $client = new Client(['base_uri' => self::API_BASE_URI]);
                $headers['Authorization'] = 'Bearer ' . $this->bearer_token;
            } elseif ($this->auth_mode === 1) { // OAuth 1.0a User Context
                // Insert Oauth1 middleware
                $stack = HandlerStack::create();
                $middleware = new Oauth1([
                    'consumer_key' => $this->consumer_key,
                    'consumer_secret' => $this->consumer_secret,
                    'token' => $this->access_token,
                    'token_secret' => $this->access_token_secret,
                ]);
                $stack->push($middleware);
                $client = new Client([
                    'base_uri' => self::API_BASE_URI,
                    'handler' => $stack,
                    'auth' => 'oauth'
                ]);
            } else { // OAuth 2.0 Authorization Code Flow
                throw new RuntimeException('OAuth 2.0 Authorization Code Flow had not been implemented & also requires user interaction.');
            }

Es decir: la pieza clave aquí es la constante AbstractController::API_BASE_URI, cuya definición no es otra que https://api.twitter.com/2/… qué sorpresa, ¿no?

Vamos a usar este dato para configurar nuestro proxy de modo de que la aplicación jamás se entere de que está hablando con un doble y no con el verdadero Twitter.

Descargar la especificación de OpenAPI de la documentación de Twitter es un buen punto de partida.

El siguiente paso es importarla en Mockoon:

Con esto estarás listo para tener un servidor mockeado para empezar a jugar

Por ejemplo, podrías cambiar el contenido de la respuesta que generó Mockoon para que retorne:

"data": [
    {
      "author_id": "22123122345",
      "created_at": "Wed Oct 23 18:10:20 +0000 2024",
      "id": "2346889456626258962",
      "text": "Check out this article by @mchojrin!",
      "username": "PHP4Ever"
    }
  ],

Y probar tu aplicación vía CLI o web, depende del caso a ver si está levantando esta respuesta.

Bueno… en realidad todavía vas a tener un pequeño inconveniente en este caso en particular: la librería tiene la URL de Twitter hardcodeada y vos necesitás que Mockoon atrape todos esos requests.

Hay varias soluciones que podés tomar para esto, las nombro solamente para no irme demasiado del tema de este post, en todo caso, escribiré algún otro detallando:

  • Cambiar el código de la librería para que apunte, en lugar de a twitter.com a localhost:3000 (La mala)
  • Montar un proxy en tu computadora que redirija el tráfico destinado a twitter.com hacia localhost:3000 (La fea)
  • Derivar una clase de la que te propone la librería y hacer que la URL base sea un parámetro del constructor (La buena)

Más allá de cuál sea la solución que tomes, con este esquema vas a poder testear End-to-End sin depender de la conectividad.

El lado oscuro de los dobles

Si bien estas técnicas son muy útiles para trabajar tranquilo, dependen de un hecho fundamental: las respuestas hechas a mano deben coincidir (en estructura al menos) con aquellas devueltas por el servicio real.

Si bien esto puede parecer obvio, es importante recordarlo porque muchas veces la propia documentación de la API contra la que estás trabajando está desactualizada y, en ese caso… nada más cierto que la realidad misma.

mchojrin

Por mchojrin

Ayudo a desarrolladores PHP a afinar sus habilidades técnicas y avanzar en sus carreras

¿Te quedó alguna duda? Publica aca tu pregunta

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.