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:
- In part 1, we set up a simple core migration.
- In part 2, I covered how to review and manually test patches.
- In this part, I’ll demonstrate how to write automated migration tests.
- In part 4, I discuss migrating simple configuration.
- In part 5, I explain how to declare a module’s migration status to the migrate wizard.
- In part 6, I show how to migrate data from a custom database table.
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:
- Set up the migration source database,
- Fill the migration source database with data to migrate (“set up Test Fixtures”),
- Run the migration (“run the System Under Test”), and,
- 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
- Clone Drupal core’s
9.2.x
branch, set up your development environment on the repository (note there is noweb/
orhtml/
folder in this setup), and runcomposer install
. - 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.
- Clone the 8.x version of the module into your D9 site, as described in part 2.
- 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.
- 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:-
Check out the branch from the issue “Version” field again (e.g.:
git checkout 8.x-2.x
) -
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
. -
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
-
Create a folder for the tests:
mkdir -p tests/src/Kernel/Migrate/d7
-
Using your preferred text editor, create a PHP file in that folder,
tests/src/Kernel/Migrate/d7/MigrateTest.php
, and edit it as follows, replacingMODULE_NAME
with the machine name of the module; andMIGRATION_NAME
with the migration name you found in themigrations/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); } }
-
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’sindex.php
, you can write temporary files to/tmp/
, and you installed the module you’re patching tomodules/MODULE_NAME
.If you’re using Lando or Ddev, you will probably need to
lando ssh -s appserver
orddev 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:
- Try splitting the
Database::getConnection(...)->...->execute()
statements into a helper function, - Try randomizing the fixtures data that you insert,
- Try making two test methods, one for
environment_indicator_favicon_overlay
, where you test both the TRUE and FALSE states; and one forenvironment_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:
- Drupal’s MigrateDrupal7TestBase class;
- api.drupal.org’s “automated tests” topic;
- Drupal.org’s “PHPUnit in Drupal” docs landing page; and;
- Drupal.org’s “Migration tests” docs landing page.
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!