Urst
Urst

Reputation: 21

How to mock/work with public readonly properties in unit tests

before PHP 8.1 we would have something like this:

<?php

declare(strict_types=1);

class Consumer
{
    public function __construct(private DataTransferObject $dto)
    {
    }

    public function getName(): string
    {
        if ($this->dto->getValueOne()->isValid()) {
            return 'Adam';
        }

        return 'Eve';
    }
}

class DataTransferObject
{
    public function __construct(private ValueObjectOne $valueOne, private ValueObjectTwo $valueTwo)
    {
    }

    public function getValueOne(): ValueObjectOne
    {
        return $this->valueOne;
    }

    public function getValueTwo(): ValueObjectTwo
    {
        return $this->valueTwo;
    }
}

Which can easily be tested like so:

class ConsumerTest
{
    public function testNameIsCorrect()
    {
        $valueOneMock = $this->createMock(ValueObjectOne::class);
        $dtoMock = $this->createMock(DataTransferObject::class);
        $dtoMock->expects($this->once())->method('getValueOne')->willReturn($valueOneMock);
        
        $consumer = new Consumer($dtoMock);
        
        $name = $consumer->getName();
        
        // ...
    }
}

Now PHP 8.1 introduced readonly properties to get rid of boilerplate code. Our example would now look like following:

<?php

declare(strict_types=1);

class Consumer
{
    public function __construct(private readonly DataTransferObject $dto)
    {
    }

    public function getName(): string
    {
        if ($this->dto->valueOne->isValid()) {
            return 'Adam';
        }

        return 'Eve';
    }
}

class DataTransferObject
{
    public function __construct(public readonly ValueObjectOne $valueOne, public readonly ValueObjectTwo $valueTwo)
    {
    }
}

Now my question would be how to make this testable? The following would result in call to method isValid on null

class ConsumerTest
{
    public function testNameIsCorrect()
    {
        $valueOneMock = $this->createMock(ValueObjectOne::class);
        $dtoMock = $this->createMock(DataTransferObject::class);
        
        // We no longer need/can mock this method because it's no longer needed
        // $dtoMock->expects($this->once())->method('getValueOne')->willReturn($valueOneMock);

        $consumer = new Consumer($dtoMock);

        $name = $consumer->getName();

        // ...
    }
}

And trying to assign a value to the public readonly property for the mock obviously will result in Cannot initialize readonly property ... from scope ...*.

class ConsumerTest
{
    public function testNameIsCorrect()
    {
        $valueOneMock = $this->createMock(ValueObjectOne::class);
        $dtoMock = $this->createMock(DataTransferObject::class);
        
        $dtoMock->valueOne = $valueOneMock;

        $consumer = new Consumer($dtoMock);

        $name = $consumer->getName();

        // ...
    }
}

Any ideas what the best solution for this issue is?

Upvotes: 2

Views: 3756

Answers (1)

hakre
hakre

Reputation: 198119

Phpunits createMock() method comes with a default configuration of the mock that disables the original constructor and therefore these properties aren't specifically initialized and have their default NULL value (maybe not 100% correct, later PHP versions may even start to throw).

Instead use the MockBuilder without disabling the constructor to perform the initialization you need for your test.

Alternatively verify - as these are DTOs - if you need to mock them at all. Just suggesting this as I normally prefer to not mock at all writing tests, so I would perhaps verify this first and only if not possible continue with mocking.

Upvotes: 2

Related Questions