skip to Main Content

I am aware that one can add custom assertion by extending the default TestCase, e.g. I added assertions to check that an array may only contain one specific value:

<?php

namespace KopernikusTimrReportManager;

use PHPUnitFrameworkTestCase;

abstract class ArrayContainsValueTestCase extends TestCase
{
    public static function assertArrayOnlyContainsTrue(array $haystack): void
    {
        static::assertArrayOnlyContainsSameValue(true, $haystack);
    }

    public static function assertArrayOnlyContainsSameValue(mixed $expectedValue, array $haystack): void
    {
        $haystack = array_unique($haystack);
        static::assertTrue(static::areOnlySameValuesInArray($expectedValue, $haystack), message: 'The array contains of different values, yet sameness was expected');
    }

    private static function areOnlySameValuesInArray(mixed $expectedValue, array $haystack): bool
    {
        $haystack = array_unique($haystack);

        if (count($haystack) !== 1) {
            return false;
        }

        return reset($haystack) === $expectedValue;
    }

    public static function assertArrayOnlyContainsFalse(array $haystack)
    {
        static::assertArrayOnlyContainsSameValue(false, $haystack);

    }
}

This is an assertion that is very agnostic to the domain.

Yet other custom assertions I want build are much more bound to the domain of the project, e.g. I created an assertion to check that a custom TimeEntry value object contains excatly one a specific numeric ticket id, and that one should live in its own class:

<?php

namespace KopernikusTimrReportManager;

use KopernikusTimrReportManagerDtoTimeEntry;
use PHPUnitFrameworkTestCase;

abstract class TimeEntryTestCase extends TestCase
{
    public static function assertTimeEntryHasTicketId(TimeEntry $timeEntry, int $ticketNumber)
    {
        $ticketNumberHashtag = '#' . (string)$ticketNumber;
        $count = substr_count($timeEntry->description, '#');
        static::assertSame(1, $count, 'the time entry must only contain one hashtag for the ticket id');
        static::assertSame($timeEntry->ticket, $ticketNumberHashtag);
    }
}

Assume I have a test class, MyTestThatRequiredBothAssertions. I could achieve that via:

  • ArrayContainsValueTestCase extends TestCase
  • TimeEntryTestCase extends ArrayContainsValueTestCase
  • MyTestThatRequiredBothAssertions extends TimeEntryTestCase

Yet not every actual TimeEntryTestCase would need the assertions provided by ArrayContainsValueTestCase.

I furthermore plan to create a couple of custom assertions, not only two, so the inheritance tree seems likely to get out of hand.

I would rather do:

  • abstract class ArrayContainsValueTestCase extends TestCase
  • abstract class TimeEntryTestCase extends TestCase

and would like to use them in a specific testcase like this:

  • class MyTestThatRequiredBothAssertions extends ArrayContainsValueTestCase, TimeEntryTestCase

and add further TestCases on demand, yet php allows only extending one class, so this won’t work.

Is there another solution I am missing to separate the concerns here?

Can I provide my custom assertions differently, while retaining IDE support (auto-completion of the method names and their parameter value) of the methods within a test class?

I want to have multiple classes defining custom assertion, so putting them all in a single file is not something I want to do.

Each of those should at best only extend the default TestCase-class, yet I want to be able to mix them with one another freely.

Only if a SpecificTestCase depends on the assertions of another CustomTestCase, I am ok with them depending on one another.


I also thought about using traits, yet a trait cannot extend another class, so this:

trait MyCustomAssertion extends TestCase {

}

is also not allowed.

2

Answers


  1. Chosen as BEST ANSWER

    PHPUnit defines its Assertions as public static classes.

    Hence, your own custom Assertions can call its assertions via static access:

    TestCase::assertTrue(...)

    That means one can use traits as you don't need to extend the TestCase anymore. You just define them as such:

    <?php
    
    namespace KopernikusTimrReportManager;
    
    use PHPUnitFrameworkTestCase;
    
    trait ArrayContainsValueTrait
    {
        public static function assertArrayOnlyContainsTrue(array $haystack): void
        {
            static::assertArrayOnlyContainsSameValue(true, $haystack);
        }
    
        public static function assertArrayOnlyContainsSameValue(mixed $expectedValue, array $haystack): void
        {
            $haystack = array_unique($haystack);
            TestCase::assertTrue(static::areOnlySameValuesInArray($expectedValue, $haystack), message: 'The array contains of different values, yet sameness was expected');
        }
    
        private static function areOnlySameValuesInArray(mixed $expectedValue, array $haystack): bool
        {
            $haystack = array_unique($haystack);
    
            if (count($haystack) !== 1) {
                return false;
            }
    
            return reset($haystack) === $expectedValue;
        }
    
        public static function assertArrayOnlyContainsFalse(array $haystack)
        {
            static::assertArrayOnlyContainsSameValue(false, $haystack);
    
        }
    }
    
    <?php
    
    namespace KopernikusTimrReportManager;
    
    use KopernikusTimrReportManagerDtoTimeEntry;
    use PHPUnitFrameworkTestCase;
    
    trait TimeEntryAssertionsTrait
    {
        public static function assertTimeEntryHasTicketId(TimeEntry $timeEntry, int $ticketNumber)
        {
            $ticketNumberHashtag = '#' . (string)$ticketNumber;
            $count = substr_count($timeEntry->description, '#');
            TestCase::assertSame(1, $count, 'the time entry must only contain one hashtag for the ticket id');
            TestCase::assertSame($timeEntry->ticket, $ticketNumberHashtag);
        }
    }
    

    In your actual TestCase you can then add those traits as needed:

    class CsvParserTest extends TestCase
    {
        use  TimeEntryAssertionsTrait;
        use ArrayContainsValueTrait;
    
        ...
    }
    

    You could even have your custom assertions trait include other traits themselves, just be aware that calling Trait::somePublicMethod() is deprecated and may stop working in the future.

    You have to use them within the trait (making it so that the testcase gets access to the other included assertions of that used trait).

    Here is a contrived TraitUsingOtherTraits:

    <?php
    
    namespace KopernikusTimrReportManagerServices;
    
    use KopernikusTimrReportManagerArrayContainsValueTrait;
    use KopernikusTimrReportManagerDtoTimeEntry;
    use KopernikusTimrReportManagerTimeEntryAssertionsTrait;
    
    trait TraitUsingOtherTraits
    {
        use ArrayContainsValueTrait;
        use TimeEntryAssertionsTrait;
    
        /**
         * Contrived dummy assertion, this is no real test
         */
        public static function assertUsingOtherTraits(): void
        {
            static::assertTimeEntryHasTicketId(new TimeEntry('foobar #123', '2024-12-31 13:00', '2024-12-31 15:00'), 123);
            static::assertArrayOnlyContainsTrue([true, true, true]);
        }
    }
    

    And it my actual contrived test class I have the method:

        public function testShowcaseTraits()
        {
            static::assertUsingOtherTraits();
        }
    

    which runs just fine:

    assertion works


  2. This is a problem that surely had some consequences in the original design of PhpUnit as well. It is similarly constrained by the single-inheritance rules of PHP and in its earlier versions there were even no traits in PHP that are commonly in use to handle the diamond problem you describe nowadays.

    Given the presumption about the extensibility of traits do not finally resolve as an early confusion and you still do not want to use traits here, you can take a look how assertions are implemented in PHP Unit itself.

    There is at least a single class per assertion, which is, how I read your question, you strive for.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search