Skip to main content

Mocking third-party API in development and test environments

A big part of our job is to integrate the tools we build with third-party services as efficiently and reliably as possible, which means maintaining robust code that is covered by tests. In one of our projects, we developed a feature that was responsible for adding or removing users to mailing lists managed by an external service, with which we communicated via an API.

On this occasion, we integrated the API using the library provided by the same provider, also developing a custom service as an interface to interact with the library.

The logic we had to apply to decide when to add or remove users from the multiple lists was somewhat complex, as it depended on the different statuses of a user, their account settings,  and roles. 

We decided to duplicate the lists in the external service for development and automated test coverage to have different versions from the production ones. The problem arose when, at a certain point, the list management tests started to fail randomly. Then we realized that the inconsistency was generated by launching tests in parallel from different Merge Requests, as they were acting on the same lists.

The solution was to mock the API. In our case, what we had to mock was not the API but the custom service. With this approach, we decouple the development of the API integration itself from the development of specific functionalities that depend on the consumption of that API.
 

 

Benefits

  • In case of bugs and with adequate test coverage, it allows us to quickly identify whether the problem comes from the API integration or from another part of the code.
  • Being able to develop in parallel the integration and other functionalities that depend on it.
  • Avoid inconsistency problems when launching automated tests.

How 

We needed to overwrite the methods of our service so that it would perform the actions we needed in the test environments, so what we did was create a custom module and "decorate" the original service. This pattern consists of adding or overwriting logic from a previously existing class, and making this completely transparent to the rest of the system:

1. We set up the new service in the module.services.yml file of our module.

services: 
  acumbamail_tests.acumbamamil_decorator: 
     class: Drupal\acumbamail_tests\AcumbamailDecorator 
     public: false 
     decorates: acumbamail.client 
     arguments: ["@config.factory", "@logger.factory", "@tempstore.private"]

The parameters, briefly explained, are the following:

  • class: we specify the class.  
  • public: we set it to "false" so that the extended service can only be called through the decorated service.
  • decorates: the service we want to decorate.
  • arguments: the arguments that the decorated service needs, and, if necessary, dependencies that we need in our decorator service.

With this configuration, when the original service is called, the decorator service is loaded, without the classes that invoke it having to be modified.

2. Next, we generate the decorator class that extends the decorated class, i.e. the original service:

<?php

namespace Drupal\acumbamail_tests;

use Drupal\acumbamail\Client\AcumbamailService;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Logger\LoggerChannelFactory;
use Drupal\Core\TempStore\PrivateTempStore;

/** 
 * Decorator for the Acumbamail API. 
 */

class AcumbamailDecorator extends AcumbamailService { 

/** 
 * Cache id. 
 * 
 * @var string 
 */ 

protected $storeId = 'acumbamail_tests'; 


/**
  * Temp Store.
  *
  * @var \Drupal\Core\TempStore\PrivateTempStore
  */
protected $tempStorePrivate;

/**
  * AcumbamailService constructor.
  *
  * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
  *   Config factory.
  * @param \Drupal\Core\Logger\LoggerChannelFactory $logger
  *   Logger.
  * @param \Drupal\Core\TempStore\PrivateTempStore $temp_store_private
  *   Store for subscription data.
  */
public function __construct(ConfigFactoryInterface $config_factory, LoggerChannelFactory $logger, PrivateTempStore $temp_store_private) {
    parent::__construct($config_factory, $logger);
    $this->tempStorePrivate = $temp_store_private;
 }

/** 
 * Initialize Acumbamail Client. 
 */ 
protected function initializeClient() { 
  if (empty($this->getAuthtoken())) { 
     throw new \Exception('Authtoken is not set, please review Acumbamail settings form.'); 
   } 
} 

/** 
 * {@inheritdoc} 
 */ 
public function subscribeUser(string $list_id, string $user_name, string $user_mail, string $user_verified) { 
  $this->initializeClient(); 
  $data = $this->getData(); 
  if (!isset($data[$user_mail])) { 
      $data[$user_mail] = []; 
  } 
   $data[$user_mail][$list_id] = TRUE; 
   $this->saveData($data); 
}  

/** 
  * {@inheritdoc} 
  */ 
public function unsubscribeUser(string $list_id, string $user_mail) { 
    $this->initializeClient(); 
    $data = $this->getData(); 
    unset($data[$user_mail][$list_id]); 
    $this->saveData($data); 
} 

/** 
  * {@inheritdoc} 
  */ 
public function userIsSubscribed(string $list_id, string $user_mail) { 
   $this->initializeClient(); 
   $data = $this->getData(); 
   return (isset($data[$user_mail][$list_id])); 
} 

/**
   * Save the data.
   *
   * @param array $data
   *   Data.
   */
  protected function saveData(array $data) {
    $temp_store = $this->tempStorePrivate->get($this->storeId);
    $temp_store->set($this->storeId, $data);
  }

  /**
   * Return data.
   *
   * @return array
   *   Data.
   */
  protected function getData() {
    $temp_store = $this->tempStorePrivate->get($this->storeId);
    $data = $temp_store->get($this->storeId);
    if (empty($data)) {
      $data = [];
    }
    return $data;
  }

}

In our case, we had to persist the association of users and lists through different requests, for which we used the "tempstore.private" service.

 

Tip 1: If the integration with the API is done directly, without using a library, and the http_client service is used to manage requests, you can apply the strategy explained by Daniel Sipos.

Tip 2: If you want this module to be visible only in certain environments, you can place it under the 'tests' folder of the main module. This way, it will only be visible to Drupal when the 'extension_discovery_scan_tests' parameter of the settings is set to "true".

$settings['extension_discovery_scan_tests'] = TRUE;

 

Image
Luis Ruiz

Luis Ruiz

Senior Drupal developer