578 lines
19 KiB
Markdown
578 lines
19 KiB
Markdown
---
|
||
title: Writing a new Drupal 8 Module using Test Driven Development (TDD)
|
||
tags: [drupal, testing, tdd, simpletest, phpunit]
|
||
excerpt: How to write automated tests and follow test driven development for Drupal modules.
|
||
meta:
|
||
image:
|
||
url: /images/talks/test-driven-drupal-development.png
|
||
width: 2560
|
||
height: 1440
|
||
type: image/png
|
||
---
|
||
<p class="text-center" markdown="1">![](/images/blog/drupalcamp-dublin.jpg)</p>
|
||
|
||
I recently gave a [talk on automated testing in Drupal][0] talk at [DrupalCamp Dublin][1] and as a lunch and learn session for my colleagues at Microserve. As part of the talk, I gave an example of how to build a Drupal 8 module using a test driven approach. I’ve released the [module code on GitHub][2], and this post outlines the steps of the process.
|
||
|
||
## Prerequisites
|
||
|
||
You have created a `core/phpunit.xml` file based on `core/phpunit.xml.dist`, and populated it with your database credentials so that PHPUnit can bootstrap the Drupal database as part of the tests. [Here is an example][5].
|
||
|
||
## Acceptance Criteria
|
||
|
||
For the module, we are going to satisfy this example acceptance criteria:
|
||
|
||
> As a site visitor,<br>
|
||
> I want to see all published pages at /pages<br>
|
||
> Ordered alphabetically by title
|
||
|
||
## Initial Setup
|
||
|
||
Let’s start by writing the minimal code needed in order for the new module to
|
||
be enabled. In Drupal 8, this is the `.info.yml` file.
|
||
|
||
```language-yaml
|
||
# tdd_dublin.info.yml
|
||
|
||
name: 'TDD Dublin'
|
||
excerpt: 'A demo module for DrupalCamp Dublin to show test driven module development.'
|
||
core: 8.x
|
||
type: module
|
||
```
|
||
|
||
We can also add the test file structure at this point too. We’ll call it `PageTestTest.php` and put it within a `tests/src/Functional` directory. As this is a functional test, it extends the `BrowserTestBase` class, and we need to ensure that the tdd_dublin module is enabled by adding it to the `$modules` array.
|
||
|
||
```language-php
|
||
// tests/src/Functional/PageListTest.php
|
||
|
||
namespace Drupal\Tests\tdd_dublin\Functional;
|
||
|
||
use Drupal\Tests\BrowserTestBase\BrowserTestBase;
|
||
|
||
class PageListTest extends BrowserTestBase {
|
||
|
||
protected static $modules = ['tdd_dublin'];
|
||
|
||
}
|
||
```
|
||
|
||
With this in place, we can now start adding test methods.
|
||
|
||
## Ensure that the Listing page Exists
|
||
|
||
### Writing the First Test
|
||
|
||
Let’s start by testing that the listing page exists at /pages. We can do this by loading the page and checking the status code. If the page exists, the code will be 200, otherwise it will be 404.
|
||
|
||
I usually like to write comments first within the test method, just to outline the steps that I'm going to take and then replace it with code.
|
||
|
||
```language-php
|
||
public function testListingPageExists() {
|
||
// Go to /pages and check that it is accessible by checking the status
|
||
// code.
|
||
}
|
||
```
|
||
|
||
We can use the `drupalGet()` method to browse to the required path, i.e. `/pages`, and then write an assertion for the response code value.
|
||
|
||
```language-php
|
||
public function testListingPageExists() {
|
||
$this->drupalGet('pages');
|
||
|
||
$this->assertSession()->statusCodeEquals(200);
|
||
}
|
||
```
|
||
|
||
### Running the Test
|
||
|
||
In order to run the tests, you either need to include `-c core` or be inside the `core` directory when running the command, to ensure that the test classes are autoloaded so can be found, though the path to the `vendor` directory may be different depending on your project structure. You can also specify a path within which to run the tests - e.g. within the module’s `test` directory.
|
||
|
||
```language-plain
|
||
$ vendor/bin/phpunit -c core modules/custom/tdd_dublin/tests
|
||
```
|
||
|
||
<div class="note" markdown="1">
|
||
Note: I’m using Docksal, and I’ve noticed that I need to run the tests from within the CLI container. You can do this by running the `fin bash` command.
|
||
</div>
|
||
|
||
```language-plain
|
||
1) Drupal\Tests\tdd_dublin\Functional\PageListTest::testListingPageExists
|
||
Behat\Mink\Exception\ExpectationException: Current response status code is 404, but 200 expected.
|
||
|
||
FAILURES!
|
||
Tests: 1, Assertions: 1, Errors: 1.
|
||
```
|
||
|
||
Because the route does not yet exist, the response code returned is 404, so the test fails.
|
||
|
||
Now we can make it pass by adding the page. For this, I will use the Views module, though you could achieve the same result with a custom route and a Controller.
|
||
|
||
### Building the View
|
||
|
||
To begin with, I will create a view showing all types of content with a default sort order of newest first. We will use further tests to ensure that only the correct content is returned and that it is ordered correctly.
|
||
|
||
![](/images/blog/tdd-drupal-1.png) { .with-border }
|
||
|
||
The only addition I will make to the view is to add a path at `pages`, as per the acceptance criteria.
|
||
|
||
![](/images/blog/tdd-drupal-2.png) { .with-border }
|
||
|
||
### Exporting the View
|
||
|
||
With the first version of the view built, it needs to be incldued within the module so that it can be enabled when the test is run. To do this, we need to export the configuration for the view, and place it within the module’s `config/install` directory. This can be done using the `drush config-export` command or from within the Drupal UI. In either case, the `uid` line at the top of the file needs to be removed so the configuration can be installed.
|
||
|
||
Here is the exported view configuration:
|
||
|
||
```language-yaml
|
||
langcode: en
|
||
status: true
|
||
dependencies:
|
||
module:
|
||
- node
|
||
- user
|
||
id: pages
|
||
label: pages
|
||
module: views
|
||
excerpt: ''
|
||
tag: ''
|
||
base_table: node_field_data
|
||
base_field: nid
|
||
core: 8.x
|
||
display:
|
||
default:
|
||
display_plugin: default
|
||
id: default
|
||
display_title: Master
|
||
position: 0
|
||
display_options:
|
||
access:
|
||
type: perm
|
||
options:
|
||
perm: 'access content'
|
||
cache:
|
||
type: tag
|
||
options: { }
|
||
query:
|
||
type: views_query
|
||
options:
|
||
disable_sql_rewrite: false
|
||
distinct: false
|
||
replica: false
|
||
query_comment: ''
|
||
query_tags: { }
|
||
exposed_form:
|
||
type: basic
|
||
options:
|
||
submit_button: Apply
|
||
reset_button: false
|
||
reset_button_label: Reset
|
||
exposed_sorts_label: 'Sort by'
|
||
expose_sort_order: true
|
||
sort_asc_label: Asc
|
||
sort_desc_label: Desc
|
||
pager:
|
||
type: mini
|
||
options:
|
||
items_per_page: 10
|
||
offset: 0
|
||
id: 0
|
||
total_pages: null
|
||
expose:
|
||
items_per_page: false
|
||
items_per_page_label: 'Items per page'
|
||
items_per_page_options: '5, 10, 25, 50'
|
||
items_per_page_options_all: false
|
||
items_per_page_options_all_label: '- All -'
|
||
offset: false
|
||
offset_label: Offset
|
||
tags:
|
||
previous: ‹‹
|
||
next: ››
|
||
style:
|
||
type: default
|
||
options:
|
||
grouping: { }
|
||
row_class: ''
|
||
default_row_class: true
|
||
uses_fields: false
|
||
row:
|
||
type: fields
|
||
options:
|
||
inline: { }
|
||
separator: ''
|
||
hide_empty: false
|
||
default_field_elements: true
|
||
fields:
|
||
title:
|
||
id: title
|
||
table: node_field_data
|
||
field: title
|
||
entity_type: node
|
||
entity_field: title
|
||
label: ''
|
||
alter:
|
||
alter_text: false
|
||
make_link: false
|
||
absolute: false
|
||
trim: false
|
||
word_boundary: false
|
||
ellipsis: false
|
||
strip_tags: false
|
||
html: false
|
||
hide_empty: false
|
||
empty_zero: false
|
||
settings:
|
||
link_to_entity: true
|
||
plugin_id: field
|
||
relationship: none
|
||
group_type: group
|
||
admin_label: ''
|
||
exclude: false
|
||
element_type: ''
|
||
element_class: ''
|
||
element_label_type: ''
|
||
element_label_class: ''
|
||
element_label_colon: true
|
||
element_wrapper_type: ''
|
||
element_wrapper_class: ''
|
||
element_default_classes: true
|
||
empty: ''
|
||
hide_alter_empty: true
|
||
click_sort_column: value
|
||
type: string
|
||
group_column: value
|
||
group_columns: { }
|
||
group_rows: true
|
||
delta_limit: 0
|
||
delta_offset: 0
|
||
delta_reversed: false
|
||
delta_first_last: false
|
||
multi_type: separator
|
||
separator: ', '
|
||
field_api_classes: false
|
||
filters:
|
||
status:
|
||
value: '1'
|
||
table: node_field_data
|
||
field: status
|
||
plugin_id: boolean
|
||
entity_type: node
|
||
entity_field: status
|
||
id: status
|
||
expose:
|
||
operator: ''
|
||
group: 1
|
||
sorts:
|
||
created:
|
||
id: created
|
||
table: node_field_data
|
||
field: created
|
||
order: DESC
|
||
entity_type: node
|
||
entity_field: created
|
||
plugin_id: date
|
||
relationship: none
|
||
group_type: group
|
||
admin_label: ''
|
||
exposed: false
|
||
expose:
|
||
label: ''
|
||
granularity: second
|
||
header: { }
|
||
footer: { }
|
||
empty: { }
|
||
relationships: { }
|
||
arguments: { }
|
||
display_extenders: { }
|
||
cache_metadata:
|
||
max-age: -1
|
||
contexts:
|
||
- 'languages:language_content'
|
||
- 'languages:language_interface'
|
||
- url.query_args
|
||
- 'user.node_grants:view'
|
||
- user.permissions
|
||
tags: { }
|
||
page_1:
|
||
display_plugin: page
|
||
id: page_1
|
||
display_title: Page
|
||
position: 1
|
||
display_options:
|
||
display_extenders: { }
|
||
path: pages
|
||
cache_metadata:
|
||
max-age: -1
|
||
contexts:
|
||
- 'languages:language_content'
|
||
- 'languages:language_interface'
|
||
- url.query_args
|
||
- 'user.node_grants:view'
|
||
- user.permissions
|
||
tags: { }
|
||
```
|
||
|
||
When the test is run again, we see a different error that leads us to the next step.
|
||
|
||
```language-plain
|
||
1) Drupal\Tests\tdd_dublin\Functional\PageListTest::testListingPageExists
|
||
Drupal\Core\Config\UnmetDependenciesException: Configuration objects provided by <em class="placeholder">tdd_dublin</em> have unmet dependencies: <em class="placeholder">node.type.page (node), views.view.pages (node, views)</em>
|
||
|
||
FAILURES!
|
||
Tests: 1, Assertions: 0, Errors: 1.
|
||
```
|
||
|
||
This error is identifying unmet dependencies within the module’s configuration. In this case, the view that we’ve added depends on the node and views modules, but these aren’t enabled. To fix this, we can add the extra modules as dependencies of tdd_dublin so they will be enabled too.
|
||
|
||
```language-yaml
|
||
# tdd_dublin.info.yml
|
||
|
||
dependencies:
|
||
- drupal:node
|
||
- drupal:views
|
||
```
|
||
|
||
```language-plain
|
||
1) Drupal\Tests\tdd_dublin\Functional\PageListTest::testListingPageExists
|
||
Drupal\Core\Config\UnmetDependenciesException: Configuration objects provided by <em class="placeholder">tdd_dublin</em> have unmet dependencies: <em class="placeholder">views.view.pages (node.type.page)</em>
|
||
|
||
FAILURES!
|
||
Tests: 1, Assertions: 0, Errors: 1.
|
||
```
|
||
|
||
With the modules enabled, we can see one more unmet dependency for `node.type.page`. This means that we need a page content type to be able to install the view. We can fix this in the same way as before, by exporting the configuration and copying it into the `config/install` directory.
|
||
|
||
With this in place, the test should now pass - and it does.
|
||
|
||
```language-plain
|
||
Time: 26.04 seconds, Memory: 6.00MB
|
||
|
||
OK (1 test, 1 assertion)
|
||
```
|
||
|
||
We now have a test to ensure that the listing page exists.
|
||
|
||
## Ensure that only Published Pages are Shown
|
||
|
||
### Writing the Test
|
||
|
||
Now that we have a working page, we can now move on to checking that the correct content is returned. Again, I’ll start by writing comments and then translate that into code.
|
||
|
||
The objectives of this test are:
|
||
|
||
* To ensure that only page nodes are returned.
|
||
* To ensure that only published nodes are returned.
|
||
|
||
```language-php
|
||
public function testOnlyPublishedPagesAreShown() {
|
||
// Given that a have a mixture of published and unpublished pages, as well
|
||
// as other types of content.
|
||
|
||
// When I view the page.
|
||
|
||
// Then I should only see the published pages.
|
||
}
|
||
```
|
||
|
||
In order to test the different scenarios, I will create an additional "article" content type, create a node of this type as well as one published and one unpublished page. From this combination, I only expect one node to be visible.
|
||
|
||
```language-php
|
||
public function testOnlyPublishedPagesAreShown() {
|
||
$this->drupalCreateContentType(['type' => 'article']);
|
||
|
||
$this->drupalCreateNode(['type' => 'page', 'status' => TRUE]);
|
||
$this->drupalCreateNode(['type' => 'article']);
|
||
$this->drupalCreateNode(['type' => 'page', 'status' => FALSE]);
|
||
|
||
// When I view the page.
|
||
|
||
// Then I should only see the published pages.
|
||
}
|
||
```
|
||
|
||
We could use `drupalGet()` again to browse to the page and write assertions based on the rendered HTML, though I’d rather do this against the data returned from the view itself. This is so that the test isn’t too tightly coupled to the presentation logic, and we won’t be in a situation where at a later date the test fails because of changes made to how the data is displayed.
|
||
|
||
Rather, I’m going to use `views_get_view_result()` to programmatically get the result of the view. This returns an array of `Drupal\views\ResultRow` objects, which contain the nodes. I can use `array_column` to extract the node IDs from the view result into an array.
|
||
|
||
```language-php
|
||
public function testOnlyPublishedPagesAreShown() {
|
||
$this->drupalCreateContentType(['type' => 'article']);
|
||
|
||
$this->drupalCreateNode(['type' => 'page', 'status' => TRUE]);
|
||
$this->drupalCreateNode(['type' => 'article']);
|
||
$this->drupalCreateNode(['type' => 'page', 'status' => FALSE]);
|
||
|
||
$result = views_get_view_result('pages');
|
||
$nids = array_column($result, 'nid');
|
||
|
||
// Then I should only see the published pages.
|
||
}
|
||
```
|
||
|
||
From the generated nodes, I can use `assertEquals()` to compare the returned node IDs from the view against an array of expected node IDs - in this case, I expect only node 1 to be returned.
|
||
|
||
```language-php
|
||
public function testOnlyPublishedPagesAreShown() {
|
||
$this->drupalCreateContentType(['type' => 'article']);
|
||
|
||
$this->drupalCreateNode(['type' => 'page', 'status' => TRUE]);
|
||
$this->drupalCreateNode(['type' => 'article']);
|
||
$this->drupalCreateNode(['type' => 'page', 'status' => FALSE]);
|
||
|
||
$result = views_get_view_result('pages');
|
||
$nids = array_column($result, 'nid');
|
||
|
||
$this->assertEquals([1], $nids);
|
||
}
|
||
```
|
||
|
||
### Running the Test
|
||
|
||
The test fails as no extra conditions have been added to the view, though the default "Content: Published" filter is already excluding one of the page nodes. We can see from the output from the test that node 1 (a page) and node 2 (the article) are both being returned.
|
||
|
||
```language-plain
|
||
1) Drupal\Tests\tdd_dublin\Functional\PageListTest::testOnlyPublishedPagesAreShown
|
||
Failed asserting that two arrays are equal.
|
||
--- Expected
|
||
+++ Actual
|
||
@@ @@
|
||
Array (
|
||
- 0 => 1
|
||
+ 0 => '2'
|
||
+ 1 => '1'
|
||
)
|
||
|
||
FAILURES!
|
||
Tests: 1, Assertions: 3, Failures: 1.
|
||
```
|
||
|
||
### Updating the Test
|
||
|
||
We can fix this by adding another condition to the view, to only show content based on the node type - i.e. only return page nodes.
|
||
|
||
![](/images/blog/tdd-drupal-3.png) { .with-border }
|
||
|
||
Once the view is updated and the configuration is updated within the module, the test should then pass - and it does.
|
||
|
||
```language-plain
|
||
Time: 24.76 seconds, Memory: 6.00MB
|
||
|
||
OK (1 test, 3 assertions)
|
||
```
|
||
|
||
## Ensure that the Pages are in the Correct Order
|
||
|
||
### Writing the Test
|
||
|
||
As we know that the correct content is being returned, we can now focus on displaying it in the correct order. We’ll start again by adding a new test method and filling out the comments.
|
||
|
||
```language-php
|
||
public function testResultsAreOrderedAlphabetically() {
|
||
// Given I have multiple nodes with different titles.
|
||
|
||
// When I view the pages list.
|
||
|
||
// Then I should see pages in the correct order.
|
||
}
|
||
```
|
||
|
||
To begin with this time, I’ll create a number of different nodes and specify the title for each. These are intentionally in the incorrect order alphabetically so that we can see the test fail initially and then see it pass after making a change so we know that the change worked.
|
||
|
||
```language-php
|
||
public function testResultsAreOrderedAlphabetically() {
|
||
$this->drupalCreateNode(['title' => 'Page A']);
|
||
$this->drupalCreateNode(['title' => 'Page D']);
|
||
$this->drupalCreateNode(['title' => 'Page C']);
|
||
$this->drupalCreateNode(['title' => 'Page B']);
|
||
|
||
// When I view the pages list.
|
||
|
||
// Then I should see pages in the correct order.
|
||
}
|
||
```
|
||
|
||
We can use the same method as the previous test to get the returned IDs, using `views_get_view_result()` and `array_column()`, and assert that the returned node IDs match the expected node IDs in the specified order. Based on the defined titles, the order should be 1, 4, 3, 2.
|
||
|
||
```language-php
|
||
public function testResultsAreOrderedAlphabetically() {
|
||
$this->drupalCreateNode(['title' => 'Page A']);
|
||
$this->drupalCreateNode(['title' => 'Page D']);
|
||
$this->drupalCreateNode(['title' => 'Page C']);
|
||
$this->drupalCreateNode(['title' => 'Page B']);
|
||
|
||
$nids = array_column(views_get_view_result('pages'), 'nid');
|
||
|
||
$this->assertEquals([1, 4, 3, 2], $nids);
|
||
}
|
||
```
|
||
|
||
### Running the Test
|
||
|
||
As expected the test fails, as the default sort criteria in the view orders the results by their created date.
|
||
|
||
In the test output, we can see the returned results are in sequential order so the results array does not match the expected one.
|
||
|
||
This would be particularly more complicated to test if I was using `drupalGet()` and having to parse the HTML, compared to getting the results as an array from the view programmatically.
|
||
|
||
```language-plain
|
||
1) Drupal\Tests\tdd_dublin\Functional\PageListTest::testResultsAreOrderedAlphabetically
|
||
Failed asserting that two arrays are equal.
|
||
--- Expected
|
||
+++ Actual
|
||
@@ @@
|
||
Array (
|
||
- 0 => 1
|
||
- 1 => 4
|
||
- 2 => 3
|
||
- 3 => 2
|
||
+ 0 => '1'
|
||
+ 1 => '2'
|
||
+ 2 => '3'
|
||
+ 3 => '4'
|
||
)
|
||
|
||
FAILURES!
|
||
Tests: 1, Assertions: 2, Failures: 1.
|
||
```
|
||
|
||
### Updating the Test
|
||
|
||
This can be fixed by removing the default sort criteria and adding a new one based on "Content: Title".
|
||
|
||
![](/images/blog/tdd-drupal-4.png) { .with-border }
|
||
|
||
Again, once the view has been updated and exported, the test should pass - and it does.
|
||
|
||
```language-plain
|
||
Time: 27.55 seconds, Memory: 6.00MB
|
||
|
||
OK (1 test, 2 assertions)
|
||
```
|
||
|
||
## Ensure all Tests Still Pass
|
||
|
||
Now we know that all the tests pass individually, all of the module tests should now be run to ensure that they all still pass and that there have been no regressions due to any of the changes.
|
||
|
||
```language-plain
|
||
docker@cli:/var/www$ vendor/bin/phpunit -c core modules/custom/tdd_dublin/tests
|
||
|
||
Testing modules/custom/tdd_dublin/tests
|
||
...
|
||
|
||
Time: 1.27 minutes, Memory: 6.00MB
|
||
|
||
OK (3 tests, 6 assertions)
|
||
```
|
||
|
||
They all pass, so we be confident that the code works as expected, we can continue to refactor if needed, and if any changes are made to this module at a later date, we have the tests to ensure that any regressions are caught and fixed before deployment.
|
||
|
||
## Next Steps
|
||
|
||
I’ve started looking into whether some of the tests can be rewritten as kernel tests, which should result in quicker test execution. I will post any updated code to the [GitHub repository][3], and will also do another blog post highlighting the differences between functional and kernel tests and the steps taken to do the conversion.
|
||
|
||
[0]: {{site.url}}/talks/tdd-test-driven-drupal
|
||
[1]: http://2017.drupal.ie
|
||
[2]: https://github.com/opdavies/tdd_dublin
|
||
[3]: https://packagist.org/packages/tightenco/collect
|
||
[4]: http://php.net/manual/en/function.array-column.php
|
||
[5]: https://gist.github.com/opdavies/dc5f0cea46ccd349b34a9f3a463c14bb
|