In un processo di sviluppo moderno, nessuna modifica dovrebbe raggiungere l'ambiente di produzione senza essere passata attraverso una pipeline di test automatizzati. Con GitLab CI/CD possiamo definire queste verifiche in modo dichiarativo nel file .gitlab-ci.yml, garantendo che ogni commit e ogni merge request rispettino standard di qualità e stabilità.
Obiettivi dei test nella pipeline
- Individuare i bug il prima possibile: fallire velocemente su commit difettosi.
- Proteggere l'ambiente di produzione: evitare regressioni e downtime.
- Standardizzare il processo di verifica: tutti gli sviluppatori passano dagli stessi controlli.
- Tracciare la qualità nel tempo: metriche ripetibili e storicizzate.
Struttura tipica di una pipeline GitLab
Una pipeline GitLab è composta da stages e jobs. Gli stages rappresentano le fasi logiche (ad esempio: lint, test, build, deploy), mentre i jobs sono i singoli passi eseguiti in ciascuna fase.
stages:
- lint
- test
- build
- deploy
Per testare il codice prima del deploy in produzione, è fondamentale che gli stages di test siano obbligatori e che lo stage di deploy venga eseguito solo se i precedenti hanno avuto successo.
Tipologie di test nella pipeline
1. Linting e formattazione
Questa è la prima linea di difesa: controlla stile, sintassi e pattern sospetti.
- Linting: ESLint per JavaScript/TypeScript, Pylint o Flake8 per Python, RuboCop per Ruby, ecc.
- Formattazione automatica: Prettier, Black, gofmt e altri.
lint:
stage: lint
image: node:22
script:
- npm ci
- npm run lint
only:
- merge_requests
- main
2. Test unitari
I test unitari verificano il comportamento delle singole unità di codice (funzioni, classi, componenti). Sono veloci da eseguire e dovrebbero coprire la maggior parte dei casi.
unit_tests:
stage: test
image: node:22
script:
- npm ci
- npm test -- --ci --reporters=junit
artifacts:
when: always
paths:
- junit.xml
reports:
junit: junit.xml
only:
- merge_requests
- main
3. Test di integrazione
Verificano l'interazione tra componenti diversi: ad esempio backend e database, oppure servizi esterni mockati. In genere sono più lenti dei test unitari e potrebbero richiedere servizi ausiliari (database, code, ecc.).
integration_tests:
stage: test
image: python:3.12
services:
- name: postgres:16
alias: db
variables:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
script:
- pip install -r requirements.txt
- pytest tests/integration --junitxml=integration.xml
artifacts:
when: always
reports:
junit: integration.xml
4. Test end-to-end (E2E)
I test E2E simulano i flussi reali dell'utente. Spesso vengono eseguiti contro un ambiente di staging o un'istanza effimera della nostra applicazione.
e2e_tests:
stage: test
image: cypress/included:13.8.1
script:
- npm ci
- npm run start:test &
- npx cypress run
artifacts:
when: always
paths:
- cypress/videos
- cypress/screenshots
5. Test di sicurezza e qualità del codice
È una buona pratica integrare scansioni di sicurezza e analisi statica del codice nella pipeline:
- Dependency scanning (vulnerabilità nelle librerie).
- SAST (Static Application Security Testing).
- Controllo licenze.
sast_scan:
stage: test
image: registry.gitlab.com/gitlab-org/security-products/sast:latest
script:
- /analyzer run
artifacts:
reports:
sast: gl-sast-report.json
Controllare il flusso fino al deploy
Per garantire che il deploy in produzione avvenga solo se tutti i test sono passati, la pipeline deve essere configurata in modo che lo stage di deploy dipenda implicitamente dal successo degli stage precedenti.
deploy_production:
stage: deploy
image: alpine:3.20
script:
- ./scripts/deploy.sh production
environment:
name: production
url: https://example.com
only:
- main
when: manual
allow_failure: false
Alcuni punti chiave:
- when: manual permette un deploy controllato (ad esempio solo da un responsabile tecnico).
- allow_failure: false impedisce di ignorare eventuali errori nel job di deploy.
- Se uno qualunque dei job negli stage precedenti fallisce, lo stage di deploy non verrà nemmeno eseguito.
Pipeline per merge request vs pipeline su branch principale
Una strategia comune è differenziare tra:
- Pipeline per le merge request: eseguono tutti i controlli necessari a decidere se la MR può essere integrata.
- Pipeline sulla branch principale (es. main): normalmente includono anche i job di build, packaging e deploy verso ambienti di staging/produzione.
workflow:
rules:
- if: $CI_MERGE_REQUEST_IID
when: always
- if: $CI_COMMIT_BRANCH == "main"
when: always
- when: never
In combinazione con le regole only o rules sui singoli job, è possibile eseguire, ad esempio, i test E2E solo sulle merge request critiche o solo quando cambia una certa directory.
Ottimizzazione dei test nella pipeline
1. Cache e artefatti
Per ridurre i tempi di esecuzione, è possibile usare la cache dei pacchetti e degli asset generati:
cache:
key: "$CI_PROJECT_ID-$CI_COMMIT_REF_SLUG"
paths:
- node_modules/
- .m2/repository
Gli artefatti permettono di condividere risultati tra job (ad esempio report di test e build):
build_app:
stage: build
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
2. Parallelizzazione
I job nello stesso stage vengono eseguiti in parallelo, se ci sono runner sufficienti. Possiamo dividere test unitari o E2E in più job per accelerare.
unit_tests_part1:
stage: test
script:
- npm test -- --runInBand tests/unit/a*.spec.js
unit_tests_part2:
stage: test
script:
- npm test -- --runInBand tests/unit/b*.spec.js
3. Selezione mirata dei test
Con le regole di GitLab è possibile eseguire solo determinati job in base ai file modificati, riducendo il carico complessivo.
backend_tests:
stage: test
script:
- pytest
rules:
- changes:
- backend/**
Integrazione con quality gate e regole di protezione
La pipeline di test diventa realmente efficace quando è integrata con le regole di protezione dei branch e delle merge request:
- Richiedere che la pipeline sia verde prima di poter eseguire il merge.
- Richiedere un certo numero di approvazioni (ad esempio da parte di un reviewer o di un team di QA).
- Impedire push diretti sulla branch di produzione (ad esempio
mainomaster), consentendo solo merge tramite MR.
In questo modo i test non sono solo un suggerimento, ma un requisito tecnico per far avanzare il codice lungo la pipeline.
Ambienti di staging ed ambienti effimeri
Per test più realistici, è consigliabile introdurre un ambiente di staging o ambienti effimeri (review app) dove eseguire i test E2E e le verifiche manuali:
- Staging: un ambiente stabile, il più simile possibile alla produzione.
- Review app: un'istanza creata dinamicamente per ogni merge request.
review_app:
stage: deploy
script:
- ./scripts/deploy_review.sh
environment:
name: review/$CI_COMMIT_REF_NAME
url: https://$CI_COMMIT_REF_SLUG.review.example.com
on_stop: stop_review_app
stop_review_app:
stage: deploy
script:
- ./scripts/stop_review.sh
environment:
name: review/$CI_COMMIT_REF_NAME
action: stop
when: manual
Questa strategia permette ai tester e agli stakeholder di validare il comportamento reale dell'applicazione prima che il codice raggiunga la produzione.
Monitoraggio e feedback continuo
Una pipeline di test efficace non si limita a dire "successo" o "fallito". Dovrebbe fornire feedback ricco e facilmente consultabile:
- Report di test con dettagli su casi falliti e tempi di esecuzione.
- Storico dei tempi di pipeline per individuare lentezze crescenti.
- Report di copertura del codice integrati con la UI di GitLab.
coverage_tests:
stage: test
script:
- pytest --cov=app --cov-report=xml
artifacts:
reports:
cobertura: coverage.xml
Buone pratiche riassuntive
- Mantenere i job di test il più veloci possibile, specialmente quelli eseguiti su ogni commit.
- Fallire velocemente: evitare di eseguire step costosi se lint e test unitari non sono passati.
- Automatizzare il più possibile: meno dipendenza da verifiche manuali ripetitive.
- Versionare la configurazione della pipeline insieme al codice.
- Rivedere periodicamente i test per eliminare quelli ridondanti o non più utili.
Con una pipeline di test ben progettata in GitLab, il deploy in produzione diventa un'operazione prevedibile e poco rischiosa. Gli sviluppatori possono concentrarsi sulle funzionalità, sapendo che ogni modifica verrà verificata automaticamente prima di raggiungere gli utenti finali.