tobento/app-testing

App testing support.

1.0.15 2024-12-12 16:14 UTC

This package is auto-updated.

Last update: 2024-12-12 16:15:18 UTC


README

Testing support for the app.

Table of Contents

Getting Started

Add the latest version of the app testing project running this command.

composer require tobento/app-testing

Requirements

  • PHP 8.0 or greater

Documentation

Getting Started

To test your application extend the Tobento\App\Testing\TestCase class.

Next, use the createApp method to create the app for testing:

use Tobento\App\Testing\TestCase;
use Tobento\App\AppInterface;

final class SomeAppTest extends TestCase
{
    public function createApp(): AppInterface
    {
        return require __DIR__.'/../app/app.php';
    }
}

Tmp App

You may use the createTmpApp method, to create an app for testing individual boots only.

use Tobento\App\Testing\TestCase;
use Tobento\App\AppInterface;

final class SomeAppTest extends TestCase
{
    public function createApp(): AppInterface
    {
        $app = $this->createTmpApp(rootDir: __DIR__.'/..');
        $app->boot(\Tobento\App\Http\Boot\Routing::class);
        $app->boot(SomeRoutes::class);
        // ...
        return $app;
    }
}

Finally, write tests using the available fakers.

By default, the app is not booted nor run yet. You will need to do it on each test method. Some faker methods will run the app automatically though such as the fakeHttp response method.

use Tobento\App\Testing\TestCase;

final class SomeAppTest extends TestCase
{
    public function testSomeRoute(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request('GET', 'foo/bar');
        
        // interact with the app:
        $app = $this->getApp();
        $app->booting();
        $app->run();
        // or
        $app = $this->bootingApp();
        $app->run();
        // or
        $app = $this->runApp();
        
        // assertions:
        $http->response()
            ->assertStatus(200)
            ->assertBodySame('foo');
    }
}

App clearing

When creating a tmp app, you may call the deleteAppDirectory method to delete the app directory.

final class SomeAppTest extends TestCase
{
    public function createApp(): AppInterface
    {
        $app = $this->createTmpApp(rootDir: __DIR__.'/..');
        $app->boot(\Tobento\App\Http\Boot\Routing::class);
        $app->boot(SomeRoutes::class);
        // ...
        return $app;
    }
    
    public function testSomeRoute(): void
    {
        // testing...
        
        $this->deleteAppDirectory();
    }
}

Or you may use the tearDown method:

final class SomeAppTest extends TestCase
{
    protected function tearDown(): void
    {
        parent::tearDown();
        
        $this->deleteAppDirectory();
    }
}

Config Tests

In some cases you may want to define or replace config values for specific tests:

use Tobento\App\Testing\TestCase;

final class SomeAppTest extends TestCase
{
    public function testSomething(): void
    {
        // faking:
        $config = $this->fakeConfig();
        $config->with('app.environment', 'testing');
        
        $this->runApp();
        
        // assertions:
        $config
            ->assertExists(key: 'app.environment')
            ->assertSame(key: 'app.environment', value: 'testing');
    }
}

Http Tests

Request And Response

If you have installed the App Http bundle you may test your application using the fakeHttp method.

use Tobento\App\Testing\TestCase;

final class SomeAppTest extends TestCase
{
    public function testSomeRoute(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request('GET', 'user/1');
        
        // you may interact with the app:
        $app = $this->getApp();
        $app->booting();
        
        // assertions:
        $http->response()
            ->assertStatus(200)
            ->assertBodySame('foo');
    }
}

Request method

$http = $this->fakeHttp();
$http->request(
    method: 'GET',
    uri: 'foo/bar',
    server: [],
    query: ['sort' => 'desc'],
    headers: ['Content-type' => 'application/json'],
    cookies: ['token' => 'xxxxxxx'],
    files: ['profile' => ...],
    body: ['foo' => 'bar'],
);

Or you may prefer using methods:

$http = $this->fakeHttp();
$http->request(method: 'GET', uri: 'foo/bar', server: [])
    ->query(['sort' => 'desc'])
    ->headers(['Content-type' => 'application/json'])
    ->cookies(['token' => 'xxxxxxx'])
    ->files(['profile' => ...])
    ->body(['foo' => 'bar']);

Use the json method to create a request with the following headers:

  • Accept: application/json
  • Content-type: application/json
$http = $this->fakeHttp();
$http->request('POST', 'foo/bar')->json(['foo' => 'bar']);

Response method

Calling the response method will automatically run the app.

use Psr\Http\Message\ResponseInterface;

$http->response()
    ->assertStatus(200)
    ->assertBodySame('foo')
    ->assertBodyNotSame('bar')
    ->assertBodyContains('foo')
    ->assertBodyNotContains('bar')
    ->assertContentType('application/json')
    ->assertHasHeader(name: 'Content-type')
    ->assertHasHeader(name: 'Content-type', value: 'application/json') // with value
    ->assertHeaderMissing(name: 'Content-type')
    ->assertCookieExists(key: 'token')
    ->assertCookieMissed(key: 'token')
    ->assertCookieSame(key: 'token', value: 'value')
    ->assertHasSession(key: 'key')
    ->assertHasSession(key: 'key', value: 'value') // with value
    ->assertSessionMissing(key: 'key')
    ->assertLocation(uri: 'uri')
    ->assertRedirectToRoute(name: 'route', parameters: []);

// you may get the response:
$response = $http->response()->response();
var_dump($response instanceof ResponseInterface::class);
// bool(true)

withoutMiddleware

use Tobento\App\Testing\TestCase;

final class SomeAppTest extends TestCase
{
    public function testSomeRoute(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->withoutMiddleware(Middleware::class, AnotherMiddleware::class);
        $http->request('GET', 'user/1');
        
        // assertions:
        $http->response()->assertStatus(200);
    }
}

previousUri

You may set a previous uri if your controller uses the previous uri to redirect back if an error occurs for example.

use Tobento\App\Testing\TestCase;

final class SomeAppTest extends TestCase
{
    public function testSomeRoute(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->previousUri('users/create');
        $http->request('POST', 'users');
        
        // Or after booting using a named route:
        $app = $this->bootingApp();
        $http->previousUri($app->routeUrl('users.create'));
        
        // assertions:
        $http->response()
            ->assertStatus(301)
            ->assertLocation(uri: 'users/create');
    }
}

Http Url

You may sometimes wish to modify the http url in order to have relative urls for instance;

use Tobento\App\Testing\TestCase;

final class SomeAppTest extends TestCase
{
    public function testSomeRoute(): void
    {
        // faking:
        $config = $this->fakeConfig();
        $config->with('http.url', ''); // modify
        
        $http = $this->fakeHttp();
        $http->request('GET', 'orders');
        
        // assertions:
        $http->response()
            // if modified:
            ->assertNodeExists('a[href="orders/5"]')
            
            // if not modified:
            ->assertNodeExists('a[href="http://localhost/orders/5"]');
    }
}

Subsequent Requests

After making a request, subsequent requests will create a new app. Any fakers from the first request will be rebooted.

use Tobento\App\Testing\TestCase;

final class SomeAppTest extends TestCase
{
    public function testSomeRoute(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request('GET', 'user/1');
        
        // assertions:
        $http->response()->assertStatus(200);
        
        // subsequent request:
        $http->request('GET', 'user/2');
        
        // assertions:
        $http->response()->assertStatus(200);
    }
}

Following redirects

use Tobento\App\Testing\TestCase;

final class SomeAppTest extends TestCase
{
    public function testSomeRoute(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request('GET', 'login');
        $auth = $this->fakeAuth();
        
        // you may interact with the app:
        $app = $this->bootingApp();
        $user = $auth->getUserRepository()->create(['username' => 'tom']);
        $auth->authenticatedAs($user);
        
        // assertions:
        $http->response()->assertStatus(302);
        $auth->assertAuthenticated();
        
        // following redirects:
        $http->followRedirects()->assertStatus(200);
        
        // fakers others than http, must be recalled.
        $this->fakeAuth()->assertAuthenticated();
        // $auth->assertAuthenticated(); // would be from previous request
    }
}

File Uploads

You can use the file factory to generate dummy files or images for testing purposes:

use Tobento\App\Testing\TestCase;

final class SomeAppTest extends TestCase
{
    public function testSomeRoute(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request(
            method: 'GET',
            uri: 'user/1',
            files: [
                // Create a fake image 640x480
                'profile' => $http->getFileFactory()->createImage('profile.jpg', 640, 480),
            ],
        );
        
        // assertions:
        $http->response()->assertStatus(200);
    }
}

Create a fake image

$image = $http->getFileFactory()->createImage(
    filename: 'profile.jpg', 
    width: 640, 
    height: 480
);

Create a fake file

$file = $http->getFileFactory()->createFile(
    filename: 'foo.txt'
);

// Create a file with size - 100kb
$file = $http->getFileFactory()->createFile(
    filename: 'foo.txt',
    kilobytes: 100
);

// Create a file with size - 100kb
$file = $http->getFileFactory()->createFile(
    filename: 'foo.txt',
    mimeType: 'text/plain'
);

$file = $http->getFileFactory()->createFileWithContent(
    filename: 'foo.txt', 
    content: 'Hello world',
    mimeType: 'text/plain'
);

Crawl Response Content

You may crawl the response content using the Symfony Dom Crawler.

use Tobento\App\Testing\TestCase;
use Symfony\Component\DomCrawler\Crawler;

final class SomeAppTest extends TestCase
{
    public function testSomeRoute(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request('GET', 'user/comments');
        
        // assertions:
        $response = $http->response()->assertStatus(200);
        
        $this->assertCount(4, $response->crawl()->filter('.comment'));
        
        // returns the crawler:
        $crawler = $response()->crawl(); // Crawler
    }
}

assertNodeExists

use Tobento\App\Testing\TestCase;
use Symfony\Component\DomCrawler\Crawler;

final class SomeAppTest extends TestCase
{
    public function testSomeRoute(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request('GET', 'user/comments');
        
        // assertions:
        $http->response()
            ->assertStatus(200)
            // Assert if a node exists:
            ->assertNodeExists('a[href="https://example.com"]')
            // Assert if a node exists based on a truth-test callback:
            ->assertNodeExists('h1', fn (Crawler $n): bool => $n->text() === 'Comments')
            // Assert if a node exists based on a truth-test callback:
            ->assertNodeExists('ul', static function (Crawler $n) {
                return $n->children()->count() === 2
                    && $n->children()->first()->text() === 'foo';
            }, 'There first ul child has no text "foo"');
    }
}

assertNodeMissing

use Tobento\App\Testing\TestCase;
use Symfony\Component\DomCrawler\Crawler;

final class SomeAppTest extends TestCase
{
    public function testSomeRoute(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request('GET', 'user/comments');
        
        // assertions:
        $http->response()
            ->assertStatus(200)
            // Assert if a node is missing:
            ->assertNodeMissing('h1')
            // Assert if a node is missing based on a truth-test callback:
            ->assertNodeMissing('p', static function (Crawler $n) {
                return $n->attr('class') === 'error'
                    && $n->text() === 'Error Message';
            }, 'An unexpected error message was found');
    }
}

Example Form Crawling

use Tobento\App\Testing\TestCase;
use Symfony\Component\DomCrawler\Crawler;

final class SomeAppTest extends TestCase
{
    public function testSomeRoute(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request('GET', 'user/comments');
        
        // assertions:
        $response = $http->response()->assertStatus(200);
        
        $form = $response->crawl(
            // you may pass a base uri or base href:
            uri: 'http://www.example.com',
            baseHref: null,
        )->selectButton('My super button')->form();
        
        $this->assertSame('POST', $form->getMethod());
    }
}

JSON Response

You may test JSON responses using the assertJson method.

use Tobento\App\Testing\TestCase;

final class SomeAppTest extends TestCase
{
    public function testSomeRoute(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request('GET', 'api/user/1');
        
        // assertions:
        $http->response()
            ->assertStatus(200)
            ->assertJson([
                'id' => 1,
                'name' => 'John',
            ]);
    }
}

Assertable Json

You may use the AssertableJson class to fluently test JSON responses.

use Tobento\App\Testing\TestCase;
use Tobento\App\Testing\Http\AssertableJson;

final class SomeAppTest extends TestCase
{
    public function testSomeRoute(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request('GET', 'api/users');
        
        // assertions:
        $http->response()
            ->assertJson(fn (AssertableJson $json) =>
                $json->has(items: 3)
                     ->has(key: '0', items: 2, value: AssertableJson $json) =>
                        $json->has(key: 'id', value: 1)
                             ->has(key: 'name', value: 'Tom')
                     )
            );
    }
}

Assert Key

Assert that the key exists:

$json->has(key: 'name');

// using dot notation:
$json->has(key: 'address.firstname');

Assert Value

Assert that the value matches:

$json->has(value: ['name' => 'Tom']);

Assert Key And Value

Assert that the value matches the key value:

$json->has(key: 'name', value: 'Tom');
$json->has(key: 'address.firstname', value: 'John');

Assert Items

Asserts that the items count matches.

$json->has(items: 3);

// with key
$json->has(key: 'colors', items: 3);

Assert Passes

Asserts that passes evaluates to true.

$json->has(passes: true);
$json->has(passes: false); // will fail

// with key
$json->has(key: 'color', passes: fn (mixed $color) => is_string($color));

Hasnt

Use the hasnt method asserting the opposite of the has method.

$json->hasnt(key: 'name');
$json->hasnt(key: 'address.firstname');
$json->hasnt(value: ['name' => 'Tom']);
$json->hasnt(key: 'address.firstname', value: 'John');
$json->hasnt(items: 3);
$json->hasnt(key: 'colors', items: 3);
$json->hasnt(value: 'name', passes: fn (mixed $color) => is_string($color));

Response Macros

You may want to write convenience helpers to the test response using macros.

use Tobento\App\Testing\Http\TestResponse;

final class SomeAppTest extends TestCase
{
    public function createApp(): AppInterface
    {
        // ...
        
        // we may add the macro here:
        TestResponse::macro('assertOk', function(): static {
            $this->assertStatus(200);                
            return $this;
        });

        return $app;
    }
    
    public function testSomeRoute(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request('GET', 'user/comments');
        
        // assertions:
        $http->response()->assertOk();
    }
}

Refresh Session

You may refresh your session after each test by using RefreshSession trait:

use Tobento\App\Testing\TestCase;
use Tobento\App\Testing\Http\RefreshSession;

final class SomeAppTest extends TestCase
{
    use RefreshSession;
    
    public function testSomething(): void
    {
        // ...
    }
}

Auth Tests

If you have installed the App User bundle you may test your application using the fakeAuth method.

The next two examples assumes you have already seeded test users in some way:

use Tobento\App\Testing\TestCase;
use Tobento\App\User\UserRepositoryInterface;

final class SomeAppTest extends TestCase
{
    public function testSomeRouteWhileAuthenticated(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request('GET', 'profile');
        $auth = $this->fakeAuth();
        
        // you may change the token storage:
        //$auth->tokenStorage('inmemory');
        //$auth->tokenStorage('session');
        //$auth->tokenStorage('repository');
        
        // boot the app:
        $app = $this->bootingApp();
        
        // authenticate user:
        $userRepo = $app->get(UserRepositoryInterface::class);
        $user = $userRepo->findByIdentity(email: 'foo@example.com');
        // or:
        $user = $auth->getUserRepository()->findByIdentity(email: 'foo@example.com');
        
        $auth->authenticatedAs($user);
        
        // assertions:
        $http->response()->assertStatus(200);
        $auth->assertAuthenticated();
        // or:
        //$auth->assertNotAuthenticated();
    }
}

Example With Token

You may want to authenticate a user by creating a token:

use Tobento\App\Testing\TestCase;
use Tobento\App\User\UserRepositoryInterface;

final class SomeAppTest extends TestCase
{
    public function testSomeRouteWhileAuthenticated(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request('GET', 'profile');
        $auth = $this->fakeAuth();
        
        // boot the app:
        $app = $this->bootingApp();
        
        // authenticate user:
        $user = $auth->getUserRepository()->findByIdentity(email: 'foo@example.com');
        
        $token = $auth->getTokenStorage()->createToken(
            payload: ['userId' => $user->id(), 'passwordHash' => $user->password()],
            authenticatedVia: 'loginform',
            authenticatedBy: 'testing',
            //issuedAt: $issuedAt,
            //expiresAt: $expiresAt,
        );
        
        $auth->authenticatedAs($token);
        
        // assertions:
        $http->response()->assertStatus(200);
        $auth->assertAuthenticated();
    }
}

Example Seeding Users

This is one possible way of seeding users for testing. You could also seed users by creating and using Seeders.

use Tobento\App\Testing\TestCase;
use Tobento\App\Testing\Database\RefreshDatabases;
use Tobento\App\User\UserRepositoryInterface;
use Tobento\App\User\AddressRepositoryInterface;
use Tobento\App\Seeding\User\UserFactory;

final class SomeAppTest extends TestCase
{
    use RefreshDatabases;

    public function testSomeRouteWhileAuthenticated(): void
    {
        // faking:
        $http = $this->fakeHttp();
        $http->request('GET', 'profile');
        $auth = $this->fakeAuth();
        
        // boot the app:
        $app = $this->bootingApp();
        
        // Create a user:
        $user = $auth->getUserRepository()->create(['username' => 'tom']);
        // or using the user factory:
        $user = UserFactory::new()->withUsername('tom')->withPassword('123456')->createOne();
        
        // authenticate user:
        $auth->authenticatedAs($user);
        
        // assertions:
        $http->response()->assertStatus(200);
        $auth->assertAuthenticated();
    }
}

You may check out the User Seeding to learn more about the UserFactory::class.

File Storage Tests

If you have installed the App File Storage bundle you may test your application using the fakeFileStorage method which allows you to create a fake storage that mimics the behavior of a real storage, but doesn't actually send any files to the cloud. This way, you can test file uploads without worrying about accidentally sending real files to the cloud.

Example using a tmp app:

use Psr\Http\Message\ServerRequestInterface;
use Tobento\App\AppInterface;
use Tobento\App\Testing\FileStorage\RefreshFileStorages;
use Tobento\Service\FileStorage\StoragesInterface;
use Tobento\Service\FileStorage\Visibility;
use Tobento\Service\Routing\RouterInterface;

class FileStorageTest extends \Tobento\App\Testing\TestCase
{
    // you may refresh all file storages after each test.
    use RefreshFileStorages;
    
    public function createApp(): AppInterface
    {
        $app = $this->createTmpApp(rootDir: __DIR__.'/..');
        $app->boot(\Tobento\App\Http\Boot\Routing::class);
        $app->boot(\Tobento\App\FileStorage\Boot\FileStorage::class);
        
        // routes: just for demo, normally done with a boot!
        $app->on(RouterInterface::class, static function(RouterInterface $router): void {
            $router->post('upload', function (ServerRequestInterface $request, StoragesInterface $storages) {    
                
                $file = $request->getUploadedFiles()['profile'];
                $storage = $storages->get('uploads');
                
                $storage->write(
                    path: $file->getClientFilename(),
                    content: $file->getStream()
                );
                
                $storage->copy(from: $file->getClientFilename(), to: 'copy/'.$file->getClientFilename());
                $storage->move(from: 'copy/'.$file->getClientFilename(), to: 'move/'.$file->getClientFilename());
                $storage->createFolder('foo/bar');
                $storage->setVisibility('foo/bar', Visibility::PRIVATE);
                
                return 'response';
            });
        });
        
        return $app;
    }

    public function testFileUpload()
    {
        // fakes:
        $fileStorage = $this->fakeFileStorage();
        $http = $this->fakeHttp();
        $http->request(
            method: 'POST',
            uri: 'upload',
            files: [
                // Create a fake image 640x480
                'profile' => $http->getFileFactory()->createImage('profile.jpg', 640, 480),
            ],
        );
        
        // run the app:
        $this->runApp();
        
        // assertions:
        $fileStorage->storage(name: 'uploads')
            ->assertCreated('profile.jpg')
            ->assertNotCreated('foo.jpg')
            ->assertExists('profile.jpg')
            ->assertNotExist('foo.jpg')
            ->assertCopied(from: 'profile.jpg', to: 'copy/profile.jpg')
            ->assertNotCopied(from: 'foo.jpg', to: 'copy/foo.jpg')
            ->assertMoved(from: 'copy/profile.jpg', to: 'move/profile.jpg')
            ->assertNotMoved(from: 'foo.jpg', to: 'copy/foo.jpg')
            ->assertFolderCreated('foo/bar')
            ->assertFolderNotCreated('baz')
            ->assertFolderExists('foo/bar')
            ->assertFolderNotExist('baz')
            ->assertVisibilityChanged('foo/bar');
    }
}

Storage Method

$fileStorage = $this->fakeFileStorage();

// Get default storage:
$defaultStorage = $fileStorage->storage();

// Get specific storage:
$storage = $fileStorage->storage(name: 'uploads');

Storages Method

use Tobento\Service\FileStorage\StoragesInterface;

$fileStorage = $this->fakeFileStorage();

// Get the storages:
$storages = $fileStorage->storages();

var_dump($storages instanceof StoragesInterface);
// bool(true)

Queue Tests

If you have installed the App Queue bundle you may test your application using the fakeQueue method which allows you to create a fake queue to prevent jobs from being sent to the actual queue.

Example using a tmp app:

use Tobento\App\AppInterface;
use Tobento\Service\Routing\RouterInterface;
use Psr\Http\Message\ServerRequestInterface;
use Tobento\Service\Queue\QueueInterface;
use Tobento\Service\Queue\JobInterface;
use Tobento\Service\Queue\Job;

class QueueTest extends \Tobento\App\Testing\TestCase
{
    public function createApp(): AppInterface
    {
        $app = $this->createTmpApp(rootDir: __DIR__.'/..');
        $app->boot(\Tobento\App\Http\Boot\Routing::class);
        $app->boot(\Tobento\App\Queue\Boot\Queue::class);
        
        // routes: just for demo, normally done with a boot!
        $app->on(RouterInterface::class, static function(RouterInterface $router): void {
            $router->post('queue', function (ServerRequestInterface $request, QueueInterface $queue) {    

                $queue->push(new Job(
                    name: 'sample',
                    payload: ['key' => 'value'],
                ));            
                
                return 'response';
            });
        });
        
        return $app;
    }

    public function testIsQueued()
    {
        // fakes:
        $fakeQueue = $this->fakeQueue();
        $http = $this->fakeHttp();
        $http->request(method: 'POST', uri: 'queue');
        
        // run the app:
        $this->runApp();
        
        // assertions:
        $fakeQueue->queue(name: 'sync')
            ->assertPushed('sample')
            ->assertPushed('sample', function (JobInterface $job): bool {
                return $job->getPayload()['key'] === 'value';
            })
            ->assertNotPushed('sample:foo')
            ->assertNotPushed('sample', function (JobInterface $job): bool {
                return $job->getPayload()['key'] === 'invalid';
            })
            ->assertPushedTimes('sample', 1);
        
        $fakeQueue->queue(name: 'file')
            ->assertNothingPushed();
    }
}

Clear Queue

Sometimes it may be useful to clear the queue using the clearQueue method:

use Tobento\Service\Queue\QueueInterface;

$fakeQueue->clearQueue(
    queue: $fakeQueue->queue(name: 'file') // QueueInterface
);

Run Jobs

Sometimes it may be useful to run jobs using the runJobs method:

$fakeQueue->runJobs($fakeQueue->queue(name: 'sync')->getAllJobs());

Event Tests

If you have installed the App Event bundle you may test your application using the fakeEvents method which records all events that are dispatched and provides assertion methods that you can use to check if specific events were dispatched and how many times. Currently, only Default Events will be recorded. Specific Events are not supported yet.

Example using a tmp app:

use Tobento\App\AppInterface;
use Tobento\Service\Routing\RouterInterface;
use Tobento\Service\Event\EventsInterface;
use Psr\Http\Message\ServerRequestInterface;

class QueueTest extends \Tobento\App\Testing\TestCase
{
    public function createApp(): AppInterface
    {
        $app = $this->createTmpApp(rootDir: __DIR__.'/..');
        $app->boot(\Tobento\App\Http\Boot\Routing::class);
        $app->boot(\Tobento\App\Event\Boot\Event::class);
        
        // routes: just for demo, normally done with a boot!
        $app->on(RouterInterface::class, static function(RouterInterface $router): void {
            $router->post('registration', function (ServerRequestInterface $request, EventsInterface $events) {    

                $events->dispatch(new UserRegistered(username: 'tom'));
                
                return 'response';
            });
        });
        
        return $app;
    }

    public function testDispatchesEvent()
    {
        // fakes:
        $events = $this->fakeEvents();
        $http = $this->fakeHttp();
        $http->request(method: 'POST', uri: 'registration');
        
        // run the app:
        $this->runApp();
        
        // assertions:
        $events
            // Assert if an event dispatched one or more times:
            ->assertDispatched(UserRegistered::class)
            // Assert if an event dispatched one or more times based on a truth-test callback:
            ->assertDispatched(UserRegistered::class, static function(UserRegistered $event): bool {
                return $event->username === 'tom';
            })
            // Asserting if an event were dispatched a specific number of times:
            ->assertDispatchedTimes(UserRegistered::class, 1)
            // Asserting an event were not dispatched:
            ->assertNotDispatched(FooEvent::class)
            // Asserting an event were not dispatched based on a truth-test callback:
            ->assertNotDispatched(UserRegistered::class, static function(UserRegistered $event): bool {
                return $event->username !== 'tom';
            })
            // Assert if an event has a listener attached to it:
            ->assertListening(UserRegistered::class, SomeListener::class);
    }
}

Mail Tests

If you have installed the App Mail bundle you may test your application using the fakeMail method which allows you to create a fake mailer to prevent messages from being sent.

Example using a tmp app:

use Tobento\App\AppInterface;
use Tobento\Service\Routing\RouterInterface;
use Psr\Http\Message\ServerRequestInterface;
use Tobento\Service\Mail\MailerInterface;
use Tobento\Service\Mail\Message;
use Tobento\Service\Mail\Address;
use Tobento\Service\Mail\Parameter;

class MailTest extends \Tobento\App\Testing\TestCase
{
    public function createApp(): AppInterface
    {
        $app = $this->createTmpApp(rootDir: __DIR__.'/..');
        $app->boot(\Tobento\App\Http\Boot\Routing::class);
        $app->boot(\Tobento\App\View\Boot\View::class); // to support message templates
        $app->boot(\Tobento\App\Mail\Boot\Mail::class);
        
        // routes: just for demo, normally done with a boot!
        $this->getApp()->on(RouterInterface::class, static function(RouterInterface $router): void {
            $router->post('mail', function (ServerRequestInterface $request, MailerInterface $mailer) {
                
                $message = (new Message())
                    ->from('from@example.com')
                    ->to(new Address('to@example.com', 'Name'))
                    ->subject('Subject')
                    ->html('<p>Lorem Ipsum</p>');

                $mailer->send($message);
                
                return 'response';
            });
        });
        
        return $app;
    }

    public function testMessageMailed()
    {
        // fakes:
        $fakeMail = $this->fakeMail();
        $http = $this->fakeHttp();
        $http->request(method: 'POST', uri: 'mail');
        
        // run the app:
        $this->runApp();
        
        // assertions:
        $fakeMail->mailer(name: 'default')
            ->sent(Message::class)
            ->assertFrom('from@example.com', 'Name')
            ->assertHasTo('to@example.com', 'Name')
            ->assertHasCc('cc@example.com', 'Name')
            ->assertHasBcc('bcc@example.com', 'Name')
            ->assertReplyTo('replyTo@example.com', 'Name')
            ->assertSubject('Subject')
            ->assertTextContains('Lorem')
            ->assertHtmlContains('Lorem')
            ->assertIsQueued()
            ->assertHasParameter(
                Parameter\File::class,
                fn (Parameter\File $f) => $f->file()->getBasename() === 'image.jpg'
            )
            ->assertTimes(1);
    }
}

Notifier Tests

If you have installed the App Notifier bundle you may test your application using the fakeNotifier method which allows you to create a fake notifier to prevent notification messages from being sent.

Example using a tmp app:

use Tobento\App\AppInterface;
use Tobento\Service\Routing\RouterInterface;
use Psr\Http\Message\ServerRequestInterface;
use Tobento\Service\Notifier\NotifierInterface;
use Tobento\Service\Notifier\ChannelMessagesInterface;
use Tobento\Service\Notifier\Notification;
use Tobento\Service\Notifier\Recipient;

class NotifierTest extends \Tobento\App\Testing\TestCase
{
    public function createApp(): AppInterface
    {
        $app = $this->createTmpApp(rootDir: __DIR__.'/..');
        $app->boot(\Tobento\App\Http\Boot\Routing::class);
        $app->boot(\Tobento\App\Notifier\Boot\Notifier::class);
        
        // routes: just for demo, normally done with a boot!
        $this->getApp()->on(RouterInterface::class, static function(RouterInterface $router): void {
            $router->post('notify', function (ServerRequestInterface $request, NotifierInterface $notifier) {
                
                $notification = new Notification(
                    subject: 'New Invoice',
                    content: 'You got a new invoice for 15 EUR.',
                    channels: ['mail', 'sms', 'storage'],
                );

                // The receiver of the notification:
                $recipient = new Recipient(
                    email: 'mail@example.com',
                    phone: '15556666666',
                    id: 5,
                );

                $notifier->send($notification, $recipient);
                
                return 'response';
            });
        });
        
        return $app;
    }

    public function testNotified()
    {
        // fakes:
        $notifier = $this->fakeNotifier();
        $http = $this->fakeHttp();
        $http->request(method: 'POST', uri: 'notify');
        
        // run the app:
        $this->runApp();
        
        // assertions:
        $notifier
            // Assert if a notification is sent one or more times:
            ->assertSent(Notification::class)
            // Assert if a notification is sent one or more times based on a truth-test callback:
            ->assertSent(Notification::class, static function(ChannelMessagesInterface $messages): bool {
                $notification = $messages->notification();
                $recipient = $messages->recipient();
                
                // you may test the sent messages
                $mail = $messages->get('mail')->message();
                $this->assertSame('New Invoice', $mail->getSubject());

                return $notification->getSubject() === 'New Invoice'
                    && $messages->successful()->channelNames() === ['mail', 'sms', 'storage']
                    && $messages->get('sms')->message()->getTo()->phone() === '15556666666'
                    && $recipient->getAddressForChannel('mail', $notification)?->email() === 'mail@example.com';
            })
            // Asserting if a notification were sent a specific number of times:
            ->assertSentTimes(Notification::class, 1)
            // Asserting a notification were not sent:
            ->assertNotSent(Notification::class)
            // Asserting a notification were not sent based on a truth-test callback:
            ->assertNotSent(Notification::class, static function(ChannelMessagesInterface $messages): bool {
                $notification = $messages->notification();
                return $notification->getSubject() === 'New Invoice';
            })
            // Asserting that no notifications were sent:
            ->assertNothingSent();
    }
}

Database Tests

If you have installed the App Database bundle you may interact with your databases.

Reset Databases

There are two strategies to reset your databases:

Refresh Strategy

This strategy cleans your database after each test.

use Tobento\App\Testing\TestCase;
use Tobento\App\Testing\Database\RefreshDatabases;

final class SomeAppTest extends TestCase
{
    use RefreshDatabases;
    
    public function testSomething(): void
    {
        // ...
    }
}

Migrate Strategy

This strategy cleans your database after each test using migrations.

use Tobento\App\Testing\TestCase;
use Tobento\App\Testing\Database\MigrateDatabases;

final class SomeAppTest extends TestCase
{
    use MigrateDatabases;
    
    public function testSomething(): void
    {
        // ...
    }
}

Replace Databases

You may replace your database to test different databases.

Example replacing the default storage database:

use Tobento\App\Testing\TestCase;
use Tobento\App\AppInterface;
use Tobento\App\Testing\Database\RefreshDatabases;
use Tobento\Service\Database\DatabasesInterface;
use Tobento\Service\Database\DatabaseInterface;
use Tobento\Service\Database\PdoDatabase;

final class SomeAppTest extends TestCase
{
    use RefreshDatabases;
    
    public function createApp(): AppInterface
    {
        $app = $this->createTmpApp(rootDir: __DIR__.'/..', folder: 'app-mysql');
        $app->boot(\Tobento\App\User\Boot\User::class);
        
        // example changing databases:
        $app->on(DatabasesInterface::class, static function (DatabasesInterface $databases) {
            // change default storage database:
            $databases->addDefault('storage', 'mysql-storage');
            
            // you may change the mysql database:
            $databases->register(
                'mysql',
                function(string $name): DatabaseInterface {
                    return new PdoDatabase(
                        new \PDO(
                            dsn: 'mysql:host=localhost;dbname=app_testing',
                            username: 'root',
                            password: '',
                        ),
                        $name
                    );
                }
            );
        });
        
        return $app;
    }
    
    public function testSomething(): void
    {
        // ...
    }
}

Logging Tests

If you have installed the App Logging bundle you may test your application using the fakeLogging method which allows you to create a fake logger to prevent logging with the actual logger.

Example using a tmp app:

use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Tobento\App\AppInterface;
use Tobento\App\Testing\Logging\LogEntry;
use Tobento\Service\Routing\RouterInterface;

class LoggingTest extends \Tobento\App\Testing\TestCase
{
    public function createApp(): AppInterface
    {
        $app = $this->createTmpApp(rootDir: __DIR__.'/..');
        $app->boot(\Tobento\App\Http\Boot\Routing::class);
        $app->boot(\Tobento\App\Logging\Boot\Logging::class);
        
        // routes: just for demo, normally done with a boot!
        $app->on(RouterInterface::class, static function(RouterInterface $router): void {
            $router->post('login', function (ServerRequestInterface $request, LoggerInterface $logger) {    
                
                $logger->info('User logged in.', ['user_id' => 3]);
                
                return 'response';
            });
        });
        
        return $app;
    }

    public function testIsLogged()
    {
        // fakes:
        $fakeLogging = $this->fakeLogging();
        $http = $this->fakeHttp();
        $http->request(method: 'POST', uri: 'login');
        
        // run the app:
        $this->runApp();
        
        // assertions using default logger:
        $fakeLogging->logger()
            ->assertLogged(fn (LogEntry $log): bool =>
                $log->level === 'info'
                && $log->message === 'User logged in.' 
                && $log->context === ['user_id' => 3]
            )
            ->assertNotLogged(
                fn (LogEntry $log): bool => $log->level === 'error'
            )
            ->assertLoggedTimes(
                fn (LogEntry $log): bool => $log->level === 'info',
                1
            );
        
        // specific logger:
        $fakeLogging->logger(name: 'error')
            ->assertNothingLogged();
    }
}

Credits