lastdragon-ru/lara-asp-testing
Testing utilities for integrating Lara ASP into Laravel apps. Provides helpers, fakes, and assertions to simplify writing automated tests around ASP policies, decisions, and request/response flows in your application.
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 😎
| Requirement | Constraint | Supported by |
|---|---|---|
| PHP | ^8.5 |
HEAD ⋯ 11.0.0 |
^8.4 |
HEAD ⋯ 8.0.0 |
|
^8.3 |
10.3.0 ⋯ 5.0.0 |
|
^8.2 |
7.2.0 ⋯ 2.0.0 |
|
^8.1 |
6.4.2 ⋯ 2.0.0 |
|
^8.0 |
4.6.0 ⋯ 2.0.0 |
|
^8.0.0 |
1.1.2 ⋯ 0.12.0 |
|
>=8.0.0 |
0.11.0 ⋯ 0.4.0 |
|
>=7.4.0 |
0.3.0 ⋯ 0.1.0 |
|
| Laravel | ^13.10.0 |
HEAD , 11.1.0 |
^13.0.0 |
11.0.0 |
|
^12.0.1 |
10.3.0 ⋯ 9.0.0 |
|
^11.0.8 |
8.1.1 ⋯ 8.0.0 |
|
^11.0.0 |
7.2.0 ⋯ 6.2.0 |
|
^10.34.0 |
7.2.0 ⋯ 6.2.0 |
|
^10.0.0 |
6.1.0 ⋯ 2.1.0 |
|
^9.21.0 |
5.6.0 ⋯ 5.0.0-beta.1 |
|
^9.0.0 |
5.0.0-beta.0 ⋯ 0.12.0 |
|
^8.22.1 |
3.0.0 ⋯ 0.2.0 |
|
^8.0 |
0.1.0 |
|
| PHPUnit | ^13.0.0 |
HEAD ⋯ 11.0.0 |
^12.0.0 |
10.3.0 ⋯ 9.0.0 |
|
^11.3.0 |
10.3.0 ⋯ 10.1.0 |
|
^11.2.0 |
10.0.0 |
|
^11.1.0 |
9.3.1 , 9.3.0 |
|
^11.0.0 |
9.2.0 ⋯ 6.2.0 |
|
^10.5.0 |
8.1.1 ⋯ 8.0.0 |
|
^10.1.0 |
7.2.0 ⋯ 6.0.0 |
[!NOTE]
The package intended to use in dev.
composer require --dev lastdragon-ru/lara-asp-testing
[!IMPORTANT]
By default, package overrides scalar comparator to make it strict! So
assertEquals(true, 1)isfalse.
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;
}
}
[!TIP]
Should be registered before test, check/use built-in traits.
DatabaseQueryComparatorCompares two Query.
We are performing following normalization before comparison to be more precise:
laravel_reserved_* (it will always start from 0 and will not contain gaps)doctrine/sql-formatter packageEloquentModelComparatorCompares 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.
TestCaseWithTranslationsAllows replacing translation strings for Laravel.
OverrideSimilar 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).
FixRecentlyCreatedAfter creating the model will have wasRecentlyCreated = true, in most
cases this is unwanted behavior, this trait fixes it.
WithoutModelEventsDisable models events during make/create.
\Illuminate\Testing\TestResponse| Name | Description |
|---|---|
assertThat() |
Asserts that response satisfies given constraint. |
assertDatabaseQueryEqualsAsserts that SQL Query equals SQL Query.
assertJsonMatchesSchemaAsserts that JSON matches schema. Validation based on the Opis JSON Schema package.
assertPsrResponseAsserts that PSR Response satisfies given constraint (we have a lot of built-in constraints and responses, but, of course, you can create a custom).
assertQueryLogEqualsAsserts that QueryLog equals QueryLog.
assertScheduledAsserts that Schedule contains task.
assertScoutQueryEqualsAsserts that Scout Query equals Scout Query.
What is wrong with the Laravel approach? Well, there are two big problems.
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');
}
}
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
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()
));
}
}
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
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\JsonSchemaValue;
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\PhpUnit\Utils\TestData;
class ValidationErrorResponse extends Response {
use WithTestData;
public function __construct() {
parent::__construct(
new UnprocessableEntity(),
new JsonContentType(),
new Body([
new JsonMatchesSchema(new JsonSchemaValue(TestData::get()->content('schema.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 😉
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(),
));
}
}
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 😸
[!IMPORTANT]
Working prototype for How to mock protected properties? (#1142). Please note that implementation relies on Reflection and internal Mockery methods/properties. Also, PHP supports Property Hooks since v8.4 so it highly recommended using them instead of regular properties (when Mockery will support it of course).
Limitations/Notes:
<?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\PropertiesMock;
use LastDragon_ru\LaraASP\Testing\Mockery\WithProperties;
use Mockery;
readonly class A {
public function __construct(
protected B $b,
) {
// empty
}
public function a(): void {
$this->b->b();
}
}
class B {
public function b(): void {
echo 1;
}
}
$mock = Mockery::mock(A::class, new WithProperties(), PropertiesMock::class);
$mock
->shouldUseProperty('b')
->value(
Mockery::mock(B::class), // or just `new B()`.
);
$mock->a();
Please follow Upgrade Guide.
Please use the main repository to report issues, send pull requests, or ask questions.
How can I help you explore Laravel packages today?