lastdragon-ru / lara-asp-testing
The Awesome Set of Packages for Laravel - Testing Helpers.
Requires
- php: ^8.2|^8.3
- ext-dom: *
- ext-json: *
- ext-libxml: *
- ext-mbstring: *
- ext-xmlreader: *
- composer/semver: ^3.2
- doctrine/sql-formatter: ^1.1
- http-interop/http-factory-guzzle: ^1.0.0
- illuminate/collections: ^10.34.0|^11.0.0
- illuminate/console: ^10.34.0|^11.0.0
- illuminate/contracts: ^10.34.0|^11.0.0
- illuminate/database: ^10.34.0|^11.0.0
- illuminate/testing: ^10.34.0|^11.0.0
- illuminate/translation: ^10.34.0|^11.0.0
- mockery/mockery: ^1.6.5
- opis/json-schema: ^2.3.0
- phpunit/phpunit: ^10.1.0|^11.0.0
- psr/http-message: ^1.0.0|^2.0.0
- sebastian/comparator: ^5.0|^6.0.0
- sebastian/exporter: ^5.0|^6.0.0
- symfony/deprecation-contracts: ^3.0.0
- symfony/filesystem: ^6.3.0|^7.0.0
- symfony/http-foundation: ^6.3.0|^7.0.0
- symfony/mime: ^6.3.0|^7.0.0
- symfony/polyfill-php83: ^1.28
- symfony/psr-http-message-bridge: ^2.0.0|^6.4.0|^7.0.0
Requires (Dev)
- fakerphp/faker: ^1.21.0
- guzzlehttp/psr7: ^2.4.5
- illuminate/http: ^10.34.0|^11.0.0
- laravel/scout: ^9.8.0|^10.0.0
- orchestra/testbench: ^8.0.0|^9.0.0
- symfony/console: ^6.3.0|^7.0.0
- symfony/http-kernel: ^6.3.0|^7.0.0
- dev-main
- 7.0.0
- 6.x-dev
- 6.4.2
- 6.4.1
- 6.4.0
- 6.3.0
- 6.2.0
- 6.1.0
- 6.0.0
- 5.x-dev
- 5.6.0
- 5.5.0
- 5.4.0
- 5.3.1
- 5.3.0
- 5.2.0
- 5.1.0
- 5.0.0
- 5.0.0-beta.1
- 5.0.0-beta.0
- 4.x-dev
- 4.6.0
- 4.5.2
- 4.5.1
- 4.5.0
- 4.4.0
- 4.3.0
- 4.2.1
- 4.2.0
- 4.1.0
- 4.0.0
- 3.0.0
- 2.x-dev
- 2.1.0
- 2.0.3
- 2.0.2
- 2.0.1
- 2.0.0
- 1.x-dev
- 1.1.2
- 1.1.1
- 1.1.0
- 1.0.4
- 1.0.3
- 1.0.2
- 1.0.1
- 1.0.0
- 0.15.0
- 0.14.1
- 0.14.0
- 0.13.0
- 0.12.0
- 0.11.0
- 0.10.0
- 0.9.0
- 0.8.1
- 0.8.0
- 0.7.0
- 0.6.1
- 0.6.0
- 0.5.0
- 0.4.0
- 0.3.0
- 0.2.0
- 0.1.0
This package is auto-updated.
Last update: 2024-11-17 07:57:57 UTC
README
This package provides various useful asserts for PHPUnit and better solution for HTTP tests - testing HTTP response has never been so easy! And this not only about TestResponse
but any PSR response ๐
Requirements
Installation
Note
The package intended to use in dev.
composer require --dev lastdragon-ru/lara-asp-testing
Usage
Important
By default, package overrides scalar comparator to make it strict! So assertEquals(true, 1)
is false
.
In the general case, you just need to update tests/TestCase.php
to include most important things, but you also can include only desired features, please see related traits and extensions below.
<?php declare(strict_types = 1); namespace Tests; use Illuminate\Contracts\Foundation\Application; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use LastDragon_ru\LaraASP\Testing\Assertions\Assertions; use LastDragon_ru\LaraASP\Testing\Concerns\Concerns; use Override; abstract class TestCase extends BaseTestCase { use Assertions; // Added use Concerns; // Added use CreatesApplication; #[Override] protected function app(): Application { return $this->app; } }
Comparators
Tip
Should be registered before test, check/use built-in traits.
DatabaseQueryComparator
Compares two Query
.
We are performing following normalization before comparison to be more precise:
- Renumber
laravel_reserved_*
(it will always start from0
and will not contain gaps) - Format the query by
doctrine/sql-formatter
package
EloquentModelComparator
Compares two Eloquent Models.
The problem is models after creating from the factory and selecting from
the database may have different types for the same properties. For example,
factory()->create()
will set key
as int
, but select
will set it to
string
and (strict) comparison will fail. This comparator normalizes
properties types before comparison.
ScalarStrictComparator
Makes comparison of scalars strict.
Extensions
PHPUnit TestCase
RefreshDatabaseIfEmpty
๐
The trait is very similar to standard \Illuminate\Foundation\Testing\RefreshDatabase
but there is one
difference: it will refresh the database only if it is empty. This is very
useful for local testing and allow significantly reduce bootstrap time.
<?php declare(strict_types = 1); namespace Tests; use LastDragon_ru\LaraASP\Testing\Database\RefreshDatabaseIfEmpty; use LastDragon_ru\LaraASP\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { use CreatesApplication; use RefreshDatabaseIfEmpty; protected function shouldSeed() { return true; } }
WithTempDirectory
Allows to create a temporary directory. The directory will be removed automatically after script shutdown.
WithTempFile
Allows to create a temporary file. The file will be removed automatically after script shutdown.
WithTestData
Allows to get instance of TestData
(a small helper to load data
associated with test)
Laravel TestCase
WithTranslations
Allows replacing translation strings for Laravel.
Override
Similar to \Illuminate\Foundation\Testing\Concerns\InteractsWithContainer
but will mark test as failed if
override was not used while test (that helps to find unused code).
Eloquent Model Factory
FixRecentlyCreated
After creating the model will have wasRecentlyCreated = true
, in most
cases this is unwanted behavior, this trait fixes it.
WithoutModelEvents
Disable models events during make/create.
Mixins
\Illuminate\Testing\TestResponse
Assertions
assertDatabaseQueryEquals
Asserts that SQL Query equals SQL Query.
assertJsonMatchesSchema
Asserts that JSON matches schema. Validation based on the Opis JSON Schema package.
assertPsrResponse
Asserts that PSR Response satisfies given constraint (we have a lot of built-in constraints and responses, but, of course, you can create a custom).
assertQueryLogEquals
Asserts that QueryLog
equals QueryLog
.
assertScheduled
Asserts that Schedule contains task.
assertScoutQueryEquals
Asserts that Scout Query equals Scout Query.
assertThatResponse
๐
Asserts that PSR Response satisfies given constraint (we have a lot of built-in constraints and responses, but, of course, you can create a custom).
assertXmlMatchesSchema
Asserts that XML matches schema XSD or Relax NG. Validation based on the standard methods of DOMDocument
class.
Laravel Response Testing
What is wrong with the Laravel approach? Well, there are two big problems.
Where is the error?
You never know why your test failed and need to debug it to find the reason. Real-life example:
<?php declare(strict_types = 1); namespace App\Http\Controllers; use PHPUnit\Framework\Attributes\CoversClass; use Tests\TestCase; /** * @internal */ #[CoversClass(IndexController::class)] class IndexControllerTest extends TestCase { public function testIndex() { $this->get('/') ->assertOk() ->assertHeader('Content-Type', 'application/json'); } }
assertOk() failed
Testing started at 15:46 ...
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.
Random Seed: 1610451974
Expected status code 200 but received 500.
Failed asserting that 200 is identical to 500.
vendor/laravel/framework/src/Illuminate/Testing/TestResponse.php:186
app/Http/Controllers/IndexControllerTest.php:16
Time: 00:01.373, Memory: 26.00 MB
assertHeader() failed
Testing started at 17:57 ...
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.
Random Seed: 1610459878
Header [Content-Type] was found, but value [text/html; charset=UTF-8] does not match [application/json].
Failed asserting that two values are equal.
Expected :'application/json'
Actual :'text/html; charset=UTF-8'
<Click to see difference>
vendor/laravel/framework/src/Illuminate/Testing/TestResponse.php:229
app/Http/Controllers/IndexControllerTest.php:18
Time: 00:01.082, Memory: 24.00 MB
FAILURES!
Tests: 1, Assertions: 3, Failures: 1.
Process finished with exit code 1
Expected status code 200 but received 500.
Hmmm, 500, probably this is php error? Why? Where? ๐ฐ
Compare with:
<?php declare(strict_types = 1); namespace App\Http\Controllers; use LastDragon_ru\LaraASP\Testing\Constraints\Response\ContentTypes\JsonContentType; use LastDragon_ru\LaraASP\Testing\Constraints\Response\Response; use LastDragon_ru\LaraASP\Testing\Constraints\Response\StatusCodes\Ok; use PHPUnit\Framework\Attributes\CoversClass; use Tests\TestCase; /** * @internal */ #[CoversClass(IndexController::class)] class IndexControllerTest extends TestCase { public function testIndex() { $this->get('/')->assertThat(new Response( new Ok(), new JsonContentType() )); } }
assertThat() failed
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.
Random Seed: 1610461475
Failed asserting that GuzzleHttp\Psr7\Response Object &000000001ef973410000000013328b0b (
'reasonPhrase' => 'Internal Server Error'
'statusCode' => 500
'headers' => Array &0 (
'cache-control' => Array &1 (
0 => 'no-cache, private'
)
'date' => Array &2 (
0 => 'Tue, 12 Jan 2021 14:24:36 GMT'
)
'content-type' => Array &3 (
0 => 'text/html; charset=UTF-8'
)
)
'headerNames' => Array &5 (
'cache-control' => 'cache-control'
'date' => 'date'
'content-type' => 'content-type'
'set-cookie' => 'Set-Cookie'
)
'protocol' => '1.1'
'stream' => GuzzleHttp\Psr7\Stream Object &000000001ef972d20000000013328b0b (
'stream' => resource(846) of type (stream)
'size' => null
'seekable' => true
'readable' => true
'writable' => true
'uri' => 'php://temp'
'customMetadata' => Array &6 ()
)
) has Status Code is equal to 200.
<!doctype html>
<html class="theme-light">
<!--
Error: Call to undefined function App\Http\Controllers\dview() in file app/Http/Controllers/IndexController.php on line 7
#0 vendor/laravel/framework/src/Illuminate/Routing/Controller.php(54): App\Http\Controllers\IndexController->index()
#1 vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php(45): Illuminate\Routing\Controller->callAction()
#2 vendor/laravel/framework/src/Illuminate/Routing/Route.php(254): Illuminate\Routing\ControllerDispatcher->dispatch()
#3 vendor/laravel/framework/src/Illuminate/Routing/Route.php(197): Illuminate\Routing\Route->runController()
#4 vendor/laravel/framework/src/Illuminate/Routing/Router.php(692): Illuminate\Routing\Route->run()
#5 vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(128): Illuminate\Routing\Router->Illuminate\Routing\{closure}()
#6 vendor/laravel/framework/src/Illuminate/Routing/Middleware/SubstituteBindings.php(41): Illuminate\Pipeline\Pipeline->Illuminate\Pipeline\{closure}()
#7 vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(167): Illuminate\Routing\Middleware\SubstituteBindings->handle()
#8 vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/VerifyCsrfToken.php(78): Illuminate\Pipeline\Pipeline->Illuminate\Pipeline\{closure}()
...
Time: 00:01.356, Memory: 28.00 MB
FAILURES!
Tests: 1, Assertions: 1, Failures: 1.
Process finished with exit code 1
Reusing the test code is problematic
In most real applications you have multiple roles (eg guest
, user
, admin
), guards, and policies. Very difficult to test all of them and usually you need create many testRouteIsNotAvailableForGuest()
, testRouteIsAvailableForAdminOnly()
, etc with a lot of boilerplate code. Also, often you cannot reuse that (boilerplate) code and must write it again and again. That is really annoying.
Resolving this problem is very simple. First, we need to create classes for the required Responses (actually package already provides few most used responses ๐). Let's start with a simple JSON response:
<?php declare(strict_types = 1); namespace Tests\Responses; use LastDragon_ru\LaraASP\Testing\Constraints\Response\ContentTypes\JsonContentType; use LastDragon_ru\LaraASP\Testing\Constraints\Response\Response; use LastDragon_ru\LaraASP\Testing\Constraints\Response\StatusCodes\Ok; class JsonResponse extends Response { public function __construct() { parent::__construct( new Ok(), new JsonContentType(), ); } }
Next, lets add JSON Validation Error:
<?php declare(strict_types = 1); namespace Tests\Responses; use LastDragon_ru\LaraASP\Testing\Constraints\Json\JsonMatchesSchema; use LastDragon_ru\LaraASP\Testing\Constraints\Json\JsonSchema; use LastDragon_ru\LaraASP\Testing\Constraints\Response\Body; use LastDragon_ru\LaraASP\Testing\Constraints\Response\ContentTypes\JsonContentType; use LastDragon_ru\LaraASP\Testing\Constraints\Response\Response; use LastDragon_ru\LaraASP\Testing\Constraints\Response\StatusCodes\UnprocessableEntity; use LastDragon_ru\LaraASP\Testing\Utils\WithTestData; class ValidationErrorResponse extends Response { use WithTestData; public function __construct() { parent::__construct( new UnprocessableEntity(), new JsonContentType(), new Body([ new JsonMatchesSchema(new JsonSchema(self::getTestData(self::class)->file('.json'))), ]), ); } }
Finally, the test:
<?php declare(strict_types = 1); namespace App\Http\Controllers; use PHPUnit\Framework\Attributes\CoversClass; use Tests\Responses; use Tests\TestCase; /** * @internal */ #[CoversClass(IndexController::class)] class IndexControllerTest extends TestCase { public function testIndex() { $this->getJson('/')->assertThat(new ValidationErrorResponse()); } public function testTest() { $this->getJson('/test')->assertThat(new ValidationErrorResponse()); } }
The same test with default assertions may look something like this:
<?php declare(strict_types = 1); namespace App\Http\Controllers; use PHPUnit\Framework\Attributes\CoversClass; use Tests\TestCase; /** * @internal */ #[CoversClass(IndexController::class)] class IndexControllerTest extends TestCase { public function testIndex() { $this->getJson('/') ->assertStatus(422) ->assertHeader('Content-Type', 'application/json') ->assertJsonStructure([ 'message', 'errors', ]); } public function testTest() { $this->getJson('/test') ->assertStatus(422) ->assertHeader('Content-Type', 'application/json') ->assertJsonStructure([ 'message', 'errors', ]);; } }
Feel the difference ๐
PSR Response Testing
Internally package uses PSR-7
so you can test any Psr\Http\Message\ResponseInterface
๐คฉ
<?php declare(strict_types = 1); use LastDragon_ru\LaraASP\Testing\Assertions\ResponseAssertions; use LastDragon_ru\LaraASP\Testing\Constraints\Response\ContentTypes\JsonContentType; use LastDragon_ru\LaraASP\Testing\Constraints\Response\Response; use LastDragon_ru\LaraASP\Testing\Constraints\Response\StatusCodes\Ok; use PHPUnit\Framework\TestCase; class ResponseInterfaceTest extends TestCase { use ResponseAssertions; public function testResponse() { /** @var \Psr\Http\Message\ResponseInterface $response */ $response = null; self::assertThatResponse($response, new Response( new Ok(), new JsonContentType(), )); } }
Data Providers on steroids
There is another cool feature that allows us to test a lot of use cases without code duplication - the CompositeDataProvider
. It's merging multiple provides into one in the following way:
Providers:
[
['expected a', 'value a'],
['expected final', 'value final'],
]
[
['expected b', 'value b'],
['expected c', 'value c'],
]
[
['expected d', 'value d'],
['expected e', 'value e'],
]
Merged:
[
'0 / 0 / 0' => ['expected d', 'value a', 'value b', 'value d'],
'0 / 0 / 1' => ['expected e', 'value a', 'value b', 'value e'],
'0 / 1 / 0' => ['expected d', 'value a', 'value c', 'value d'],
'0 / 1 / 1' => ['expected e', 'value a', 'value c', 'value e'],
'1' => ['expected final', 'value final'],
]
So we can organize our tests like this:
<?php declare(strict_types = 1); namespace Tests\Feature; use App\Models\User; use Closure; use Illuminate\Http\Request; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Support\Facades\Route; use LastDragon_ru\LaraASP\Testing\Constraints\Response\Response; use LastDragon_ru\LaraASP\Testing\Constraints\Response\StatusCodes\NotFound; use LastDragon_ru\LaraASP\Testing\Constraints\Response\StatusCodes\Ok; use LastDragon_ru\LaraASP\Testing\Constraints\Response\StatusCodes\Unauthorized; use LastDragon_ru\LaraASP\Testing\Providers\ArrayDataProvider; use LastDragon_ru\LaraASP\Testing\Providers\CompositeDataProvider; use LastDragon_ru\LaraASP\Testing\Providers\DataProvider as DataProviderContract; use LastDragon_ru\LaraASP\Testing\Providers\ExpectedFinal; use LastDragon_ru\LaraASP\Testing\Responses\Laravel\Json\ValidationErrorResponse; use PHPUnit\Framework\Attributes\DataProvider;use Tests\TestCase; class ExampleTest extends TestCase { // <editor-fold desc="Prepare"> // ========================================================================= public function setUp(): void { parent::setUp(); Route::get('/users/{user}', function (User $user) { return $user->email; })->middleware(['auth', SubstituteBindings::class]); Route::post('/users/{user}', function (Request $request, User $user) { $user->email = $request->validate([ 'email' => 'required|email', ]); return $user->email; })->middleware(['auth', SubstituteBindings::class]); } // </editor-fold> // <editor-fold desc="Tests"> // ========================================================================= #[DataProvider('dataProviderGet')] public function testGet(Response $expected, Closure $actingAs = null, Closure $user = null): void { $user = $user ? $user()->getKey() : 0; if ($actingAs) { $this->actingAs($actingAs()); } $this->getJson("/users/{$user}")->assertThat($expected); } #[DataProvider('dataProviderUpdate')] public function testUpdate(Response $expected, Closure $actingAs = null, Closure $user = null, array $data = []) { $user = $user ? $user()->getKey() : 0; if ($actingAs) { $this->actingAs($actingAs()); } $this->postJson("/users/{$user}", $data)->assertThat($expected); } // </editor-fold> // <editor-fold desc="DataProvider"> // ========================================================================= public static function dataProviderGet(): array { return (new CompositeDataProvider( self::getUserDataProvider(), self::getModelDataProvider(), ))->getData(); } public static function dataProviderUpdate(): array { return (new CompositeDataProvider( self::getUserDataProvider(), self::getModelDataProvider(), new ArrayDataProvider([ 'no email' => [ new ValidationErrorResponse(['email' => null]), [], ], 'invalid email' => [ new ValidationErrorResponse([ 'email' => 'The email must be a valid email address.', ]), [ 'email' => '123', ], ], 'valid email' => [ new Ok(), [ 'email' => 'test@example.com', ], ], ]) ))->getData(); } // </editor-fold> // <editor-fold desc="Shared"> // ========================================================================= protected static function getUserDataProvider(): DataProviderContract { return new ArrayDataProvider([ 'guest' => [ new ExpectedFinal(new Unauthorized()), null, ], 'authenticated' => [ new Ok(), function () { return User::factory()->create(); }, ], ]); } protected static function getModelDataProvider(): DataProviderContract { return new ArrayDataProvider([ 'user not exists' => [ new ExpectedFinal(new NotFound()), null, ], 'user exists' => [ new Ok(), function () { return User::factory()->create(); }, ], ]); } // </editor-fold> }
Enjoy ๐ธ
Mocking properties (Mockery) ๐งช
Important
Working prototype for How to mock protected properties? (#1142). Please note that implementation relies on Reflection and internal Mockery methods/properties.
Limitations/Notes:
- Readonly properties should be uninitialized.
- Private properties aren't supported.
- Property value must be an object.
- Property must be used while test.
- Property can be mocked only once.
- Objects without methods will be marked as unused.
<?php declare(strict_types = 1); // phpcs:disable PSR1.Files.SideEffects // phpcs:disable PSR1.Classes.ClassDeclaration namespace LastDragon_ru\LaraASP\Testing\Docs\Examples\MockProperties; use LastDragon_ru\LaraASP\Testing\Mockery\MockProperties; use Mockery; class A { public function __construct( protected readonly B $b, ) { // empty } public function a(): void { $this->b->b(); } } class B { public function b(): void { echo 1; } } $mock = Mockery::mock(A::class, MockProperties::class); $mock ->shouldUseProperty('b') ->value( Mockery::mock(B::class), // or just `new B()`. ); $mock->a();
Custom Test Requirements
Unfortunately, PHPUnit doesn't allow to add/extend existing requirements and probably will not:
I do not think that additional attributes for test requirements should be added. After all, the existing ones are only convenient syntax sugar. Simply check your custom requirements in a before-test method and call
markTestSkipped()
when they are not met. ยฉ @sebastianbergmann
The extension listen several events and checks all attributes of test class/method which are implements Requirement
. If the requirements don't meet, the test will be marked as skipped. Please note that at least one "before" hook will be executed anyway (PHPUnit emits events after hook execution).
You need to register extension first:
<extensions> <bootstrap class="LastDragon_ru\LaraASP\Testing\Requirements\PhpUnit\Extension"/> </extensions>
And then
<?php declare(strict_types = 1); use LastDragon_ru\LaraASP\Testing\Requirements\Requirements\RequiresComposerPackage; use PHPUnit\Framework\TestCase; class SomePackageTest extends TestCase { #[RequiresComposerPackage('some/package')] public function testSomePackage(): void { // ..... } }
Upgrading
Please follow Upgrade Guide.
Contributing
This package is the part of Awesome Set of Packages for Laravel. Please use the main repository to report issues, send pull requests, or ask questions.