Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Add a "toPassAny" expectation method #1286

Open
wants to merge 1 commit into
base: 3.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/Expectation.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
use Pest\Support\ExpectationPipeline;
use Pest\Support\Reflection;
use PHPUnit\Architecture\Elements\ObjectDescription;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\ExpectationFailedException;
use ReflectionEnum;
use ReflectionMethod;
Expand Down Expand Up @@ -324,6 +325,39 @@ public function when(callable|bool $condition, callable $callback): self
return $this;
}

/**
* Asserts that any one of the given tests pass with the given expectation target.
*
* @param (\Closure(self<TValue>): (mixed|void)) ...$tests
* @return self<TValue>
*
* @throws AssertionFailedError Rethrows first caught exception on failure
*/
public function toPassAny(\Closure ...$tests): Expectation
Copy link

@dshafik dshafik Oct 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to recommend toBeOneOf(), and to allow non-closure values that will be converted to toBe() assertions. This allows for:

expect($foo)->toBeOneOf(1, 2, 3);

Here's the code:

public function toBeOneOf(mixed ...$tests): Expectation {
    $firstException = null;
    if ($tests === []) {
        return $this;
    }

    foreach ($tests as $test) {
        if (!($test instanceof \Closure)) {
            $test = fn ($expectation) => $expectation->toBe($test);
        }

        try {
            $test(new Expectation($this->value));

            return $this;
        } catch (AssertionFailedError $e) {
            $firstException ??= $e;
        }
    }

    /** @var AssertionFailedError $firstException */
    throw $firstException;
}

Copy link
Author

@KorvinSzanto KorvinSzanto Oct 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me "toBeOneOf" both implies an exact count and hides the fact that any expectation could be provided.

I also don't like falling back to a default expectation because in my opinion it makes the behavior confusing when your array might contain values that are closures. Id prefer an additional ->toBeAny(...) that does something like return $this->toPassAny(array_map(fn($i) => fn($e) => $e->toBe($i), $items));

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toBeAny is good, or toBeAnyOf().

You shouldn't be passing an array, it's variadic. Your example above should spread the array as args (… array_map(…)) or it won't work with any of the code…

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually like the idea of "toBeOneOf" as you say the name implies, an xor type deal. I wrote it and then ditched it but it sounds more useful with that name

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah you'd need to spread it. Not the easiest code snippet to write on mobile 🙂

{
$firstException = null;

// Ignore calls that include no tests
if ($tests === []) {
return $this;
}

foreach ($tests as $test) {
try {
$test(new Expectation($this->value));
} catch (AssertionFailedError $e) {
$firstException ??= $e;

continue;
}

return $this;
}

/** @var AssertionFailedError $firstException */
throw $firstException;
}

/**
* Dynamically calls methods on the class or creates a new higher order expectation.
*
Expand Down
35 changes: 35 additions & 0 deletions tests/Features/Expect/toPassAny.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

use PHPUnit\Framework\AssertionFailedError;

it('throws first exception', function (array $tests) {
$mappedTests = array_map(fn ($i) => fn ($e) => $e->toContain($i), $tests);
expect(fn () => expect('Foo')->toPassAny(...$mappedTests))
->toThrow(fn (AssertionFailedError $e) => expect($e->getMessage())->toContain('Foo')->toContain($tests[0]));

// Make sure inverted test has the opposite effect
expect(fn () => expect('Foo')->not->toPassAny(...$mappedTests))->not->toThrow(\Throwable::class);
})->with([
[['First']],
[['First', 'Second']],
[['First', 'Second', 'Third']],
]);

it('succeeds with valid tests', function ($tests) {
$mappedTests = array_map(fn ($i) => fn ($e) => $e->toBe($i), $tests);
expect(fn () => expect('Foo')->toPassAny(...$mappedTests))
->not->toThrow(\Throwable::class);

// Make sure inverted test has the opposite effect
expect(fn () => expect('Foo')->not->toPassAny(...$mappedTests))
->toThrow(AssertionFailedError::class);
})->with([
[['Fail', 'Fail', 'Fail', 'Foo']],
[['Fail', 'Fail', 'Foo', 'Fail']],
[['Fail', 'Foo', 'Fail', 'Fail']],
[['Foo', 'Fail', 'Fail', 'Fail']],
[['Foo', 'Foo', 'Foo', 'Foo']],
[['Fail', 'Foo']],
[['Foo']],
[[]],
]);