eznix86/pest-plugin-testcontainers is a Laravel-focused Pest plugin that starts containers directly from your tests.
- Test your own containers to ensure it runs smoothly with the expected behavior.
- Spin up services (Postgres, Redis, Meilisearch, MinIO, and more) in tests with minimal setup.
- Integrates with Laravel (
database,cache,scout,storage) in one fluent chain.
- Requirements
- Install
- Quick Start (Laravel + Pest)
- Function API
- Laravel Helpers
- Expectations
- API Reference
- Docker Comparison
- Local Development
- Troubleshooting
- Notes
- Credits
- PHP 8.3+
- Docker running locally
- PestPHP v4
composer require --dev eznix86/pest-plugin-testcontainersThe plugin autoloads itself and auto-registers the helper functions, expectations, and InteractsWithContainers trait for Pest tests.
tests/Pest.php
<?php
use Tests\TestCase;
pest()->extend(TestCase::class)->in('Feature', 'Unit');This is usually not required because the plugin auto-registers InteractsWithContainers for Pest tests.
tests/TestCase.php
<?php
namespace Tests;
use Eznix86\PestPluginTestContainers\InteractsWithContainers;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use InteractsWithContainers;
}it('starts nginx and exposes a mapped port', function () {
$container = $this->container('nginx:alpine')
->ports([80])
->waitForHttp(path: '/', port: 80)
->start();
expect($container->getGeneratedPortFor(80))->toBeInt();
});You can use either style:
- Trait style:
$this->container('image')->...->start() - Function style:
container('image')->...->start()
Function-style example:
use function Eznix86\PestPluginTestContainers\container;
it('starts nginx with the helper function', function () {
$container = container('nginx:alpine')
->ports([80])
->waitForHttp(path: '/', port: 80)
->start();
expect($container->getGeneratedPortFor(80))->toBeInt();
});Note: the function helper must run inside an active Pest test. InteractsWithContainers is auto-registered by the plugin (or can be added manually).
Specialized helpers are available for common Laravel services:
postgres(?string $version = null)mysql(?string $version = null)mariadb(?string $version = null)redis(?string $version = null)typesense(?string $version = null)meilisearch(?string $version = null)minio(?string $version = null)
All specialized builders also support:
->image(string $image)to override the helper image
Use postgres(), mysql(), or mariadb() with asDatabase() to inject a dedicated Laravel database connection and set it as default.
By default, the connection name is randomized per builder (testcontainer_xxx). If reuse('name') is enabled, the connection name matches the reuse name (including perWorker suffixes).
You can read that name from $container->connectionName() after start().
use function Eznix86\PestPluginTestContainers\postgres;
it('uses postgres as laravel database', function () {
$builder = postgres('16')
->credentials('app_user', 'secret-pass')
->asDatabase(); // random database name by default
$container = $builder->start();
$connection = $container->connectionName();
expect(config('database.default'))->toBe($connection)
->and(config("database.connections.{$connection}.driver"))->toBe('pgsql')
->and(config("database.connections.{$connection}.port"))->toBe($container->mappedPort(5432));
});Use redis()->asCache() to configure Laravel cache against Redis in the test container.
Use postgres()->asCache(), mysql()->asCache(), or mariadb()->asCache() to configure Laravel cache with the database store over the injected database connection.
use function Eznix86\PestPluginTestContainers\redis;
it('uses redis as laravel cache', function () {
$builder = redis()->asCache();
$container = $builder->start();
$connection = $container->connectionName();
expect(config('cache.default'))->toBe($connection)
->and(config("database.redis.{$connection}.port"))->toBe($container->mappedPort(6379));
});Use typesense() or meilisearch() with asSearch() to inject Scout config and set scout.driver.
use function Eznix86\PestPluginTestContainers\typesense;
it('uses typesense as laravel scout driver', function () {
$container = typesense()
->passphrase('typesense-key')
->asSearch()
->start();
expect(config('scout.driver'))->toBe('typesense')
->and(config('scout.typesense.port'))->toBe($container->mappedPort(8108));
});Use minio()->asStorage() to inject an S3 disk and set it as default.
use Illuminate\Support\Facades\Storage;
use function Eznix86\PestPluginTestContainers\minio;
it('uses minio as laravel storage', function () {
$builder = minio()->credentials('storage_user', 'storage_pass')->asStorage();
$container = $builder->start();
Storage::disk($container->connectionName())->put('hello.txt', 'ok');
expect('hello.txt')->toBeInStorage();
expect('missing.txt')->toNotBeInStorage();
});$container->expect(...) supports container-focused assertions:
$container->expect('echo hello')->toRunSuccessfully()->toContain('hello')$container->expect('/tmp/file')->toExist()->toBeReadable()->toNotExist()$container->expect('app started')->toBeInLogs()
Global storage expectations (useful with minio()->asStorage()):
expect('path/to/file')->toBeInStorage()expect('path/to/file')->toNotBeInStorage()
Builder methods available before start():
// container(...)
->ports(array $ports)
->env(array $env)
->labels(array $labels)
->volume(string $sourcePath, string $containerPath)
->reuse(string $name, bool $perWorker = false)
->command(array $command)
->healthcheck(array $command)
->waitForLog(string $message, bool $regex = false, int $timeoutSeconds = 30, int $pollIntervalMilliseconds = 500)
->waitForHttp(
string $path = '/',
?int $port = null,
int $expectedStatusCode = 200,
Testcontainers\Container\HttpMethod|string $method = Testcontainers\Container\HttpMethod::GET,
bool $https = false,
bool $allowInsecure = false,
int $timeoutSeconds = 30,
int $pollIntervalMilliseconds = 500,
int $readTimeoutMilliseconds = 1000,
array $headers = [],
)
->waitForPort(?int $port = null, int $timeoutSeconds = 30, int $pollIntervalMilliseconds = 500)
->waitForCommand(array $command, int $timeoutSeconds = 30, int $pollIntervalMilliseconds = 500)
->start()Specialized helper methods:
// postgres(), mysql(), mariadb()
->credentials(string $username, string $password)
->asDatabase(?string $databaseName = null)
->asCache()
->asQueue(?string $connection = null)
// redis()
->passphrase(string $phrase)
->asCache()
->asQueue(?string $connection = null)
// typesense(), meilisearch()
->passphrase(string $phrase)
->asSearch()
// minio()
->credentials(string $username, string $password)
->asStorage(?string $disk = null)
// all specialized builders
->reuse(string $name, bool $perWorker = false)
->image(string $image)Port mapping notes:
ports([80])picks a free host port automatically.ports([80 => 8080])maps container80to host8080.
Expectation methods available from $container->expect(...):
// $container->expect(...)
->toExist()
->toNotExist()
->toBeDirectory()
->toBeReadable()
->toRunSuccessfully()
->toFail()
->toContain(string $needle)
->toBeInLogs()Started container helpers:
// $container
->connectionName() // nullable string, set when started from specialized builders
->host()
->mappedPort(int $containerPort)
->getGeneratedPortFor(int $containerPort)
->logs() // sanitized logs
->rawLogs() // raw Docker stream output
->exec(array $command) // returns ExecResult with sanitized output
->execRaw(array $command) // returns ExecResult with raw outputdocker run --rm \
-p 8080:80 \
-v "$(pwd)/tests/Fixtures/nginx/index.html:/usr/share/nginx/html/index.html" \
--health-cmd="test -f /usr/share/nginx/html/index.html" \
nginx:alpineEquivalent test setup:
$container = $this->container('nginx:alpine')
->ports([80 => 8080])
->volume(base_path('tests/Fixtures/nginx/index.html'), '/usr/share/nginx/html/index.html')
->healthcheck(['sh', '-lc', 'test -f /usr/share/nginx/html/index.html'])
->start();From the project root:
composer install
composer test- Docker is not running: start Docker Desktop (or your Docker daemon) and rerun tests.
- Container startup timeout: add an explicit wait strategy (
waitForLog,waitForHttp,waitForPort, orwaitForCommand) and increase timeout values. - Port conflicts: prefer dynamic host ports (
ports([80])) over fixed host mappings (ports([80 => 8080])). - Parallel test collisions: for reusable containers in parallel runs, use
->reuse('name', perWorker: true).
- Started containers are tracked per test class and cleaned up during Laravel teardown.
->reuse('name', perWorker: false)attaches to a running container with that Docker name when available, and keeps it running across test teardown.->reuse('name', perWorker: true)appends a worker token suffix in parallel runs so each worker gets its own reusable container name.- Connection naming follows the same rule: randomized by default, reuse name when
->reuse(...)is configured.