Please feel free to create issues and/or submit pull requests to this
repository whilst working through these instructions. Any contributions
would be greatly appreciated!
Feedback
--------
Feedback would also be appreciated! You can contact me via oliver@odwd.uk, [@opdavies on Twitter](https://twitter.com/opdavies) or ``opdavies`` on Drupal Slack.
Introduction
------------
Creating a new Drupal project with Composer
-------------------------------------------
If don’t have Composer, visit https://getcomposer.org/download for instructions on how to install it on your computer.
This assumes that Composer is installed globally and is available by running the ``composer`` command. Alternatively, you can download the phar file and run ``php composer.phar`` instead.
Firstly, we need to create a ``phpunit.xml`` file to configure PHPUnit. Core has a ``phpunit.xml.dist`` file that we can duplicate and edit.
..code:: bash
cp web/core/phpunit.xml.dist web/core/phpunit.xml
Update the ``SIMPLETEST_BASE_URL`` value to be the address that the website is currently running on. This needs to be correct so that functional tests return the correct response codes, so ensure that any port numbers are correct and also that the site is correctly marked as HTTP or HTTPS.
We also need to configure the database for Drupal to connect to and use when running functional and kernel tests. This could be your project’s MySQL or PostgreSQL database with a table prefix, but in this case, we’ll use a separate SQLite database.
To simplify running tests, the command could be simplified by `adding a script <https://getcomposer.org/doc/articles/scripts.md#writing-custom-commands>` to ``composer.json``:
This means that you can run just ``ddev composer test:phpunit`` or ``ddev composer test`` and it will execute the ``phpunit`` command.
This approach can be useful if you want to run other commands in addition to PHPUnit such as PHPStan, PHP Code Sniffer or Drupal Check. Each command can be added to the script and they will each be executed.
If needed, you can still pass additional arguments and options to the command by appending ``--`` followed by the arguments.
Locally, ensure that the command is prefixed with ``ddev`` so that it is run within the container. This ensures that the correct PHP version etc is used.
Now that we’re sure that the front page loads correctly, lets also check anonymous users cannot access the administration area. This test is very similar to the previous one, though instead we’re making a GET request to ``/admin`` and ensuring that the response code is 403 (forbidden).
As this functionality is provided by Drupal core by default, this should pass automatically.
..code:: php
/** @test */
public function the_admin_page_is_not_accessible_to_anonymous_users() {
Now let’s check that an administrator user *can* access the admin pages.
This introduces some new concepts. We need to create a user to begin with, and assign it some permissions. Because tests may be included within Drupal core a contributed module, permissions need to be added to users directly as modules won’t know about roles that are specific to your site.
The ``BrowserTestBase`` class gives access to a number of helper methods, including ones for creating and logging-in users (``createUser`` and ``drupalLogin`` respectively). When creating a user, the first argument is an array of permission names to add. In this case, we can make the user an admin user by adding the ``access administration pages`` permission.
..code:: php
/** @test */
public function the_admin_page_is_accessible_by_admin_users() {
Again, as this functionality is provided by Drupal core by default, this should pass. However, we can be confident that the test is doing what’s needed by making it fail by removing or changing the assigned permissions, or not logging in the user before accessing the page.
Let’s start by building a blog page. This will look very similar to the admin page tests, but instead we’ll be testing the ``/blog`` page.
..code:: php
<?php
// tests/src/Functional/BlogPageTest.php
namespace Drupal\my_module\Functional;
use Drupal\Tests\BrowserTestBase;
use Symfony\Component\HttpFoundation\Response;
class BlogPageTest extends BrowserTestBase {
protected $defaultTheme = 'stark';
protected static $modules = [
'my_module',
];
/** @test */
public function the_blog_page_loads_for_anonymous_users_and_contains_the_right_text() {
$this->drupalGet('blog');
$session = $this->assertSession();
$session->statusCodeEquals(Response::HTTP_OK);
}
}
This test will fail as there’s no route for ``/blog`` and no View that generates that page. Because of this, the response code will be a 404 instead of the 200 that we want.
Current response status code is 404, but 200 expected.
We’ll create a blog page using a custom route in the module. You could also do this with the Views module by creating a View with a page on that path, and exporting the configuration into the module’s ``config/install`` directory.
To add a route, we need to create a ``my_module.routing.yml`` file.
Let’s start by creating a minimal controller, that returns an empty render array. Because we didn’t specify a method to use within the route file, we use PHP’s ``__invoke()`` method.
..code:: php
<?php
// src/Controller/BlogPageController
namespace Drupal\my_module\Controller;
class BlogPageController {
public function __invoke(): array {
return [];
}
}
This is enough for the test to pass. Though it just returns an empty page, it now returns the correct 200 response code.
We’ll be using an ArticleRepository class to get the blog posts from the database, and this is also a good time to switch to writing kernel tests as we don’t need to check any responses from the browser.
Within the tests directory, create a new ``Kernel`` directory.
::
mkdir tests/src/Kernel
And an ``ArticleRepositoryTest`` class.
..code:: php
<?php
// tests/src/Kernel/ArticleRepositoryTest.php
namespace Drupal\Tests\my_module\Kernel;
use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
class ArticleRepositoryTest extends EntityKernelTestBase {
/** @test */
public function it_returns_blog_posts() {
}
}
This test looks very similar to the functional ones that we’ve already written, except it extends a different base class.
This test is extending ``EntityKernelTestBase`` as we’re working with entities and this performs some useful setup steps for us. There are different base classes that can be used though based on what you need - including ``KernelTestBase`` and ``ConfigFormTestBase``.
As the test name suggests, we’re going to be retrieving the articles from an ``ArticleRepository`` service - though this doesn’t exist yet, but let’s let the tests tell us that.
We’ll use a ``getAll()`` method on the repository to retrieve the articles from the database, and use the value of this for the ``$articles`` variable:
The ``ArticleRepository`` needs to return some articles. We can do this by injecting the ``EntityTypeManager`` and using it to return nodes from the ``getAll()`` method rather than the empty array.
..code:: diff
+ use Drupal\Core\Entity\EntityTypeManagerInterface;
Within our services file, we now need to add the ``EntityTypeManager`` as an argument so that it’s used to create the ``ArticleRepository``. Currently we don’t have enough arguments.
ArgumentCountError : Too few arguments to function
Drupal\_module::\_\_construct(), 0 passed and exactly 1 expected
The quickest way to do that is to enable autowiring for the ArticleRepository within ``my_module.services.yml``. This will automatically inject services rather than needing to specify each argument individually.
We now know that only article nodes are returned, but *all* articles are being returned. On our blog, we only want to published articles to be displayed.
Let’s create another test for this.
..code:: diff
+ /** @test */
+ public function only_published_articles_are_returned() {
We already know that only articles are returned, so in this test we can focus on the published status. We can create a number of articles, some which are published and some which are unpublished.
..code:: diff
/** @test */
public function only_published_articles_are_returned() {
Use ``createNode()`` again to create some article nodes, each with a different ``created`` date to match our assertion. This is to ensure that the test doesn’t pass by default.
..code:: diff
/** @test */
public function nodes_are_ordered_by_date_and_returned_newest_first() {
+ $this->createNode(['type' => 'article', 'created' => (new DrupalDateTime('-2 days'))->getTimestamp()]);
+ $this->createNode(['type' => 'article', 'created' => (new DrupalDateTime('-1 week'))->getTimestamp()]);
+ $this->createNode(['type' => 'article', 'created' => (new DrupalDateTime('-1 hour'))->getTimestamp()]);
+ $this->createNode(['type' => 'article', 'created' => (new DrupalDateTime('-1 year'))->getTimestamp()]);
+ $this->createNode(['type' => 'article', 'created' => (new DrupalDateTime('-1 month'))->getTimestamp()]);
What if we wanted to return a custom ``Post`` class from the repository with its own data and logic rather than a generic Drupal node? As the repository is responsible for finding and returning nodes, we can make changes there and return what we want.
Let's start by changing one of our existing tests.
In ``ArticleRepositoryTest`` we have existing assertions as to what type of object is returned. Currently, this should be an instance of a ``Node::class``. Let's change that to a new ``Post::class`` and also change the ``label`` method to a more desciriptive ``getTitle()``.
Create a ``Unit`` directory, an ``Entity`` sub-directory, and a ``PostTest.php`` file. Typically, unit tests match the directory structure in ``src`` and the class name that they're testing.
::
mkdir -p tests/src/Unit/Entity
..code:: php
<?php
namespace Drupal\Tests\my_module\Unit\Entity;
use Drupal\Tests\UnitTestCase;
class PostTest extends UnitTestCase {
}
For the first test case, let's ensure that the title is returned.
As we are working with a unit test, we can't interact with the database in the same way that we can with functional or kernel tests. This means that using methods like ``Node::create`` won't work in unit tests, so we need to create our own mock node and tell it what to return.
..code:: php
$node = $this->createMock(NodeInterface::class);
$node->expects($this->once())
->method('label')
->willReturn('Test post');
This ensures that the ``label()`` method will only be called once and that it will return the string ``Test post``.
As this is the same value as our expection in the test, this test should now pass.
However, whilst the unit tests are all passing, one of the kernel tests is now failing.
Currently any node is able to be passed to the ``Post`` class. Let's ensure that only article nodes can be added by adding a check and throwing an Exception.
..code:: php
/** @test */
public function it_throws_an_exception_if_the_node_is_not_an_article() {