Utilizando el contenedor de inyección de dependencias de Symfony y PHPUnit para hacer pruebas en Drupal

June 18, 2021

Hace algún tiempo tuvimos un proyecto usando Drupal 7 que, entre varios requisitos, requería pruebas automáticas para demostrar funcionalidad. Drupal trae, de fábrica, un módulo de pruebas llamado SimpleTest. Haciendo pruebas de SimpleTest para usarlo, nos dimos cuenta que no iba a ser suficiente para satisfacer los requerimientos del proyecto.

Por cuestiones de diseño, el código fuente de Drupal 7 está escrito basado en procedimientos. El "Drupal Way", que es la manera o el estilo de escribir código impulsado por la comunidad de Drupal, sigue esta misma línea. Esto presenta un problema ya que las funciones en Drupal 7 están ligadas las unas con las otras y es muy difícil hacer pruebas unitarias en este tipo de código.

Para solucionar nuestro problema decidimos hacer tres cosas:

  • Trabajar con código orientado a objetos dentro de los hooks de Drupal.
  • Usar un contenedor de inyección de dependencias para poder pasar servicios requeridos por nuestros objetos.
  • Usar PHPUnit para nuestras pruebas.

Decidimos usar PHPUnit por algunas razones, entre ellas:

  • Lo estable, robusta, popular y documentada que es esta librería.
  • Drupal 8 utiliza esta misma librería para hacer pruebas unitarias en su código.

Para poder realizar pruebas unitarias, es preciso usar otro tipo de arquitectura en el código. Se debe utilizar objetos para poder encapsular funcionalidad que se pueda probar discretamente.

Nuestro sistema hace peticiones a un servidor remoto que maneja los datos necesarios para poder desplegar al usuario la información que requiere.

Normalmente un callback para implementar una petición de un listado de propiedades se vería así:


function my_module_get_properties_from_remote($person_id) {
  $client = new Client();

  $params = array(
    'query' => array(
      'owner_id' => $this->person_id;
    )
  );

  $response =
    $client->request('GET', 'http://example.com/person/properties', $params);

  return $response;
}

Esto presenta una particularidad, que la función es dependiente de un cliente de Guzzle. Para utilizar programación orientada a objetos debemos hacer cambios para implementar un objeto que haga lo que queremos:


use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;

class Owner {
  private $person_id = NULL;
  private $reponse = NULL;

  public function __contruct($person_id) {
    $this->person_id = $person_id;
  }

  public function getPropertyList() {
    $client = new Client();

    $params = array(
      'query' => array(
        'owner_id' => $this->person_id;
      )
    );

    $response =
      $client->request('GET', 'http://example.com/person/properties', $params);

    return $response;
  }
}

La función que usa el objeto se reescribiría de esta forma:


function my_module_get_properties_from_remote($person_id) {
  $owner = new Owner($person_id);

  return $owner->getPropertyList();
}

Con esto ganamos la habilidad de poner a prueba el objeto Owner y más específicamente probar que el método getPropertyList esté retornando lo que se espera, que es el núcleo de esta función.


class OwnerTest extends PHPUnit_Framework_TestCase {

  public function testCanGetListOfProperties() {
    $owner = new Owner('3345');

    $properties = $owner->getPropertyList();

    $this->assertCount(3, $properties);
    $this->assertContains('21 JumpStreet', $properties);
  }

}

Ahora, tenemos una dependencia a una conexión de Guzzle. Pero, ¿Y si no queremos / podemos usar esa conexión para hacer nuestras pruebas unitarias automáticas? ¿Qué tal si debemos usar una conexión a un servidor de pruebas? ¿Qué tal si la funcionalidad que necesitamos no está lista en el servidor remoto y debemos usar mocks? Eso se soluciona inyectando una conexión al objeto.


use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;

class Owner {
  private $person_id = NULL;
  private $reponse = NULL;
  private $client = NULL;

  public function __contruct($person_id, Client $client) {
    $this->person_id = $person_id;
    $this->client = $client
  }

  public function getPropertyList() {
    $params = array(
      'query' => array(
        'owner_id' => $this->person_id;
      )
    );

    $response =
      $this->client->request('GET', 'http://example.com/person/properties', $params);

    return $response;
  }
}

Ahora sí podemos hacer pruebas con alguna conexión mock, por ejemplo, la que provee Guzzle.


class OwnerTest extends PHPUnit_Framework_TestCase {
  private $mockResponses = NULL;
  private $handler = NULL;
  private $client = NULL;

  function setUp() {
    parent::setUp();

    $this->mockResponses = new MockHandler([
      new Response(200, ['Content-Type' => 'application/json'], $some_json_listing),
    ]);

    $this->handler = HandlerStack::create($this->mockResponses);
    $this->client = new Client(['handler' => $this->handler]);
  }

  public function testCanGetListOfProperties() {
    $owner = new Owner('3345', $this->client);

    $properties = $owner->getPropertyList();

    $this->assertCount(3, $properties);
    $this->assertContains('21 JumpStreet', $properties);
  }

}

Ya tenemos las pruebas funcionando pero, ¿Cómo le pasamos la conexión a un objeto dentro de una función X en nuestra aplicación? Aquí es donde entra en juego el contenedor de inyección de dependencias. En nuestro caso, optamos en usar el módulo inject, que usa el contenedor de Symfony, que es también el que se usa en Drupal 8.

En nuestro módulo podemos escribir una función como esta para obtener un service container:


function my_module_service_container() {
  static $container;

  if (is_null($container)) {
    $container = new Symfony\Component\DependencyInjection\ContainerBuilder();
  }

  if (!$container->has('remote_service')) {
    $remote_settings = variable_get('remote_settings');
    $guzzle_options = array(
      'uri' => $remote_settings['default']['endpoint'],
      'auth' => array(
        $remote_settings['default']['user'],
        $remote_settings['default']['password']
      ),
      'connect_timeout' => 30,
      'timeout' => 30,
    );

    $container->setParameter('remote.settings', $guzzle_options);
    $container
      ->register('remote_service', '\GuzzleHttp\Client')
      ->addArgument('% remote.settings%');
  }

  return $container;
}

Unas llamadas al módulo donde está declarado el contenedor y listo, habemus dependencia inyectada.


function my_module_get_properties_from_remote($person_id) {

  $container = my_module_service_container();
  $owner = new Owner($person_id, $container->get('remote_service'));

  return $owner->getPropertyList();
}

¡Boom! Listo.

En conclusión, nuestra estrategia consiste en varias herramientas que nos permiten ejecutar pruebas unitarias repetibles que se pueden automatizar. Utilizar la inyección de dependencias, PHPUnit y la programación orientada a objetos nos permite alcanzar esta meta de automatizar nuestras pruebas.