Skip to main content

Refactoring Drupal batch API callbacks to increase testability

Drupal's Batch API is great, it allows you to easily perform long running processes with feedback to the user.

But during Drupal 8's development processes it was one of the remaining systems that didn't get the full object oriented, service-based architecture.

Much of the batch API is largely unchanged from Drupal 7.

But that doesn't mean you can't write unit-testable callbacks.

Let's get started.

by lee.rowlands /

Our goal

Our goal here is to end up with a method that we can test with PHPUnit, without an installed Drupal and without the service container. So we're talking about a pure unit test. Not an integration or functional test. Or in Drupal 8 terms, a class that extends from UnitTestCase, not KernelTestBase or BrowserTestBase.

Our starting point

So lets start with a hypothetical example of the Drupal 7 norm for batch callbacks, a global function. This would typically live in a .module file or perhaps a .inc file. Loading this file would be performed by the Kernel during boostrap or the batch processing if we used the file key. We're going to use a hypothetical example of a batch callback that examines a product and if it is on sale but the sale date has passed, removes it from a fictional search index. The key point here is that we need some services from the container and we have some logic that is worth testing. Other that than, its purely fictional.

/**
 * Batch callback.
 */
function mymodule_batch_callback($product_id, &$context){
$repository = \Drupal::service('mymodule.product.repository');
$promotion_search_index = \Drupal::service('mymodule.promotion_search_index');
/** @var \Drupal\mymodule\ProductInterface $product */
$product = $repository->find($product_id);
if ($product->isOnSale()){
$end = $product->getSaleEndDateTime();
$now = new \DateTime();
if ($end->getTimestamp() < $now->getTimestamp()){
// Sale is finished.
$promotion_search_index->delete($product);
}
}
}

Our first step towards refactoring is to move this into a static method on an object. That way we can use the auto-loader to take care of loading files. PHPunit can't find code hidden in .module files without the Kernel to bootstrap loading these files. Now its an object, the autoloader will load it for PHPUnit.

I like to call these objects batch workers, cause they do the work of the callback. So let's name our class ProductSaleExpiryBatchWorker. Our code now looks something like this and lives in mymodule/src/Batch/ProductSaleExpiryBatchWorker.php.

namespace Drupal\mymodule\Batch;
/**
 * Batch Worker to handle processing expired sale items.
 */
class ProductSaleExpiryBatchWorker {

/**
   * Process the expiration for a given product.
   *
   * @param int $product_id
   *   Product ID to test.
   * @param array $context
   *   Batch context.
   */
public static function process($product_id, &$context){
$repository = \Drupal::service('mymodule.product.repository');
$promotion_search_index = \Drupal::service('mymodule.promotion_search_index');
/** @var \Drupal\mymodule\ProductInterface $product */
$product = $repository->find($product_id);
if ($product->isOnSale()){
$end = $product->getSaleEndDateTime();
$now = new \DateTime();
if ($end->getTimestamp() < $now->getTimestamp()){
// Sale is finished.
$promotion_search_index->delete($product);
}
}
}

}

As you can see, all we've really done is move the code into an autoloaded class and into the static process method. We still can't unit test this code, because we need a bootstrapped container. This is because we call \Drupal::service(). We can do this because the Batch API uses call_user_func_array to execute the callback which works with static functions too.

Unfortunately, we can't use a service in our callback. Many places in Drupal core use the controller resolver service for their callbacks/executables which supports using serviceid:method notation. E.g you can do something like this for many form API attributes that support callbacks:

$output['comment_form'] = [
'#lazy_builder' => ['comment.lazy_builders:renderForm', [
$entity->getEntityTypeId(),
$entity->id(),
$field_name,
$this->getFieldSetting('comment_type'),
]],
'#create_placeholder' => TRUE,
];

So that uses the renderForm() method on the comment.lazy_builders service as its callback. But Batch API didn't get controller resolver integration. So we're stuck with things that can be passed to call_user_func_array(). But all is not lost. Enter the factory method.

The factory method

One of the key tennants of unit testability is dependency injection. And the most common method of dependency injection is constructor injection. So let take our static method, and instead of having it do the processing, let's make it a factory method.

Our code now looks like this:

namespace Drupal\mymodule\Batch;
use Drupal\mymodule\ProductRepositoryInterface;
use Drupal\mymodule\SearchIndexerInterface;
/**
 * Batch Worker to handle processing expired sale items.
 */
class ProductSaleExpiryBatchWorker {

/**
   * The product ID we're processing.
   *
   * @var int
   */
protected $productId;
/**
   * @var \Drupal\mymodule\ProductRepositoryInterface
   */
protected $repository;
/**
   * @var \Drupal\mymodule\SearchIndexerInterface
   */
protected $searchIndexer;
/**
   * Constructs a new ProductSaleExpiryBatchWorker object.
   *
   * @param \Drupal\mymodule\ProductRepositoryInterface $repository
   *   Product repo.
   * @param \Drupal\mymodule\SearchIndexerInterface $search_indexer
   *   Search indexer.
   * @param int $product_id
   *   Product ID.
   */
public function __construct(ProductRepositoryInterface $repository, SearchIndexerInterface $search_indexer, $product_id){
$this->productId = $product_id;
$this->repository = $repository;
$this->searchIndexer = $search_indexer;
}

/**
   * Process the expiration for a given product.
   *
   * @param int $product_id
   *   Product ID to test.
   * @param array $context
   *   Batch context.
   */
public static function process($product_id, &$context){
$repository = \Drupal::service('mymodule.product.repository');
$promotion_search_index = \Drupal::service('mymodule.promotion_search_index');
$worker = new static($repository, $promotion_search_index, $product_id);
$worker->dispatch($context);
}

/**
   * Process the expiration for a given product.
   *
   * @param int $product_id
   *   Product ID to test.
   * @param array $context
   *   Batch context.
   */
protected function dispatch(&$context){
$product = $this->repository->find($this->productId);
if ($product->isOnSale()){
$end = $product->getSaleEndDateTime();
$now = new \DateTime();
if ($end->getTimestamp() < $now->getTimestamp()){
// Sale is finished.
$this->searchIndexer->delete($product);
}
}
}

}

So we're changing the primary function of the static process method to be

  • Creating a new instance (factory method)
  • Calling the dispatch method

Now we have the bulk of our logic in the dispatch method.

And the dispatch method no longer relies on the container. It uses the injected repository and search indexer.

So now we can write our unit test case. We can mock products that are on sale, or have past end dates and we can wire up a mock repository to return them.

Wrapping up

So while we didn't get all the Object oriented advantages in core, it doesn't mean you can't write unit-testable batch callbacks.

If you're interested in working on modernizing the Batch API - as always - there's an issue for that.