Writing Custom Assertions

Any complex test suite will come across the need to create its own assertions. AsyncUnit aims to make your custom Assertion an easy-to-use, first-class citizen within the testing framework.

Understanding the Assertion interface

Before you can write your own Assertion first you should make sure you understand its API and responsibility. It is responsible for determining whether or not some condition is true or false and to give details on the values that are used to determine whether that condition is true or false.

<?php

namespace Cspray\Labrador\AsyncUnit;

interface Assertion {

    public function assert() : AssertionResult;

}

The AssertionResult returned holds the information about whether the condition we were checking passed and the information about the values used. Since the assert() method itself doesn't actually have any arguments everything that you might need for the check should be passed in to the constructor of the Assertion.

Ultimately, that's it! There's some other setup that will be required to actually get your Assertion to be usable within the framework. We'll get to that momentarily but first let's finish step 1!

This guide focuses on creating a synchronous Assertion. Creating an AsyncAssertion follows the same principles except that its assert() method returns a Promise that should resolve to the AssertionResult.

Assertion rules

Before we get started with the actual Assertion implementation let's go over a few rules we expect Assertions to follow.

  • Your Assertion MUST NOT throw an exception unless a truly unexpected circumstance has occurred. If an exception could be expected to be thrown during your condition checking you should catch it, mark your AssertionResult as failed, and provide the appropriate information about that thrown exception.

  • Your AssertionResult MUST include summary information about both the normal case and the not case. An Assertion SHOULD never make assumptions about whether it is being called in the normal case or the not case. If you haven't done so you can read up about the not case in "Getting Started".

Step 1 - Writing your Assertion

For our example we'll create an Assertion that will check if an object with a given type returns the correct value from an invoked method. Let's assume that the objects under test will implement the below interfaces:

<?php

interface WidgetService {

    public function getWidget() : Widget;

}

interface Widget {
    
    public function getName() : string;
    
}

Ultimately, what we want our Assertion to do is verify that a Widget produced by a given WidgetService has the correct name. Now, let's actually write our custom implementation!

In a more thorough, "real" implementation you would ensure that these Assertions are properly unit tested!

<?php

use Cspray\Labrador\AsyncUnit\Assertion;

class WidgetServiceWidgetNameAssertion implements Assertion {

    public function __construct(
        private string $expected, 
        private WidgetService $actual
    ) {}

    public function assert() : AssertionResult {
         $actual = $this->actual->getWidget()->getName();
         $passes = $this->expected === $actual;
    }
    
}

Step 2 - Write your summary AssertionMessage

The next step is to write an AssertionMessage used to provide a summary of the assertion. The summary shouldn't go into heavy details about any potential problems with the assertion, just a brief description of how the assertion failed. These messages are ultimately used as a fragment in a greater message. For example, if the assertion failed you might expect to see output like the following...

Failed comparing that 2 values are equal to one another.

In the above message only the emphasized text is provided by you. The bold Failed is part of the message provided by other parts of the framework. Why do we do it this way? In future functionality we expect to be able to allow for doing things like creating a summary of all assertions made or allowing you to skip individual assertions in a test. In those cases the messages output by the system might look like...

Succeeded comparing that 2 values are equal to one another.

Skipped comparing that 2 values are equal to one another.

Your Assertion wouldn't know for any given invocation in what context it is being invoked; "Failed", "Succeeded", and "Skipped" are all contextual pieces of information that the Assertion doesn't, and shouldn't, have access to. So, when writing summary messages make sure that you construct them in such a way that it works with all 3 of the scenarios described above.

If possible we recommend you make use of one of the existing AssertionMessage implementations. However, in our example none of the existing implementations really does what we need it to do. We will create our own, in this case an anonymous class that implements the AssertionMessage interface.

<?php

use Cspray\Labrador\AsyncUnit\Assertion;

class WidgetServiceWidgetNameAssertion implements Assertion {

    public function __construct(
        private string $expected, 
        private WidgetService $actual
    ) {}

    public function assert() : AssertionResult {
         $actual = $this->actual->getWidget()->getName();
         $passes = $this->expected === $actual;
    }
    
    private function getSummary() : AssertionMessage {
        return new class($this->actual) implements AssertionMessage {
             public function __construct(private WidgetService $actual) {}
             
             public function toString() : string {
                return sprintf("asserting %s creates expected Widget", $this->actual::class);                 
             }
             
             public function toNotString() : string {
                 return sprintf("asserting %s does not create expected Widget", $this->actual::class);
             }              
        }
    }
    
}

For now, that's it! We'll make use of this method a little later on.

Step 3 - Write your detailed messages

Obviously if something went wrong the summary messages above wouldn't provide nearly enough information to diagnose what is happening. The responsibility for creating what could potentially be highly complex messages is handled by a detailed AssertionMessage. Just like summary messages this implementation requires providing both the normal and not use cases.

The same rules above about summary messages interacting with the rest of the framework applies to detailed messages as well!

<?php

use Cspray\Labrador\AsyncUnit\Assertion;

class WidgetServiceWidgetNameAssertion implements Assertion {

    public function __construct(
        private string $expected, 
        private WidgetService $actual
    ) {}

    public function assert() : AssertionResult {
         $actual = $this->actual->getWidget()->getName();
         $passes = $this->expected === $actual;
    }
    
    private function getSummary() : AssertionMessage {
        return new class($this->actual) implements AssertionMessage {
             public function __construct(private WidgetService $actual) {}
             
             public function toString() : string {
                return sprintf("asserting %s creates expected Widget", $this->actual::class);                 
             }
             
             public function toNotString() : string {
                 return sprintf("asserting %s does not create expected Widget", $this->actual::class);
             }              
        }
    }
    
    private function getDetails() : AssertionMessage {
        return new class($this->expected, $this->actual) implements AssertionMessage {
            public function __construct(
                private string $expected, 
                private WidgetService $actual
            ) {}
            
            public function toString() : string {
                return sprintf(
                    "asserting %s creates a Widget with name \"%s\"",
                    $this->actual::class,
                    $this->expected
                );
            }
            
            public function toNotString() : string {
                return sprintf(
                    "asserting %s creates a Widget with name different than \"%s\"",
                    $this->actual::class,
                    $this->expected
                );
            }
        }
    }
    
}

And that's it for messages! In different assertions it could be easy to see how including type information or diffs could cause the detailed message to become very complex. Many assertions though will follow this pattern where the detailed messages simply provides additional context about the values being asserted.

Step 4 - Return your AssertionResult

Next is to get the information about your assertion returned with an AssertionResult. To create concrete implementations of this interface you can use the AssertionResultFactory or roll your own! For our example we'll make use of the existing factory. Continuing to build upon what we've worked on so far let's finish off our custom Assertion!

<?php

use Cspray\Labrador\AsyncUnit\Assertion;
use Cspray\Labrador\AsyncUnit\Assertion\AssertionResultFactory;

class WidgetServiceWidgetNameAssertion implements Assertion {

    public function __construct(
        private string $expected, 
        private WidgetService $actual
    ) {}

    public function assert() : AssertionResult {
        $actual = $this->actual->getWidget()->getName();
        $passes = $this->expected === $actual;
        $factoryMethod = $passes ? 'validAssertion' : 'invalidAssertion';
        return AssertionResultFactory::$factoryMethod(
            $this->getSummary(),
            $this->getDetail()
        );
    }
    
    private function getSummary() : AssertionMessage {
        return new class($this->actual) implements AssertionMessage {
             public function __construct(private WidgetService $actual) {}
             
             public function toString() : string {
                return sprintf("asserting %s creates expected Widget", $this->actual::class);                 
             }
             
             public function toNotString() : string {
                 return sprintf("asserting %s does not create expected Widget", $this->actual::class);
             }              
        }
    }
    
    private function getDetails() : AssertionMessage {
        return new class($this->expected, $this->actual) implements AssertionMessage {
            public function __construct(
                private string $expected, 
                private WidgetService $actual
            ) {}
            
            public function toString() : string {
                return sprintf(
                    "asserting %s creates a Widget with name \"%s\"",
                    $this->actual::class,
                    $this->expected
                );
            }
            
            public function toNotString() : string {
                return sprintf(
                    "asserting %s creates a Widget with name different than \"%s\"",
                    $this->actual::class,
                    $this->expected
                );
            }
        }
    }
    
}

And we're done with writing our Assertion! Now we need to make sure we let the framework know about it so it can be used within your tests.

Step 5 - Add your Assertion to the framework

It is very important that all assertions, even those you custom implement, happen with the Assertion API provided by the framework; namely, you should expect to invoke something similar to assert()->widgetServiceCreatesNamedWidget('widgetName', $widgetService) in your test cases to actually invoke your Assertion. We'll accomplish this by implementing the CustomAssertionPlugin provided by the framework.

<?php

use Cspray\Labrador\AsyncUnit\CustomAssertionPlugin;
use Cspray\Labrador\AsyncUnit\Context\CustomAssertionContext;

class WidgetServiceWidgetNameAssertionPlugin implements CustomAssertionPlugin {

    public function registerCustomAssertions(CustomAssertionContext $context) : Promise {
        $context->registerAssertion('widgetServiceCreatesNamedWidget', function(string $expected, WidgetService $actual) {
            return new WidgetServiceWidgetNameAssertion($expected, $actual);
        });
    }

}

And that's it! As long as this implementation is within the directories scanned by the framework in your TestCases you will be able to invoke your custom assertion! Happy Asserting!

If you'd like to learn more about how the Plugin system works within the framework we recommend reading over the Labrador Core documentation about Plugins.

Last updated