CodeCaster schreef op zaterdag 27 augustus 2022 @ 15:15:
Maar @
DevWouter, jij roept hier al jaren dat TDD de heilige graal is en beter dan gesneden brood en wat dies meer zij, maar je moet er wel mee in aanraking komen, of jezelf erin verdiepen, mensen hebben die je (in persoon, online of op schrift) de fijne kneepjes kunnen bijbrengen. En laat veel geschreven materiaal en video online nou bar slecht zijn en nooit verder gaan dan Hello World of een TODO-app...
offtopic:
TDD is beter dan gesneden brood, zelfs een perfecte tosti kan je er niet mee vergelijken

Inderdaad, TDD is echt een vaardigheid die tijd kost om te ontwikkelen. Het is ook één van de weinige vaardigheden waarbij een paar weken/maanden aan de slag gaan met kata's een vereisten is, het liefst met een vriend/collega waarbij je elkaar coacht. Zonder dat is het inderdaad lastig te leren en het kost extreem veel tijd.
Weetje wat? Laat ik dmv een anekdote de waarde uitleggen waarbij ik aangeef hoeveel tijd het kost om goed grip te krijgen op TDD.
Anekdote: TDD kapot krijgen had een onverwacht resultaat
Waarschuwing De onderstaande code is uit mijn hoofd geschreven, het oorspronkelijk probleem was ook een stuk complexer dan een simple null-check.
Ik was niet altijd overtuigd dat TDD goed was. Het was soms handig, maar het werkte zeker in het begin niet altijd voor mij. Dus gaf ik mij zelf een klein projectje waarbij ik wat extreme regels hanteerde.
- Alleen minimale aanpassingen aan de library mag mag, zie je een minimaler oplossing als in het aantal toetsaanslagen dan ben je verplicht om die te doen.
- Oude code mag alleen aangepast worden wanneer het onmogelijk is om nieuwe functionaliet toe te voegen.
- Refactoren is ten alle tijden verboden. (normaal mag je dit wanneer alle testen groen zijn)
- "git commit -a -m 'done'" wordt altijd gedaan na elke aanpassing als elke test groen is.
- "git reset --hard" wordt altijd gedaan wanneer er 2 of meer tests rood zijn.
- "git reset --hard" wordt onmiddelijk uitgevoerd bij overtreding van de regels, ook als het per ongeluk is
C#:
1
2
3
4
5
6
7
8
9
10
| class Player{
public int MagicDefense {get;set;}
int calculateDamage(Spell spell) {
// Old early out
if(spell == null) return 0; // no spell, no damage
// Prevent healing when defense is higher than damage.
return Math.Max(0, spell.MagicDamage - this.MagicDefense);
}
} |
Met deze regels had ik uiteindelijk de bovenstaande code. Maar toen besloot ik dat ipv een 0 waarde een exception wilde hebben
De code voor de no-spell-no-damage uitkomst was al verwijderd gezien die ongewenst was maar mijn regels verbood refactoren in de lib-code (de test-code was vrijspel).
Regel 2 zegt duidelijk dat bestaande code alleen aangepast mag worden als er geen andere oplossing mogelijk was en dus was ik gedwongen tot twee early-outs (zoals hieronder), waarbij de tweede early-out nooit kan plaats vinden. Maar de code werkte volgens de test.
C#:
1
2
3
4
5
6
7
8
9
10
11
12
13
| class Player{
public int MagicDefense {get;set;}
int calculateDamage(Spell spell) {
// New early out
if(spell == null) throw new InvalidSpellException();
// Old early out
if(spell == null) return 0; // no spell, no damage
// Prevent healing when defense is higher than damage.
return Math.Max(0, spell.MagicDamage - this.MagicDefense);
}
} |
Na twee dagen stond nog steeds het volgende op mijn notitie:
1. Zorg voor een test die nullref op `spell.MagicDamage` als correct gedrag beschouwdt (hiermee wordt bewijzen we dat old-early-out ongewenst gedrag is).
2. Zorg dat je de "new-early-out" kan paseren. Hoe? HOE!?
Uiteraard was het veel pragmatischer om de body van de if aan te passen. Maar mijn doel was niet om te bewijzen dat ik goeie code kon schrijven, maar dat TDD tot betere code zou leiden. En hoewel ik de property MagicDamage nullRef kon laten geven is het doel ook niet om te bewijzen dat ik binnen de lijntjes kan kleuren door slechte code te schrijven. Het doel was dat TDD mij dwong tot goeie code.
Op dag 3 vond ik de oplossing die aan alle regels voldeed. Hieronder zie je de status van één van de tussen stappen.
C#:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| /* De code t/m stap 5*/
class Player{
private ISpellValidator _spellValidator;
public int MagicDefense {get;set;}
int calculateDamage(Spell spell) {
// New early out
if(!_spellValidator.IsValid(spell)) throw new InvalidSpellException();
// old default value
if(spell == null) return 0; // no spell, no damage
// Prevent healing when defense is higher than damage.
return Math.Max(0, spell.MagicDamage - this.MagicDefense);
}
} |
Oké, de aanpassing stelde niks voor, ik introduceerde een validator class waardoor ik controle over de uitkomst van validatie binnen de code kan veranderen door in de constructor een andere type mee te geven. De stappen:
- Stap 1: Schrijf een test voor de niet bestaande SpellValidator.IsValid(...) die false terug geeft bij een null-object. Dan heb ik 1 non-build/rode test.
- Stap 2: Maak de SpellValidator die gewoon `return src != null;` doet. Nu waren alle tests weer groen
- Stap 3: Bewijs dat InvalidSpellException alleen terug komt als _spellValidator.IsValid(...) een negatief advies geeft. Dit kan door 1 recente test aan te passen waardoor ik weer 1 rode test heb.
- Stap 4: Geef een Mocked ISpellValidator mee aan `Player` en zorg dat die aangeroepen wordt en altijd true terug geeft (hier introduceer ik ook de interface). Ik had nog steeds 1 rode test
- Stap 5: Zorg dat bij de validatie test `false` terug geeft. Nu was alles wel groen.
- Stap 6: Maak een test waarbij null meegegeven wordt maar de validator nog steeds aangeeft dat het goed is. De uitkomst moet een NullRefException zijn en geen InvalidSpellException. Nu was er weer 1 rode test
- Stap 7: Verwijder de no-spell-no-damage code. En nu was alles weer groen.
Had ik mij aan de regels gehouden? Ja, er was geen refactor nodig en was elke aanpassing in de code het gevolg van test.
Had ik met opzet geen slechte code geschreven? Nee, ik heb geen vreemde trucjes uitgehaald.
Dus geen aanpassingen code als gevolg van omdat "ik beter kan" of omdat "ik slechter kan". Elke aanpassing was "test driven"
C#:
1
2
3
4
5
6
7
8
9
10
11
12
| /* The final result */
class Player{
private ISpellValidator _spellValidator;
public int MagicDefense {get;set;}
int calculateDamage(Spell spell) {
// New early out
if(!_spellValidator.IsValid(spell)) throw new InvalidSpellException();
// Prevent healing when defense is higher than damage.
return Math.Max(0, spell.MagicDamage - this.MagicDefense);
}
} |
Het mooiste was dat aan het einde van die dag ik begon aan de Enemy class waarbij de validatie hergebruikt kon worden.
En met al deze realisaties kwam ik tot één andere belangerijke realisatie: Zonder TDD hadden deze verbeteringen nooit zo snel plaats gevonden.
En dus was dit het moment dat ik overtuigd werd dat TDD zoveel meer waarde heeft dan alleen "tests".
De extreme dogmatisch regels waren vooral om te kijken of ik TDD kapot kon krijgen. Vooral omdat ik nog niet overtuigd was van test-first en test-later. En met extreme dogmatisch regels loop je eerder tegen grenzen op (hoewel de vergelijking zelden eerlijk is).
Het doel was dan ook om te bewijzen dat TDD incidenteel zou werken. Tegen de verwachting om leidde het tot een beter resultaat dan dat ik had verwacht.
Ps: normaal is er veel meer pragmatisme in TDD. En zijn de bovenstaande stappen gewoon 1 directe stap.
[Voor 3% gewijzigd door DevWouter op 29-08-2022 03:27]
"Doubt—the concern that my views may not be entirely correct—is the true friend of wisdom and (along with empathy, to which it’s related) the greatest enemy of polarization." -- Václav Havel