TDD in Laravel nel contesto CI/CD

Il Test-Driven Development non è semplicemente una tecnica per scrivere test prima del codice. Quando viene integrato in una pipeline CI/CD, diventa l'ossatura portante di un processo di rilascio affidabile, ripetibile e privo di sorprese. Laravel, grazie alla sua architettura e agli strumenti nativi che mette a disposizione, rappresenta uno degli ecosistemi più maturi per praticare TDD in modo rigoroso e produttivo.

Questo articolo esplora in profondità come strutturare un flusso TDD in Laravel che si integri naturalmente con le pipeline di Continuous Integration e Continuous Deployment, partendo dai fondamenti fino ad arrivare a configurazioni avanzate con GitHub Actions, parallelizzazione dei test e strategie di deploy condizionale.

I tre ritmi del TDD: Red, Green, Refactor

Il ciclo TDD si articola in tre fasi che si ripetono in modo disciplinato. Nella fase Red si scrive un test che descrive un comportamento atteso non ancora implementato: il test deve fallire. Nella fase Green si scrive la quantità minima di codice necessaria a far passare il test. Nella fase Refactor si migliora la struttura del codice senza alterarne il comportamento, con la rete di sicurezza dei test già scritti a proteggere da regressioni.

Questo ciclo ha un valore particolare nel contesto CI/CD: ogni commit che entra nella pipeline porta con sé la garanzia che il comportamento atteso è stato definito prima dell'implementazione, e che il codice che lo soddisfa è stato scritto nella forma più semplice possibile.

Configurazione dell'ambiente di test in Laravel

Laravel fornisce un file phpunit.xml preconfigurato nella root del progetto. Per il TDD in ambiente CI/CD è fondamentale che i test siano deterministici e isolati. La configurazione tipica prevede l'uso di un database SQLite in memoria, che garantisce velocità e assenza di stato residuo tra un test e l'altro.

<!-- phpunit.xml - Configurazione per test deterministici in CI/CD -->
<phpunit
    colors="true"
    stopOnFailure="false"
    cacheResult="false"
>
    <testsuites>
        <!-- Suite per i test unitari -->
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <!-- Suite per i test funzionali -->
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
    </testsuites>
    <php>
        <!-- Forza l'uso di SQLite in memoria per isolamento completo -->
        <env name="APP_ENV" value="testing"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="MAIL_MAILER" value="array"/>
    </php>
    <source>
        <include>
            <directory>app</directory>
        </include>
    </source>
</phpunit>

Il file .env.testing completa la configurazione, sovrascrivendo le variabili d'ambiente per il contesto di test. In CI/CD, questo file viene generato dinamicamente dalla pipeline oppure versionato nel repository con valori sicuri e predefiniti.

# .env.testing - Variabili d'ambiente dedicate ai test
APP_NAME="AppTest"
APP_ENV=testing
APP_KEY=base64:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
APP_DEBUG=true

# Database in memoria per massima velocità
DB_CONNECTION=sqlite
DB_DATABASE=:memory:

# Driver che non richiedono servizi esterni
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array

TDD applicato: costruire una feature partendo dal test

Vediamo il ciclo TDD completo attraverso un esempio concreto: un sistema di gestione degli ordini con validazione dello stato e calcolo del totale. Partiamo dal test, che definisce il contratto del comportamento atteso.

Fase Red: il test che fallisce

<?php
// tests/Feature/OrderCreationTest.php

namespace Tests\Feature;

use App\Models\User;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class OrderCreationTest extends TestCase
{
    use RefreshDatabase;

    public function test_authenticated_user_can_create_order(): void
    {
        // Prepara: crea un utente e dei prodotti nel database di test
        $user = User::factory()->create();

        $productA = Product::factory()->create([
            'name'  => 'Widget Alpha',
            'price' => 2500, // Prezzo in centesimi per evitare problemi con i decimali
        ]);

        $productB = Product::factory()->create([
            'name'  => 'Widget Beta',
            'price' => 1800,
        ]);

        // Agisci: invia la richiesta di creazione ordine
        $response = $this->actingAs($user)->postJson('/api/orders', [
            'items' => [
                ['product_id' => $productA->id, 'quantity' => 2],
                ['product_id' => $productB->id, 'quantity' => 1],
            ],
        ]);

        // Verifica: controlla risposta HTTP e struttura JSON
        $response->assertStatus(201);
        $response->assertJsonStructure([
            'data' => ['id', 'status', 'total', 'items'],
        ]);

        // Verifica: il totale deve essere (2500 * 2) + (1800 * 1) = 6800
        $response->assertJsonPath('data.total', 6800);
        $response->assertJsonPath('data.status', 'pending');

        // Verifica: l'ordine deve esistere nel database
        $this->assertDatabaseHas('orders', [
            'user_id' => $user->id,
            'status'  => 'pending',
            'total'   => 6800,
        ]);
    }

    public function test_order_creation_requires_at_least_one_item(): void
    {
        // Prepara: utente autenticato senza prodotti nell'ordine
        $user = User::factory()->create();

        // Agisci: invia un ordine vuoto
        $response = $this->actingAs($user)->postJson('/api/orders', [
            'items' => [],
        ]);

        // Verifica: la validazione deve rifiutare la richiesta
        $response->assertStatus(422);
        $response->assertJsonValidationErrors(['items']);
    }

    public function test_guest_cannot_create_order(): void
    {
        // Agisci: tentativo di creazione senza autenticazione
        $response = $this->postJson('/api/orders', [
            'items' => [
                ['product_id' => 1, 'quantity' => 1],
            ],
        ]);

        // Verifica: accesso negato
        $response->assertStatus(401);
    }
}

A questo punto, eseguendo php artisan test, tutti e tre i test falliranno. La rotta non esiste, il modello Order non esiste, il controller non esiste. Questo è esattamente lo stato che vogliamo: abbiamo definito il comportamento atteso con precisione.

Fase Green: l'implementazione minima

Ora creiamo solo ciò che serve a far passare i test. Partiamo dalla migration e dal modello.

<?php
// database/migrations/2026_01_15_000001_create_orders_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('status')->default('pending');
            $table->unsignedBigInteger('total')->default(0); // Totale in centesimi
            $table->timestamps();
        });

        Schema::create('order_items', function (Blueprint $table) {
            $table->id();
            $table->foreignId('order_id')->constrained()->cascadeOnDelete();
            $table->foreignId('product_id')->constrained();
            $table->unsignedInteger('quantity');
            $table->unsignedBigInteger('unit_price'); // Prezzo unitario al momento dell'acquisto
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('order_items');
        Schema::dropIfExists('orders');
    }
};
<?php
// app/Models/Order.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    use HasFactory;

    protected $fillable = ['user_id', 'status', 'total'];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }
}
<?php
// app/Models/OrderItem.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class OrderItem extends Model
{
    protected $fillable = ['order_id', 'product_id', 'quantity', 'unit_price'];

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }
}

Il controller e il Form Request completano l'implementazione minima.

<?php
// app/Http/Requests/StoreOrderRequest.php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        // Solo utenti autenticati possono creare ordini
        return true;
    }

    public function rules(): array
    {
        return [
            'items'              => ['required', 'array', 'min:1'],
            'items.*.product_id' => ['required', 'exists:products,id'],
            'items.*.quantity'   => ['required', 'integer', 'min:1'],
        ];
    }
}
<?php
// app/Http/Controllers/OrderController.php

namespace App\Http\Controllers;

use App\Http\Requests\StoreOrderRequest;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Support\Facades\DB;

class OrderController extends Controller
{
    public function store(StoreOrderRequest $request)
    {
        // Transazione per garantire consistenza dei dati
        $order = DB::transaction(function () use ($request) {
            $order = Order::create([
                'user_id' => $request->user()->id,
                'status'  => 'pending',
                'total'   => 0,
            ]);

            $total = 0;

            foreach ($request->validated()['items'] as $itemData) {
                $product = Product::findOrFail($itemData['product_id']);

                $order->items()->create([
                    'product_id' => $product->id,
                    'quantity'   => $itemData['quantity'],
                    'unit_price' => $product->price,
                ]);

                // Accumula il totale: prezzo unitario per quantità
                $total += $product->price * $itemData['quantity'];
            }

            $order->update(['total' => $total]);

            return $order;
        });

        // Ricarica le relazioni per la risposta
        $order->load('items');

        return response()->json([
            'data' => [
                'id'     => $order->id,
                'status' => $order->status,
                'total'  => $order->total,
                'items'  => $order->items,
            ],
        ], 201);
    }
}
<?php
// routes/api.php

use App\Http\Controllers\OrderController;

// Rotte protette da autenticazione
Route::middleware('auth:sanctum')->group(function () {
    Route::post('/orders', [OrderController::class, 'store']);
});

Eseguendo nuovamente php artisan test, tutti e tre i test passeranno. Siamo nella fase Green.

Fase Refactor: estrarre la logica di business

Il controller contiene troppa logica. Estraiamo il calcolo in un Service dedicato, sapendo che i test ci proteggeranno da qualsiasi regressione.

<?php
// app/Services/OrderService.php

namespace App\Services;

use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class OrderService
{
    /**
     * Crea un nuovo ordine con i relativi elementi.
     * Tutto avviene in transazione per garantire atomicità.
     */
    public function createFromItems(User $user, array $items): Order
    {
        return DB::transaction(function () use ($user, $items) {
            $order = Order::create([
                'user_id' => $user->id,
                'status'  => 'pending',
                'total'   => 0,
            ]);

            $total = $this->attachItems($order, $items);

            $order->update(['total' => $total]);
            $order->load('items');

            return $order;
        });
    }

    /**
     * Collega i prodotti all'ordine e restituisce il totale calcolato.
     */
    private function attachItems(Order $order, array $items): int
    {
        $total = 0;

        foreach ($items as $itemData) {
            $product = Product::findOrFail($itemData['product_id']);

            $order->items()->create([
                'product_id' => $product->id,
                'quantity'   => $itemData['quantity'],
                'unit_price' => $product->price,
            ]);

            $total += $product->price * $itemData['quantity'];
        }

        return $total;
    }
}

Il controller diventa snello e delegante.

<?php
// app/Http/Controllers/OrderController.php (dopo il refactoring)

namespace App\Http\Controllers;

use App\Http\Requests\StoreOrderRequest;
use App\Services\OrderService;

class OrderController extends Controller
{
    public function __construct(
        private readonly OrderService $orderService
    ) {}

    public function store(StoreOrderRequest $request)
    {
        // Delega interamente al service la logica di creazione
        $order = $this->orderService->createFromItems(
            $request->user(),
            $request->validated()['items']
        );

        return response()->json([
            'data' => [
                'id'     => $order->id,
                'status' => $order->status,
                'total'  => $order->total,
                'items'  => $order->items,
            ],
        ], 201);
    }
}

Rieseguiamo i test: tutto verde. Il refactoring è completo e verificato.

Test unitari per la logica isolata

Accanto ai test funzionali che verificano il comportamento end-to-end, i test unitari coprono la logica pura senza coinvolgere il framework. Supponiamo di avere un Value Object per il calcolo dei prezzi.

<?php
// app/ValueObjects/Money.php

namespace App\ValueObjects;

use InvalidArgumentException;

final class Money
{
    /**
     * Rappresenta un importo monetario in centesimi.
     * L'uso dei centesimi evita tutti i problemi legati all'aritmetica in virgola mobile.
     */
    public function __construct(
        private readonly int $cents
    ) {
        if ($cents < 0) {
            throw new InvalidArgumentException(
                'L\'importo non può essere negativo.'
            );
        }
    }

    public function cents(): int
    {
        return $this->cents;
    }

    /** Moltiplica l'importo per una quantità intera */
    public function multiply(int $factor): self
    {
        return new self($this->cents * $factor);
    }

    /** Somma due importi monetari */
    public function add(self $other): self
    {
        return new self($this->cents + $other->cents);
    }

    /** Restituisce la rappresentazione formattata con due decimali */
    public function formatted(): string
    {
        return number_format($this->cents / 100, 2, '.', '');
    }
}
<?php
// tests/Unit/MoneyTest.php

namespace Tests\Unit;

use App\ValueObjects\Money;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;

class MoneyTest extends TestCase
{
    public function test_it_stores_amount_in_cents(): void
    {
        $money = new Money(1500);

        // Verifica che il valore interno sia corretto
        $this->assertSame(1500, $money->cents());
    }

    public function test_it_rejects_negative_amounts(): void
    {
        // Un importo negativo deve lanciare un'eccezione
        $this->expectException(InvalidArgumentException::class);

        new Money(-100);
    }

    public function test_it_multiplies_correctly(): void
    {
        $price = new Money(2500);
        $total = $price->multiply(3);

        // 25.00 per 3 unità deve dare 75.00, ovvero 7500 centesimi
        $this->assertSame(7500, $total->cents());
    }

    public function test_it_adds_two_amounts(): void
    {
        $a = new Money(1500);
        $b = new Money(2300);

        // 15.00 + 23.00 = 38.00
        $this->assertSame(3800, $a->add($b)->cents());
    }

    public function test_it_formats_as_decimal_string(): void
    {
        $money = new Money(1050);

        // 1050 centesimi devono essere formattati come "10.50"
        $this->assertSame('10.50', $money->formatted());
    }

    public function test_zero_is_valid(): void
    {
        $money = new Money(0);

        // Zero è un importo valido
        $this->assertSame(0, $money->cents());
        $this->assertSame('0.00', $money->formatted());
    }
}

Questi test non toccano il database, non avviano il framework Laravel, e si eseguono in millisecondi. Nella pipeline CI/CD è utile eseguirli per primi, come feedback rapido.

Mocking e isolamento dei servizi esterni

In TDD, i servizi esterni (gateway di pagamento, API di terze parti, servizi di notifica) vanno sempre simulati. Laravel offre strumenti nativi per questo scopo.

<?php
// tests/Feature/PaymentProcessingTest.php

namespace Tests\Feature;

use App\Models\Order;
use App\Models\User;
use App\Contracts\PaymentGateway;
use App\DTOs\PaymentResult;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PaymentProcessingTest extends TestCase
{
    use RefreshDatabase;

    public function test_successful_payment_updates_order_status(): void
    {
        // Prepara: crea un mock del gateway di pagamento
        $mockGateway = $this->mock(PaymentGateway::class);

        $mockGateway
            ->shouldReceive('charge')
            ->once()
            ->andReturn(new PaymentResult(
                success: true,
                transactionId: 'txn_abc123',
                message: 'Payment approved',
            ));

        $user  = User::factory()->create();
        $order = Order::factory()->for($user)->create([
            'status' => 'pending',
            'total'  => 5000,
        ]);

        // Agisci: richiedi il pagamento dell'ordine
        $response = $this->actingAs($user)->postJson(
            "/api/orders/{$order->id}/pay",
            ['payment_method' => 'tok_visa']
        );

        // Verifica: l'ordine passa allo stato "paid"
        $response->assertStatus(200);
        $response->assertJsonPath('data.status', 'paid');

        $this->assertDatabaseHas('orders', [
            'id'     => $order->id,
            'status' => 'paid',
        ]);
    }

    public function test_failed_payment_leaves_order_pending(): void
    {
        // Prepara: il gateway simula un pagamento rifiutato
        $mockGateway = $this->mock(PaymentGateway::class);

        $mockGateway
            ->shouldReceive('charge')
            ->once()
            ->andReturn(new PaymentResult(
                success: false,
                transactionId: null,
                message: 'Insufficient funds',
            ));

        $user  = User::factory()->create();
        $order = Order::factory()->for($user)->create([
            'status' => 'pending',
            'total'  => 5000,
        ]);

        // Agisci
        $response = $this->actingAs($user)->postJson(
            "/api/orders/{$order->id}/pay",
            ['payment_method' => 'tok_declined']
        );

        // Verifica: l'ordine resta "pending" e la risposta indica il fallimento
        $response->assertStatus(422);

        $this->assertDatabaseHas('orders', [
            'id'     => $order->id,
            'status' => 'pending',
        ]);
    }
}

Il mock garantisce che i test siano veloci, deterministici e non dipendano dalla disponibilità di servizi esterni. In una pipeline CI/CD, questa proprietà è essenziale: un test che fallisce a causa di un timeout verso un'API esterna non è un test utile, è un generatore di falsi negativi.

Test delle notifiche e degli eventi

Laravel permette di intercettare notifiche, eventi e job in coda senza che vengano realmente eseguiti. Questo è fondamentale per verificare che i side effect siano innescati correttamente.

<?php
// tests/Feature/OrderNotificationTest.php

namespace Tests\Feature;

use App\Models\Order;
use App\Models\User;
use App\Notifications\OrderConfirmation;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class OrderNotificationTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_receives_confirmation_after_payment(): void
    {
        // Intercetta tutte le notifiche senza inviarle davvero
        Notification::fake();

        $user  = User::factory()->create();
        $order = Order::factory()->for($user)->create([
            'status' => 'paid',
        ]);

        // Agisci: scatena l'evento che invia la conferma
        $order->sendConfirmation();

        // Verifica: la notifica è stata inviata all'utente corretto
        Notification::assertSentTo(
            $user,
            OrderConfirmation::class,
            function (OrderConfirmation $notification) use ($order) {
                // Controlla che la notifica contenga l'ordine giusto
                return $notification->order->id === $order->id;
            }
        );
    }

    public function test_pending_order_does_not_trigger_confirmation(): void
    {
        Notification::fake();

        $user  = User::factory()->create();
        $order = Order::factory()->for($user)->create([
            'status' => 'pending',
        ]);

        // Agisci: un ordine non pagato non deve generare conferme
        $order->sendConfirmation();

        // Verifica: nessuna notifica inviata
        Notification::assertNothingSent();
    }
}

La pipeline CI/CD con GitHub Actions

Una pipeline CI/CD ben progettata per un progetto Laravel con TDD deve eseguire i test in modo affidabile, veloce e informativo. GitHub Actions è la scelta più diffusa per i progetti ospitati su GitHub.

# .github/workflows/ci.yml
name: CI Pipeline

# Esegui su ogni push e pull request verso i branch principali
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  static-analysis:
    # Analisi statica: veloce, cattura errori senza eseguire codice
    name: Static Analysis
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          tools: composer:v2

      # Cache delle dipendenze per velocizzare le esecuzioni successive
      - name: Cache Composer
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist

      # PHPStan rileva errori di tipo e logica senza eseguire il codice
      - name: Run PHPStan
        run: vendor/bin/phpstan analyse --memory-limit=512M

      # Pint verifica la conformità allo stile di codice
      - name: Run Pint
        run: vendor/bin/pint --test

  test-unit:
    # Test unitari: nessuna dipendenza esterna, feedback immediato
    name: Unit Tests
    runs-on: ubuntu-latest
    needs: static-analysis

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: xdebug

      - name: Cache Composer
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist

      # Esegui solo i test unitari con raccolta della copertura
      - name: Run unit tests
        run: |
          php artisan test --testsuite=Unit \
            --coverage-clover=coverage-unit.xml

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage-unit
          path: coverage-unit.xml

  test-feature:
    # Test funzionali: verificano il comportamento end-to-end
    name: Feature Tests
    runs-on: ubuntu-latest
    needs: static-analysis

    services:
      # Redis per i test che coinvolgono cache e code
      redis:
        image: redis:7
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pdo_sqlite, redis
          coverage: xdebug

      - name: Cache Composer
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist

      # Genera il file di configurazione per l'ambiente di test
      - name: Prepare environment
        run: |
          cp .env.testing .env
          php artisan key:generate

      # Esegui i test funzionali in parallelo per ridurre i tempi
      - name: Run feature tests
        run: |
          php artisan test --testsuite=Feature \
            --parallel --processes=4 \
            --coverage-clover=coverage-feature.xml

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage-feature
          path: coverage-feature.xml

  coverage-gate:
    # Gate di qualità: blocca il merge se la copertura è insufficiente
    name: Coverage Gate
    runs-on: ubuntu-latest
    needs: [test-unit, test-feature]

    steps:
      - uses: actions/checkout@v4

      - name: Download unit coverage
        uses: actions/download-artifact@v4
        with:
          name: coverage-unit

      - name: Download feature coverage
        uses: actions/download-artifact@v4
        with:
          name: coverage-feature

      # Verifica che la copertura complessiva superi la soglia minima
      - name: Check coverage threshold
        run: |
          COVERAGE=$(php -r "
            \$xml = simplexml_load_file('coverage-unit.xml');
            \$metrics = \$xml->project->metrics;
            \$total = (int)\$metrics['elements'];
            \$covered = (int)\$metrics['coveredelements'];
            echo \$total > 0 ? round((\$covered / \$total) * 100, 2) : 0;
          ")
          echo "Copertura: ${COVERAGE}%"
          if (( $(echo "$COVERAGE < 80" | bc -l) )); then
            echo "La copertura è sotto la soglia dell'80%."
            exit 1
          fi

  deploy-staging:
    # Deploy automatico su staging dopo il superamento di tutti i check
    name: Deploy to Staging
    runs-on: ubuntu-latest
    needs: coverage-gate
    if: github.ref == 'refs/heads/develop'

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to staging
        env:
          DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}
          STAGING_HOST: ${{ secrets.STAGING_HOST }}
        run: |
          # Esegui il deploy tramite SSH con zero-downtime
          ssh -o StrictHostKeyChecking=no \
            -i <(echo "$DEPLOY_KEY") \
            deployer@${STAGING_HOST} \
            "cd /var/www/staging && \
             git pull origin develop && \
             composer install --no-dev --optimize-autoloader && \
             php artisan migrate --force && \
             php artisan config:cache && \
             php artisan route:cache && \
             php artisan queue:restart"

  deploy-production:
    # Deploy in produzione: solo dal branch main, con approvazione manuale
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: coverage-gate
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to production
        env:
          DEPLOY_KEY: ${{ secrets.PRODUCTION_DEPLOY_KEY }}
          PRODUCTION_HOST: ${{ secrets.PRODUCTION_HOST }}
        run: |
          # Deploy con zero-downtime tramite release atomiche
          ssh -o StrictHostKeyChecking=no \
            -i <(echo "$DEPLOY_KEY") \
            deployer@${PRODUCTION_HOST} \
            "cd /var/www/production && \
             php artisan down --retry=60 && \
             git pull origin main && \
             composer install --no-dev --optimize-autoloader && \
             php artisan migrate --force && \
             php artisan config:cache && \
             php artisan route:cache && \
             php artisan view:cache && \
             php artisan up && \
             php artisan queue:restart"

La pipeline è strutturata in fasi sequenziali con dipendenze esplicite. L'analisi statica viene eseguita per prima perché è la più veloce. I test unitari e funzionali corrono in parallelo tra loro ma dopo l'analisi statica. Il gate di copertura blocca il deploy se la qualità del codice non raggiunge la soglia. Infine, il deploy avviene solo se tutti i controlli sono superati.

Parallelizzazione dei test in Laravel

Laravel supporta nativamente l'esecuzione parallela dei test tramite il pacchetto brianium/paratest. Ogni processo parallelo riceve il proprio database SQLite in memoria, garantendo l'isolamento.

<?php
// tests/TestCase.php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    /**
     * Configurazione condivisa da tutti i test.
     * Il trait CreatesApplication è incluso automaticamente da Laravel.
     */
    protected function setUp(): void
    {
        parent::setUp();

        // Disabilita la gestione delle eccezioni per vedere gli errori reali nei test
        // Utile in sviluppo; in CI potrebbe essere condizionale
        if (app()->environment('testing')) {
            $this->withoutExceptionHandling();
        }
    }
}

Per abilitare la parallelizzazione, il comando nella pipeline diventa:

# Esecuzione parallela con 4 processi e output dettagliato
php artisan test --parallel --processes=4 --log-junit=test-results.xml

Il file JUnit prodotto può essere letto da GitHub Actions o da altri strumenti CI per mostrare i risultati dei test direttamente nella pull request.

Test di database e migration in CI/CD

Un aspetto critico spesso trascurato è la verifica che le migration funzionino correttamente da zero. In CI/CD, ogni esecuzione parte da un database vuoto, il che costituisce un test implicito delle migration. Tuttavia, è buona pratica aggiungere test espliciti per le migration più complesse.

<?php
// tests/Feature/MigrationIntegrityTest.php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

class MigrationIntegrityTest extends TestCase
{
    use RefreshDatabase;

    public function test_all_migrations_run_without_errors(): void
    {
        // RefreshDatabase ha già eseguito le migration.
        // Verifica che le tabelle principali esistano.
        $this->assertTrue(Schema::hasTable('users'));
        $this->assertTrue(Schema::hasTable('orders'));
        $this->assertTrue(Schema::hasTable('order_items'));
        $this->assertTrue(Schema::hasTable('products'));
    }

    public function test_migrations_can_rollback_completely(): void
    {
        // Verifica che il rollback completo non generi errori
        Artisan::call('migrate:rollback', ['--step' => 100]);

        // Dopo il rollback totale, le tabelle applicative non devono esistere
        $this->assertFalse(Schema::hasTable('orders'));
        $this->assertFalse(Schema::hasTable('order_items'));
    }

    public function test_migrations_are_idempotent(): void
    {
        // Esegui migrate:fresh per verificare che il ciclo completo funzioni
        Artisan::call('migrate:fresh');

        $this->assertTrue(Schema::hasTable('users'));
        $this->assertTrue(Schema::hasTable('orders'));
    }
}

Test delle API con dataset complessi

Per testare scenari realistici, le Factory di Laravel permettono di costruire grafi di dati complessi in modo leggibile e manutenibile.

<?php
// database/factories/OrderFactory.php

namespace Database\Factories;

use App\Models\Order;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class OrderFactory extends Factory
{
    protected $model = Order::class;

    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'status'  => 'pending',
            'total'   => $this->faker->numberBetween(1000, 50000),
        ];
    }

    /** Stato: ordine già pagato */
    public function paid(): static
    {
        return $this->state(fn (array $attrs) => [
            'status' => 'paid',
        ]);
    }

    /** Stato: ordine annullato */
    public function cancelled(): static
    {
        return $this->state(fn (array $attrs) => [
            'status' => 'cancelled',
        ]);
    }

    /** Stato: ordine con un numero specifico di elementi */
    public function withItems(int $count = 3): static
    {
        return $this->has(
            \App\Models\OrderItem::factory()->count($count),
            'items'
        );
    }
}
<?php
// tests/Feature/OrderListingTest.php

namespace Tests\Feature;

use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class OrderListingTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_sees_only_own_orders(): void
    {
        // Prepara: due utenti con ordini diversi
        $alice = User::factory()->create();
        $bob   = User::factory()->create();

        Order::factory()->count(3)->for($alice)->create();
        Order::factory()->count(5)->for($bob)->create();

        // Agisci: Alice richiede i propri ordini
        $response = $this->actingAs($alice)->getJson('/api/orders');

        // Verifica: Alice vede solo i propri 3 ordini
        $response->assertStatus(200);
        $response->assertJsonCount(3, 'data');
    }

    public function test_orders_are_sorted_by_most_recent(): void
    {
        $user = User::factory()->create();

        // Crea ordini con date specifiche per verificare l'ordinamento
        $old    = Order::factory()->for($user)->create(['created_at' => now()->subDays(5)]);
        $recent = Order::factory()->for($user)->create(['created_at' => now()]);
        $middle = Order::factory()->for($user)->create(['created_at' => now()->subDays(2)]);

        $response = $this->actingAs($user)->getJson('/api/orders');

        // L'ordine più recente deve apparire per primo
        $ids = collect($response->json('data'))->pluck('id')->toArray();
        $this->assertEquals([$recent->id, $middle->id, $old->id], $ids);
    }

    public function test_orders_can_be_filtered_by_status(): void
    {
        $user = User::factory()->create();

        Order::factory()->count(2)->for($user)->paid()->create();
        Order::factory()->count(3)->for($user)->create(); // stato: pending

        // Filtra solo gli ordini pagati
        $response = $this->actingAs($user)->getJson('/api/orders?status=paid');

        $response->assertStatus(200);
        $response->assertJsonCount(2, 'data');
    }
}

Gestione dei test lenti e categorizzazione

In una pipeline CI/CD, il tempo è denaro. I test lenti (integrazione con servizi reali, test del browser) vanno separati e eseguiti con una frequenza diversa rispetto ai test veloci.

<?php
// tests/Integration/ExternalApiTest.php

namespace Tests\Integration;

use PHPUnit\Framework\Attributes\Group;
use Tests\TestCase;

// Questo test verrà eseguito solo quando richiesto esplicitamente
#[Group('slow')]
#[Group('integration')]
class ExternalApiTest extends TestCase
{
    public function test_real_api_responds_with_expected_format(): void
    {
        // Questo test chiama un'API reale: da eseguire solo in CI notturno
        // o prima di un rilascio in produzione
        $this->markTestSkipped(
            'Test di integrazione reale: eseguire solo con --group=integration'
        );
    }
}

Nella pipeline, la distinzione si traduce in comandi diversi:

# Pipeline veloce: eseguita su ogni push (esclude i test lenti)
php artisan test --exclude-group=slow,integration

# Pipeline notturna: esegue tutti i test inclusi quelli lenti
php artisan test --group=slow,integration

Coverage e quality gate

La copertura del codice, da sola, non garantisce qualità. Un test che copre una riga senza verificarne il comportamento è inutile. Tuttavia, la copertura è un indicatore utile quando combinata con altre metriche. La configurazione PHPUnit per generare report di copertura è la seguente:

# Genera report in formato Clover (leggibile da strumenti CI)
# e in formato HTML (navigabile per analisi locale)
XDEBUG_MODE=coverage php artisan test \
    --coverage-clover=build/coverage.xml \
    --coverage-html=build/coverage-html \
    --min=80

Il flag --min=80 fa fallire il comando se la copertura scende sotto l'80%. Nella pipeline, questo blocca automaticamente la pull request, impedendo che codice scarsamente testato raggiunga il branch principale.

Hook pre-commit per il feedback immediato

L'integrazione del TDD nella CI/CD non inizia con la pipeline remota, ma sulla macchina dello sviluppatore. Un hook pre-commit che esegue i test unitari fornisce feedback immediato, prima ancora che il codice raggiunga il repository.

#!/bin/sh
# .git/hooks/pre-commit

echo "Esecuzione dei test unitari prima del commit..."

# Esegui solo i test unitari: sono veloci e non richiedono servizi
php artisan test --testsuite=Unit --stop-on-failure --quiet

if [ $? -ne 0 ]; then
    echo ""
    echo "I test unitari sono falliti. Il commit è stato bloccato."
    echo "Esegui 'php artisan test --testsuite=Unit' per i dettagli."
    exit 1
fi

# Controlla lo stile del codice
vendor/bin/pint --test --quiet

if [ $? -ne 0 ]; then
    echo ""
    echo "Lo stile del codice non è conforme. Esegui 'vendor/bin/pint' per correggere."
    exit 1
fi

echo "Tutti i controlli superati."
exit 0

Strategia di branching e test

In un flusso CI/CD maturo, la strategia di branching si allinea con la strategia di test. Il modello tipico prevede che ogni feature branch richieda il superamento completo della pipeline prima di poter essere fuso nel branch di sviluppo. Il branch develop attiva un deploy automatico su staging. Il branch main attiva il deploy in produzione, spesso con un gate di approvazione manuale.

Il TDD si inserisce in questo flusso in modo naturale: ogni feature viene sviluppata scrivendo prima i test nel feature branch. La pull request mostra chiaramente, attraverso i test, quale comportamento viene aggiunto o modificato. Il reviewer può valutare non solo il codice, ma anche la completezza e la chiarezza dei test. La pipeline CI esegue tutti i test automaticamente, e il merge è possibile solo se tutto è verde.

Gestione dei test in ambienti multipli

In scenari reali, l'applicazione potrebbe dover funzionare con diverse versioni di PHP o diversi database. La matrice di GitHub Actions permette di testare tutte le combinazioni.

# Frammento di .github/workflows/ci.yml per test su matrice
jobs:
  matrix-tests:
    name: PHP ${{ matrix.php }} - ${{ matrix.db }}
    runs-on: ubuntu-latest

    # Testa tutte le combinazioni di PHP e database
    strategy:
      fail-fast: false
      matrix:
        php: ['8.2', '8.3', '8.4']
        db: ['sqlite', 'mysql']
        include:
          # Configurazioni specifiche per MySQL
          - db: mysql
            db_host: 127.0.0.1
            db_port: 3306
            db_database: testing
            db_username: root
            db_password: secret

    services:
      mysql:
        image: ${{ matrix.db == 'mysql' && 'mysql:8.0' || '' }}
        env:
          MYSQL_ROOT_PASSWORD: secret
          MYSQL_DATABASE: testing
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: pdo_sqlite, pdo_mysql

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist

      - name: Prepare environment
        run: |
          cp .env.testing .env
          php artisan key:generate

      # Sovrascrive la connessione database in base alla matrice
      - name: Configure database
        run: |
          if [ "${{ matrix.db }}" == "mysql" ]; then
            echo "DB_CONNECTION=mysql" >> .env
            echo "DB_HOST=${{ matrix.db_host }}" >> .env
            echo "DB_PORT=${{ matrix.db_port }}" >> .env
            echo "DB_DATABASE=${{ matrix.db_database }}" >> .env
            echo "DB_USERNAME=${{ matrix.db_username }}" >> .env
            echo "DB_PASSWORD=${{ matrix.db_password }}" >> .env
          fi

      - name: Run tests
        run: php artisan test --parallel

Quando il TDD cambia il modo di pensare al deploy

La conseguenza più profonda del TDD nel contesto CI/CD non è tecnica, ma culturale. Quando ogni comportamento è definito da un test prima di essere implementato, il deploy smette di essere un evento rischioso e diventa una procedura ordinaria. La pipeline CI/CD, alimentata da test scritti con disciplina TDD, diventa una macchina che trasforma ogni commit in un potenziale rilascio, filtrato automaticamente attraverso strati di verifica.

Non si tratta di raggiungere il 100% di copertura del codice. Si tratta di avere la certezza che ogni singolo comportamento critico del sistema è protetto da un test che è stato scritto prima dell'implementazione, che è stato eseguito migliaia di volte nella pipeline, e che fallirà rumorosamente nel momento esatto in cui qualcosa si rompe.

In un team che pratica TDD in modo coerente, la domanda non è mai "possiamo fare il deploy oggi?". La risposta è sempre nella pipeline: se è verde, si rilascia. Se è rossa, si corregge. Non c'è spazio per l'ansia del venerdì pomeriggio.