Why I mostly write functional and integration tests

In Wednesday's email, I showed how quick it is to get started writing automated tests for a new Drupal module, starting with a functional test.

I prefer the outside-in style (or London approach) of test-driven development, where I start with a the highest-level test that I can for a task. If the task needs me to make a HTTP request, then I'll use a functional test. If not, I'll use a kernel (or integration) test.

I find that these higher-level types of tests are easier and quicker to set up compared to starting with lower-level unit tests, cover more functionality, and make it easier to refactor.

An example

For example, this Device class which is a data transfer object around Drupal's NodeInterface. It ensures that the correct type of node is provided, and includes a named constructor and a helper method to retrieve a device's asset ID from a field:

final class Device {

private NodeInterface $node;

public function __construct(NodeInterface $node) {
if ($node->bundle() != 'device') {
throw new \InvalidArgumentException();
}

$this->node = $node;
}

public function getAssetId(): string {
return $this->node->get('field_asset_id')->getString();
}

public static function fromNode(NodeInterface $node): self {
return new self($node);
}

}

Testing getting the asset ID using a unit test

As the Node::create() method (what I'd normally use to create a node) interacts with the database, I need to create a mock node to wrap with my DTO.

I need to specify what value is returned from the bundle() method as well as getting the asset ID field value.

I need to mock the get() method and specify the field name that I'm getting the value for, which also returns it's own mock for FieldItemListInterface with a value set for the getString() method.

/** @test */
public function should_return_an_asset_id(): void {
// Arrange.
$fieldItemList = $this->createMock(FieldItemListInterface::class);

$fieldItemList
->method('getString')
->willReturn('ABC');

$deviceNode = $this->createMock(NodeInterface::class);

$deviceNode
->method('bundle')
->willReturn('device');

$deviceNode
->method('get')
->with('field_asset_id')
->willReturn($fieldItemList);

// Act.
$device = Device::fromNode($deviceNode);

// Assert.
self::assertSame('ABC', $device->getAssetId());
}

This is quite a long 'arrange' section for this test, and just be confusing for those new to automated testing.

If I was to refactor from using the get() and getString() methods to a different implementation, it's likely that the test would fail.

Refactoring to a kernel test

This is how I could write the same test using a kernel (integration) test:

/** @test */
public function should_return_an_asset_id(): void {
// Arrange.
$node = Node::create([
'field_asset_id' => 'ABC',
'type' => 'device'
]);

// Assert.
self::assertSame('ABC', Device::fromNode($node)->getAssetId());
}

I can create a real Node object, pass that to the Device DTO, and call the getAssetId() method.

As I can interact with the database, there's no need to create mocks or define return values.

The 'arrange' step is much smaller, and I think that this is easier to read and understand.

Trade-offs

Even though the test is cleaner, because there are no mocks there's other setup to do, including having the required configuration available, enabling modules, and installing schemas and configuration as part of the test - and having test-specific modules to store the needed configuration files.

Because of this, functional and kernel tests will take more time to run than unit tests, but an outside-in approach could be worth considering, depending on your project and team.