Automated testing and Continuous Integration in Drupal 8: an Introduction

Learn how to embrace automated testing and continuous integration in Drupal 8 projects

Automated testing and Continuous Integration in Drupal 8

 

Besides frontend consulting, part of my job consists in helping teams to embrace automated testing and continuous integration. These days I’m consulting with a Drupal shop in Italy, Whitedrop. Whitedrop is run by Giovanni and Federico. I met Giovanni at a Meetup in Arezzo last year and soon we became friend.

The team at Whitedrop were interested in knowing more about continuous integration and continuous delivery before taking on more challenging Drupal projects.

In this post I’d like to share the goals, the tools and the outcomes of this journey.

Enjoy the reading!

Automated testing and Continuous Integration in Drupal 8: Whitedrop, Drupal, and DevOps

I met Giovanni in 2017. He’s an easy going guy, we shared a lot of common interests and soon we became friend.

I love JavaScript and React for building frontends and I proposed myself for consulting with his agency, Whitedrop. But he told me they were trying to achieve a different thing before picking up more challenging projects: automated testing and continuous integration in Drupal.

I’m a big fan of test-driven development, especially for the frontend.

The team at Whitedrop were already testing their Drupal projects but they were using little or no automation at all.

We sit down at the table and we jot down some notes. Our goals were the following:

  • introduce continuous integration in the workflow
  • catch bugs early (before they reached production)
  • introduce automated testing within Drupal projects
  • explore continuous delivery
  • get rid of FTP for managing websites
  • put everything under version control
  • compile an extensive documentation for implementing the workflow

With these requirement in place I began exploring tools and workflows for introducing continuous integration in Drupal projects.

In the next section I’ll outline the tools I choose.

Automated testing and Continuous Integration in Drupal 8: the CI/CD service

For putting automated testing and continuous integration to work you need some sort of CI/CD server. There are hosted services out there like TravisCI, CircleCI, GitLab CI.

The team was already working with Bitbucket so I start exploring Bitbucket Pipelines.

Bitbucket Pipelines is controlled by a configuration file in which you can define parallel and sequential steps among other things. I’ll show a bit of this configuration later.

One thing I noted in Bitbucket Pipelines is that you can save artifacts but there’s no way to see them in the UI. You can just download the artifacts and open them in your workstation.

The nice thing with Bitbucket Pipelines though is that you pay per minutes. That lets you experiment with the platform and pay as you go.

While there a lot of alternatives (TravisCI, CircleCI, GitLab CI), the team at Whitedrop were already familiar with Bitbucket so we choose Bitbucket Pipelines.

Automated testing and Continuous Integration in Drupal 8: A test runner for functional JavaScript tests

I must admit I’m a bit biased towards writing functional tests in JavaScript. But I kept exploring testing tools in Drupal 8 and I read about JavascriptTestBase.

The only problem is that I don’t like the way you should wait for asyncronous interactions. Then I discovered that Nightwatch has been adopted by the Drupal community but yet the developer needs to explicitly put await statements all over the place when testing JavaScript (example: waitForElementVisible).

That’s a solved problem with Cypress, of which I’m a long time user. Cypress can await automatically and the developer can focus on writing tests rather than thinking of waitForElementVisible.

In the end I suggested the team adopting Cypress for functional JavaScript testing. I was expecting a lot of friction with this choice but the team had no problem with the Cypress’s syntax or JavaScript.

Automated testing and Continuous Integration in Drupal 8: A test runner for functional Drupal tests

While Cypress can solve a lot of problems when testing JavaScript and Drupal, there is no better thing that BrowserTestBase when you’re testing non-JavaScript Drupal pages.

Tests written in PHP with BrowserTestBase for PHPUnit can be really fast and effective. I like BrowserTestBase and I strongly suggested the team keep writing this kind of tests for pages that don’t require JavaScript.

And with the tools in place let’s see how I helped Whitedrop to explore and implement automated testing and continuous integration in Drupal 8.

Automated testing and Continuous Integration in Drupal 8: the implementation

For implementing automated testing and continuous integration in Drupal 8 all the tools listed above must work together. But first things first, every piece of code you work on should live under version control.

There’s no way around it: for making CI to work every project should be in version control.

The next step? Writing some tests for your Drupal project. The kind of test you may want to write in this preliminary phase are functional tests.

Let’s see how!

Automated testing and Continuous Integration in Drupal 8: functional JavaScript tests with Cypress

Assuming you have a working Drupal installation already in place initialize a package.json with:

npm init -y

and install Cypress with:

npm i cypress --save-dev

Note that you should launch the above commands inside the main Drupal’s project folder.

Next up start Cypress for the first time with:

node_modules/cypress/bin/cypress open

and start writing your functional test in a new file named cypress/integration/Drupal.spec.js.

Your first test can check that the Drupal frontpage returns the expected title:

describe("Drupal project", () => {

  beforeEach(() => {
    cy.visit("http://localhost:8080/");
  });

  it("As a user I can see the expected page title", () => {
    cy.title().should("include", "My first Drupal CI project");
  });

});

Save and close the file, then run the test suite in headless mode with:

node_modules/cypress/bin/cypress run

or in visual mode with:

node_modules/cypress/bin/cypress open

Later on you can configure two custom commands in package.json for quickly lauching tests:

"scripts": {
  "cy:open": "cypress open",
  "cy:run": "cypress run"
},

With a first test in place you can keep extending it. Here’s an example for checking that a node exists and it’s not empty:

describe("Drupal project", () => {

  beforeEach(() => {
    cy.visit("http://localhost:8080/");
  });

  it("As a user I can see the expected page title", () => {
    cy.title().should("include", "My first Drupal CI project");
  });

  it("As a user I can navigate to node 1 and when I reach the page I can see the title alongside some text", () => {
    cy.visit("node/1");
    cy.get("h1").should("not.be.empty");
    cy.get(".content").should("not.be.empty");
  });

});

and so on. Note that the first functional tests should happen on your local workstation. As a developer it’s your responsibility to test the code before committing to the repo.

Also you should not focus too much on testing text and strings on your pages. In other words I would test that a Drupal project is coming up with the expected content structure rather than testing if h1 contains “hello world”.

So in your local environment you would create the structure, populate it with some content and test that your nodes are ok.

Now let’s see how we can automate this first test in our continuous integration environment.

Automated testing and Continuous Integration in Drupal 8: a simple workflow for Drupal

Now, how can we put all the pieces together? We have a Drupal project and a functional test. How do we run these things in continuous integration?

Here’s the flow.

First, the developer must ensure that all the tests are passing on the local workstation.

Then he/she must export the Drupal configuration with:

drush cex

That step is fundamental! Also, the Drupal configuration must be committed to the repo. Again, that’s really important.

The configuration gets picked up in continuous integration where the system spins up a fresh installation of Drupal from the exported configuration. We’ll see that in a moment.

A note: you need to rely on the config_installer module until Drupal will implement a fully working configuration installer in core. Install the module in your project before committing your initial work:

composer require drupal/config_installer

From there you can commit everything up and configure your CI service. Let’s see how in the next section!

Automated testing and Continuous Integration in Drupal 8: configuring Bitbucket Pipelines

A CI/CD service is a service, usually hosted or in house which basically builds your projects, run tests against it and then deploy everything to staging or to production (or both).

Among the alternatives (TravisCI, CircleCI, GitLab CI) the team at Whitedrop were already familiar with Bitbucket so we choose Bitbucket Pipelines for CI/CD.

Bitbucket Pipelines is controlled by a configuration file in which you can define parallel and sequential steps, scripts and commands.

But first a quick check.

For putting everything to work you should have:

  • a local working Drupal installation
  • a package.json with Cypress installed
  • an export of the entire Drupal configuration
  • a functional test in place
  • a repo where you committed the entire project

Now you can write your CI configuration, which for Bitbucket Pipelines is a file named bitbucket-pipelines.yml. Here’s how it looks like:

image: YOUR DOCKER IMAGE

options:
  max-time: 10

pipelines:
  default:
    - step:
        name: Install Drupal and run functional tests with Cypress
        caches:
          - composer
          - npm
          - cypress
        script:
          - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
          - composer --verbose validate
          - composer --verbose install
          - cd web
          - ../vendor/bin/drush si config_installer config_installer_sync_configure_form.sync_directory=../config/sync --db-url=$SIMPLETEST_DB --yes
          - ../vendor/bin/drush en devel_generate
          - ../vendor/bin/drush genc 4
          - npm ci
          - ../vendor/bin/drush runserver $SIMPLETEST_BASE_URL &
          - npm run cy:run
          - sh ../deploy_to_staging.sh # Deploy to staging

definitions:
  caches:
    npm: $HOME/.npm
    cypress: $HOME/.cache/Cypress

The configuration is pretty straightforward but let’s focus on the script section. Here in order we:

  • install Composer
  • validate and install dependencies
  • install Drupal from the exported configuration

Here’s the relevant command for installing Drupal from the configuration:

../vendor/bin/drush si config_installer config_installer_sync_configure_form.sync_directory=../config/sync --db-url=$SIMPLETEST_DB --yes

For this to work you may want to configure SIMPLETEST_DB and SIMPLETEST_BASE_URL as pipeline variables in the Bitbucket global settings (don’t worry, global variables are overridable per repo):

SIMPLETEST_BASE_URL = http://127.0.0.1:8080/
SIMPLETEST_DB = sqlite://localhost//dev/shm/test.sqlite

Next up in the script we:

  • enable the devel_generate module
  • generate dummy content with ../vendor/bin/drush genc 4

Finally we:

  • install JavaScript dependencies with npm ci
  • run our functional test with npm run cy:run

For completeness: the first line in bitbucket-pipelines.yml should be a Docker image. For this project I built a custom Docker image starting from PHP Apache and Cypress base. Feel free to assemble your own image!

At the end of the script section we can deploy Drupal to staging if all the tests are passing (notice the deploy_to_staging script). If your functional test passes in continuous integration the result will be a nice green screen in Bitbucket Pipelines:

Automated testing and Continuous Integration in Drupal 8

How nice!

You might think: automated testing and continuous integration are hard! How do I even start? But really, with modern CI service everything it takes is assembling commands in the right order.

Start doing it now, it’s easy!

BONUS: Functional Drupal tests in continuous integration and parallel steps

When you’re testing a Drupal module or for testing static pages (no JavaScript) there’s no better tool than BrowserTestBase. Let’s say you created a Drupal module called greeting for displaying some links in the menu, and a simple block. Here’s how a test will look like:

<?php

namespace Drupal\Tests\greeting\Functional;

use Drupal\Tests\BrowserTestBase;

/**
 * Functional test for Greeting module
 * @group greeting
 */

class GreetingTest extends BrowserTestBase {
    /**
     * Modules to enable during tests
     * @var array
     */

     protected static $modules = [
        'node',
        'block',
        'greeting'
     ];

  protected function placeMenu() {

      $this->drupalPlaceBlock('system_menu_block:main', [
        'id' => 'test_menu',
      ]);

    }

     /**
      * As a user I can see a new link named "What time is it" in the main menu and when I click the link I can see a new page containing the current time
     */

    public function test_what_time_is_it() {
      $this->placeMenu();
      $this->drupalGet('');
      $this->assertSession()->linkByHrefExists('what-time');
      $this->clickLink('What time is it');
      $this->assertSession()->statusCodeEquals(200);
      
      $date_formatter = \Drupal::service('date.formatter');
      $raw_time = \Drupal::time()->getCurrentTime();
      $expected_time = $date_formatter->format($raw_time);
      $this->assertSession()->elementTextContains('css', '#current-time', $expected_time);

      // Test that the page should not be cached
      $this->drupalGet('what-time');
      $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), null);
      
    }

    /**
     * As a user I can see a new link named "Who am I" in the main menu and when I click the link I can see a new page containing my user info
     */

    public function test_who_am_i() {
       $this->placeMenu();
       // Create a new user for this test
       $user = $this->drupalCreateUser();
       $expected_name = $user->getUsername();

       $this->drupalGet('');
       $this->assertSession()->linkByHrefExists('whoami');
       $this->drupalLogin($user);
       $this->clickLink('Whoami');
       $this->assertSession()->statusCodeEquals(200);
       $this->assertSession()->elementTextContains('css', '#username', $expected_name);

       // Logout and check for anonymous user
       $this->drupalLogout();
       $this->drupalGet('whoami');
       $this->assertSession()->elementTextContains('css', '#username', 'anonymous');   
      
    }

  public function test_greeting_block() {

      $this->drupalPlaceBlock('greeting_block');

      $this->drupalGet('');
      $this->assertSession()->statusCodeEquals(200);
      $this->assertSession()->pageTextContains('Greeting block');
      $this->assertSession()->elementExists('css', '#block-greeting');
    }

}

You will run this test in your local project with:

phpunit -c core --group greeting

And when you’re ready you would commit to the repo. Every commit will trigger a pipeline which in turn will runs tests. But now let’s say you want to run parallel tests in continuous integration: functional JavaScript tests with Cypress and BrowserTestBase at the same time. How do you configure that?

Bitbucket Pipelines can run parallel steps. Here’s a configuration for running both tests at the same time (cache section omitted for brevity):

image: YOUR DOCKER IMAGE

options:
  max-time: 10

pipelines:
  default:
    - step:
        name: Install Drupal 
        caches:
          - composer
        script:
          - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
          - composer --verbose validate
          - composer --verbose install
          - cd web
          - ../vendor/bin/drush si config_installer config_installer_sync_configure_form.sync_directory=../config/sync --db-url=$SIMPLETEST_DB --yes
        artifacts:
          # copy the resulting build to be used for the next parallel steps
          - vendor/**
          - web/**
          - package.json
          - package-lock.json
          - cypress.json
          - cypress/**
          - deploy_to_staging.sh
    - parallel:
        - step:
            name: Run functional tests with BrowserTestBase
            script: 
              - cd web
              - ../vendor/bin/drush runserver $SIMPLETEST_BASE_URL &
              - ../vendor/bin/phpunit -c core --group greeting # Other groups go here
        - step:
            name: Install Cypress and run functional JS tests
            caches:
              - npm
              - cypress
            script:
              - cd web
              - ../vendor/bin/drush en devel_generate
              - ../vendor/bin/drush genc 4
              - ../vendor/bin/drush runserver $SIMPLETEST_BASE_URL &
              - npm ci
              - npm run cy:run
            artifacts:
              # Save test failures as artifacts
              - cypress/screenshots/**
              - cypress/videos/**
    - step:
        name: Deploy to staging
        script:
          - sh deploy_to_staging.sh

# put cache here

Notice the parallel block above. In that block you can run parallel steps after the first sequential step has run. In our case the first step should install Drupal and copy over the resulting files (artifacts) to the next step.

Additionaly you can deploy to staging if all the tests are passing. This is how parallel steps look like in Bitbucket Pipelines:

Automated testing and Continuous Integration in Drupal 8

Great job! If you want to learn more about Bitbucket Pipelines configuration they have a great documentation at Build, test, and deploy with Pipelines.

Automated testing and Continuous Integration in Drupal 8: wrapping up

I never worked with Drupal 8 before, my experience with Drupal dates back to 10 year ago. But I can see how it’s getting better day after day.

PHP frameworks are evolving: long gone are those times when you just need FTP for managing and deploying websites. These days Drupal gives you a lot of flexibility for managing tests, builds and deployments. The configuration export is a great tool and Drush is second to none: almost everything is automatable with Drush.

Put Drupal inside a CI/CD service and you get continuous integration and automated testing almost for free. Here’s what you need for starting now:

  • a local working Drupal installation
  • a package.json, and Cypress installed
  • an export of the entire Drupal configuration
  • a functional test in place
  • a repo where you committed the entire project
  • a CI service

Be aware: the prerequisite for continuous integration is that everything should be under version control. It is paramount to have a tech savy team for implementing continuous integration, automated testing, and continuous delivery. But once you get there you won’t never look back!

Automated testing and Continuous Integration in Drupal 8: resources

If you’re interested in learning more about continuous integration in Drupal 8 I can recommend the Lullabot blog. They have a series for learning CI on Travis CI and other services: Continuous Integration in Drupal 8 with Travis CI.

Also, Dmitry Romanovsky has a blog post on Continuous integration with Drupal 8 and Gitlab CI/CD.

And if you want to learn more about Cypress take a look at my blog post: JavaScript End to End Testing with Cypress.

Thanks for reading and stay tuned for more!

Valentino Gagliardi

Valentino Gagliardi

I help busy people to learn Web Development and JavaScript - DevOps Consultant
Valentino Gagliardi

2 Replies to “Automated testing and Continuous Integration in Drupal 8: an Introduction”

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.