Testing utilities for Marko---reusable fakes with built-in assertions, and an in-process HTTP test client for feature tests.
marko/testing provides a set of in-memory fakes that replace real infrastructure dependencies during tests. Instead of mocking interfaces by hand or spinning up real services, you drop in a fake, run your code, and call assertion methods directly on the fake. This removes hundreds of lines of boilerplate and keeps tests focused on behavior.
composer require marko/testing --devTestCase registers PSR-4 autoloaders for app/* and modules/* before tests run, so module classes resolve without per-project Composer classmap configuration. No container or application boot is performed.
Wire it as the global base in tests/Pest.php:
use Marko\Testing\TestCase;
uses(TestCase::class)->in(__DIR__);After that, every Pest test in the project can reference module and app classes directly---no additional Composer path repos or classmaps required.
TestClient sends requests through your application in process (real router, middleware and controllers) and boots the application once for many requests:
use Marko\Testing\Http\TestClient;
$client = TestClient::boot(dirname(__DIR__));
$client->postJson('/api/shows/42/events', ['type' => 'view'])
->assertStatus(202)
->assertJsonPath('data.type', 'view');
$client->actingAs($user)->get('/dashboard')->assertOk();Cookies persist across requests like a browser's, so session-backed flows work. Assertions on the returned TestResponse throw AssertionFailedException with the status and a body excerpt. See the HTTP tests docs.
With marko/database and a driver installed, TestDatabase boots and migrates your application once per process, and RefreshDatabase wraps each test in a transaction that is rolled back afterwards:
use Marko\Testing\Database\RefreshDatabase;
use Marko\Testing\Database\TestDatabase;
beforeEach(function () {
$this->database = TestDatabase::boot(dirname(__DIR__));
$this->refresh = new RefreshDatabase($this->database);
$this->refresh->begin();
$this->http = $this->database->client(); // shares the test transaction
});
afterEach(fn () => $this->refresh->rollback());TruncateDatabase empties the entity tables instead, for code that must see committed data. Both refuse to run in production, and TruncateDatabase runs only in a testing environment (testing, test); set APP_ENV=testing. See the database tests docs.
FakeEventDispatcher, FakeBroadcaster, FakeMailer, FakeQueue, FakeSession, FakeCookieJar, FakeLogger, FakeConfigRepository, FakeAuthenticatable, FakeUserProvider, FakeGuard, FakeHttpClient, FakeEncryptor, FakeClock, FakeSleeper, FakeConfirmationPrompter
use Marko\Testing\Fake\FakeEventDispatcher;
$dispatcher = new FakeEventDispatcher();
$dispatcher->dispatch(new OrderPlaced($order));
$dispatcher->assertDispatched(OrderPlaced::class);
$dispatcher->assertDispatchedCount(OrderPlaced::class, 1);
$dispatcher->assertNotDispatched(OrderShipped::class);use Marko\Testing\Fake\FakeMailer;
$mailer = new FakeMailer();
$mailer->send($message);
$mailer->assertSent(WelcomeEmail::class);
$mailer->assertSentCount(WelcomeEmail::class, 1);
$mailer->assertNothingSent();use Marko\Testing\Fake\FakeQueue;
$queue = new FakeQueue();
$queue->push(new ProcessOrder($order));
$queue->assertPushed(ProcessOrder::class);
$queue->assertPushedCount(ProcessOrder::class, 1);
$queue->assertNotPushed(SendInvoice::class);
$queue->assertNothingPushed();use Marko\Testing\Fake\FakeSession;
$session = new FakeSession();
$session->put('user_id', 42);
$value = $session->get('user_id'); // 42
$session->forget('user_id');use Marko\Testing\Fake\FakeCookieJar;
$cookies = new FakeCookieJar();
$cookies->set('token', 'abc123');
$value = $cookies->get('token'); // 'abc123'use Marko\Testing\Fake\FakeLogger;
$logger = new FakeLogger();
$logger->info('User logged in');
$logger->error('Something failed');
$logger->assertLogged('User logged in');
$logger->assertNothingLogged();use Marko\Testing\Fake\FakeConfigRepository;
$config = new FakeConfigRepository([
'auth.defaults.guard' => 'web',
'app.name' => 'Marko',
]);
$value = $config->get('auth.defaults.guard'); // 'web'use Marko\Testing\Fake\FakeAuthenticatable;
$user = new FakeAuthenticatable(id: 1, password: 'hashed-secret');
$user->getAuthIdentifier(); // 1
$user->getAuthPassword(); // 'hashed-secret'use Marko\Testing\Fake\FakeUserProvider;
use Marko\Testing\Fake\FakeAuthenticatable;
$user = new FakeAuthenticatable(id: 1);
$provider = new FakeUserProvider($user);
$found = $provider->retrieveById(1); // returns $useruse Marko\Testing\Fake\FakeGuard;
use Marko\Testing\Fake\FakeAuthenticatable;
$guard = new FakeGuard(name: 'web', attemptResult: true);
// Set a user directly
$user = new FakeAuthenticatable(id: 1);
$guard->setUser($user);
$guard->assertAuthenticated();
// Test login attempt
$guard->attempt(['email' => 'user@example.com', 'password' => 'secret']);
$guard->assertAttempted();
// Assert no user logged in
$guard->logout();
$guard->assertGuest();
$guard->assertLoggedOut();use Marko\Http\HttpResponse;
use Marko\Testing\Fake\FakeHttpClient;
use Marko\Testing\Fake\Http\RecordedRequest;
$http = new FakeHttpClient();
$http->stub('https://api.example.com/orders/*', new HttpResponse(200, '{"id":1}'));
$service = new OrderSync($http);
$service->push($order);
$http->assertSent(fn (RecordedRequest $r) => $r->method === 'POST');
$http->assertSentCount(1);use Marko\Testing\Fake\FakeEncryptor;
$encryptor = new FakeEncryptor();
$encrypted = $encryptor->encrypt('123-45-6789', 'users.ssn');
$encryptor->decrypt($encrypted, 'users.ssn'); // '123-45-6789'
$encryptor->decrypt($encrypted, 'users.email'); // throws DecryptionException: AAD mismatch
$encryptor->assertEncrypted('123-45-6789', 'users.ssn');
$encryptor->assertDecrypted(aad: 'users.ssn');use Marko\Testing\KnownDrivers\KnownDriversValidator;
KnownDriversValidator::assertDocsUrlsResolveToValidPattern(
__DIR__ . '/../known-drivers.php',
);
KnownDriversValidator::assertSkeletonSuggestContainsAll(
__DIR__ . '/../known-drivers.php',
__DIR__ . '/../../skeleton/composer.json',
);setUp(): void— CallsregisterModuleAutoloaders()before each testregisterModuleAutoloaders(): void— Registers PSR-4 autoloaders forapp/*andmodules/*; safe to call multiple times (skips already-registered roots)projectRoot(): string— Walks up the directory tree from cwd to find the nearest ancestor containingvendor/
dispatch($event): void— Record a dispatched eventdispatched(string $class): array— Return all dispatched events of a classassertDispatched(string $class): void— Assert an event was dispatchedassertNotDispatched(string $class): void— Assert an event was not dispatchedassertDispatchedCount(string $class, int $count): void— Assert exact dispatch count
send($message): void— Record a sent messageassertSent(string $class): void— Assert a message was sentassertNothingSent(): void— Assert no messages were sentassertSentCount(string $class, int $count): void— Assert exact sent count
push($job): void— Record a pushed jobassertPushed(string $class): void— Assert a job was pushedassertNotPushed(string $class): void— Assert a job was not pushedassertPushedCount(string $class, int $count): void— Assert exact pushed countassertNothingPushed(): void— Assert no jobs were pushed
info(string $message): void,error(),warning(), etc. — Record log entriesassertLogged(string $message): void— Assert a message was loggedassertNothingLogged(): void— Assert nothing was logged
new FakeGuard(string $name, bool $attemptResult)— Create guard;$attemptResultcontrols whatattempt()returnssetUser(?AuthenticatableInterface $user): void— Set the current authenticated userattempt(array $credentials): bool— Record credentials attempt and return configured resultlogin(AuthenticatableInterface $user): void— Log in a user directlylogout(): void— Clear current user and record logoutassertAuthenticated(): void— Assert a user is currently authenticatedassertGuest(): void— Assert no user is authenticatedassertAttempted(?callable $callback = null): void— Assert attempt() was called, optionally matching credentials via callbackassertNotAttempted(): void— Assert attempt() was never calledassertLoggedOut(): void— Assert logout() was called
stub(string $urlPattern, HttpResponse|HttpException $response): self— Respond to matching URLs (*wildcard)queue(HttpResponse|HttpException ...$responses): self— Sequential responses for unmatched URLspreventStrayRequests(bool $prevent = true): self— Throw on unmatched requests (default on)assertSent(?callable $callback = null): void,assertNotSent(callable $callback): void,assertSentCount(int $expected): void,assertNothingSent(): void
TestClient::boot(string $basePath): self,TestClient::forApplication(Application $app): self— Boot once, serve many requestsget(),post(),put(),patch(),delete(),options(),head(),getJson(),postJson(),putJson(),patchJson(),deleteJson(),call()— Send a request, return aTestResponsewithHeaders(),withServerVariables(),withCookie(),withoutCookies(),withFile(),withFiles(),actingAs($user, ?string $guard = null)— Client state for later requestscookies(),cookieJar()— The cookie jar, scoped by path, domain,Secure, expiry andSameSitelike a browserwithoutResetting(ResettableInterface ...$services): static— Services the client must not reset between requests
TestDatabase::boot(string $basePath, bool $fresh = false): self— Boot and migrate once per process; refuses production, andfresh: trueruns only in a testing environmentapplication(),connection(),transaction(),client(),seedTable(),getTableRowCount(),appliedMigrations()new RefreshDatabase(TestDatabase $database)—begin(),rollback(),runAfterCommitCallbacks()new TruncateDatabase(TestDatabase $database)—truncate(),tables()
assertStatus(),assertOk(),assertCreated(),assertNoContent(),assertNotFound(),assertForbidden(),assertUnauthorized(),assertUnprocessable(),assertRedirect(?string $to = null)assertHeader(),assertHeaderMissing(),assertCookie(),assertCookieMissing(),assertSee(),assertDontSee()assertJson(),assertExactJson(),assertJsonPath(),assertJsonCount(),assertJsonMissingPath()status(),body(),header(),json(),response()
assertDocsUrlsResolveToValidPattern(string $knownDriversPath): void— Assert every key inknown-drivers.phpfollows themarko/*prefix patternassertSkeletonSuggestContainsAll(string $knownDriversPath, string $skeletonComposerPath): void— Assert the skeleton'ssuggestblock contains every entry fromknown-drivers.phpwith matching descriptions
marko/testing ships Pest custom expectations. They register automatically through a Pest plugin (extra.pest.plugins), so Pest.php needs no require. Pest 4 is required for the expectations; the fakes and their assert*() methods work without it.
use Marko\Testing\Fake\FakeEventDispatcher;
use Marko\Testing\Fake\FakeGuard;
use Marko\Testing\Fake\FakeMailer;
use Marko\Testing\Fake\FakeQueue;
use Marko\Testing\Fake\FakeLogger;
// FakeEventDispatcher
expect($dispatcher)->toHaveDispatched(OrderPlaced::class);
// FakeMailer
expect($mailer)->toHaveSent();
expect($mailer)->toHaveSent(fn ($msg) => $msg instanceof WelcomeEmail);
// FakeQueue
expect($queue)->toHavePushed(ProcessOrder::class);
// FakeLogger
expect($logger)->toHaveLogged('User logged in');
// FakeGuard
expect($guard)->toHaveAttempted();
expect($guard)->toHaveAttempted(fn ($creds) => $creds['email'] === 'user@example.com');
expect($guard)->toBeAuthenticated();
// FakeHttpClient
expect($http)->toHaveSentRequest(fn ($request) => $request->method === 'POST');
// TestResponse
expect($response)->toHaveStatus(201);
expect($response)->toHaveJsonPath('data.status', 'live');Full usage, API reference, and examples: marko/testing