Sorry if this is a long post, it was a bit of a journey for me!
When trying to implement my own Constraint
, I was a bit confused by the API. My understanding is that the core of execution is this:
// Constraint.php
public function evaluate($other, string $description = '', bool $returnResult = false): ?bool
{
$success = false;
if ($this->matches($other)) {
$success = true;
}
if ($returnResult) {
return $success;
}
if (!$success) {
$this->fail($other, $description);
}
return null;
}
The documentation however suggest that the right extension point is Constraint::matches()
and by extension, Constraint::failureDescription()
.
Looking at PHPUnit codebase for more examples I see a mix of the two which makes me confused.
Indeed, overriding matches
looks very simple and the best solution. However, when wanting to provide a more detailed and helpful failure description, I feel you are just "duplicating" matches
but returning the description string instead, because depending of the step or the why matches return false
, the failure description changes.
You can see how this is a problem even within PHPUnit with the constraint IsJson
:
IsJson.php excerpt
/**
* Evaluates the constraint for parameter $other. Returns true if the
* constraint is met, false otherwise.
*
* @param mixed $other value or object to evaluate
*/
protected function matches($other): bool
{
if ($other === '') {
return false;
}
json_decode($other);
if (json_last_error()) {
return false;
}
return true;
}
/**
* Returns the description of the failure.
*
* The beginning of failure messages is "Failed asserting that" in most
* cases. This method should return the second part of that sentence.
*
* @param mixed $other evaluated value or object
*
* @throws \SebastianBergmann\RecursionContext\InvalidArgumentException
*/
protected function failureDescription($other): string
{
if ($other === '') {
return 'an empty string is valid JSON';
}
json_decode($other);
$error = (string) JsonMatchesErrorMessageProvider::determineJsonError(
(string) json_last_error()
);
return sprintf(
'%s is valid JSON (%s)',
$this->exporter()->shortenedExport($other),
$error
);
}
Looking at both methods, we can notice that:
$other
is effectively being evaluated twice
- how with a slightly more complex scenario, you repeat the matching process to provide a more tailored error
I think this is not really desirable for both performance (a failure does not necessarily stop the process, so having it's evaluation expansive for no reason is not desired) and robustness (duplicating the evaluation feels brittle).
My first impression when seeing this was to either create the failure description directly in matches
as a constraint state via a property or to have a memoized method that does "evaluate and provide error message upon non match". I am not too found of neither.†
Fishing around for more examples, I actually saw a few constraints such as:
The second case actually looks more right to me compared to †, but completely forgo the nice utils provided Constraint::fail()
. At least with the current API. I find strange however that evaluate()
return type is bool
when clearly it is true|throw ExpectationFailedException
.
This opinion is completely uninformed about the historical reasons, future plans and BC regards, so maybe it's just silly, but WDYT of changing the API of matches()
to return true|string|null
instead, string
replacing failureDescription()
in usage, which is only need if the return is null
(to keep the default).
And regardless if feels that evaluate()
return type could be changed to void
?
type/refactoring feature/assertion