oliverdavies.uk/source/_pages/atdc/9.md
2024-02-18 01:35:59 +00:00

175 lines
5.1 KiB
Markdown

---
title: 'ATDC: Lesson 9 - Introducing Unit Tests'
permalink: /atdc/9-introducing-unit-tests
---
{% block head_meta %}
<meta name="robots" content="noindex">
{% endblock %}
{% block content %}
Unit tests are the last type of test we'll cover in this course.
Similar to Kernel tests, in a Unit test, there is no browser to make HTTP requests with, but also no database or service container, so everything needs to be created from scratch.
I do outside-in testing and start with Functional and Kernel tests, so I don't tend to write many Unit tests.
I prefer to use real objects as opposed to mocks and have seen tests that create mocks and only test the mock and not the rest of the code.
I've also seen Unit tests that are very tightly coupled to the implementation, such as asserting a method is only called a certain number of times. This makes the code harder to refactor and could result in a test failing when its functionality is working.
## Your first Unit test
Based on what you've learned so far, let's write a Unit test that we'd expect to pass:
```language-php
<?php
namespace Drupal\Tests\atdc\Unit;
use Drupal\node\Entity\Node;
use Drupal\node\NodeInterface;
use Drupal\Tests\UnitTestCase;
final class PostWrapperTest extends UnitTestCase {
/** @test */
public function it_wraps_a_post(): void {
$node = Node::create(
entity_type: 'post',
values: [],
);
self::assertInstanceOf(NodeInterface::class, $node);
}
}
```
This test is within the `tests/src/Unit` directory and the equivalent namespace and extends the `UnitTestCase` class.
However, when you run the test, you'll get an error:
> Drupal\Core\DependencyInjection\ContainerNotInitializedException: \Drupal::$container is not initialized yet. \Drupal::setContainer() must be called with a real container.
In a Unit test, there is no database or service container, so you need to use mocks instead.
Update the test to create a mock version of `NodeInterface` instead.
As the mock an instance of `NodeInterface`, it satisfies the assertion and the test passes.
```language-php
/** @test */
public function it_wraps_a_post(): void {
$node = $this->createMock(NodeInterface::class);
self::assertInstanceOf(NodeInterface::class, $node);
}
```
Next, add an assertion to ensure the bundle is correct:
```language-php
self::assertSame('post', $node->bundle());
```
This will fail with this error:
> Failed asserting that null is identical to 'post'.
Because you're using a mock, all methods will return `NULL`.
To get this to pass, you need to define what `$this->bundle()` will return:
```language-php
$node->method('bundle')->willReturn('post');
```
However, this leads us to the situation I described, where you're only testing what's defined in the mock and not any valuable logic.
Let's improve this by introducing a `PostWrapper`.
## Wrapping posts
Let's create a `PostWrapper` class that wraps a post node and has some methods that return specific values from it.
Within the test, instantiate a new `Postwrapper` class that takes the node as an argument.
Then, add an assertion that a `getType()` method should return `post`.
```language-php
$wrapper = new PostWrapper($node);
self::assertSame('post', $wrapper->getType());
```
Next, create a `PostWrapper` class with the `getType()` method:
```language-php
<?php
namespace Drupal\atdc;
use Drupal\node\NodeInterface;
final class PostWrapper {
public function __construct(private NodeInterface $post) {
}
public function getType(): string {
return $this->post->bundle();
}
}
```
Now the test isn't testing the mock data directly, but the mock data is used within the `PostWrapper` to assert it is returning the expected value.
## Not wrapping a page
We've tested that the `PostWrapper` works with post nodes, but let's also ensure it won't work with other node types.
Create a new test that creates a mock node and returns `page` as the bundle:
```language-php
/**
* @test
* @testdox It can't wrap a page
*/
public function it_cant_wrap_a_page(): void {
self::expectException(\InvalidArgumentException::class);
$node = $this->createMock(NodeInterface::class);
$node->method('bundle')->willReturn('page');
new PostWrapper($node);
}
```
Before creating a new `PostWrapper`, assert that an `InvalidArgumentException` should be thrown. As no assertion is thrown, this test should fail:
> Failed asserting that exception of type "InvalidArgumentException" is thrown.
To fix it, within the constructor for `PostWrapper`, check the bundle and throw the expected Exception if the bundle is not `post`:
```language-php
/**
* @throws \InvalidArgumentException
*/
public function __construct(private NodeInterface $post) {
if ($post->bundle() !== 'post') {
throw new \InvalidArgumentException();
}
}
```
Again, instead of making assertions against the mock data directly, it's used to provide known data to the classes that need it.
## Conclusion
In this lesson, I introduced unit testing and mocking.
In tomorrow's lesson, the final one in this course, I'll show you an example of how to use mocks with Service classes.
{% endblock %}