Entrare in una nuova azienda – con una cultura e pratiche di programmazione consolidate – può essere un’esperienza scoraggiante. Quando mi sono unito al team di Ansible, ho deciso di scrivere le pratiche e i principi di ingegneria del software che ho imparato nel corso degli anni e a cui mi sforzo di lavorare. Questa è una lista non definitiva e non esaustiva di principi che dovrebbero essere applicati con saggezza e flessibilità.
La mia passione è il testing, poiché credo che buone pratiche di testing possano sia assicurare uno standard minimo di qualità (tristemente carente in molti prodotti software), sia guidare e modellare lo sviluppo stesso. Molti di questi principi riguardano le pratiche e gli ideali di testing. Alcuni di questi principi sono specifici per Python, ma la maggior parte no. (Per gli sviluppatori Python, PEP 8 dovrebbe essere la vostra prima fermata per lo stile di programmazione e le linee guida.)
In generale, noi programmatori siamo un sacco di opinionisti, e le opinioni forti sono spesso un segno di grande passione. Con questo in mente, sentitevi liberi di non essere d’accordo con questi punti, e possiamo discuterne nei commenti.
Buone pratiche di sviluppo e test
1. YAGNI: “You Aint Gonna Need It”. Non scrivete codice che pensate possa servirvi in futuro, ma di cui non avete ancora bisogno. Questo è codificare per casi d’uso futuri immaginari, e inevitabilmente il codice diventerà codice morto o dovrà essere riscritto perché il caso d’uso futuro risulta sempre funzionare in modo leggermente diverso da come lo avete immaginato.
Se mettete del codice per un caso d’uso futuro, lo metterò in discussione in una revisione del codice. (Si può, e si deve, progettare le API, per esempio, per permettere casi d’uso futuri, ma questa è una questione diversa.)
Lo stesso vale per il codice commentato; se un blocco di codice commentato sta andando in una release, non dovrebbe esistere. Se si tratta di codice che può essere ripristinato, fate un biglietto e fate riferimento all’hash di commit per la cancellazione del codice. YAGNI è un elemento centrale della programmazione agile. Il miglior riferimento per questo è Extreme Programming Explained, di Kent Beck.
2. I test non hanno bisogno di test. Le infrastrutture, i framework e le librerie per i test hanno bisogno di test. Non testate il browser o le librerie esterne a meno che non ne abbiate davvero bisogno. Testate il codice che scrivete voi, non quello degli altri.
3. La terza volta che scrivete lo stesso pezzo di codice è il momento giusto per estrarlo in un helper generico (e scrivere i test per esso). Le funzioni di aiuto all’interno di un test non hanno bisogno di test; quando le si estrae e le si riutilizza hanno bisogno di test. Entro la terza volta che hai scritto codice simile, tendi ad avere una chiara idea di quale sia la forma del problema general-purpose che stai risolvendo.
4. Quando si tratta di design API (external facing e object API): Le cose semplici dovrebbero essere semplici; le cose complesse dovrebbero essere possibili. Progettate prima per il caso semplice, preferibilmente con zero configurazione o parametrizzazione, se è possibile. Aggiungere opzioni o metodi API aggiuntivi per casi d’uso più complessi e flessibili (quando sono necessari).
5. Fallire velocemente. Controllare l’input e fallire in caso di input insensato o di stato non valido il più presto possibile, preferibilmente con un’eccezione o una risposta di errore che renda chiaro il problema esatto a chi chiama. Permettete comunque casi d’uso “innovativi” del vostro codice (ad esempio, non fate il type checking per la validazione dell’input a meno che non ne abbiate davvero bisogno).
6. I test unitari testano l’unità di comportamento, non l’unità di implementazione. Cambiare l’implementazione, senza cambiare il comportamento o dover cambiare uno qualsiasi dei vostri test è l’obiettivo, anche se non sempre possibile. Quindi, dove possibile, trattate i vostri oggetti di test come scatole nere, testando attraverso l’API pubblica senza chiamare metodi privati o armeggiare con lo stato.
Per alcuni scenari complessi – come testare il comportamento su uno specifico stato complesso per trovare un oscuro bug – questo potrebbe non essere possibile. Scrivere prima i test aiuta molto in questo senso, poiché vi costringe a pensare al comportamento del vostro codice e a come lo testerete prima di scriverlo. Testare prima incoraggia unità di codice più piccole e modulari, il che generalmente significa codice migliore. Un buon riferimento per iniziare con l’approccio “test first” è Test Driven Development by Example, di Kent Beck.
7. Per i test di unità (inclusi i test dell’infrastruttura di test) tutti i percorsi del codice dovrebbero essere testati. Il 100% di copertura è un buon punto di partenza. Non si possono coprire tutte le possibili permutazioni/combinazioni di stato (esplosione combinatoria), quindi questo richiede considerazione. Solo se c’è una ragione molto buona, i percorsi del codice dovrebbero essere lasciati non testati. La mancanza di tempo non è una buona ragione e finisce per costare più tempo. Possibili buone ragioni includono: veramente non testabile (in qualsiasi modo significativo), impossibile da colpire nella pratica, o coperto altrove in un test. Il codice senza test è una responsabilità. Misurare la copertura e rifiutare le PR che riducono la percentuale di copertura è un modo per assicurarsi di fare progressi graduali nella giusta direzione.
8. Il codice è il nemico: può andare male, e ha bisogno di manutenzione. Scrivere meno codice. Cancellare il codice. Non scrivere codice che non ti serve.
9. Inevitabilmente, i commenti al codice diventano bugie nel tempo. In pratica, poche persone aggiornano i commenti quando le cose cambiano. Sforzatevi di rendere il vostro codice leggibile e auto-documentante attraverso buone pratiche di denominazione e uno stile di programmazione conosciuto.
Il codice che non può essere reso ovvio – aggirare un bug oscuro o una condizione improbabile, o una necessaria ottimizzazione – ha bisogno di commenti. Commentate l’intento del codice, e perché sta facendo qualcosa piuttosto che cosa sta facendo. (Questo particolare punto sui commenti che sono bugie è controverso, a proposito. Io penso ancora che sia corretto, e Kernighan e Pike, autori di The Practice of Programming, sono d’accordo con me)
10. Scrivere in modo difensivo. Pensate sempre a cosa può andare storto, cosa succederà in caso di input non valido, e cosa potrebbe fallire, il che vi aiuterà a catturare molti bug prima che accadano.
11. La logica è facile da testare se è senza stato e senza effetti collaterali. Rompere la logica in funzioni separate, piuttosto che mescolare la logica in codice statico e pieno di effetti collaterali. Separare il codice statico e il codice con effetti collaterali in funzioni più piccole li rende più facili da deridere e testare unitariamente senza effetti collaterali. (Meno overhead per i test significa test più veloci.) Gli effetti collaterali hanno bisogno di essere testati, ma testarli una volta e deriderli ovunque è generalmente un buon modello.
12. I globali sono cattivi. Le funzioni sono meglio dei tipi. Gli oggetti sono probabilmente migliori delle strutture dati complesse.
13. Usare i tipi integrati in Python – e i loro metodi – sarà più veloce che scrivere i propri tipi (a meno che non stiate scrivendo in C). Se la performance è una considerazione, cercate di capire come usare i tipi standard integrati piuttosto che oggetti personalizzati.
14. L’iniezione di dipendenza è un utile pattern di codifica per essere chiari su quali sono le vostre dipendenze e da dove vengono. (Fate in modo che gli oggetti, i metodi e così via ricevano le loro dipendenze come parametri piuttosto che istanziare essi stessi nuovi oggetti). Questo rende le firme API più complesse, quindi è un compromesso. Finire con un metodo che ha bisogno di 10 parametri per tutte le sue dipendenze è un buon segno che il vostro codice sta facendo troppo, comunque. L’articolo definitivo sulla dependency injection è “Inversion of Control Containers and the Dependency Injection Pattern,” di Martin Fowler.
15. Più dovete prendere in giro per testare il vostro codice, peggio è il vostro codice. Più codice dovete istanziare e mettere in atto per essere in grado di testare un pezzo specifico di comportamento, peggiore è il vostro codice. L’obiettivo è di avere piccole unità testabili, insieme a test di integrazione e funzionali di livello superiore per verificare che le unità cooperino correttamente.
16. Le API rivolte all’esterno sono quelle in cui il “design up front” – e la considerazione sui casi d’uso futuri – contano davvero. Cambiare le API è un dolore per noi e per i nostri utenti, e creare incompatibilità all’indietro è orribile (anche se a volte impossibile da evitare). Progettate le API rivolte all’esterno con attenzione, mantenendo il principio “le cose semplici dovrebbero essere semplici”.
17. Se una funzione o un metodo supera le 30 linee di codice, considerate di spezzarlo. Una buona dimensione massima del modulo è di circa 500 linee. I file di test tendono ad essere più lunghi di così.
18. Non fate lavoro nei costruttori di oggetti, che sono difficili da testare e sorprendenti. Non mettete codice in __init__.py (eccetto le importazioni per il namespacing). __init__.py non è dove i programmatori generalmente si aspettano di trovare del codice, quindi è “sorprendente”.
19. DRY (Don’t Repeat Yourself) conta molto meno nei test che nel codice di produzione. La leggibilità di un singolo file di test è più importante della manutenibilità (rompere i pezzi riutilizzabili). Questo perché i test sono eseguiti e letti individualmente piuttosto che essere essi stessi parte di un sistema più grande. Ovviamente un’eccessiva ripetizione significa che i componenti riutilizzabili possono essere creati per convenienza, ma è molto meno preoccupante di quanto lo sia per la produzione.
20. Rifattorizzare ogni volta che se ne vede la necessità e se ne ha la possibilità. La programmazione riguarda le astrazioni, e più le vostre astrazioni sono vicine al dominio del problema, più il vostro codice è facile da capire e mantenere. Quando i sistemi crescono organicamente, hanno bisogno di cambiare struttura per il loro caso d’uso in espansione. I sistemi superano le loro astrazioni e la loro struttura, e non cambiarle diventa un debito tecnico che è più doloroso (e più lento e più buggato) da aggirare. Includere il costo della cancellazione del debito tecnico (refactoring) nelle stime del lavoro sulle feature. Più a lungo si lascia il debito in giro, più alti sono gli interessi che si accumulano. Un grande libro sul refactoring e sul testing è Working Effectively with Legacy Code, di Michael Feathers.
21. Rendere il codice corretto prima e veloce dopo. Quando si lavora su problemi di performance, fare sempre un profilo prima di fare correzioni. Di solito il collo di bottiglia non è proprio dove si pensava che fosse. Scrivere codice oscuro perché è più veloce vale la pena solo se avete profilato e dimostrato che ne vale effettivamente la pena. Scrivere un test che esercita il codice che state profilando con la temporizzazione intorno ad esso rende più facile sapere quando avete finito, e può essere lasciato nella suite di test per prevenire regressioni delle prestazioni. (Con la solita nota che l’aggiunta di codice di temporizzazione cambia sempre le caratteristiche di performance del codice, rendendo il lavoro sulle performance uno dei compiti più frustranti.)
22. I test unitari più piccoli e con uno scopo più stretto danno informazioni più preziose quando falliscono – dicono specificamente cosa è sbagliato. Un test che si alza per metà del sistema per testare il comportamento richiede più indagini per determinare cosa è sbagliato. Generalmente un test che impiega più di 0.1 secondi per essere eseguito non è un test unitario. Non esistono test unitari lenti. Con test unitari strettamente mirati che testano il comportamento, i vostri test agiscono come una specifica de facto per il vostro codice. Idealmente se qualcuno vuole capire il vostro codice, dovrebbe essere in grado di rivolgersi alla suite di test come “documentazione” del comportamento. Una grande presentazione sulle pratiche di unit testing è Fast Test, Slow Test, di Gary Bernhardt:
23. “Not Invented Here” non è così male come la gente dice. Se scriviamo il codice, allora sappiamo cosa fa, sappiamo come mantenerlo, e siamo liberi di estenderlo e modificarlo come meglio crediamo. Questo segue il principio YAGNI: abbiamo codice specifico per i casi d’uso di cui abbiamo bisogno piuttosto che codice generico che ha complessità per cose di cui non abbiamo bisogno. D’altra parte, il codice è il nemico, e possedere più codice del necessario è male. Considerare il compromesso quando si introduce una nuova dipendenza.
24. La proprietà condivisa del codice è l’obiettivo; la conoscenza isolata è un male. Come minimo, questo significa discutere o documentare le decisioni di design e le importanti decisioni di implementazione. La revisione del codice è il momento peggiore per iniziare a discutere le decisioni di design, poiché l’inerzia di fare cambiamenti radicali dopo che il codice è stato scritto è difficile da superare. (Naturalmente è sempre meglio far notare e cambiare gli errori di design al momento della revisione che mai.)
25. I generatori spaccano! Sono generalmente più brevi e più facili da capire degli oggetti statici per l’iterazione o l’esecuzione ripetuta. Una buona introduzione ai generatori è “Generator Tricks for Systems Programmers,” di David Beazley.
26. Diventiamo ingegneri! Pensiamo a progettare e costruire sistemi robusti e ben implementati, piuttosto che far crescere mostri organici. La programmazione è un atto di equilibrio, tuttavia. Non stiamo sempre costruendo un’astronave. L’over-engineering (architettura a cipolla) è tanto doloroso da lavorare quanto il codice sottoprogettato. Quasi ogni cosa di Robert Martin vale la pena di essere letta, e Clean Architecture: A Craftsman’s Guide to Software Structure and Design è una buona risorsa su questo argomento. Design Patterns è un classico libro di programmazione che ogni ingegnere dovrebbe leggere.
27. I test che falliscono ad intermittenza erodono il valore della tua suite di test, al punto che alla fine tutti ignorano i risultati dei test perché c’è sempre qualcosa che fallisce. Correggere o cancellare i test che falliscono ad intermittenza è doloroso, ma ne vale la pena.
28. In generale, in particolare nei test, aspettate un cambiamento specifico piuttosto che dormire per un tempo arbitrario. I voodoo sleeps sono difficili da capire e rallentano la vostra suite di test.
29. Vedete sempre il vostro test fallire almeno una volta. Mettete un bug intenzionale e assicuratevi che fallisca, o eseguite il test prima che il comportamento sotto test sia completo. Altrimenti non sapete che state veramente testando qualcosa. Scrivere accidentalmente dei test che in realtà non testano nulla o che non possono mai fallire è facile.
30. E infine, un punto per il management: La macinazione costante delle caratteristiche è un modo terribile di sviluppare software. Non lasciare che gli sviluppatori siano orgogliosi del loro lavoro assicura che non otterrete il meglio da loro. Non affrontare il debito tecnico rallenta lo sviluppo e si traduce in un prodotto peggiore e più buggato.