Compare commits

...

No commits in common. "main" and "rst" have entirely different histories.
main ... rst

28 changed files with 45 additions and 715 deletions

View file

@ -5,14 +5,12 @@ Workshop: Automated Testing and Test Driven Development in Drupal 8
.. contents::
.. raw:: pdf
PageBreak
Contribution
------------
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!
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
--------
@ -41,18 +39,24 @@ This assumes that Composer is installed globally and is available by running the
You should now have files present including ``web/index.php`` and ``vendor/bin/phpunit``. Now you can start serving this site locally.
**Note:** Development dependencies, including PHPUnit, should only be installed locally and should not be present on public servers. Doing so would present a security risk to your application.
**Note:** Development dependencies, including PHPUnit, should only be
installed locally and should not be present on public servers. Doing so
would present a security risk to your application.
Using DDEV for local development
--------------------------------
- Docker based development environment for PHP applications (Drupal, WordPress, Magento etc).
- Docker based development environment for PHP applications (Drupal,
WordPress, Magento etc).
- More information at https://www.ddev.com.
- Documentation at https://ddev.readthedocs.io.
- Installation via Homebrew on Linux and macOS, and Chocolatey on Windows. More information at https://www.ddev.com/get-started.
- Example at https://github.com/opdavies/workshop-drupal-automated-testing-code.
- Installation via Homebrew on Linux and macOS, and Chocolatey on
Windows. More information at https://www.ddev.com/get-started.
- Example at
https://github.com/opdavies/workshop-drupal-automated-testing-code.
To run PHPUnit within DDEV, we can prefix the command with ``ddev exec``:
To run PHPUnit within DDEV, we can prefix the command with
``ddev exec``:
::
@ -61,11 +65,16 @@ To run PHPUnit within DDEV, we can prefix the command with ``ddev exec``:
Using the PHP web server for local development
----------------------------------------------
If you have all of `Drupal's required PHP extensions <https://www.drupal.org/docs/system-requirements/php-requirements#extensions>`__ installed and would like better performance (particularly on macOS), you could use the PHP's local web server.
If you have all of `Drupal's required PHP
extensions <https://www.drupal.org/docs/system-requirements/php-requirements#extensions>`__
installed and would like better performance (particularly on macOS), you
could use the PHP's local web server.
As we're going to use SQLite to run the tests, there's no need for a connection to a MySQL database or another service.
As we're going to use SQLite to run the tests, there's no need for a
connection to a MySQL database or another service.
If you need to override any environment variables, you can do so before running the command:
If you need to override any environment variables, you can do so before
running the command:
::
@ -74,21 +83,29 @@ If you need to override any environment variables, you can do so before running
The different types of available tests
--------------------------------------
- **Functional** (web, feature) - tests behaviour and functionality, makes HTTP requests to the webserver and has access to the database and other services via the service container. Slower to run.
- **FunctionalJavascript** - functional tests, but access to JavaScript.
- **Kernel** (integration) - no browser capabilities, has access to the database and other services but requires more configuration.
- **Unit** - no access to the database or service container, all dependencies need to be mocked. Fast to run.
- **Functional** (web, feature) - tests behaviour and functionality,
makes HTTP requests to the webserver and has access to the database
and other services via the service container. Slower to run.
- **FunctionalJavascript** - functional tests, but access to
JavaScript.
- **Kernel** (integration) - no browser capabilities, has access to the
database and other services but requires more configuration.
- **Unit** - no access to the database or service container, all
dependencies need to be mocked. Fast to run.
Different approaches to testing
-------------------------------
- Inside-out (testing pyramid) - mostly unit tests, some integration tests, few functional tests.
- Outside-in (testing trophy) - mostly functional tests, some integration tests, few unit tests. More flexible, easier to refactor.
- Inside-out (testing pyramid) - mostly unit tests, some integration
tests, few functional tests.
- Outside-in (testing trophy) - mostly functional tests, some
integration tests, few unit tests. More flexible, easier to refactor.
The structure of a test
-----------------------
- **Arrange** - set up the environment. Create users, nodes, set up dependencies
- **Arrange** - set up the environment. Create users, nodes, set up
dependencies
- **Act** - perform an action
- **Assert** - verify that something happened
@ -109,7 +126,8 @@ What is Test Driven Development?
Acceptance criteria
-------------------
This module will be used to demonstrate how to take a test-driven approach to develop a module to the following acceptance criteria:
This module will be used to demonstrate how to take a test-driven
approach to develop a module to the following acceptance criteria:
- As a site visitor
- I want to see a list of all published articles at ``/blog``
@ -128,7 +146,8 @@ To begin, we need the site to be running.
# Using PHP's web server
php -S localhost:8000 -t web
You dont need to install Drupal. It just needs to be able to connect to the database.
You dont need to install Drupal. It just needs to be able to connect to
the database.
Writing your first test
-----------------------
@ -238,7 +257,8 @@ If a test failed, the output would show the class and method name for the failin
Drupal\ *module::the*\ front\_page\_loads\_for\_anonymous\_users
Behat: Current response status code is 404, but 200 expected.
Other useful options include ``--stop-on-failure``, ``--filter`` and ``--testdox``.
Other useful options include ``--stop-on-failure``, ``--filter`` and
``--testdox``.
(Optional) Running tests via a Composer script
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -700,7 +720,8 @@ Adding articles
To test the ArticleRepository, we need articles to be created so that they can be returned.
Within the ``ArticleRepositoryTest`` we can make use of one of a number of traits that are provided.
Within the ``ArticleRepositoryTest`` we can make use of one of a number
of traits that are provided.
Within the class, enable the trait:

View file

@ -1,5 +0,0 @@
name: My Module
type: module
core: 8.x
core_version_requirement: ^8 || ^9
package: Custom

View file

@ -1,19 +0,0 @@
<?php
namespace Drupal\Tests\my_module\Functional;
use Drupal\Tests\BrowserTestBase;
use Symfony\Component\HttpFoundation\Response;
class MyModuleTest extends BrowserTestBase {
protected $defaultTheme = 'stark';
/** @test */
public function the_front_page_loads_for_anonymous_users() {
$this->drupalGet('<front>');
$this->assertResponse(Response::HTTP_OK);
}
}

View file

@ -1,5 +0,0 @@
name: My Module
type: module
core: 8.x
core_version_requirement: ^8 || ^9
package: Custom

View file

@ -1,39 +0,0 @@
<?php
namespace Drupal\Tests\my_module\Functional;
use Drupal\Tests\BrowserTestBase;
use Symfony\Component\HttpFoundation\Response;
class MyModuleTest extends BrowserTestBase {
protected $defaultTheme = 'stark';
/** @test */
public function the_front_page_loads_for_anonymous_users() {
$this->drupalGet('<front>');
$this->assertResponse(Response::HTTP_OK);
}
/** @test */
public function the_admin_page_is_not_accessible_to_anonymous_users() {
$this->drupalGet('admin');
$this->assertResponse(Response::HTTP_FORBIDDEN);
}
/** @test */
public function the_admin_page_is_accessible_by_admin_users() {
$adminUser = $this->createUser([
'access administration pages',
]);
$this->drupalLogin($adminUser);
$this->drupalGet('/admin');
$this->assertResponse(Response::HTTP_OK);
}
}

View file

@ -1,5 +0,0 @@
name: My Module
type: module
core: 8.x
core_version_requirement: ^8 || ^9
package: Custom

View file

@ -1,7 +0,0 @@
blog.page:
path: /blog
defaults:
_controller: Drupal\my_module\Controller\BlogPageController
_title: Blog
requirements:
_permission: access content

View file

@ -1,17 +0,0 @@
<?php
namespace Drupal\my_module\Controller;
use Drupal\Core\StringTranslation\StringTranslationTrait;
class BlogPageController {
use StringTranslationTrait;
public function __invoke(): array {
return [
'#markup' => $this->t('Welcome to my blog!'),
];
}
}

View file

@ -1,28 +0,0 @@
<?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 = [
'node',
'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);
$session->responseContains('<h1>Blog</h1>');
$session->pageTextContains('Welcome to my blog!');
}
}

View file

@ -1,19 +0,0 @@
<?php
namespace Drupal\Tests\my_module\Functional;
use Drupal\Tests\BrowserTestBase;
use Symfony\Component\HttpFoundation\Response;
class MyModuleTest extends BrowserTestBase {
protected $defaultTheme = 'stark';
/** @test */
public function the_front_page_loads_for_anonymous_users() {
$this->drupalGet('<front>');
$this->assertResponse(Response::HTTP_OK);
}
}

View file

@ -1,5 +0,0 @@
name: My Module
type: module
core: 8.x
core_version_requirement: ^8 || ^9
package: Custom

View file

@ -1,7 +0,0 @@
blog.page:
path: /blog
defaults:
_controller: Drupal\my_module\Controller\BlogPageController
_title: Blog
requirements:
_permission: access content

View file

@ -1,6 +0,0 @@
services:
Drupal\my_module\Controller\BlogPageController:
autowire: true
Drupal\my_module\Repository\ArticleRepository:
autowire: true

View file

@ -1,45 +0,0 @@
<?php
namespace Drupal\my_module\Controller;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\my_module\Repository\ArticleRepository;
class BlogPageController {
use StringTranslationTrait;
/**
* @var \Drupal\my_module\Repository\ArticleRepository
*/
private $articleRepository;
/**
* @var \Drupal\Core\Entity\EntityViewBuilderInterface
*/
private $nodeViewBuilder;
public function __construct(
EntityTypeManagerInterface $entityTypeManager,
ArticleRepository $articleRepository
) {
$this->nodeViewBuilder = $entityTypeManager->getViewBuilder('node');
$this->articleRepository = $articleRepository;
}
public function __invoke(): array {
$build = [];
$articles = $this->articleRepository->getAll();
foreach ($articles as $article) {
$build[] = $this->nodeViewBuilder->view($article, 'teaser');
}
return [
'#markup' => render($build),
];
}
}

View file

@ -1,33 +0,0 @@
<?php
namespace Drupal\my_module\Repository;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\node\Entity\Node;
use Drupal\node\NodeInterface;
class ArticleRepository {
/**
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
private $nodeStorage;
public function __construct(EntityTypeManagerInterface $entityTypeManager) {
$this->nodeStorage = $entityTypeManager->getStorage('node');
}
public function getAll(): array {
$articles = $this->nodeStorage->loadByProperties([
'status' => Node::PUBLISHED,
'type' => 'article',
]);
uasort($articles, function (NodeInterface $a, NodeInterface $b): bool {
return $a->getCreatedTime() < $b->getCreatedTime();
});
return $articles;
}
}

View file

@ -1,27 +0,0 @@
<?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 = [
'node',
'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);
$session->responseContains('<h1>Blog</h1>');
}
}

View file

@ -1,19 +0,0 @@
<?php
namespace Drupal\Tests\my_module\Functional;
use Drupal\Tests\BrowserTestBase;
use Symfony\Component\HttpFoundation\Response;
class MyModuleTest extends BrowserTestBase {
protected $defaultTheme = 'stark';
/** @test */
public function the_front_page_loads_for_anonymous_users() {
$this->drupalGet('<front>');
$this->assertResponse(Response::HTTP_OK);
}
}

View file

@ -1,75 +0,0 @@
<?php
namespace Drupal\Tests\my_module\Kernel;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\my_module\Repository\ArticleRepository;
use Drupal\node\Entity\Node;
use Drupal\Tests\node\Traits\NodeCreationTrait;
class ArticleRepositoryTest extends EntityKernelTestBase {
use NodeCreationTrait;
public static $modules = [
'node',
'my_module',
];
protected function setUp() {
parent::setUp();
$this->installSchema('node', ['node_access']);
$this->installConfig([
'filter',
]);
}
/** @test */
public function nodes_that_are_not_articles_are_not_returned() {
$this->createNode(['type' => 'article'])->save();
$this->createNode(['type' => 'page'])->save();
$this->createNode(['type' => 'article'])->save();
$this->createNode(['type' => 'page'])->save();
$this->createNode(['type' => 'article'])->save();
$this->assertCount(5, Node::loadMultiple());
$repository = $this->container->get(ArticleRepository::class);
$articles = $repository->getAll();
$this->assertCount(3, $articles);
}
/** @test */
public function only_published_articles_are_returned() {
$this->createNode(['type' => 'article', 'status' => Node::PUBLISHED])->save();
$this->createNode(['type' => 'article', 'status' => Node::NOT_PUBLISHED])->save();
$this->createNode(['type' => 'article', 'status' => Node::PUBLISHED])->save();
$this->createNode(['type' => 'article', 'status' => Node::NOT_PUBLISHED])->save();
$this->createNode(['type' => 'article', 'status' => Node::PUBLISHED])->save();
$repository = $this->container->get(ArticleRepository::class);
$articles = $repository->getAll();
$this->assertCount(3, $articles);
}
/** @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()]);
$repository = $this->container->get(ArticleRepository::class);
$nodes = $repository->getAll();
$nodeIds = array_keys($nodes);
$this->assertSame([3, 1, 2, 5, 4], $nodeIds);
}
}

View file

@ -1,5 +0,0 @@
name: My Module
type: module
core: 8.x
core_version_requirement: ^8 || ^9
package: Custom

View file

@ -1,7 +0,0 @@
blog.page:
path: /blog
defaults:
_controller: Drupal\my_module\Controller\BlogPageController
_title: Blog
requirements:
_permission: access content

View file

@ -1,6 +0,0 @@
services:
Drupal\my_module\Controller\BlogPageController:
autowire: true
Drupal\my_module\Repository\ArticleRepository:
autowire: true

View file

@ -1,45 +0,0 @@
<?php
namespace Drupal\my_module\Controller;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\my_module\Repository\ArticleRepository;
class BlogPageController {
use StringTranslationTrait;
/**
* @var \Drupal\my_module\Repository\ArticleRepository
*/
private $articleRepository;
/**
* @var \Drupal\Core\Entity\EntityViewBuilderInterface
*/
private $nodeViewBuilder;
public function __construct(
EntityTypeManagerInterface $entityTypeManager,
ArticleRepository $articleRepository
) {
$this->nodeViewBuilder = $entityTypeManager->getViewBuilder('node');
$this->articleRepository = $articleRepository;
}
public function __invoke(): array {
$build = [];
$articles = $this->articleRepository->getAll();
foreach ($articles as $article) {
$build[] = $this->nodeViewBuilder->view($article, 'teaser');
}
return [
'#markup' => render($build),
];
}
}

View file

@ -1,33 +0,0 @@
<?php
namespace Drupal\my_module\Repository;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\node\Entity\Node;
use Drupal\node\NodeInterface;
class ArticleRepository {
/**
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
private $nodeStorage;
public function __construct(EntityTypeManagerInterface $entityTypeManager) {
$this->nodeStorage = $entityTypeManager->getStorage('node');
}
public function getAll(): array {
$articles = $this->nodeStorage->loadByProperties([
'status' => Node::PUBLISHED,
'type' => 'article',
]);
uasort($articles, function (NodeInterface $a, NodeInterface $b): bool {
return $a->getCreatedTime() < $b->getCreatedTime();
});
return $articles;
}
}

View file

@ -1,40 +0,0 @@
<?php
namespace Drupal\my_module\Wrapper;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\node\NodeInterface;
class ArticleWrapper {
private $article;
public function __construct(TimeInterface $time, NodeInterface $node) {
$this->verifyNodeType($node);
$this->time = $time;
$this->article = $node;
}
public function getOriginal(): NodeInterface {
return $this->article;
}
private function verifyNodeType(NodeInterface $node): void {
if ($node->bundle() != 'article') {
throw new \InvalidArgumentException(sprintf(
'%s is not an article',
$node->bundle()
));
}
}
public function isPublishable(): bool {
$created = $this->article->getCreatedTime();
$difference = $this->time->getRequestTime() - $created;
return $difference >= 60 * 60 * 24 * 3;
}
}

View file

@ -1,27 +0,0 @@
<?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 = [
'node',
'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);
$session->responseContains('<h1>Blog</h1>');
}
}

View file

@ -1,19 +0,0 @@
<?php
namespace Drupal\Tests\my_module\Functional;
use Drupal\Tests\BrowserTestBase;
use Symfony\Component\HttpFoundation\Response;
class MyModuleTest extends BrowserTestBase {
protected $defaultTheme = 'stark';
/** @test */
public function the_front_page_loads_for_anonymous_users() {
$this->drupalGet('<front>');
$this->assertResponse(Response::HTTP_OK);
}
}

View file

@ -1,75 +0,0 @@
<?php
namespace Drupal\Tests\my_module\Kernel;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\my_module\Repository\ArticleRepository;
use Drupal\node\Entity\Node;
use Drupal\Tests\node\Traits\NodeCreationTrait;
class ArticleRepositoryTest extends EntityKernelTestBase {
use NodeCreationTrait;
public static $modules = [
'node',
'my_module',
];
protected function setUp() {
parent::setUp();
$this->installSchema('node', ['node_access']);
$this->installConfig([
'filter',
]);
}
/** @test */
public function nodes_that_are_not_articles_are_not_returned() {
$this->createNode(['type' => 'article'])->save();
$this->createNode(['type' => 'page'])->save();
$this->createNode(['type' => 'article'])->save();
$this->createNode(['type' => 'page'])->save();
$this->createNode(['type' => 'article'])->save();
$this->assertCount(5, Node::loadMultiple());
$repository = $this->container->get(ArticleRepository::class);
$articles = $repository->getAll();
$this->assertCount(3, $articles);
}
/** @test */
public function only_published_articles_are_returned() {
$this->createNode(['type' => 'article', 'status' => Node::PUBLISHED])->save();
$this->createNode(['type' => 'article', 'status' => Node::NOT_PUBLISHED])->save();
$this->createNode(['type' => 'article', 'status' => Node::PUBLISHED])->save();
$this->createNode(['type' => 'article', 'status' => Node::NOT_PUBLISHED])->save();
$this->createNode(['type' => 'article', 'status' => Node::PUBLISHED])->save();
$repository = $this->container->get(ArticleRepository::class);
$articles = $repository->getAll();
$this->assertCount(3, $articles);
}
/** @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()]);
$repository = $this->container->get(ArticleRepository::class);
$nodes = $repository->getAll();
$nodeIds = array_keys($nodes);
$this->assertSame([3, 1, 2, 5, 4], $nodeIds);
}
}

View file

@ -1,73 +0,0 @@
<?php
namespace Drupal\Tests\my_module\Unit\Wrapper;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\my_module\Wrapper\ArticleWrapper;
use Drupal\node\NodeInterface;
use Drupal\Tests\UnitTestCase;
class ArticleWrapperTest extends UnitTestCase {
private $time;
protected function setUp() {
$this->time = $this->createMock(TimeInterface::class);
}
/** @test */
public function it_returns_the_article() {
$article = $this->createMock(NodeInterface::class);
$article->method('id')->willReturn(5);
$article->method('bundle')->willReturn('article');
$articleWrapper = new ArticleWrapper($this->time, $article);
$this->assertInstanceOf(NodeInterface::class, $articleWrapper->getOriginal());
$this->assertSame(5, $articleWrapper->getOriginal()->id());
$this->assertSame('article', $articleWrapper->getOriginal()->bundle());
}
/** @test */
public function it_throws_an_exception_if_the_node_is_not_an_article() {
$this->expectException(\InvalidArgumentException::class);
$page = $this->createMock(NodeInterface::class);
$page->method('bundle')->willReturn('page');
new ArticleWrapper($this->time, $page);
}
/**
* @test
* @dataProvider articleCreatedDateProvider
*/
public function articles_created_less_than_3_days_ago_are_not_publishable(
string $offset,
bool $expected
) {
$this->time->method('getRequestTime')->willReturn(
(new \DateTime())->getTimestamp()
);
$article = $this->createMock(NodeInterface::class);
$article->method('bundle')->willReturn('article');
$article->method('getCreatedTime')->willReturn(
(new \DateTime())->modify($offset)->getTimestamp()
);
$articleWrapper = new ArticleWrapper($this->time, $article);
$this->assertSame($expected, $articleWrapper->isPublishable());
}
public function articleCreatedDateProvider() {
return [
['-1 day', FALSE],
['-2 days 59 minutes', FALSE],
['-3 days', TRUE],
['-1 week', TRUE],
];
}
}