Packages · utilities

k2gl/phpunit-fluent-assertions

Fluent, readable assertions for PHPUnit.

Write fact($value)->is(...) chains instead of remembering assertEquals argument order.

k2gl/phpunit-fluent-assertions version on Packagist k2gl/phpunit-fluent-assertions total downloads k2gl/phpunit-fluent-assertions license

Install

composer require k2gl/phpunit-fluent-assertions
PHP
>=8.1

Reach for it when

  • You want more readable, chainable assertions in PHPUnit tests.

Look elsewhere when

  • You prefer PHPUnit’s native assertions, or you’re not on PHPUnit.

Fluent assertions for PHPUnit

This library is inspired by Vladimir Khorikov, the author of Unit Testing: Principles, Patterns and Practices and makes checks in tests more readable.

Installation

You can add this library as a local, per-project dependency to your project using Composer:

composer require --dev k2gl/phpunit-fluent-assertions

Usage

Write tests as usual, just use fluent assertions short aliases check($x)->...;, expect($x)->...; or fact($x)->...; instead of self::assert...($x, $y).

// arrange
$user = UserFactory::createOne();

// act
$user->setPhone($e164PhoneNumber = faker()->e164PhoneNumber);

// traditional PHPUnit assertions
self::assertSame($e164PhoneNumber, $user->getPhone());

// fluent assertions
fact($user->getPhone())
    ->is($e164PhoneNumber)
    ->isString()
    ->startsWith('+7')
    // etc.
    ;

Array assertions

fact([1, 2, 3])->count(3); // Passes
fact([1, 2])->count(3); // Fails

fact([1, 2])->notCount(3); // Passes
fact([1, 2, 3])->notCount(3); // Fails
     
fact(['a' => ['b' => 'c']])->arrayContainsAssociativeArray(['a' => ['b' => 'c']]); // Passes
fact(['a' => ['b' => 'd']])->arrayContainsAssociativeArray(['a' => ['b' => 'c']]); // Fails

fact(['a' => 1])->arrayHasKey('a'); // Passes
fact(['a' => 1])->arrayHasKey('b'); // Fails
     
fact(['a' => 1])->arrayNotHasKey('b'); // Passes
fact(['a' => 1])->arrayNotHasKey('a'); // Fails

fact([1, 2, 3])->contains(2); // Passes
fact([1, 2])->contains(3); // Fails

fact([1, 2])->doesNotContain(3); // Passes
fact([1, 2, 3])->doesNotContain(3); // Fails     
     
fact([1, 2])->hasSize(2); // Passes
fact([1, 2, 3])->hasSize(2); // Fails

fact([])->isEmptyArray(); // Passes
fact([1, 2])->isEmptyArray(); // Fails
     
fact([1, 2])->isNotEmptyArray(); // Passes
fact([])->isNotEmptyArray(); // Fails

fact([2, 4, 6])->every(fn($v) => $v % 2 === 0); // Passes
fact([1, 2, 3])->every(fn($v) => $v > 5); // Fails

fact([1, 2, 3])->some(fn($v) => $v > 2); // Passes
fact([1, 2, 3])->some(fn($v) => $v > 10); // Fails

fact([1, 2, 3])->none(fn($v) => $v > 10); // Passes
fact([1, 2, 3])->none(fn($v) => $v > 2); // Fails

Boolean assertions

fact(true)->true(); // Passes
fact(1)->true(); // Fails due to strict comparison

fact(false)->notTrue(); // Passes
fact(true)->notTrue(); // Fails

fact(false)->false(); // Passes
fact(0)->false(); // Fails due to strict comparison

fact(true)->notFalse(); // Passes
fact(false)->notFalse(); // Fails

Comparison and equality assertions

fact(42)->is(42); // Passes
fact(42)->is('42'); // Fails due to type difference

fact(42)->equals(42); // Passes
fact(42)->equals('42'); // Passes due to loose comparison

fact(42)->not(43); // Passes
fact(42)->not(42); // Fails

Null assertions

fact(null)->null(); // Passes
fact('')->null(); // Fails

fact(42)->notNull(); // Passes
fact(null)->notNull(); // Fails

Numeric assertions

fact(5)->isLowerThan(10); // Passes
fact(10)->isLowerThan(5); // Fails

fact(10)->isGreaterThan(5); // Passes
fact(5)->isGreaterThan(10); // Fails

fact(5)->isPositive(); // Passes
fact(-3)->isPositive(); // Fails

fact(-3)->isNegative(); // Passes
fact(5)->isNegative(); // Fails

fact(0)->isZero(); // Passes
fact(0.0)->isZero(); // Passes
fact(1)->isZero(); // Fails

fact(5)->isBetween(1, 10); // Passes
fact(15)->isBetween(1, 10); // Fails

String assertions

fact('abc123')->matchesRegularExpression('/^[a-z]+\d+$/'); // Passes
fact('123abc')->matchesRegularExpression('/^[a-z]+\d+$/'); // Fails

fact('123abc')->notMatchesRegularExpression('/^[a-z]+\d+$/'); // Passes
fact('abc123')->notMatchesRegularExpression('/^[a-z]+\d+$/'); // Fails

fact('hello world')->containsString('world'); // Passes
fact('hello world')->containsString('foo'); // Fails

fact('hello world')->notContainsString('foo'); // Passes
fact('hello world')->notContainsString('world'); // Fails

fact('Hello World')->containsStringIgnoringCase('world'); // Passes
fact('Hello World')->containsStringIgnoringCase('foo'); // Fails

fact('Hello World')->notContainsStringIgnoringCase('foo'); // Passes
fact('Hello World')->notContainsStringIgnoringCase('world'); // Fails

fact('hello world')->startsWith('hello'); // Passes
fact('world hello')->startsWith('hello'); // Fails

fact('file.txt')->endsWith('.txt'); // Passes
fact('txt.file')->endsWith('.txt'); // Fails

fact('abc')->hasLength(3); // Passes
fact('abcd')->hasLength(3); // Fails

fact('')->isEmptyString(); // Passes
fact('hello')->isEmptyString(); // Fails

fact('hello')->isNotEmptyString(); // Passes
fact('')->isNotEmptyString(); // Fails

fact('{"key": "value"}')->isJson(); // Passes
fact('invalid json')->isJson(); // Fails

fact('{"a":1,"b":2}')->matchesJson('{"b":2,"a":1}'); // Passes (key order ignored)
fact('{"a":1}')->matchesJson('{"a":2}'); // Fails

fact('{"a":1}')->notMatchesJson('{"a":2}'); // Passes
fact('{"a":1,"b":2}')->notMatchesJson('{"b":2,"a":1}'); // Fails

fact('user@example.com')->isValidEmail(); // Passes
fact('invalid-email')->isValidEmail(); // Fails

fact('01ARZ3NDEKTSV4RRFFQ69G5FAV')->ulid(); // Passes (if valid ULID)
fact('invalid-ulid')->ulid(); // Fails

Type Checking assertions

fact(new stdClass())->instanceOf(stdClass::class); // Passes
fact(new stdClass())->instanceOf(Exception::class); // Fails

fact(new stdClass())->notInstanceOf(Exception::class); // Passes
fact(new stdClass())->notInstanceOf(stdClass::class); // Fails

fact(42)->isInt(); // Passes
fact('42')->isInt(); // Fails

fact('text')->isString(); // Passes
fact(42)->isString(); // Fails

fact((object)['name' => 'John'])->hasProperty('name'); // Passes
fact((object)['name' => 'John'])->hasProperty('age'); // Fails

fact(new stdClass())->hasMethod('__construct'); // Passes
fact(new stdClass())->hasMethod('nonExistentMethod'); // Fails

fact(3.14)->isFloat(); // Passes
fact(42)->isFloat(); // Fails

fact(true)->isBool(); // Passes
fact(1)->isBool(); // Fails

fact([1, 2])->isArray(); // Passes
fact('not array')->isArray(); // Fails

fact(fopen('php://memory', 'r'))->isResource(); // Passes
fact('string')->isResource(); // Fails

fact('strlen')->isCallable(); // Passes
fact(123)->isCallable(); // Fails

fact(3.14)->isFloat(); // Passes
fact(42)->isFloat(); // Fails

fact(true)->isBool(); // Passes
fact(1)->isBool(); // Fails

Exception assertions

The subject is a callable; throws() invokes it and asserts the expected exception is thrown. Pass a message substring to also assert on the exception message.

fact(fn () => throw new RuntimeException('boom'))->throws(RuntimeException::class); // Passes
fact(fn () => $service->run())->throws(DomainException::class, 'invalid'); // Passes if message contains "invalid"
fact(fn () => 42)->throws(RuntimeException::class); // Fails — nothing thrown

Date/time assertions

The subject is a DateTimeInterface; a string expectation is parsed as a DateTimeImmutable.

fact(new DateTimeImmutable('2024-01-01'))->isBefore('2024-12-31'); // Passes
fact(new DateTimeImmutable('2024-12-31'))->isAfter('2024-01-01'); // Passes
fact(new DateTimeImmutable('2024-06-13 23:59'))->isSameDate('2024-06-13'); // Passes — same calendar date, time ignored

Enum assertions

Work on any native UnitEnum / BackedEnum.

fact($order->status)->isEnum(Status::Paid);   // identity: the subject is that case
fact(Status::Paid)->hasValue('paid');          // BackedEnum backing value
fact(Status::Paid)->hasName('Paid');           // case name

PHPStan support

The package ships a PHPStan extension that narrows the asserted value, so the analyser keeps following your types after a fluent assertion:

/** @var array{something: string}|null $context */
$context = $user->getContext();

echo $context['something']; // PHPStan error: Cannot access offset 'something' on array{something: string}|null

fact($context)->notNull();

echo $context['something']; // OK: $context is narrowed to array{something: string}

More examples — every supported assertion narrows the subject in place:

// instanceOf(): a union is narrowed to the concrete class
/** @var DateTimeInterface|null $date */
fact($date)->instanceOf(DateTimeImmutable::class);
// → $date is DateTimeImmutable

// notInstanceOf(): the class is subtracted from the union
/** @var DateTime|DateTimeImmutable $createdAt */
fact($createdAt)->notInstanceOf(DateTimeImmutable::class);
// → $createdAt is DateTime

// true() / false(): a bool is narrowed to the literal
/** @var bool $flag */
fact($flag)->true();      // → $flag is true
/** @var bool $enabled */
check($enabled)->false(); // → $enabled is false

// is(): narrowed to the exact expected value (assertSame semantics)
/** @var mixed $status */
fact($status)->is('active');
// → $status is 'active'

// null(): narrowed to null
/** @var string|null $token */
fact($token)->null();
// → $token is null

// type checks: narrowed to the asserted PHP type
/** @var mixed $value */
fact($value)->isString();
// → $value is string  (likewise isInt/isFloat/isBool/isArray/isCallable/isResource)

// chains accumulate every supported step
/** @var string|null $name */
fact($name)->notNull()->is('admin');
// → $name is 'admin'

The chain can be opened with any of check(), expect(), fact() or FluentAssertions::for(), and the subject may be a variable or a property (e.g. fact($user->getContext())->notNull()).

If you use phpstan/extension-installer it is picked up automatically. Otherwise include it manually in your phpstan.neon:

includes:
    - vendor/k2gl/phpunit-fluent-assertions/extension.neon

Narrowing is applied for notNull(), null(), true(), notTrue(), false(), notFalse(), instanceOf(), notInstanceOf(), is(), the type checks isString(), isInt(), isFloat(), isBool(), isArray(), isCallable() and isResource(), and the JSON assertions isJson(), matchesJson() and notMatchesJson() (subject narrowed to string). Loose or negated assertions such as equals() (loose ==) and not() would not narrow soundly, so they are intentionally left out and leave the type unchanged.

Pull requests are always welcome

Collaborate with pull requests

API

Public classes and methods, generated from the source.

K2gl\PHPUnitFluentAssertions\FluentAssertions class

  • __construct( public readonly mixed $variable = null )
  • for(mixed $variable = null): self

K2gl\PHPUnitFluentAssertions\PHPStan\FluentAssertionsTypeSpecifyingExtension class

  • setTypeSpecifier(TypeSpecifier $typeSpecifier): void
  • getClass(): string
  • isMethodSupported( MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context, ): bool
  • specifyTypes( MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context, ): SpecifiedTypes
  • (Expr $s, array $args, Scope $scope): ?Expr
  • (Expr $s, array $args, Scope $scope): ?Expr
  • (Expr $s, array $args): ?Expr

K2gl\PHPUnitFluentAssertions\Reference\RegularExpressionPattern class

K2gl\PHPUnitFluentAssertions\Traits\ArrayAssertions trait

  • count(int $expectedCount, string $message = ''): self
  • notCount(int $elementsCount, string $message = ''): self
  • arrayContainsAssociativeArray(array $values): self
  • arrayHasKey(int|string $key, string $message = ''): self
  • arrayNotHasKey(int|string $key, string $message = ''): self
  • contains(mixed $value, string $message = ''): self
  • doesNotContain(mixed $value, string $message = ''): self
  • hasSize(int $size, string $message = ''): self
  • isEmptyArray(string $message = ''): self
  • isNotEmptyArray(string $message = ''): self
  • every(callable $callback, string $message = ''): self
  • some(callable $callback, string $message = ''): self
  • none(callable $callback, string $message = ''): self

K2gl\PHPUnitFluentAssertions\Traits\BooleanAssertions trait

  • true(string $message = ''): self
  • notTrue(string $message = ''): self
  • false(string $message = ''): self
  • notFalse(string $message = ''): self

K2gl\PHPUnitFluentAssertions\Traits\ComparisonAndEqualityAssertions trait

  • is(mixed $expected, string $message = ''): self
  • equals(mixed $expected, string $message = ''): self
  • not(mixed $expected, string $message = ''): self

K2gl\PHPUnitFluentAssertions\Traits\DateTimeAssertions trait

  • isBefore(DateTimeInterface|string $expected, string $message = ''): self
  • isAfter(DateTimeInterface|string $expected, string $message = ''): self
  • isSameDate(DateTimeInterface|string $expected, string $message = ''): self

K2gl\PHPUnitFluentAssertions\Traits\EnumAssertions trait

  • isEnum(UnitEnum $case, string $message = ''): self
  • hasValue(int|string $value, string $message = ''): self
  • hasName(string $name, string $message = ''): self

K2gl\PHPUnitFluentAssertions\Traits\ExceptionAssertions trait

  • throws(string $exception, string $message = ''): self

K2gl\PHPUnitFluentAssertions\Traits\NullAssertions trait

  • null(string $message = ''): self
  • notNull(string $message = ''): self

K2gl\PHPUnitFluentAssertions\Traits\NumericAssertions trait

  • isLowerThan(int|float $expected, string $message = ''): self
  • isGreaterThan(int|float $expected, string $message = ''): self
  • isPositive(string $message = ''): self
  • isNegative(string $message = ''): self
  • isZero(string $message = ''): self
  • isBetween(int|float $min, int|float $max, string $message = ''): self

K2gl\PHPUnitFluentAssertions\Traits\StringAssertions trait

  • matchesRegularExpression(string $pattern, string $message = ''): self
  • notMatchesRegularExpression(string $pattern, string $message = ''): self
  • containsString(string $string, string $message = ''): self
  • notContainsString(string $string, string $message = ''): self
  • containsStringIgnoringCase(string $string, string $message = ''): self
  • notContainsStringIgnoringCase(string $string, string $message = ''): self
  • startsWith(string $prefix, string $message = ''): self
  • endsWith(string $suffix, string $message = ''): self
  • hasLength(int $length, string $message = ''): self
  • isEmptyString(string $message = ''): self
  • isNotEmptyString(string $message = ''): self
  • isJson(string $message = ''): self
  • matchesJson(string $expectedJson, string $message = ''): self
  • notMatchesJson(string $expectedJson, string $message = ''): self
  • isValidEmail(string $message = ''): self
  • ulid(): self

K2gl\PHPUnitFluentAssertions\Traits\TypeCheckingAssertions trait

  • instanceOf(string $expected, string $message = ''): self
  • notInstanceOf(string $expected, string $message = ''): self
  • isInt(string $message = ''): self
  • isString(string $message = ''): self
  • hasProperty(string $property, string $message = ''): self
  • hasMethod(string $method, string $message = ''): self
  • isFloat(string $message = ''): self
  • isBool(string $message = ''): self
  • isArray(string $message = ''): self
  • isResource(string $message = ''): self
  • isCallable(string $message = ''): self