Shared assertions and validations

Shared assertions and validations

Some time ago, I decided to try to tackle a problem that I experienced on many projects without any successful solution. What I came up with is by no means perfect or finished, keep in mind this is just an experiment and I'm looking for feedback from the community.

The problem we're looking at

In order to make sure that I don't have an object in an incoherent state, I sometimes use simple assertions in the object constructor so the instance never gets constructed if the data is corrupted in the first place. This is a classical thing to do for value objects.

class Person
{
    public function __construct(string $name)
    {
        Assert::minLength($name, 4);
        Assert::maxLength($name, 12);
    }
}

I usually use the very nice library webmozart/assert, a fork of beberlei/assert, to do this job and it has tons of good assertions that generate an InvalidArgumentException if the passed parameter does not match the condition.

However, when your user is sending invalid data and you try to create an object, you only get to receive the first error as an exception, when you would ideally present most of the errors to them so they can fix it in one go. This is the case whether you are using HTML forms or an API.

Let's start by writing a few tests

I obviously don't want to break existing behavior of the library I'm using so I created a regression test to make sure everything would still work after my modification:

class AssertTest extends TestCase
{
    public function test_it_creates_a_valid_person()
    {
        $this->assertInstanceOf(Person::class, new Person('Thomas'));
    }

    /**
     * @dataProvider invalid_person_provider
     */
    public function test_it_throws_for_an_invalid_person($name)
    {
        $this->expectException(InvalidArgumentException::class);
        new Person($name);
    }

    public function invalid_person_provider()
    {
        yield ['Tom'];
        yield ['ExtremelyLongName'];
    }
}

This is pretty basic stuff, it tests a valid creation and two invalid ones. This makes sure that I cannot construct an invalid object.

The second test class is for the new validation behavior, that I want to be able to use without creating new validation code.

class ValidateTest extends TestCase
{
    private Validator $validator;

    public function setUp()
    {
        $this->validator = new Validator();
    }

    public function test_it_accepts_a_valid_person()
    {
        $this->assertTrue($this->validator->validate(function () {
            return new Person('Thomas');
        }));
    }

    /**
     * @dataProvider invalid_person_provider
     */
    public function test_it_throws_for_an_invalid_person($name)
    {
        $this->assertFalse($this->validator->validate(function () use ($name) {
            return new Person($name);
        }));
    }

    public function invalid_person_provider()
    {
        yield ['Tom'];
        yield ['ExtremelyLongName'];
    }
}

This tests makes sure that I get a boolean as an output and this does not generate an exception if the object is invalid. Obviously, I would like to get all the errors in some way but we'll see that in the next section.

The experiment

Now that we know what we're trying to do, let's write some code for the Validator class.

Since the API for the assertion class is static, we also need to use static methods. This is far from ideal because this means that our state will belong to a singleton, and this is the main reason why I'm not sure this experiment is useful in the real world.

class Validator
{
    public function validate(callable $subjectFactory)
    {
        Assert::beginValidation();
        $subjectFactory();
        Assert::endValidation();

        return Assert::isValid();
    }
}

And now for the main course, our own Assert class that supports both use cases. We need to use beginValidation and endValidation to set a flag that knows whether an invalid assertion should throw exceptions, or just record them in an errors array.

The webmozart/assert being well thought out, we can override the reportInvalidArgument method that is a documented way to extend it.

class Assert extends \Webmozart\Assert\Assert
{
    private static $validate = false;
    private static $errors = [];

    public static function beginValidation()
    {
        self::$validate = true;
        self::$errors = [];
    }

    public static function endValidation()
    {
        self::$validate = false;
    }

    public static function isValid()
    {
        return empty(self::$errors);
    }

    public static function errors()
    {
        return self::$errors;
    }

    protected static function reportInvalidArgument($message)
    {
        if (self::$validate) {
            self::$errors[] = $message;
        } else {
            throw new \InvalidArgumentException($message);
        }
    }
}

It's actually not that hard but it's using global state, which is not a good thing for a service that would be used by business code.

As we can see, the Validator class can just as well return errors as a boolean, I only used a boolean because it was simpler to write the test.

What do you think?

While I'm not truly satisfied with the solution I came up with, I still do think that sharing part of the validation / assertion code could be good, mostly to make sure that you never forget to update one when you change the other.

Some complicated assertions, like reaching out to the database to make sure a nickname does not already exist cannot be represented by assertions because they would need to use services, but the point of this article is to see if we can do something for those simple assertions that don't need services or IO of any kind.

Please tell me what you think on Twitter, and remember, it's only an experiment :)

Show Comments