Skip to content

Repository files navigation

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.

CILatest Stable VersionTotal DownloadsPHPStan LevelLicense

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 assertionsself::assertSame($e164PhoneNumber, $user->getPhone());
// fluent assertionsfact($user->getPhone())
->is($e164PhoneNumber)
->isString()
->startsWith('+7')
// etc.
;

Array assertions

fact([1, 2, 3])->count(3); // Passesfact([1, 2])->count(3); // Failsfact([1, 2])->notCount(3); // Passesfact([1, 2, 3])->notCount(3); // Failsfact(['a' => ['b' => 'c']])->arrayContainsAssociativeArray(['a' => ['b' => 'c']]); // Passesfact(['a' => ['b' => 'd']])->arrayContainsAssociativeArray(['a' => ['b' => 'c']]); // Failsfact(['a' => 1])->arrayHasKey('a'); // Passesfact(['a' => 1])->arrayHasKey('b'); // Failsfact(['a' => 1])->arrayNotHasKey('b'); // Passesfact(['a' => 1])->arrayNotHasKey('a'); // Failsfact([1, 2, 3])->contains(2); // Passesfact([1, 2])->contains(3); // Failsfact([1, 2])->doesNotContain(3); // Passesfact([1, 2, 3])->doesNotContain(3); // Fails fact([1, 2])->hasSize(2); // Passesfact([1, 2, 3])->hasSize(2); // Failsfact([])->isEmptyArray(); // Passesfact([1, 2])->isEmptyArray(); // Failsfact([1, 2])->isNotEmptyArray(); // Passesfact([])->isNotEmptyArray(); // Failsfact([2, 4, 6])->every(fn($v) => $v % 2 === 0); // Passesfact([1, 2, 3])->every(fn($v) => $v > 5); // Failsfact([1, 2, 3])->some(fn($v) => $v > 2); // Passesfact([1, 2, 3])->some(fn($v) => $v > 10); // Failsfact([1, 2, 3])->none(fn($v) => $v > 10); // Passesfact([1, 2, 3])->none(fn($v) => $v > 2); // Fails

Boolean assertions

fact(true)->true(); // Passesfact(1)->true(); // Fails due to strict comparisonfact(false)->notTrue(); // Passesfact(true)->notTrue(); // Failsfact(false)->false(); // Passesfact(0)->false(); // Fails due to strict comparisonfact(true)->notFalse(); // Passesfact(false)->notFalse(); // Fails

Comparison and equality assertions

fact(42)->is(42); // Passesfact(42)->is('42'); // Fails due to type differencefact(42)->equals(42); // Passesfact(42)->equals('42'); // Passes due to loose comparisonfact(42)->not(43); // Passesfact(42)->not(42); // Fails

Null assertions

fact(null)->null(); // Passesfact('')->null(); // Failsfact(42)->notNull(); // Passesfact(null)->notNull(); // Fails

Numeric assertions

fact(5)->isLowerThan(10); // Passesfact(10)->isLowerThan(5); // Failsfact(10)->isGreaterThan(5); // Passesfact(5)->isGreaterThan(10); // Failsfact(5)->isPositive(); // Passesfact(-3)->isPositive(); // Failsfact(-3)->isNegative(); // Passesfact(5)->isNegative(); // Failsfact(0)->isZero(); // Passesfact(0.0)->isZero(); // Passesfact(1)->isZero(); // Failsfact(5)->isBetween(1, 10); // Passesfact(15)->isBetween(1, 10); // Fails

String assertions

fact('abc123')->matchesRegularExpression('/^[a-z]+\d+$/'); // Passesfact('123abc')->matchesRegularExpression('/^[a-z]+\d+$/'); // Failsfact('123abc')->notMatchesRegularExpression('/^[a-z]+\d+$/'); // Passesfact('abc123')->notMatchesRegularExpression('/^[a-z]+\d+$/'); // Failsfact('hello world')->containsString('world'); // Passesfact('hello world')->containsString('foo'); // Failsfact('hello world')->notContainsString('foo'); // Passesfact('hello world')->notContainsString('world'); // Failsfact('Hello World')->containsStringIgnoringCase('world'); // Passesfact('Hello World')->containsStringIgnoringCase('foo'); // Failsfact('Hello World')->notContainsStringIgnoringCase('foo'); // Passesfact('Hello World')->notContainsStringIgnoringCase('world'); // Failsfact('hello world')->startsWith('hello'); // Passesfact('world hello')->startsWith('hello'); // Failsfact('file.txt')->endsWith('.txt'); // Passesfact('txt.file')->endsWith('.txt'); // Failsfact('abc')->hasLength(3); // Passesfact('abcd')->hasLength(3); // Failsfact('')->isEmptyString(); // Passesfact('hello')->isEmptyString(); // Failsfact('hello')->isNotEmptyString(); // Passesfact('')->isNotEmptyString(); // Failsfact('{"key": "value"}')->isJson(); // Passesfact('invalid json')->isJson(); // Failsfact('{"a":1,"b":2}')->matchesJson('{"b":2,"a":1}'); // Passes (key order ignored)fact('{"a":1}')->matchesJson('{"a":2}'); // Failsfact('{"a":1}')->notMatchesJson('{"a":2}'); // Passesfact('{"a":1,"b":2}')->notMatchesJson('{"b":2,"a":1}'); // Failsfact('user@example.com')->isValidEmail(); // Passesfact('invalid-email')->isValidEmail(); // Failsfact('01ARZ3NDEKTSV4RRFFQ69G5FAV')->ulid(); // Passes (if valid ULID)fact('invalid-ulid')->ulid(); // Fails

Type Checking assertions

fact(newstdClass())->instanceOf(stdClass::class); // Passesfact(newstdClass())->instanceOf(Exception::class); // Failsfact(newstdClass())->notInstanceOf(Exception::class); // Passesfact(newstdClass())->notInstanceOf(stdClass::class); // Failsfact(42)->isInt(); // Passesfact('42')->isInt(); // Failsfact('text')->isString(); // Passesfact(42)->isString(); // Failsfact((object)['name' => 'John'])->hasProperty('name'); // Passesfact((object)['name' => 'John'])->hasProperty('age'); // Failsfact((object)['name' => 'John'])->notHasProperty('age'); // Passesfact((object)['name' => 'John'])->notHasProperty('name'); // Failsfact(newstdClass())->hasMethod('__construct'); // Passesfact(newstdClass())->hasMethod('nonExistentMethod'); // Failsfact(3.14)->isFloat(); // Passesfact(42)->isFloat(); // Failsfact(true)->isBool(); // Passesfact(1)->isBool(); // Failsfact([1, 2])->isArray(); // Passesfact('not array')->isArray(); // Failsfact(fopen('php://memory', 'r'))->isResource(); // Passesfact('string')->isResource(); // Failsfact('strlen')->isCallable(); // Passesfact(123)->isCallable(); // Failsfact(3.14)->isFloat(); // Passesfact(42)->isFloat(); // Failsfact(true)->isBool(); // Passesfact(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 () => thrownewRuntimeException('boom'))->throws(RuntimeException::class); // Passesfact(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(newDateTimeImmutable('2024-01-01'))->isBefore('2024-12-31'); // Passesfact(newDateTimeImmutable('2024-12-31'))->isAfter('2024-01-01'); // Passesfact(newDateTimeImmutable('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 casefact(Status::Paid)->hasValue('paid'); // BackedEnum backing valuefact(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}|nullfact($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

About

Fluent assertions for PHPUnit

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages