Strategie di testing in ambiente Cloud
Spostare un'applicazione nel cloud cambia molte cose, e il modo in cui la si testa è una di quelle che si sottovalutano più spesso. Un'applicazione monolitica che gira su un server e parla con un database locale si presta bene ai test tradizionali: si avvia tutto su una macchina di sviluppo e si verifica il comportamento. Un'applicazione cloud, invece, è fatta di servizi distribuiti che comunicano in rete, si appoggia a servizi gestiti dal provider come code di messaggi, storage a oggetti e database serverless, viene descritta da codice di infrastruttura e gira su risorse effimere che nascono e muoiono di continuo. Molti dei guasti più gravi non nascono da un errore nella logica di una funzione, ma dall'interazione tra componenti, da una configurazione sbagliata, da un permesso mancante o da un servizio esterno che risponde più lentamente del previsto.
In questo articolo vediamo come costruire una strategia di testing adatta a questo contesto: quali livelli di test servono, come isolare il codice dai servizi del provider, come eseguire test di integrazione realistici senza toccare l'account di produzione, come verificare l'infrastruttura, come sfruttare ambienti effimeri e come spingere la verifica fino all'ambiente di produzione in modo controllato. Gli esempi di codice sono in Go, ma i principi valgono per qualsiasi linguaggio.
Perché il cloud cambia il modo di testare
Le differenze principali rispetto a un ambiente tradizionale sono quattro.
- Distribuzione: i componenti comunicano in rete, e la rete introduce latenza, timeout, errori parziali e consegne duplicate. Il codice deve essere testato anche per questi casi, non solo per il percorso felice.
- Servizi gestiti: buona parte della logica dipende da API del provider che non possiamo eseguire in locale nella loro forma reale. Dobbiamo decidere dove usare simulazioni, dove usare emulatori e dove usare il servizio vero.
- Infrastruttura come codice: reti, permessi, bucket e database sono descritti in file di configurazione che vengono modificati con la stessa frequenza del codice applicativo. Anche quei file possono contenere errori e vanno verificati.
- Costo e tempo: ogni test eseguito sul cloud reale costa denaro e richiede minuti per creare e distruggere le risorse. Una strategia che si basa solo su test di questo tipo diventa lenta e cara.
La conseguenza è che non esiste un unico tipo di test sufficiente. Serve una combinazione di livelli, ciascuno con un compito preciso e un costo proporzionato.
I livelli della strategia
La classica piramide dei test prevede una base ampia di test unitari, uno strato intermedio di test di integrazione e una punta sottile di test end-to-end. Nei sistemi a microservizi molti team preferiscono una forma diversa, spesso chiamata honeycomb, in cui il peso principale si sposta sui test di integrazione: un microservizio contiene in genere poca logica complessa e molta interazione con database, code e altri servizi, e sono proprio queste interazioni a rompersi. Qualunque forma si scelga, i livelli da coprire sono questi:
- Test unitari sulla logica di dominio, isolata dai servizi esterni.
- Test di integrazione contro dipendenze reali o emulate, eseguite in container.
- Test di contratto tra servizi che comunicano tramite API o messaggi.
- Test dell'infrastruttura sul codice che descrive le risorse cloud.
- Test end-to-end e smoke test su un ambiente completo, effimero o di staging.
- Test di resilienza e di carico, per verificare il comportamento in condizioni avverse.
- Verifiche in produzione, tramite rilasci graduali, monitoraggio sintetico e osservabilità.
Vediamoli uno per uno.
Test unitari: isolare il codice dal provider
L'errore più comune nei progetti cloud è scrivere la logica applicativa chiamando direttamente l'SDK del provider. Una funzione che costruisce il percorso di un file, applica una regola di business e poi chiama PutObject sul client S3 non può essere testata senza un bucket, reale o emulato. La soluzione è introdurre un'interfaccia che descrive ciò di cui il dominio ha bisogno, non ciò che il provider offre.
package storage
import (
"context"
"io"
)
// ObjectStore descrive le operazioni di storage richieste dal dominio,
// indipendentemente dal provider che le implementa.
type ObjectStore interface {
Put(ctx context.Context, key string, body io.Reader) error
Get(ctx context.Context, key string) (io.ReadCloser, error)
}
Il servizio di dominio riceve l'interfaccia tramite il costruttore. Anche l'orologio viene iniettato come funzione: è una dipendenza esterna come le altre e, se lasciata implicita, renderebbe il test dipendente dalla data di esecuzione.
package reports
import (
"bytes"
"context"
"errors"
"fmt"
"time"
"example.com/app/storage"
)
var ErrEmptyName = errors.New("report name is required")
type ReportService struct {
store storage.ObjectStore
now func() time.Time
}
func NewReportService(store storage.ObjectStore, now func() time.Time) *ReportService {
return &ReportService{store: store, now: now}
}
// Archive salva il report in un percorso partizionato per data e restituisce la chiave.
func (s *ReportService) Archive(ctx context.Context, name string, content []byte) (string, error) {
if name == "" {
return "", ErrEmptyName
}
key := fmt.Sprintf("reports/%s/%s.json", s.now().UTC().Format("2006-01-02"), name)
if err := s.store.Put(ctx, key, bytes.NewReader(content)); err != nil {
return "", fmt.Errorf("archive report %q: %w", name, err)
}
return key, nil
}
Nel test usiamo un'implementazione in memoria dell'interfaccia, più un'implementazione che fallisce sempre per verificare la propagazione degli errori. Non serve alcuna libreria di mocking: per interfacce piccole un fake scritto a mano è più leggibile e più robusto.
package reports
import (
"bytes"
"context"
"errors"
"io"
"sync"
"testing"
"time"
)
// memoryStore è un fake in memoria dell'interfaccia ObjectStore.
type memoryStore struct {
mu sync.Mutex
objects map[string][]byte
}
func newMemoryStore() *memoryStore {
return &memoryStore{objects: make(map[string][]byte)}
}
func (m *memoryStore) Put(_ context.Context, key string, body io.Reader) error {
data, err := io.ReadAll(body)
if err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
m.objects[key] = data
return nil
}
func (m *memoryStore) Get(_ context.Context, key string) (io.ReadCloser, error) {
m.mu.Lock()
defer m.mu.Unlock()
data, ok := m.objects[key]
if !ok {
return nil, errors.New("not found")
}
return io.NopCloser(bytes.NewReader(data)), nil
}
// failingStore simula un servizio di storage non disponibile.
type failingStore struct{}
var errUnavailable = errors.New("service unavailable")
func (failingStore) Put(context.Context, string, io.Reader) error { return errUnavailable }
func (failingStore) Get(context.Context, string) (io.ReadCloser, error) {
return nil, errUnavailable
}
func fixedClock() time.Time {
return time.Date(2026, 10, 9, 23, 30, 0, 0, time.FixedZone("CEST", 2*60*60))
}
func TestArchiveStoresReportUnderDatePartition(t *testing.T) {
store := newMemoryStore()
service := NewReportService(store, fixedClock)
key, err := service.Archive(context.Background(), "sales", []byte(`{"total": 42}`))
if err != nil {
t.Fatalf("errore inatteso: %v", err)
}
// La data va calcolata in UTC: alle 23:30 CEST è ancora il 9 ottobre in UTC
want := "reports/2026-10-09/sales.json"
if key != want {
t.Fatalf("chiave = %q, attesa %q", key, want)
}
if got := string(store.objects[key]); got != `{"total": 42}` {
t.Fatalf("contenuto salvato = %q", got)
}
}
func TestArchiveRejectsEmptyName(t *testing.T) {
service := NewReportService(newMemoryStore(), fixedClock)
_, err := service.Archive(context.Background(), "", nil)
if !errors.Is(err, ErrEmptyName) {
t.Fatalf("errore = %v, atteso ErrEmptyName", err)
}
}
func TestArchiveWrapsStorageErrors(t *testing.T) {
service := NewReportService(failingStore{}, fixedClock)
_, err := service.Archive(context.Background(), "sales", []byte("{}"))
if !errors.Is(err, errUnavailable) {
t.Fatalf("l'errore originale non è stato propagato: %v", err)
}
}
Questi test si eseguono in pochi millisecondi, non richiedono credenziali né rete e coprono la logica che ci interessa: il formato della chiave, la gestione del fuso orario, la validazione dell'input e la propagazione degli errori. L'implementazione reale dell'interfaccia, quella che usa l'SDK, resta sottile e senza logica, e verrà verificata dai test di integrazione.
Test di integrazione con i container
Il fake in memoria ci dice che la logica è corretta, ma non che il codice parla davvero con il servizio di storage nel modo giusto. Serve un test che usi un'implementazione reale del protocollo. La libreria Testcontainers permette di avviare dal codice di test un container Docker con la dipendenza necessaria, attendere che sia pronto, eseguire il test e distruggerlo alla fine. Per lo storage a oggetti compatibile con S3 possiamo usare MinIO; per un database PostgreSQL, l'immagine ufficiale di Postgres; per code e altri servizi esistono moduli dedicati.
Ecco l'implementazione dell'interfaccia basata sull'SDK AWS per Go. È volutamente priva di logica: traduce soltanto le chiamate.
package storage
import (
"context"
"io"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
type S3Store struct {
client *s3.Client
bucket string
}
func NewS3Store(client *s3.Client, bucket string) *S3Store {
return &S3Store{client: client, bucket: bucket}
}
func (s *S3Store) Put(ctx context.Context, key string, body io.Reader) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: body,
})
return err
}
func (s *S3Store) Get(ctx context.Context, key string) (io.ReadCloser, error) {
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, err
}
return out.Body, nil
}
Il test di integrazione avvia MinIO, crea un client S3 che punta all'endpoint del container, crea un bucket e verifica un ciclo completo di scrittura e lettura. Il vincolo di build integration lo esclude dalla normale esecuzione di go test ./...: i test di integrazione sono più lenti e richiedono Docker, quindi li lanciamo esplicitamente.
//go:build integration
package storage
import (
"context"
"io"
"strings"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/minio"
)
const (
testUser = "testuser"
testPassword = "testpassword"
testBucket = "reports"
)
func startObjectStore(t *testing.T) *s3.Client {
t.Helper()
ctx := context.Background()
// Fissare il tag dell'immagine rende il test riproducibile nel tempo
container, err := minio.Run(ctx, "minio/minio:RELEASE.2024-01-16T16-07-38Z",
minio.WithUsername(testUser),
minio.WithPassword(testPassword),
)
testcontainers.CleanupContainer(t, container)
if err != nil {
t.Fatalf("avvio di MinIO fallito: %v", err)
}
endpoint, err := container.ConnectionString(ctx)
if err != nil {
t.Fatalf("endpoint non disponibile: %v", err)
}
client := s3.New(s3.Options{
Region: "us-east-1",
BaseEndpoint: aws.String("http://" + endpoint),
UsePathStyle: true,
Credentials: credentials.NewStaticCredentialsProvider(testUser, testPassword, ""),
})
if _, err := client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(testBucket)}); err != nil {
t.Fatalf("creazione del bucket fallita: %v", err)
}
return client
}
func TestS3StoreRoundTrip(t *testing.T) {
client := startObjectStore(t)
store := NewS3Store(client, testBucket)
ctx := context.Background()
if err := store.Put(ctx, "reports/2026-10-09/sales.json", strings.NewReader(`{"total": 42}`)); err != nil {
t.Fatalf("Put fallita: %v", err)
}
body, err := store.Get(ctx, "reports/2026-10-09/sales.json")
if err != nil {
t.Fatalf("Get fallita: %v", err)
}
defer body.Close()
data, err := io.ReadAll(body)
if err != nil {
t.Fatalf("lettura fallita: %v", err)
}
if string(data) != `{"total": 42}` {
t.Fatalf("contenuto letto = %q", data)
}
}
func TestS3StoreGetMissingKey(t *testing.T) {
client := startObjectStore(t)
store := NewS3Store(client, testBucket)
if _, err := store.Get(context.Background(), "missing.json"); err == nil {
t.Fatal("era atteso un errore per una chiave inesistente")
}
}
# Test unitari: veloci, senza dipendenze
go test ./...
# Test di integrazione: richiedono Docker
go test -tags=integration ./...
Un aspetto da tenere presente è la differenza tra emulatore e servizio reale. MinIO implementa fedelmente l'API S3 per le operazioni comuni, ma non riproduce le policy IAM, le notifiche verso altri servizi, le classi di storage o i limiti di throughput di AWS. Lo stesso vale per gli emulatori multi-servizio come LocalStack, che simulano un ampio ventaglio di API AWS in un unico container: sono preziosi per testare il codice applicativo, ma non garantiscono che la configurazione reale dell'account sia corretta. Per questo motivo i test con emulatori non sostituiscono i test su un ambiente cloud reale, li rendono semplicemente meno frequenti e più mirati.
Test di contratto tra servizi
In un sistema a microservizi ogni servizio può essere testato in isolamento in modo impeccabile e il sistema può comunque rompersi, perché il produttore di un'API ha rinominato un campo da cui un consumatore dipende. I test end-to-end scoprirebbero il problema, ma sono lenti, fragili e richiedono di avviare tutto il sistema. I test di contratto affrontano il problema in un altro modo.
Nell'approccio consumer-driven, ogni consumatore descrive in un contratto le richieste che invia e la forma minima delle risposte di cui ha bisogno. Il contratto viene generato dai test del consumatore, pubblicato in un repository condiviso (con strumenti come Pact e il relativo broker) e poi verificato nella pipeline del produttore, che riproduce le richieste contro la propria implementazione. Se il produttore introduce una modifica che rompe un contratto, la sua pipeline fallisce prima del rilascio, e soprattutto sa esattamente quale consumatore ne sarebbe colpito.
Anche senza adottare uno strumento dedicato si può ottenere buona parte del beneficio con disciplina: descrivere le API con OpenAPI e i messaggi asincroni con uno schema versionato, e validare nella pipeline che ogni modifica allo schema sia retrocompatibile. Il principio da rispettare è che un produttore può aggiungere campi, ma non rimuoverli o cambiarne il tipo senza una nuova versione dell'API.
Testare l'infrastruttura come codice
Quando l'infrastruttura è descritta con Terraform, OpenTofu, CloudFormation o Pulumi, gli errori di configurazione diventano errori nel codice, e come tali si possono intercettare prima del rilascio. Le verifiche si dispongono anche qui su livelli di costo crescente.
- Formattazione e validazione:
terraform fmt -checketerraform validatecontrollano la sintassi e la coerenza dei riferimenti senza contattare il provider. - Analisi statica e policy: strumenti come Checkov, Trivy o tflint individuano configurazioni rischiose, ad esempio bucket pubblici, gruppi di sicurezza aperti a tutta Internet, crittografia disabilitata o risorse senza tag obbligatori.
- Test sul piano: si genera il piano di esecuzione e si verificano asserzioni sulle risorse che verrebbero create, senza crearle davvero.
- Test con risorse reali: si applica la configurazione in un account di test, si verificano le risorse create e poi le si distrugge.
Terraform include un framework di test nativo. I file con estensione .tftest.hcl contengono blocchi run che eseguono un plan o un apply e verificano asserzioni sul risultato.
# tests/storage.tftest.hcl
variables {
environment = "test"
project = "acme-reports"
}
run "bucket_name_follows_convention" {
command = plan
assert {
condition = startswith(aws_s3_bucket.reports.bucket, "acme-reports-test-")
error_message = "Il nome del bucket non rispetta la convenzione progetto-ambiente."
}
}
run "versioning_is_enabled" {
command = plan
assert {
condition = aws_s3_bucket_versioning.reports.versioning_configuration[0].status == "Enabled"
error_message = "Il versioning del bucket dei report deve essere attivo."
}
}
run "public_access_is_blocked" {
command = plan
assert {
condition = aws_s3_bucket_public_access_block.reports.block_public_acls && aws_s3_bucket_public_access_block.reports.restrict_public_buckets
error_message = "Il bucket dei report non deve essere accessibile pubblicamente."
}
}
Il comando terraform test esegue tutti i file di test della cartella tests. Con command = plan i test sono veloci e non creano risorse; con command = apply Terraform crea davvero le risorse e le distrugge automaticamente alla fine, il che è utile per le verifiche che richiedono valori noti solo dopo la creazione.
Una buona pratica complementare è pubblicare il piano come commento della merge request, in modo che chi revisiona il codice veda esattamente quali risorse verranno create, modificate o distrutte. Un piano che prevede la distruzione e ricreazione di un database di produzione è il tipo di sorpresa che è meglio scoprire durante la revisione.
Ambienti effimeri per ogni merge request
Uno degli strumenti più potenti che il cloud mette a disposizione è la possibilità di creare un ambiente completo, identico a quello di produzione nella struttura, per ogni ramo di sviluppo, e di distruggerlo quando non serve più. Questi ambienti effimeri, chiamati anche review app o preview environment, risolvono il problema storico dell'unico ambiente di staging conteso tra più sviluppatori, sempre in uno stato incerto e con dati che nessuno sa più da dove vengano.
GitLab supporta nativamente questo modello tramite gli ambienti dinamici. L'esempio seguente esegue i test unitari e di integrazione, crea un ambiente dedicato per ogni merge request, esegue gli smoke test su di esso e lo distrugge alla chiusura della merge request o dopo due giorni di inattività.
stages:
- test
- review
- verify
- cleanup
unit_tests:
stage: test
image: golang:1.25
script:
- go test -race ./...
integration_tests:
stage: test
image: golang:1.25
services:
- name: docker:27-dind
alias: docker
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
script:
- go test -tags=integration ./...
infrastructure_tests:
stage: test
image: hashicorp/terraform:1.13
script:
- cd infra
- terraform fmt -check -recursive
- terraform init -backend=false
- terraform validate
- terraform test
deploy_review:
stage: review
script:
- ./scripts/deploy-review.sh "$CI_COMMIT_REF_SLUG"
environment:
name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_COMMIT_REF_SLUG.review.example.com
on_stop: stop_review
auto_stop_in: 2 days
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
smoke_tests:
stage: verify
script:
- ./scripts/smoke-test.sh "https://$CI_COMMIT_REF_SLUG.review.example.com"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
stop_review:
stage: cleanup
script:
- ./scripts/destroy-review.sh "$CI_COMMIT_REF_SLUG"
environment:
name: review/$CI_COMMIT_REF_SLUG
action: stop
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: manual
allow_failure: true
Lo script di deploy può creare un namespace Kubernetes dedicato, uno stack Terraform con un workspace separato o un insieme di risorse identificate da un prefisso comune: la tecnica conta meno del principio. Gli elementi essenziali sono due. Il primo è che ogni risorsa dell'ambiente sia etichettata con il nome del ramo, in modo che lo script di distruzione possa trovarla ed eliminarla senza dimenticare nulla. Il secondo è la scadenza automatica, perché gli ambienti dimenticati sono una delle prime voci di spreco nelle bollette cloud.
Per contenere i costi, gli ambienti effimeri usano in genere dimensionamenti ridotti e condividono le componenti più costose: un unico cluster di database con uno schema per ambiente, invece di un'istanza dedicata per ogni ramo.
Smoke test dopo il rilascio
Uno smoke test è una verifica rapida, eseguita subito dopo un rilascio, che risponde a una sola domanda: l'applicazione è in piedi e le sue funzioni essenziali rispondono? Non sostituisce i test funzionali, ma intercetta la categoria di guasti più frequente nei rilasci cloud: configurazioni mancanti, segreti non accessibili, permessi insufficienti, migrazioni non applicate. Sono errori che nessun test locale può scoprire, perché dipendono dall'ambiente.
#!/usr/bin/env bash
# Smoke test post-rilascio: verifica gli endpoint essenziali con tentativi ripetuti
set -euo pipefail
BASE_URL="${1:?Uso: smoke-test.sh BASE_URL}"
MAX_ATTEMPTS=30
DELAY_SECONDS=5
wait_for_health() {
local attempt=1
until curl --silent --fail --max-time 5 "${BASE_URL}/healthz" > /dev/null; do
if (( attempt >= MAX_ATTEMPTS )); then
echo "L'applicazione non è diventata disponibile dopo ${MAX_ATTEMPTS} tentativi" >&2
exit 1
fi
echo "Tentativo ${attempt}: applicazione non ancora pronta"
attempt=$(( attempt + 1 ))
sleep "${DELAY_SECONDS}"
done
}
check_endpoint() {
local path="$1"
local expected_status="$2"
local status
status=$(curl --silent --output /dev/null --write-out "%{http_code}" --max-time 10 "${BASE_URL}${path}")
if [[ "${status}" != "${expected_status}" ]]; then
echo "FALLITO ${path}: stato ${status}, atteso ${expected_status}" >&2
exit 1
fi
echo "OK ${path} (${status})"
}
wait_for_health
check_endpoint "/readyz" 200
check_endpoint "/api/reports?limit=1" 200
check_endpoint "/api/reports/does-not-exist" 404
echo "Smoke test completati con successo"
È utile distinguere tra i due endpoint di stato. /healthz indica che il processo è vivo; /readyz indica che è pronto a servire traffico, quindi dovrebbe verificare anche la raggiungibilità del database e delle altre dipendenze critiche. Lo smoke test deve controllare entrambi, perché un'applicazione viva ma non pronta è esattamente il caso che vogliamo intercettare.
Testare la resilienza
In un sistema distribuito i guasti parziali non sono un'eccezione ma la normalità: un servizio risponde con un errore temporaneo, una richiesta va in timeout, una connessione viene chiusa a metà. Il codice deve gestire questi casi con timeout espliciti, tentativi ripetuti con attesa crescente e, dove serve, interruttori automatici (circuit breaker). E questo comportamento va testato, perché è proprio il codice che si esegue raramente quello che contiene più errori.
Il primo livello si testa senza alcuna infrastruttura. Ecco una funzione che ripete una richiesta HTTP in caso di errori temporanei, con un'attesa che raddoppia a ogni tentativo.
package client
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
type RetryPolicy struct {
Attempts int
BaseBackoff time.Duration
}
func isRetryable(status int) bool {
return status == http.StatusTooManyRequests || status >= 500
}
// FetchWithRetry ripete la richiesta per gli errori temporanei con backoff esponenziale.
func FetchWithRetry(ctx context.Context, httpClient *http.Client, url string, policy RetryPolicy) ([]byte, error) {
var lastErr error
backoff := policy.BaseBackoff
for attempt := 1; attempt <= policy.Attempts; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err == nil {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode < 300 {
return body, nil
}
if !isRetryable(resp.StatusCode) {
// Gli errori del client non migliorano ripetendo la richiesta
return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
lastErr = fmt.Errorf("retryable status %d", resp.StatusCode)
} else {
lastErr = err
}
if attempt == policy.Attempts {
break
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
backoff *= 2
}
}
return nil, fmt.Errorf("all %d attempts failed: %w", policy.Attempts, lastErr)
}
Il test usa httptest.NewServer per simulare un servizio che fallisce le prime due volte e risponde correttamente alla terza, poi un servizio che fallisce sempre, poi uno che restituisce un errore non ripetibile, e infine uno troppo lento rispetto al timeout del client.
package client
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
var fastPolicy = RetryPolicy{Attempts: 3, BaseBackoff: 10 * time.Millisecond}
func TestFetchRecoversFromTransientErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Le prime due chiamate falliscono con un errore temporaneo
if calls.Add(1) <= 2 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Write([]byte("ok"))
}))
defer server.Close()
body, err := FetchWithRetry(context.Background(), server.Client(), server.URL, fastPolicy)
if err != nil {
t.Fatalf("errore inatteso: %v", err)
}
if string(body) != "ok" || calls.Load() != 3 {
t.Fatalf("body = %q, chiamate = %d", body, calls.Load())
}
}
func TestFetchGivesUpAfterMaxAttempts(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusBadGateway)
}))
defer server.Close()
if _, err := FetchWithRetry(context.Background(), server.Client(), server.URL, fastPolicy); err == nil {
t.Fatal("era atteso un errore")
}
if calls.Load() != 3 {
t.Fatalf("chiamate = %d, attese 3", calls.Load())
}
}
func TestFetchDoesNotRetryClientErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
if _, err := FetchWithRetry(context.Background(), server.Client(), server.URL, fastPolicy); err == nil {
t.Fatal("era atteso un errore")
}
if calls.Load() != 1 {
t.Fatalf("un errore 404 non va ripetuto: chiamate = %d", calls.Load())
}
}
func TestFetchRespectsClientTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Risposta più lenta del timeout configurato sul client
select {
case <-time.After(200 * time.Millisecond):
case <-r.Context().Done():
}
}))
defer server.Close()
httpClient := &http.Client{Timeout: 20 * time.Millisecond}
start := time.Now()
if _, err := FetchWithRetry(context.Background(), httpClient, server.URL, fastPolicy); err == nil {
t.Fatal("era atteso un errore di timeout")
}
if elapsed := time.Since(start); elapsed > 150*time.Millisecond {
t.Fatalf("i timeout non sono stati rispettati: %v", elapsed)
}
}
Il secondo livello è l'iniezione di guasti a livello di rete durante i test di integrazione. Strumenti come Toxiproxy si interpongono tra l'applicazione e una dipendenza e permettono, dal codice di test, di aggiungere latenza, limitare la banda o interrompere la connessione, così da verificare il comportamento reale del driver del database o del client della coda in queste condizioni.
Il terzo livello è la chaos engineering vera e propria: introdurre guasti controllati in un ambiente realistico, come la terminazione di istanze, l'interruzione di una zona di disponibilità o il degrado di un servizio gestito, per verificare che il sistema si comporti come previsto. I principali provider offrono servizi dedicati, come AWS Fault Injection Service e Azure Chaos Studio, e nel mondo Kubernetes esistono progetti come Chaos Mesh e LitmusChaos. La regola è procedere per esperimenti: si formula un'ipotesi ("se un nodo viene terminato, la latenza al 99esimo percentile resta sotto i 500 millisecondi"), si limita l'impatto potenziale e si definiscono in anticipo le condizioni di interruzione dell'esperimento.
Test di carico
L'elasticità del cloud induce a pensare che le prestazioni non siano un problema: se il carico aumenta, si aggiungono istanze. In pratica, la scalabilità automatica ha tempi di reazione, i database gestiti hanno limiti di connessioni, le API del provider hanno quote e un collo di bottiglia condiviso annulla qualsiasi numero di repliche. I test di carico servono a scoprire questi limiti prima degli utenti.
k6 è uno strumento che permette di scrivere gli scenari in JavaScript e di definire soglie che fanno fallire il test, il che lo rende adatto a essere inserito in una pipeline.
import http from 'k6/http';
import { check, sleep } from 'k6';
// Profilo di carico: rampa di salita, plateau e discesa
export const options = {
stages: [
{ duration: '2m', target: 50 },
{ duration: '5m', target: 50 },
{ duration: '2m', target: 200 },
{ duration: '5m', target: 200 },
{ duration: '2m', target: 0 },
],
thresholds: {
// Il test fallisce se il 95% delle richieste supera i 300 ms
http_req_duration: ['p(95)<300'],
// oppure se più dell'1% delle richieste restituisce un errore
http_req_failed: ['rate<0.01'],
},
};
const baseUrl = __ENV.BASE_URL;
export default function () {
const response = http.get(`${baseUrl}/api/reports?limit=20`);
check(response, {
'status is 200': (r) => r.status === 200,
'body is not empty': (r) => r.body && r.body.length > 0,
});
// Pausa che simula il tempo di riflessione di un utente reale
sleep(1);
}
k6 run -e BASE_URL=https://staging.example.com load-test.js
Durante un test di carico non basta guardare i numeri prodotti dallo strumento. Occorre osservare contemporaneamente le metriche del sistema: quante istanze ha aggiunto la scalabilità automatica e in quanto tempo, come si è comportato il pool di connessioni al database, se qualche API del provider ha iniziato a restituire errori di quota. Va anche ricordato che un test di carico sul cloud genera costi reali, e che i test contro servizi di terze parti vanno concordati con chi li gestisce.
Verificare in produzione
Per quanto curata sia la strategia, nessun ambiente di test riproduce esattamente la produzione: il traffico reale, i dati reali e le combinazioni reali di configurazioni restano unici. Le pratiche di verifica in produzione non sostituiscono i test precedenti, ma ne riducono il rischio residuo.
- Rilasci canary: la nuova versione riceve inizialmente una piccola percentuale del traffico. Se gli indicatori di errore e di latenza restano entro le soglie, la percentuale aumenta gradualmente; altrimenti il rilascio viene annullato in automatico.
- Rilasci blue-green: la nuova versione viene avviata accanto a quella attuale e il traffico viene spostato in un colpo solo, con la possibilità di tornare indietro istantaneamente.
- Feature flag: il codice nuovo viene rilasciato disattivato e abilitato progressivamente per gruppi di utenti, separando il rilascio tecnico dall'attivazione funzionale.
- Monitoraggio sintetico: script che eseguono a intervalli regolari i percorsi critici, come login, ricerca e pagamento, da più regioni geografiche, segnalando un problema prima che lo facciano gli utenti.
- Osservabilità: log strutturati, metriche e tracciamento distribuito, ad esempio con OpenTelemetry, permettono di capire cosa è successo quando qualcosa va storto e di verificare gli effetti di un rilascio con dati oggettivi.
Queste pratiche richiedono che il sistema sia progettato per supportarle: endpoint di stato affidabili, metriche con etichette di versione, migrazioni di database compatibili con due versioni dell'applicazione in esecuzione contemporaneamente. Sono scelte architetturali, non semplici aggiunte alla pipeline.
Sicurezza e costi dei test
Due aspetti trasversali meritano attenzione. Il primo è la gestione delle credenziali. Le pipeline che accedono al cloud non dovrebbero usare chiavi di accesso statiche salvate come variabili: tutti i principali provider supportano la federazione OIDC, con cui il sistema di CI ottiene credenziali temporanee legate al singolo job. Gli account o i progetti usati per i test devono essere separati da quelli di produzione, con permessi limitati a ciò che serve davvero.
Il secondo è il controllo dei costi. Ogni risorsa creata dai test deve avere un'etichetta che ne identifica l'origine, ad esempio il nome del ramo e l'identificativo della pipeline, e un processo periodico deve eliminare le risorse orfane rimaste dopo pipeline interrotte. Gli avvisi di budget sull'account di test sono una rete di sicurezza indispensabile: un test di carico dimenticato in esecuzione o un ambiente effimero mai distrutto possono generare in un fine settimana una spesa superiore a quella mensile dell'intero progetto.
Conclusione
Una strategia di testing efficace in ambiente cloud non consiste nello scegliere il tipo di test migliore, ma nel distribuire le verifiche sui livelli dove costano meno e trovano più errori. La logica di dominio va isolata dall'SDK del provider e coperta da test unitari veloci. Le interazioni con database, storage e code vanno verificate con dipendenze reali in container. I contratti tra servizi impediscono che un cambiamento in un'API rompa i consumatori. L'infrastruttura come codice va validata e testata come qualsiasi altro codice. Gli ambienti effimeri permettono di verificare ogni modifica in condizioni realistiche senza contendersi un unico staging. Infine, rilasci graduali, monitoraggio sintetico e osservabilità estendono la verifica fino alla produzione, dove si trovano i problemi che nessun ambiente di test può riprodurre. Il risultato è un sistema in cui ogni modifica arriva in produzione dopo aver superato una serie di controlli proporzionati al rischio, con tempi di feedback brevi per gli errori più comuni e una protezione robusta contro quelli più rari e costosi.