Testcontainers per PHP: test di integrazione con database e servizi reali in CI

Testcontainers per PHP: test di integrazione con database e servizi reali in CI

Il 12 marzo 2025 mi ha contattato il CTO di una piattaforma e-commerce verticale bolognese - marketplace B2B per prodotti artigianali made in Italy distribuito internazionalmente - 6 sviluppatori interni, 4.100 venditori attivi, fatturato transato annuo di circa 11 milioni di euro. Il team tecnico aveva un problema operativo ricorrente: i test automatici giravano verdi in CI ma producevano bug inaspettati quando le modifiche arrivavano su staging. In sei mesi avevano accumulato 34 bug di produzione attribuibili a situazioni in cui "i test passavano ma il codice rompeva lo stesso". L'analisi forense dei bug aveva rivelato un pattern comune: i test di integrazione usavano SQLite in-memory per velocità, ma l'applicazione di produzione usava MySQL 8.0. Le differenze sottili fra i due DBMS - tipi di colonna, comportamento dei transaction isolation level, parser SQL, encoding dei caratteri Unicode - non venivano catturate dai test perché il test environment era diverso dall'environment reale.

La soluzione architetturale a questa classe di bug si chiama Testcontainers - un pattern di testing che utilizza istanze reali dei servizi di produzione (database MySQL reale, Redis reale, Elasticsearch reale) dentro container Docker temporanei creati e distrutti automaticamente per ciascun test run. In quattro giornate di lavoro distribuite in due settimane, ho migrato l'architettura di test del cliente bolognese da SQLite in-memory a MySQL reale via Testcontainers, Redis reale via container, Elasticsearch reale via container. Ho configurato la pipeline CI GitHub Actions per avviare automaticamente i container di test, eseguire la suite, pulire l'ambiente. Ho ottimizzato la velocità di test attraverso pattern di caching e fixture condivise. Al go-live, i test di integrazione completavano in 6 minuti (contro i 4 minuti del setup SQLite precedente) - un overhead di appena il 50% contro bug production scesi del 64% nei sei mesi successivi. Il costo consulenziale dell'intervento è stato 4.800 euro.

Questo articolo descrive il pattern di Testcontainers per PHP, basato sull'esperienza di circa 14 progetti simili negli ultimi tre anni. Il principio guida è uno: i test devono essere il più vicino possibile alla realtà di produzione, non il più lontano possibile per velocità. Ottimizzare per velocità al costo di fedeltà ai bug produce fiducia illusoria nei test - passano ma non prevengono i problemi reali.

Perché SQLite in-memory per test è un anti-pattern per applicazioni MySQL in produzione

Il pattern di testing che trovo più frequentemente in applicazioni Laravel PMI italiane è l'uso di SQLite in-memory come database di test - configurato in phpunit.xml con DB_CONNECTION=sqlite e DB_DATABASE=:memory:. Il pattern è popolare per un motivo legittimo: velocità estrema. SQLite in-memory non tocca il disco, ogni test parte con database vergine, setup e teardown sono istantanei. Una suite di 500 test di integrazione può completare in 60-90 secondi - performance impossibili con qualunque DBMS vero.

Ma SQLite in-memory produce fiducia illusoria nei test. Le differenze fra SQLite e MySQL/PostgreSQL sono significative e producono bug sottili. Primo esempio: tipi di colonna differenti. SQLite ha type affinity debole - una colonna definita INTEGER accetta stringhe, una definita TEXT accetta numeri. MySQL è strict. Codice che funziona su SQLite può fallire su MySQL quando incontra dati inaspettati. Secondo esempio: transaction isolation level. SQLite usa SERIALIZABLE implicito, MySQL usa REPEATABLE READ di default. Bug di race condition che emergono su MySQL sono invisibili su SQLite. Terzo esempio: parser SQL. MySQL e SQLite hanno dialetti diversi - sintassi che lavorano su SQLite possono fallire su MySQL per uso di funzioni non standard. Quarto esempio: charset e collation. SQLite usa UTF-8 semplice; MySQL ha collation complesse che influenzano ordinamento, comparazione, case sensitivity. Test su SQLite non catturano bug di collation che emergono su MySQL.

Il risultato cumulativo è che una suite di test "verde al 100% su SQLite" può nascondere bug che emergeranno solo in produzione o staging. Il cliente bolognese aveva 34 bug documentati in sei mesi riconducibili a questa categoria - un ritmo di circa 1 bug produzione ogni 5 giorni, tutti evitabili con test più realistici.

Se gestisci un'applicazione Laravel o Symfony con MySQL/PostgreSQL in produzione e ancora testi con SQLite, nel mio profilo professionale trovi il dettaglio degli interventi di modernizzazione di test infrastructure che ho condotto in contesti PMI italiane, sempre con approccio pragmatico e focalizzato sul ROI reale del cambiamento.

Testcontainers: il pattern canonico per test con servizi reali

Testcontainers è un progetto open-source originariamente sviluppato per ecosistema Java e ora disponibile in tutti i linguaggi principali inclusa PHP tramite il pacchetto testcontainers/testcontainers. Il pattern è elegante: per ciascun test (o suite di test), Testcontainers avvia automaticamente un container Docker con il servizio richiesto (MySQL, PostgreSQL, Redis, Elasticsearch, MongoDB, RabbitMQ, qualsiasi immagine Docker), attende che il servizio sia pronto, espone le credenziali di connessione al test, e distrugge il container al termine. Il test interagisce con un MySQL vero di produzione, con tutta la sua semantica esatta, ma in completo isolamento.

Il pattern di utilizzo in PHPUnit è:

namespace Tests\Integration;

use PHPUnit\Framework\TestCase;
use Testcontainers\Container\MySQLContainer;

abstract class IntegrationTestCase extends TestCase
{
    protected static MySQLContainer $mysql;

    public static function setUpBeforeClass(): void
    {
        self::$mysql = (new MySQLContainer('8.0'))
            ->withDatabase('test')
            ->withUsername('test_user')
            ->withPassword('test_pass');

        self::$mysql->start();

        $dsn = sprintf(
            'mysql:host=%s;port=%d;dbname=test',
            self::$mysql->getHost(),
            self::$mysql->getMappedPort(3306)
        );

        // configura connessione applicativa al container
    }

    public static function tearDownAfterClass(): void
    {
        self::$mysql->stop();
    }
}

Il container viene creato una volta per classe di test (non per ogni test method) per efficienza. Il costo di startup del container MySQL è di circa 8-12 secondi - significativo ma ammortizzato su molti test della stessa classe. Fra un test e l'altro, lo stato del database viene ripulito con transaction rollback o truncate rapido.

Fixture management: come popolare rapidamente database reale fra test

Una volta che il container MySQL è disponibile, il problema operativo diventa come popolare il database con dati rappresentativi per ogni test, in modo rapido. Il pattern naive (eseguire migration e seed da zero per ogni test) è troppo lento. Il pattern corretto usa transaction rollback.

abstract class IntegrationTestCase extends TestCase
{
    protected function setUp(): void
    {
        DB::connection()->beginTransaction();
    }

    protected function tearDown(): void
    {
        DB::connection()->rollBack();
    }
}

Ogni test inizia in una transazione, popola i dati, esegue asserzioni, e la transazione viene rollback-ata al termine. Il database torna allo stato iniziale senza costo di truncate. Questo pattern funziona finché il codice testato non commit-ta transazioni esplicitamente (raro nella logica applicativa pulita).

Per test che richiedono dati già presenti (fixtures), il pattern è la creazione una volta di una snapshot del database popolato con fixture, e il restore della snapshot all'inizio di ogni classe di test. MySQL supporta snapshot rapide via mysqldump --single-transaction o creazione di un database temporaneo clonato. Il pattern di testing con Queue::fake e withFakeQueueInteractions su Laravel 12 che ho descritto in un articolo dedicato è complementare - fake in-memory per componenti isolabili, Testcontainers per componenti che interagiscono con servizi esterni.

Integrazione con GitHub Actions CI/CD

Il principio operativo di Testcontainers si integra naturalmente con pipeline CI che già usano Docker. In GitHub Actions, il pattern è:

name: Tests
on: [push, pull_request]

jobs:
  integration-tests:
    runs-on: ubuntu-latest
    services:
      docker:
        image: docker:dind
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pdo_mysql, redis, curl
      - run: composer install --no-interaction --prefer-dist
      - run: vendor/bin/phpunit --testsuite Integration
        env:
          TESTCONTAINERS_HOST_OVERRIDE: host.docker.internal

GitHub Actions runner Ubuntu ha Docker pre-installato, quindi Testcontainers funziona nativamente. L'overhead principale nella CI è il tempo di pull delle immagini Docker al primo run (circa 20-40 secondi per MySQL); i run successivi usano cache locale e l'overhead è praticamente zero. Il pattern di pipeline si integra con il workflow CI/CD con GitHub Actions per deploy Laravel che ho descritto in un articolo dedicato, dove Testcontainers fornisce la base solida di test realistici prima del deploy.

Pattern avanzato: container condivisi fra test per ottimizzare velocità

Per suite di test grandi (500+ test integrazione), anche l'overhead di startup dei container diventa significativo. Il pattern di ottimizzazione avanzata condivide un singolo container fra molte classi di test, con rollback transaction per isolamento fra test. Il container viene creato all'inizio dell'intera suite (in bootstrap.php o globalSetup) e distrutto al termine.

// tests/bootstrap.php
$GLOBALS['mysql_container'] = (new MySQLContainer('8.0'))
    ->withDatabase('test')
    ->start();

register_shutdown_function(function () {
    $GLOBALS['mysql_container']->stop();
});

Ogni classe di test accede al container globale invece di crearne uno proprio. Il costo di startup viene pagato una sola volta per l'intera suite. Per suite di 500 test, questo riduce l'overhead totale da 500 × 8 secondi a 8 secondi una tantum - drammatico. La disciplina richiesta è l'isolamento rigoroso fra test tramite transaction rollback.

Sul cliente bolognese, l'adozione di questo pattern ha portato la suite integrazione da 18 minuti (primo prototipo con container per classe) a 6 minuti (container globale condiviso + transaction rollback).

Quando Testcontainers non è la scelta giusta

Per onestà intellettuale, vale la pena documentare gli scenari dove Testcontainers non è la scelta ottimale. Primo scenario: test unitari puri - logica applicativa senza dipendenze esterne. Per questi test, mock in-memory sono più veloci e appropriati. Testcontainers è per test di integrazione, non unitari. Secondo scenario: ambienti dove Docker non è disponibile - alcuni runner CI di vecchia generazione o laptop aziendali con restrizioni. In questi casi, SQLite resta il fallback. Terzo scenario: performance benchmark estremi dove il tempo totale di suite è critico. Una suite di 5000 test di integrazione con Testcontainers prende diverse ore; conviene valutare se tutti quei test siano davvero di integrazione o se alcuni possano essere demoted a unitari con mock.

Il risultato finale dell'intervento sul cliente bolognese a sei mesi dal go-live è stato il seguente. Numero di bug di produzione attribuibili a differenze fra test environment e produzione: sceso da 34 nei sei mesi pre-intervento a 2 nei sei mesi post-intervento. Suite di test di integrazione completata in 6 minuti contro i 4 minuti precedenti - overhead del 50% accettato in cambio di maggiore affidabilità. Test suite complessiva (unit + integration) completata in 8-10 minuti in CI. Zero falsi positivi dei test - quando i test passano, il codice funziona su produzione. Feedback qualitativo del team: ritrovata fiducia nei test, maggiore velocità di merge di pull request perché i test sono davvero affidabili.

Se gestisci un'applicazione Laravel o Symfony con MySQL/PostgreSQL in produzione e stai ancora testando con SQLite o mock database, probabilmente stai accumulando una classe di bug sottili che emergono solo in staging o produzione. L'adozione di Testcontainers è uno degli interventi di qualità del codice con ROI più visibile - non immediatamente (i bug prevenuti sono invisibili per definizione) ma cumulativo nel tempo. Se vuoi confrontarti sul tuo caso specifico con una proposta di introduzione Testcontainers, contattami per una consulenza preliminare: in una sessione di analisi guidata valutiamo insieme la tua attuale test infrastructure, identifichiamo i candidati di migrazione a test realistici, e pianifichiamo un rollout incrementale che non richiede blocco del ciclo di delivery.

Ultima modifica: