5 KiB
title | permalink |
---|---|
ATDC: Lesson 9 - Introducing Unit Tests | /atdc/9-introducing-unit-tests |
{% block head_meta %}
{% 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:
<?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.
/** @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:
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:
$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
.
$wrapper = new PostWrapper($node);
self::assertSame('post', $wrapper->getType());
Next, create a PostWrapper
class with the getType()
method:
<?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:
/**
* @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
:
/**
* @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 %}