Toetreden tot een nieuw bedrijf – met een gevestigde cultuur en programmeerpraktijken – kan een ontmoedigende ervaring zijn. Toen ik bij het Ansible-team kwam, heb ik besloten om de softwareontwikkelingspraktijken en -principes op te schrijven die ik in de loop der jaren heb geleerd en waar ik naar streef. Dit is een niet-definitieve, niet-uitputtende lijst van principes die met wijsheid en flexibiliteit moeten worden toegepast.
Mijn passie gaat uit naar testen, omdat ik geloof dat goede testpraktijken zowel een minimale kwaliteitsstandaard kunnen garanderen (waaraan het helaas in veel softwareproducten ontbreekt), als de ontwikkeling zelf kunnen sturen en vormgeven. Veel van deze principes hebben betrekking op testpraktijken en idealen. Sommige van deze principes zijn Python-specifiek, maar de meeste niet. (Voor Python ontwikkelaars zou PEP 8 je eerste stop moeten zijn voor programmeerstijl en richtlijnen.)
In het algemeen zijn wij programmeurs nogal opvliegend, en sterke meningen zijn vaak een teken van grote passie. Met dat in gedachten, voel je vrij om het oneens te zijn met deze punten, en we kunnen erover discussiëren en debatteren in de commentaren.
Ontwikkelen en testen van best practices
1. YAGNI: “You Aint Gonna Need It”. Schrijf geen code die je in de toekomst nodig denkt te hebben, maar nu nog niet nodig hebt. Dit is coderen voor denkbeeldige toekomstige use-cases, en onvermijdelijk zal de code dode code worden of herschreven moeten worden omdat de toekomstige use-case altijd net iets anders blijkt te werken dan je je had voorgesteld.
Als je code invoert voor een toekomstige use-case, zal ik daar in een code review vraagtekens bij zetten. (Je kunt, en moet, API’s ontwerpen, bijvoorbeeld, om toekomstige toepassingen mogelijk te maken, maar dat is een andere kwestie.)
Hetzelfde geldt voor het uitcommentariëren van code; als een blok becommentarieerde code in een release gaat, zou het niet moeten bestaan. Als het code is die hersteld mag worden, maak dan een ticket aan en verwijs naar de commit hash voor het verwijderen van de code. YAGNI is een kern element van agile programming. De beste referentie hiervoor is Extreme Programming Explained, door Kent Beck.
2. Tests hoeven niet getest te worden. Infrastructuur, frameworks, en bibliotheken voor testen hebben testen nodig. Test niet de browser of externe bibliotheken, tenzij het echt nodig is. Test de code die je zelf schrijft, niet die van anderen.
3. De derde keer dat je hetzelfde stukje code schrijft is het juiste moment om het in een algemene helper te gieten (en er tests voor te schrijven). Helper-functies binnen een test hoeven niet getest te worden; wanneer je ze uit elkaar haalt en hergebruikt hebben ze wel tests nodig. Tegen de derde keer dat je soortgelijke code hebt geschreven, heb je meestal een duidelijk idee van wat de vorm is van het algemene probleem dat je oplost.
4. Als het gaat om API-ontwerp (external facing en object API): Eenvoudige dingen moeten eenvoudig zijn; complexe dingen moeten mogelijk zijn. Ontwerp eerst voor het eenvoudige geval, met bij voorkeur nul configuratie of parameterisatie, als dat mogelijk is. Voeg opties of extra API methoden toe voor meer complexe en flexibele use cases (als ze nodig zijn).
5. Faal snel. Controleer input en faal zo vroeg mogelijk op onzinnige input of ongeldige state, bij voorkeur met een exception of error response die het exacte probleem duidelijk maakt aan je caller. Sta echter wel “innovatieve” toepassingen van uw code toe (d.w.z., doe geen type-controle voor invoervalidatie tenzij het echt nodig is).
6. Unit-tests testen op de gedragseenheid, niet op de implementatie-eenheid. Het veranderen van de implementatie, zonder het gedrag te veranderen of een van je tests te hoeven veranderen is het doel, hoewel niet altijd mogelijk. Dus waar mogelijk, behandel je test objecten als zwarte dozen, waarbij je test via de publieke API zonder private methoden aan te roepen of aan de state te sleutelen.
Voor sommige complexe scenario’s-zoals het testen van gedrag op een specifieke complexe state om een obscure bug te vinden- kan dat niet mogelijk zijn. Eerst testen helpt hierbij, omdat het je dwingt na te denken over het gedrag van je code en hoe je het gaat testen voordat je het schrijft. Eerst testen moedigt kleinere, meer modulaire eenheden van code aan, wat over het algemeen betere code betekent. Een goede referentie om te beginnen met de “test eerst” aanpak is Test Driven Development by Example, door Kent Beck.
7. Voor unit tests (inclusief test infrastructuur tests) moeten alle code paden worden getest. 100% dekking is een goede plek om te beginnen. Je kunt niet alle mogelijke permutaties/combinaties van toestanden afdekken (combinatorische explosie), dus daar moet over nagedacht worden. Alleen als er een zeer goede reden is zouden code paden niet getest mogen worden. Tijdgebrek is geen goede reden en kost uiteindelijk meer tijd. Mogelijke goede redenen zijn: echt ontestbaar (op enige zinvolle manier), onmogelijk om in de praktijk te raken, of elders gedekt in een test. Code zonder tests is een verplichting. Het meten van de dekking en het afwijzen van PR’s die het dekkingspercentage verlagen is één manier om ervoor te zorgen dat je geleidelijk vooruitgang boekt in de goede richting.
8. Code is de vijand: het kan fout gaan, en het heeft onderhoud nodig. Schrijf minder code. Verwijder code. Schrijf geen code die je niet nodig hebt.
9. Het is onvermijdelijk dat commentaar op code na verloop van tijd leugens wordt. In de praktijk zijn er maar weinig mensen die commentaar bijwerken als er iets verandert. Probeer uw code leesbaar en zelf-documenterend te maken door goede naamgeving en een bekende programmeerstijl.
Code die niet duidelijk kan worden gemaakt – het omzeilen van een obscure bug of onwaarschijnlijke omstandigheid, of een noodzakelijke optimalisatie – moet worden becommentarieerd. Geef commentaar op de bedoeling van de code, en waarom het iets doet in plaats van wat het doet. (Dit specifieke punt over commentaar dat leugens zijn is controversieel, tussen haakjes. Ik denk nog steeds dat het klopt, en Kernighan en Pike, auteurs van The Practice of Programming, zijn het met me eens.)
10. Schrijf defensief. Denk altijd na over wat er mis kan gaan, wat er gebeurt bij ongeldige invoer, en wat er zou kunnen mislukken, wat je zal helpen veel bugs te vangen voordat ze gebeuren.
11. Logica is gemakkelijk te unit-testen als het stateless is en vrij van neveneffecten. Breek logica op in aparte functies, in plaats van het mengen van logica in stateful en side-effect gevulde code. Het scheiden van stateful code en code met neveneffecten in kleinere functies maakt ze makkelijker te mock out en unit-testen zonder neveneffecten. (Minder overhead voor tests betekent snellere tests.) Neveneffecten moeten wel getest worden, maar ze één keer testen en ze verder overal uit mocken is over het algemeen een goed patroon.
12. Globals zijn slecht. Functies zijn beter dan types. Objecten zijn waarschijnlijk beter dan complexe datastructuren.
13. Het gebruik van de in Python ingebouwde types – en hun methoden – zal sneller zijn dan het schrijven van je eigen types (tenzij je in C schrijft). Als performance een overweging is, probeer dan uit te vinden hoe je de standaard ingebouwde types kunt gebruiken in plaats van eigen objecten.
14. Dependency injection is een nuttig coderingspatroon om duidelijk te maken wat je afhankelijkheden zijn en waar ze vandaan komen. (Laat objecten, methoden, enzovoort hun afhankelijkheden als parameters ontvangen in plaats van zelf nieuwe objecten te instantiëren). Dit maakt API handtekeningen complexer, dus het is een afweging. Als je eindigt met een methode die 10 parameters nodig heeft voor al zijn afhankelijkheden, is dat een goed teken dat je code toch al te veel doet. Het ultieme artikel over dependency injection is “Inversion of Control Containers and the Dependency Injection Pattern,” door Martin Fowler.
15. Hoe meer je moet mocken om je code te testen, hoe slechter je code is. Hoe meer code je moet instantiëren en plaatsen om een specifiek stukje gedrag te kunnen testen, hoe slechter je code is. Het doel is kleine testbare eenheden, samen met integratie- en functionele tests op hoger niveau om te testen of de eenheden correct samenwerken.
16. Bij API’s die naar buiten gericht zijn, is “design up front” – en nadenken over toekomstige gebruikssituaties – van groot belang. Het veranderen van API’s is vervelend voor ons en voor onze gebruikers, en het creëren van achterwaartse incompatibiliteit is vreselijk (hoewel soms onmogelijk te vermijden). Ontwerp externe API’s zorgvuldig, maar houd je wel aan het principe “eenvoudige dingen moeten eenvoudig zijn”.
17. Als een functie of methode langer is dan 30 regels code, overweeg dan om deze op te splitsen. Een goede maximale omvang van een module is ongeveer 500 regels. Test bestanden hebben de neiging langer te zijn dan dit.
18. Doe geen werk in object constructors, die zijn moeilijk te testen en verrassend. Zet geen code in __init__.py (behalve imports voor namespacing). __init__.py is niet waar programmeurs over het algemeen verwachten code te vinden, dus het is “verrassend.”
19. DRY (Don’t Repeat Yourself) is veel minder belangrijk in tests dan in productiecode. Leesbaarheid van een individueel testbestand is belangrijker dan onderhoudbaarheid (het uitsplitsen van herbruikbare brokken). Dat komt omdat tests individueel worden uitgevoerd en gelezen in plaats van zelf deel uit te maken van een groter systeem. Uiteraard betekent overmatige herhaling dat herbruikbare componenten kunnen worden gemaakt voor het gemak, maar het is veel minder een punt van zorg dan het is voor productie.
20. Refactor wanneer je de noodzaak ziet en de kans hebt. Programmeren gaat over abstracties, en hoe dichter je abstracties bij het probleemdomein liggen, hoe makkelijker je code te begrijpen en te onderhouden is. Als systemen organisch groeien, moeten ze van structuur veranderen voor hun groeiende use-case. Systemen ontgroeien hun abstracties en structuur, en ze niet veranderen wordt technische schuld die pijnlijker (en trager en meer buggy) is om rond te werken. Neem de kosten van het wegwerken van de technische schuld (refactoring) op in de ramingen voor het feature werk. Hoe langer je de schuld laat bestaan, hoe hoger de rente die het accumuleert. Een goed boek over refactoring en testen is Working Effectively with Legacy Code, van Michael Feathers.
21. Maak code eerst correct en dan snel. Als je aan prestatieproblemen werkt, maak dan altijd eerst een profiel voordat je reparaties uitvoert. Meestal zit het knelpunt niet precies waar je dacht dat het zat. Het schrijven van obscure code omdat het sneller is, is alleen de moeite waard als je hebt geprofileerd en bewezen dat het ook echt de moeite waard is. Het schrijven van een test die de code oefent die je aan het profilen bent met timing eromheen maakt het makkelijker om te weten wanneer je klaar bent, en kan in de testsuite gelaten worden om performance regressies te voorkomen. (Met de gebruikelijke kanttekening dat het toevoegen van timing code altijd de performance karakteristieken van de code verandert, waardoor performance werk een van de meer frustrerende taken wordt.)
22. Kleinere, strakker afgebakende unit tests geven meer waardevolle informatie als ze falen – ze vertellen je specifiek wat er mis is. Een test die het halve systeem overeind houdt om gedrag te testen, vergt meer onderzoek om te bepalen wat er mis is. Over het algemeen is een test die meer dan 0.1 seconde duurt om te draaien geen unit test. Er bestaat niet zoiets als een langzame unit test. Met strak afgebakende unit tests die gedrag testen, fungeren je tests als een de facto specificatie voor je code. In het ideale geval, als iemand je code wil begrijpen, zou hij zich moeten kunnen wenden tot de test suite als “documentatie” voor het gedrag. Een goede presentatie over unit testing praktijken is Fast Test, Slow Test, door Gary Bernhardt:
23. “Not Invented Here” is niet zo slecht als mensen zeggen. Als we de code schrijven, weten we wat deze doet, weten we hoe we deze moeten onderhouden en zijn we vrij om deze naar eigen inzicht uit te breiden en te wijzigen. Dit volgt het YAGNI principe: We hebben specifieke code voor de use cases die we nodig hebben in plaats van code voor algemene doeleinden die complex is voor dingen die we niet nodig hebben. Aan de andere kant is code de vijand, en meer code bezitten dan nodig is slecht. Overweeg de afweging bij het introduceren van een nieuwe afhankelijkheid.
24. Gedeelde code is het doel; kennis in silo’s is slecht. Dit betekent op zijn minst het bespreken of documenteren van ontwerpbeslissingen en belangrijke implementatiebeslissingen. Code review is het slechtste moment om ontwerpbeslissingen te bespreken, omdat de inertie om ingrijpende veranderingen door te voeren nadat de code is geschreven moeilijk te overwinnen is. (Natuurlijk is het nog altijd beter om ontwerpfouten aan te wijzen en te veranderen tijdens de review dan nooit.)
25. Generatoren zijn geweldig! Ze zijn over het algemeen korter en makkelijker te begrijpen dan stateful objecten voor iteratie of herhaalde uitvoering. Een goede introductie tot generatoren is “Generator Tricks for Systems Programmers,” door David Beazley.
26. Laten we ingenieurs zijn! Laten we nadenken over ontwerp en het bouwen van robuuste en goed geïmplementeerde systemen, in plaats van organische monsters te kweken. Programmeren is echter een evenwichtsoefening. We bouwen niet altijd een raketschip. Over-engineering (ui-architectuur) is even pijnlijk om mee te werken als onder-ontworpen code. Bijna alles van Robert Martin is het lezen waard, en Clean Architecture: A Craftsman’s Guide to Software Structure and Design is een goede bron over dit onderwerp. Design Patterns is een klassiek programmeerboek dat elke ingenieur zou moeten lezen.
27. Intermitterend falende tests eroderen de waarde van je testsuite, tot het punt waarop uiteindelijk iedereen de testresultaten negeert omdat er altijd wel iets faalt. Het repareren of verwijderen van intermitterend falende tests is pijnlijk, maar de moeite waard.
28. Wacht in het algemeen, vooral in tests, op een specifieke verandering in plaats van te slapen voor een arbitraire hoeveelheid tijd. Voodoo-slaapjes zijn moeilijk te begrijpen en vertragen je testsuite.
29. Laat je test altijd minstens één keer falen. Stop er opzettelijk een bug in en zorg ervoor dat hij faalt, of voer de test uit voordat het geteste gedrag is voltooid. Anders weet je niet of je echt iets aan het testen bent. Per ongeluk tests schrijven die eigenlijk niets testen of die nooit kunnen falen is makkelijk.
30. En tot slot, een punt voor het management: Voortdurend nieuwe functies toevoegen is een verschrikkelijke manier om software te ontwikkelen. Als je ontwikkelaars niet trots laat zijn op hun werk, haal je niet het beste uit ze naar boven. Het niet aanpakken van technische schuld vertraagt de ontwikkeling en resulteert in een slechter, meer buggy product.