11 minute read Published: Author: Matt Parker
Drupal Planet , Migrations


Easy commit credits with migrations, part 3: Automated tests

This is the third in a series of blog posts on writing migrations for contrib modules:

Stay tuned for more in this series!

Background

While migrating off Drupal 7 Core is very easy, there are still many contrib modules without any migrations. Any sites built using a low-code approach likely use a lot of contrib modules, and are likely blocked from migrating because of contrib. But — as of this writing — Drupal 7 still makes up 60% of all Drupal sites, and time is running out to migrate them!

If we are to make Drupal the go-to technology for site builders, we need to remember that migrating contrib is part of the Site Builder experience too. If we make migrating easy, then fewer site builders will put off the upgrade or abandon Drupal. Plus, contributing to migrations gives us the opportunity to gain recognition in the Drupal community with contribution credits.

Problem / motivation

In the last blog post, we tested migration patches by manually creating content in the D7 site, and manually verifying that the content we created was migrated to the new site.

But entering test data, running the migration, and verifying the test data by hand is tedious and error-prone, especially if we want to be able to perform the exact same tests a few months later to ensure that recent changes to the module haven’t caused a regression by breaking the migration!

Being able to quickly run a migration is also quite useful when writing a migration from scratch (a topic we will cover in future blog posts), because you can get continuous feedback on whether your changes were effective (i.e.: you can do test driven development (TDD) — a style of programming where you (1) write a (failing) test, (2) write operational code so the test passes, and (3) refactor… and you repeat that cycle until you’ve solved the problem).

Proposed resolution

Let’s automate running the migration: automation will ensure that the test is performed the same way next time.

We will do so by writing PHPUnit tests. PHPUnit is an automated testing tool used in Drupal core. Because Drupal’s PHPUnit tests run in an isolated environment, this will save us time reverting the database before each migration test.

As an added bonus, Drupal CI — Drupal.org’s testing infrastructure — can be configured to run tests when patches and/or merge requests are posted to the module’s issue queue, to remind other contributors if the change they are proposing would break migrations in some way.

What do these tests look like?

Migration tests typically follow a pattern:

  1. Set up the migration source database,
  2. Fill the migration source database with data to migrate (“set up Test Fixtures”),
  3. Run the migration (“run the System Under Test”), and,
  4. Verify the migration destination database to see if the test fixtures were migrated successfully.

You might notice that we’ve been following this pattern in our manual tests.

PHPUnit tests themselves are expressed as PHP code. Note that this is different from Behat behavioural tests (where tests are expressed in the Gherkin language), or visual regression tests (where — depending on your testing tool — tests could be expressed as JavaScript code, as a list of URLs to compare, etc.).

Drupal’s convention is to put D7 migration tests into a module’s tests/src/Kernel/Migrate/d7/ folder. You’ll find many Core modules with migration tests in this location (Core’s ban and telephone modules are good places to start). But, most Core tests set up their test fixtures in a completely different file than the test itself, which can be confusing. In this blog post, I’ll walk you through writing tests that look a bit more like the steps we’ve been doing manually.

Steps to complete

Automated migration tests don’t strictly require a Drupal 7 site at all, because the D7 testing tools in Core’s Migrate Drupal module know how to set up something that looks just enough like D7 to make the tests work.

In order to run PHPUnit, you will need to set up the Drupal 9 site a bit differently than you may be accustomed to — the composer create-project commands (or the tar/zip files) you normally use when creating a Drupal site will not install the tools we need for running tests. We should clone Drupal core from source if we want to use PHPUnit.

If we are going to write tests, we should seriously consider sharing them with the community, either by pushing the tests to an Issue fork, or by generating a patch and interdiff that includes them. While we won’t actually generate a patch this week, the instructions below will get you to set up your environment as if you were going to generate a patch.

Setting up

  1. Clone Drupal core’s 9.2.x branch, set up your development environment on the repository (note there is no web/ or html/ folder in this setup), and run composer install.
  2. Find a contrib module that has a migration patch (as described in part 2 of this blog series). As before, read through the issue with the patch in detail.
  3. Clone the 8.x version of the module into your D9 site, as described in part 2.
  4. Apply the migrations patch to its own branch the 8.x module and commit the contents of the patch to the branch; or checkout the Issue fork, as described in part 2.
  5. If the issue is using patches, then before you continue, you should create a second branch to add your tests in — the changes in this second branch will become your patch; and running git diff between the first and second branches will become your interdiff. To do this:
    1. Check out the branch from the issue “Version” field again (e.g.: git checkout 8.x-2.x)

    2. Create a new branch to put your work in. I name this branch after the issue ID and the number of comments in the issue plus one.

      For example, if the issue number is 123456, and there are currently 8 comments in the issue (i.e.: the comment number of the most recent comment is #8), then I would name my branch 123456-9.

    3. Apply the patch again — but this time, don’t commit the changes yet (you need to add your tests first).

Finding a migration to test

Before we write a test, we need to take a closer look at the migration that we want to test. Recall from the migration patches that migrations are defined by YAML files inside the module’s migrations/ directory. These files have roughly the following structure…

# In a file named migrations/MIGRATION_NAME.yml...
id: MIGRATION_NAME
label: # a human-friendly name
migration tags:
  - Drupal 7
  # possibly more tags
source:
  plugin: # a @MigrateSource plugin id
  # some config for that @MigrateSource plugin
process:
  # some process config
destination:
  plugin: # a @MigrateDestination plugin id
  # some config for that @MigrateDestination plugin

Right now, we only need to know the MIGRATION_NAME from the id line for one of the Drupal 7 migrations. If you find several migrations in the migrations/ folder, I’d suggest starting with a configuration migration, because those are usually the simplest.

Writing a test and running it

  1. Create a folder for the tests: mkdir -p tests/src/Kernel/Migrate/d7

  2. Using your preferred text editor, create a PHP file in that folder, tests/src/Kernel/Migrate/d7/MigrateTest.php, and edit it as follows, replacing MODULE_NAME with the machine name of the module; and MIGRATION_NAME with the migration name you found in the migrations/MIGRATION_NAME.yml file you’re going to test…

    <?php
    
    namespace Drupal\Tests\MODULE_NAME\Kernel\Migrate\d7;
    
    use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase;
    
    /**
     * Test the MIGRATION_NAME migration.
     *
     * @group MODULE_NAME
     */
    class MigrateTest extends MigrateDrupal7TestBase {
    
      /**
       * {@inheritdoc}
       */
      protected static $modules = ['MODULE_NAME'];
    
      /**
       * Test the MIGRATION_NAME migration.
       */
      public function testMigration() {
        // TODO: Set up fixtures in the source database.
    
        // Run the migration.
        $this->executeMigrations(['MIGRATION_NAME']);
    
        // TODO: Verify the fixtures data is now present in the destination site.
    
        // TODO: Remove this comment and the $this->assertTrue(TRUE); line after it once you've added at least one other assertion:
        $this->assertTrue(TRUE);
      }
    
    }
    
  3. Let’s run the test: php core/scripts/run-tests.sh --sqlite /tmp/test.sqlite --file modules/MODULE_NAME/tests/src/Kernel/Migrate/d7/MigrateTest.php

    This assumes php is in your shell’s $PATH, you’ve changed directories to the path containing Drupal 9’s index.php, you can write temporary files to /tmp/, and you installed the module you’re patching to modules/MODULE_NAME.

    If you’re using Lando or Ddev, you will probably need to lando ssh -s appserver or ddev ssh -s web before running the line above.

    If all goes well, you should see output like…

    Drupal test run
    ---------------
    
    Tests to be run:
      - Drupal\Tests\MODULE_NAME\Kernel\Migrate\d7\MigrateTest
    
    Test run started:
      Tuesday, August 24, 2021 - 13:00
    
    Test summary
    ------------
    
    Drupal\Tests\MODULE_NAME\Kernel\Migrate\d7\MigrateTest   1 passes
    
    Test run duration: 5 sec
    

But the test isn’t very useful yet. Exactly how to fill in the TODOs we’ve left in there depends on the specific module you’re working on (i.e.: the data it stored in D7, and how that data maps to D9).

A real example

For now, let’s look at a real-world example: migrating the configuration for the Environment Indicator module (note there’s already a migration to do that in issue #3198995 — please do not create a new issue, and please do not leave patches in that issue).

To keep this blog post (relatively) short, I will provide a sample migration definition to migrate two pieces of configuration in environment_indicator. We will discuss how to find data to migrate and how to write migration definitions in future blog posts in this series.

Looking at the code in the latest D7 release, I see 2 pieces of config to migrate: environment_indicator_integration, and environment_indicator_favicon_overlay. Suppose that someone has written following migration definition at migrations/d7_environment_indicator_settings.yml to migrate those 2 pieces of config:

id: d7_environment_indicator_settings
label: Environment indicator settings
migration_tags:
  - Drupal 7
  - Configuration
source:
  plugin: variable
  variables:
    - environment_indicator_integration
    - environment_indicator_favicon_overlay
  source_module: environment_indicator
process:
  toolbar_integration: environment_indicator_integration
  favicon: environment_indicator_favicon_overlay
destination:
  plugin: config
  config_name: environment_indicator.settings

You can see here that the MIGRATION_NAME in our template can be filled in with d7_environment_indicator_settings.

So let’s start by copying the migration test template above into the file tests/src/Kernel/Migrate/d7/MigrateTest.php, and replacing MIGRATION_NAME with d7_environment_indicator_settings.

Now, since these two pieces of config were stored in the variable table in D7; we will start by inserting those variables into the variable table through the migrate database connection (i.e.: the source database)…

// TODO: Set up fixtures in the source database.
\Drupal\Core\Database\Database::getConnection('default', 'migrate')
  ->insert('variable')
  ->fields([
    'name' => 'environment_indicator_integration',
    'value' => serialize(['toolbar' => 'toolbar']),
  ])
  ->execute();
\Drupal\Core\Database\Database::getConnection('default', 'migrate')
  ->insert('variable')
  ->fields([
    'name' => 'environment_indicator_favicon_overlay',
    'value' => serialize(TRUE),
  ])
  ->execute();

Looking at the D9 version of environment_indicator, I can see global config is stored in the environment_indicator.settings config object; and there are two global settings in that object — toolbar_integration and favicon — whose behaviour matches the D7 variables we found. So let’s test the config after the migration:

// TODO: Verify the fixtures data is now present in the destination site.
$this->assertSame(['toolbar' => 'toolbar'], $this->config('environment_indicator.settings')->get('toolbar_integration'));
$this->assertSame(TRUE,  $this->config('environment_indicator.settings')->get('favicon'));

Now let’s run the migration test that we’ve been filling in…

$ php core/scripts/run-tests.sh --sqlite /tmp/test.sqlite --file modules/environment_indicator/tests/src/Kernel/Migrate/d7/MigrateTest.php

Drupal test run
---------------

Tests to be run:
  - Drupal\Tests\environment_indicator\Kernel\Migrate\d7\MigrateTest

Test run started:
  Tuesday, August 24, 2021 - 13:05

Test summary
------------

Drupal\Tests\environment_indicator\Kernel\Migrate\d7\Migrate   1 passes

Test run duration: 5 sec

… great!

Let’s clean up a bit by deleting the dummy assertion at the end and its comment (since we’ve added other assertions); and removing the remaining TODOs (since they are done). We can also add a use statement for Drupal\Core\Database\Database and modify the ::getConnection() lines accordingly. Now the full test looks like:

<?php

namespace Drupal\Tests\environment_indicator\Kernel\Migrate\d7;

use Drupal\Core\Database\Database;
use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase;

/**
 * Test the d7_environment_indicator_settings migration.
 *
 * @group environment_indicator
 */
class MigrateTest extends MigrateDrupal7TestBase {

  /**
   * {@inheritdoc}
   */
  protected static $modules = ['environment_indicator'];

  /**
   * Test the d7_environment_indicator_settings migration.
   */
  public function testMigration() {
    // Set up fixtures in the source database.
    Database::getConnection('default', 'migrate')
      ->insert('variable')
      ->fields([
        'name' => 'environment_indicator_integration',
        'value' => serialize(['toolbar' => 'toolbar']),
      ])
      ->execute();
    Database::getConnection('default', 'migrate')
      ->insert('variable')
      ->fields([
        'name' => 'environment_indicator_favicon_overlay',
        'value' => serialize(TRUE),
      ])
      ->execute();

    // Run the migration.
    $this->executeMigrations(['d7_environment_indicator_settings']);

    // Verify the fixtures data is now present in the destination site.
    $this->assertSame(['toolbar' => 'toolbar'], $this->config('environment_indicator.settings')->get('toolbar_integration'));
    $this->assertSame(TRUE,  $this->config('environment_indicator.settings')->get('favicon'));
  }

}

Congratulations, you’ve written your first automated Migration test!

Next steps

In the next blog post, we’ll talk about migrating simple configuration (i.e.: D7 variables to D9 config objects).

In the meantime, you could try refactoring the tests/src/Kernel/Migrate/d7/MigrateTest.php test we built in this blog post. Some ideas:

  1. Try splitting the Database::getConnection(...)->...->execute() statements into a helper function,
  2. Try randomizing the fixtures data that you insert,
  3. Try making two test methods, one for environment_indicator_favicon_overlay, where you test both the TRUE and FALSE states; and one for environment_indicator_integration.

If this is your first time writing automated tests, you might be interested in reading PHPUnit’s documentation on writing tests. PHPUnit’s assertions reference can also be pretty handy to refer to when writing tests.

If you have a lot of time, some optional, longer reads are:


The article Easy commit credits with migrations, part 3: Automated tests first appeared on the Consensus Enterprises blog.

We've disabled blog comments to prevent spam, but if you have questions or comments about this post, get in touch!