feat: use testcontainers for isolated MySQL in test runs (#153)

## Summary

- Add `testcontainers/testcontainers` to `require-dev` to spin up an
ephemeral MySQL 8.0 container per test process, eliminating the need for
a local database or Docker Compose stack
- Create `tests/bootstrap.php` that starts a `MySQLContainer`, sets
`DB_*` env vars dynamically, and auto-generates `APP_KEY` when missing
(supports fresh worktrees and CI)
- Update `phpunit.xml` to use the new bootstrap file and remove the
hardcoded `DB_DATABASE` env var

## How it works

Running `php artisan test` now automatically starts a short-lived MySQL
container on a random port. Each test process gets its own isolated
database, so multiple agents, worktrees, or CI jobs can run in parallel
without conflicts.

Set `TESTCONTAINERS=false` to bypass containers and use the database
configured in `.env` instead.
This commit is contained in:
Víctor Falcón 2026-02-25 10:14:20 +01:00 committed by GitHub
parent f2a7f955e6
commit e4243c2eaa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1236 additions and 4 deletions

View File

@ -60,6 +60,7 @@ jobs:
- name: Tests - name: Tests
run: ./vendor/bin/pest --exclude-testsuite=Browser,Performance run: ./vendor/bin/pest --exclude-testsuite=Browser,Performance
env: env:
TESTCONTAINERS: false
DB_CONNECTION: mysql DB_CONNECTION: mysql
DB_HOST: 127.0.0.1 DB_HOST: 127.0.0.1
DB_PORT: 3306 DB_PORT: 3306
@ -118,6 +119,7 @@ jobs:
- name: Browser Tests - name: Browser Tests
run: ./vendor/bin/pest --testsuite=Browser --ci run: ./vendor/bin/pest --testsuite=Browser --ci
env: env:
TESTCONTAINERS: false
DB_CONNECTION: mysql DB_CONNECTION: mysql
DB_HOST: 127.0.0.1 DB_HOST: 127.0.0.1
DB_PORT: 3306 DB_PORT: 3306
@ -197,6 +199,7 @@ jobs:
- name: Performance Tests - name: Performance Tests
run: ./vendor/bin/pest --testsuite=Performance run: ./vendor/bin/pest --testsuite=Performance
env: env:
TESTCONTAINERS: false
DB_CONNECTION: mysql DB_CONNECTION: mysql
DB_HOST: 127.0.0.1 DB_HOST: 127.0.0.1
DB_PORT: 3306 DB_PORT: 3306

View File

@ -32,7 +32,8 @@
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.1", "pestphp/pest": "^4.1",
"pestphp/pest-plugin-browser": "^4.0", "pestphp/pest-plugin-browser": "^4.0",
"pestphp/pest-plugin-laravel": "^4.0" "pestphp/pest-plugin-laravel": "^4.0",
"testcontainers/testcontainers": "^1.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {

1158
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php" bootstrap="tests/bootstrap.php"
colors="true" colors="true"
> >
<testsuites> <testsuites>
@ -29,7 +29,6 @@
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/> <env name="CACHE_STORE" value="array"/>
<env name="DB_DATABASE" value="testing"/>
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>

73
tests/bootstrap.php Normal file
View File

@ -0,0 +1,73 @@
<?php
use Testcontainers\Modules\MySQLContainer;
/*
|--------------------------------------------------------------------------
| Test Bootstrap Testcontainers
|--------------------------------------------------------------------------
|
| This bootstrap starts an ephemeral MySQL container before any tests run.
| Each test process gets its own isolated database on a random port, so
| multiple agents, worktrees, or CI jobs can run tests in parallel
| without conflicts.
|
| Set TESTCONTAINERS=false to skip container creation and use the
| database configured in your .env file instead.
|
*/
$useContainers = filter_var(
$_SERVER['TESTCONTAINERS'] ?? $_ENV['TESTCONTAINERS'] ?? 'true',
FILTER_VALIDATE_BOOLEAN,
);
// Autoload must be available before anything else
require __DIR__.'/../vendor/autoload.php';
// Create a minimal .env file when none exists so PHPDotenv does not
// emit a file_get_contents warning on every single test.
$envPath = __DIR__.'/../.env';
if (! file_exists($envPath)) {
file_put_contents($envPath, "APP_ENV=testing\n");
}
if ($useContainers) {
$container = (new MySQLContainer('8.0'))
->withMySQLDatabase('testing')
->withMySQLUser('testing', 'testing')
->start();
// Stop and remove the container when the PHP process exits, even on
// crashes or SIGINT. testcontainers-php has no built-in cleanup.
register_shutdown_function(function () use ($container): void {
$container->stop();
});
$host = $container->getHost();
$port = (string) $container->getFirstMappedPort();
// Set environment variables before Laravel boots so config/database.php
// reads the dynamic host and port via env(). Both putenv() and $_ENV/$_SERVER
// are needed because Laravel's env() helper checks multiple sources.
$env = [
'DB_CONNECTION' => 'mysql',
'DB_HOST' => $host,
'DB_PORT' => $port,
'DB_DATABASE' => 'testing',
'DB_USERNAME' => 'testing',
'DB_PASSWORD' => 'testing',
];
// Generate an APP_KEY if one is not already set, so tests can run
// without a .env file (e.g. fresh worktrees, CI environments).
if (empty($_SERVER['APP_KEY'] ?? $_ENV['APP_KEY'] ?? getenv('APP_KEY'))) {
$env['APP_KEY'] = 'base64:'.base64_encode(random_bytes(32));
}
foreach ($env as $key => $value) {
putenv("{$key}={$value}");
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}