Creating tests for Drupal module Update Hooks

By lostcarpark_admin, 10 June, 2026
A fish shaped like an arrow and a fishing hook.

What is an Update Hook

When working on contributed Drupal modules, you sometimes need to make changes to schema or data structures.

This will generally need an update hook to make necessary changes to existing stored data on sites that installed the module before the change.

The update hook itself is generally simple enough. Here’s an example from the Smart Trim module, to update “read more” link settings on each display type:

/**
 * Update Smart Trim more settings.
 *
 * Iterate through entity view displays and for any with Smart Trim as formatter
 * type, move top level more link settings into more array.
 */
function smart_trim_update_10201() {
  /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager */
  $entityTypeManager = \Drupal::service('entity_type.manager');
  $storage = $entityTypeManager->getStorage('entity_view_display');
  foreach ($storage->loadMultiple() as $display) {
    $changed = FALSE;
    /** @var \Drupal\Core\Entity\Entity\EntityViewDisplay $display */
    $content = $display->get('content');
    foreach ($content as $key => $content_entry) {
      if (($content_entry['type'] ?? '') == 'smart_trim'
        && isset($content_entry['settings']['more_link'])) {

        $content[$key]['settings']['more'] = [
          'display_link' => $content_entry['settings']['more_link'],
          'target_blank' => $content_entry['settings']['more_target_blank'] ?? FALSE,
          'link_trim_only' => $content_entry['settings']['more_link_trim_only'] ?? FALSE,
          'class' => $content_entry['settings']['more_class'] ?? 'more-link',
          'text' => $content_entry['settings']['more_text'] ?? t('More'),
          'aria_label' => $content_entry['settings']['more_aria_label'] ?? t('Read more about [node:title]'),
        ];
        unset($content[$key]['settings']['more_link']);
        unset($content[$key]['settings']['more_target_blank']);
        unset($content[$key]['settings']['more_link_trim_only']);
        unset($content[$key]['settings']['more_class']);
        unset($content[$key]['settings']['more_text']);
        unset($content[$key]['settings']['more_aria_label']);
        $changed = TRUE;
      }
    }
    if ($changed) {
      $display->set('content', $content);
      $display->save();
    }
  }
}

This is looping through every display, and if it is using Smart Trim, move the “more” settings from discrete settings to an array within the overall field config.

Testing these changes can be fiddly during development, since every time you run it changes the database structure. Tools like DDEV with its ddev snapshot command can be invaluable.

And really, you should test this update hook whenever you make other changes to the module, to confirm the update still works and to ensure future changes to the module don’t break the upgrade path – potentially locking out anyone slow to update.

But what if you could automate the testing?

Why are automated tests valuable

The aim of any automated testing is to create a consistent, repeatable test that can be run before any new changes are applied to the module. This will ensure that these changes don’t impact the functionality being tested.

This doesn’t replace manual testing, but it allows us to create a suite of test cases that cover the main functionality, and ensure things behave as expected.

Generally, these tests are self-contained. They set up all the data needed to carry out the test and throw away the installation when finished„ so no cleanup is required.

Considerations for automated testing of an update hook

Unfortunately, the usual pattern of creating test data within the test case doesn’t hold up well when testing an update hook.

As the module defines its schema, and Drupal validates configuration updates, setting up the “before” configuration can be tricky as it doesn’t comply with the updated schema, so Drupal won’t allow the configuration to be saved. It’s also questionable whether a configuration mocked to look like the old schema will stay that way as the module evolves, and really test an upgrade.

The solution is to use a “fixture”. This is essentially a database backup of a site with the module as it was before the update hook.

This allows the test to load a site with the module in its “before update” state, run the update hook, and test that the configuration now matches the new schema.

Creating a fixture

The fixture is created from an installed Drupal site, so first set up a test environment with the required version of Drupal and the version of your module prior to the schema change. Note that if your test pipeline includes “previous major”, your fixture needs to be built off the version of Drupal that will run against. You will need to set up any test data you need your test to check after the upgrade. You generally want to stick to a minimal theme (such as “Stark”), and avoid any modules you don’t need as those will add overhead and result in a slower running test. The “minimal” install profile is usually a good starting point.

Once you have set up your site you can use the db-tools.php script to create your fixture. This needs to be run with the php shell command, and it needs to be run from the root directory of the Drupal project, which is in a /web/ subdirectory in most repositories.

The db-tools.php script has a number of subcommands, and the one you are interested in is the less than intuitively named dump-database-d8-mysql.

When using DDEV, it generally works best to run the commands inside a container shell with ddev ssh:

$ ddev ssh
$ cd web
$ php core/scripts/db-tools.php dump-database-d8-mysql > ../tests/fixtures/update/drupal-10.3.2-smart_trim-2.0.php
$ gzip ../tests/fixtures/update/drupal-10.3.2-smart_trim-2.0.php

Running db-tools with the dump-database-d8-mysql command outputs to the standard output, so we need the > symbol to redirect it into a file. The file will contain PHP commands to rebuild the database. The general convention is to put this within a /tests/fixtures/update/ directory within your module. The final command compresses the file to save space.

Using a fixture in a test

Our test needs to load the saved database. By definition, this means it needs to be a Functional Test, since Unit and Kernel tests don’t have a full database.

The convention seems to be to place upgrade tests in a /tests/src/Functional/Update/ directory within your module.

There is a handy Drupal base class Drupal\FunctionalTests\Update\UpdatePathTestBase that allows you to just tell it where your fixture is, meaning you don’t need to worry about any of the messy loading stuff. You can just implement a setDatabaseDumpFiles, and let Drupal handle the rest:

class SmartTrimUpdateMoreTest extends UpdatePathTestBase {

  /**
   * {@inheritdoc}
   */
  protected function setDatabaseDumpFiles(): void {
    // Database script for Drupal 10.3.
    $this->databaseDumpFiles = [
      __DIR__ . '/../../../fixtures/update/drupal-10.3.2-smart_trim-2.0.php.gz',
    ];
  }

}

Of course, it’s not much use just loading the database, you need to add a test function. The main thing it needs to include is a call to $this->runUpdates(); but before that it needs to ensure it’s using a user with permission to run the updates. After that it needs to make assertions to show the data changes have been correctly applied. For example:

  public function testUpdateMoreSettings(): void {
    $adminUser = $this->drupalCreateUser();
    $adminUser->addRole($this->createAdminRole('admin', 'admin'));
    $adminUser->save();
    $this->drupalLogin($adminUser);

    $this->runUpdates();

    $display_repository = \Drupal::service('entity_display.repository');
    $body = $display_repository->getViewDisplay('node', 'article', 'teaser')->getComponent('body');
    // Check that the more settings are in the more array.
    $this->assertEquals('smart_trim', $body['type']);
    $this->assertTrue($body['settings']['more']['display_link']);
    // Additional assertions here...
  }

But what happens when Drupal updates

For the most part, $this->runUpdates(); will handle Drupal updates as well as your module updates, but Drupal has an oldest version it supports, and once your module passes that threshold, your fixture will no longer work. For example, let’s say you had a fixture that was built for Drupal 9, it will generally work for Drupal 10 versions, but will fail on Drupal 11.

When this happens, you need to make a new fixture. You could start over, install a version of Drupal that will upgrade to the current, install the pre-update version of your module, and recreate any test data you used. But this can be messy, and if you don’t create the test data in exactly the same way, you risk your test failing.

Fortunately, db-test.php comes to your aid again. Its import command allows a previously saved fixture to be loaded.

You still need to ensure the Drupal version you want to upgrade from is loaded, and that you use a branch of the module from before the update hook was added (otherwise, the hook will run and apply your changes to the fixture). However, you might want to switch to that branch between loading the fixture and running the updates. It’s also extremely useful to have the Drush command-line tool available to actually apply the Drupal updates. And, most importantly, you need to start with an empty database (if using DDEV, run ddev delete).

The sequence generally follows the pattern below:

$ ddev ssh
$ cd web
$ php core/scripts/db-tools.php import ../tests/fixtures/update/drupal-10.3.2-smart_trim-2.0.php.gz

# You might want to switch to the branch without the update hook here...

$ drush updb
$ php core/scripts/db-tools.php dump-database-d8-mysql > ../tests/fixtures/update/drupal-11.3.11-smart_trim-2.0.php
$ gzip ../tests/fixtures/update/drupal-11.3.11-smart_trim-2.0.php

Using multiple fixture versions

Now that you’ve updated your fixture, you could just replace the previous one in our project. But what if you need to run against multiple Drupal versions? For example, at the time of writing, the current Drupal version is 11.3, but “previous major” tests against Drupal 10.6, and “next major” tests against the beta of Drupal 12. There is no Drupal database that will load across all of those. A Drupal 10 database will be upgradable to Drupal 11, but won’t load on Drupal 12. A Drupal 11 database will upgrade to Drupal 12, but Drupal 10 won’t know what to do with it.

Fortunately, you can handle this in the setDatabaseDumpFiles function, by checking the Drupal version and loading the most appropriate fixture:

  protected function setDatabaseDumpFiles(): void {
    // Database script for Drupal 11.3 or higher.
    if (version_compare(\Drupal::VERSION, '11.3.11', '>')) {
      $this->databaseDumpFiles = [
        __DIR__ . '/../../../fixtures/update/drupal-11.3.11-smart_trim-2.0.php.gz',
      ];
      return;
    }

    // Database script for Drupal 10.3 or higher.
    if (version_compare(\Drupal::VERSION, '10.3.2', '>')) {
      $this->databaseDumpFiles = [
        __DIR__ . '/../../../fixtures/update/drupal-10.3.2-smart_trim-2.0.php.gz',
      ];
      return;
    }

    // Database script for Drupal 10.0.
    $this->databaseDumpFiles = [
      __DIR__ . '/../../../fixtures/update/drupal-10.0.8-smart_trim-2.0.php.gz',
    ];
  }

Have I missed anything?

My main reason for writing this post is that every time I need to do it, I’ve forgotten how and need to figure it out again. It’s quite possible that there are better ways of doing this that I haven’t found. So if you see anything that I could improve, please let me know.

You can reach me on Drupal Slack, LinkedIn, or Mastodon.

Topic

Comments

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.