Implementare una suite di test (automatici e manuali) che copra il sistema che si sta sviluppando a vari livelli (unità, integrazione, sistema) e secondo vari criteri (copertura di casi d’uso, partizioni equivalenti, ecc.) è senz’altro un passo fondamentale per poter garantire certi standard di qualità del software.
Tuttavia, è solitamente preferibile approntare ulteriori meccanismi di controllo della qualità, in modo da rilevare eventuali difetti che aggirino i test. Questi meccanismi includono: analisi statica e revisione del codice, log, debugging, e la tecnica descritta in questo articolo, ovvero il “design by contract”.
Cos’è il design by contract
Il design by contract è una tecnica di progettazione del software che consiste nel definire l’interfaccia di ciascun componente (funzione, classe, metodo, ecc.) in termini di:
- Caratteristiche degli input accettabili e non (e.g. se un certo parametro di una funzione può essere
null
); - Caratteristiche degli output possibili (e.g.: se possono essere
null
, se sono numeri inclusi in un certo intervallo, ecc.); - Precondizioni (e.g. per poter chiamare un certo metodo deve prima essere impostata una certa proprietà della classe);
- Postcondizioni (e.g. al termine dell’esecuzione di un certo metodo, dev’essere stato inserito un nuovo record nel database);
- Invarianti, ovvero predicati che rimangono veri sia prima che dopo l’esecuzione del componente (e.g. un utente ha sempre una password di una certa lunghezza sia prima che dopo aver eseguito la funzione di cambio password);
- Effetti collaterali, ovvero modifiche allo stato di altri componenti. La distinzione con le postcondizioni è talvolta sfumata, comunque le postcondizioni sono sempre vere, mentre gli effetti collaterali non necessariamente;
- Condizioni di errore (e.g. se si passa una certa combinazione di input, o le precondizioni non sono rispettate, viene generato un certo errore).
A differenza dei test, i contratti hanno il vantaggio di poter essere verificati sia, almeno in parte, durante la scrittura del codice, sia durante la normale esecuzione del codice (in debug o a volte anche in release). D’altro canto, i test permettono solitamente di verificare condizioni più complesse.
Il linguaggio più famoso che permette nativamente di definire contratti è Eiffel. In tale linguaggio sono presenti delle parole chiave per definire precondizioni, postcondizioni e invarianti, e queste vengono verificate a runtime. Le altre parti del contratto possono invece essere definite nei commenti. Inoltre, esistono dei tool che permettono di generare documentazione e addirittura test a partire da tali definizioni. Purtroppo, non è un linguaggio molto diffuso.
Per quanto riguarda gli altri linguaggi di programmazione, in molti casi sono state sviluppate negli anni delle librerie per introdurre i contratti, verificabili a runtime o, talvolta, già durante la scrittura del codice.
Design by contract in C#
Ai tempi di C# 4 era stata sviluppata una libreria, CodeContracts, che, insieme ad un analizzatore presente in visual studio fino alla versione 2015, permetteva di definire contratti verificabili sia a runtime che (un sottoinsieme) durante la scrittura del codice. C’era inoltre un generatore di documentazione che combinava le informazioni dei contratti con i classici commenti XML. Purtroppo il progetto è stato poi abbandonato e sostanzialmente non è più supportato dalle versioni recenti di visual studio.
Ad oggi, ci sono 3 principali opzioni (eventualmente combinabili tra loro) per implementare i contratti in C#:
Debug.Assert(bool, string)
: consiste nel definire il contratto inserendo delle opportune asserzioni nei punti giusti del metodo. Queste vengono valutate solo quando si esegue il codice con configurazione Debug. Hanno il grosso vantaggio che, non essendo attributi, non ci sono particolari limiti sul tipo di condizioni definibili. D’altro canto, vengono eseguite solo in debug, mentre vengono rimosse dal compilatore in release;- Eccezioni: simili alle asserzioni, ma vengono eseguite anche in release. Tuttavia sono più verbose da utilizzare;
- System.Diagnostics.CodeAnalysis: disponibile solo da C# 8 in poi, permette di definire quando un input / output di un metodo o proprietà è
null
o meno. Vengono controllati in parte già durante la scrittura del codice, in parte a runtime; - Jetbrains.Annotations: disponibile anche per versioni di C# più vecchie, oltre ai controlli di nullabilità permette di definire contratti per input / output booleani e relazioni tra input e output. Inoltre si possono verificare svariate altre condizioni (e.g. che a un parametro o una property vengano assegnati solo i valori corrispondenti a costanti definite in una certa classe). Anche in questo caso i controlli avvengono in parte durante la scrittura e in parte a runtime. Richiede il plugin Resharper per visual studio o l’IDE Rider per l’analisi statica dei contratti, ma il codice compila in ogni caso se si è installata la libreria.
Personalmente, quando lavoro da solo tendo a combinare tutti e quattro gli approcci: in prima battuta cerco di definire il più possibile i contratti con le annotazioni di Jetbrain; poi, per alcune condizioni particolari (e.g. un certo parametro può essere null se una certa proprietà della classe è null) uso gli attributi in System.Diagnostics.CodeAnalysis; infine, per le condizioni che rimangono, uso le asserzioni o le eccezioni bilanciando il vantaggio delle seconde di essere eseguite anche in release con il vantaggio delle prime di essere più concise e non causare overhead in release.. Se invece lavoro in un team che non usa strumenti Jetbrain, uso solo le prime tre opzioni (o solo le prime due se si usa una versione vecchia di C#).
Conclusione
Il design by contract è una tecnica che permette di rilevare difetti del software sia a runtime che, talvolta, già durante la scrittura del codice. Combinata con una suite di test, concorre al raggiungimento di elevati standard di qualità del software.
Per approfondimenti o consulenze in ambito testing e quality assurance, scrivere a info@dvisentin.com.