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.