Unit Testing - Hoofdstuk 10

Ingediend door Dirk Hornstra op 28-feb-2022 20:57

Na hoofdstuk 1: link, hoofdstuk 2: link, hoofdstuk 3: link, hoofdstuk 4: link, hoofdstuk 5: link, hoofdstuk 6: link, hoofdstuk 7: link, hoofdstuk 8: link en hoofdstuk 9: link is het nu tijd voor hoofdstuk 10.

De Timo-samenvatting

Beheer je database-model in je .NET project via Migrations, zorgt ook dat je basis-data (referentie-data) daarin meegenomen is. Gebruik "units of work" om je SQL-acties te verzamelen aan het einde van de functie, zodat ze transactie-gewijs doorgevoerd kunnen worden. Zorg voor opruim-scripts en voer die uit aan het begin van je testen. En een aantal tips om je test-code overzichtelijk en leesbaar te houden.

En dan nu het complete verhaal:

Hoofdstuk 10 heeft "Testing the database" en dat is wat we gaan doen. Het laatste deel van integratietesten is het testen van beheerde (managed) out-of-proces afhankelijkheden. Het testen op een échte database zorgt dat regressie van code voorkomen wordt. We gaan de verschillende soorten databases behandelen, de "state-based" en de "migration-based", waarbij migration-based de voorkeur van de auteur heeft. Hoewel de focus in het hoofdstuk op relationele databases ligt, kun je de principes als het goed is ook toepassen op document-databases, noSQL-databases e.d.

De voorbereidingen om een database te testen.

Hierbij worden een aantal stappen genoemd:

  • zorg dat de database in source-control zit
  • gebruik een losse database instantie voor elke developer
  • pas een migratie-gebaseerde opbouw toe om een database op te bouwen


Source-control

De auteur komt met het voorbeeld waarbij het schema van de database in GIT wordt bijgehouden. In de "echte wereld" was hij betrokken bij een project waar je een "model database" hebt. Met een compare-tool werden de afwijkingen geconstateerd en vervolgens met scripts op de live-database doorgevoerd. De auteur noemt dit een "anti-pattern".  En waarom is dat?

  • je hebt geen versie-historie, je kunt dus niet terug naar een bepaald punt en zo constateren waarom er toen fouten optraden (toen was het veld maximaal 50 tekens, nu is het een varchar(max))
  • geen "enkele waarheid", als "stiekem" in productie even wat aangepast wordt, niet in de model-database, wat is dan het correcte model? Hetzelfde geldt voor je model-database én het bijhouden in GIT.


Referentie-data is deel van het database-schema

Waarschijnlijk zullen 9 van de 10 applicaties met een lege database niet werken omdat ze "iets" van begin-data moeten hebben. 1 account om mee in te kunnen loggen. Bepaalde rollen waarmee rechten worden toegewezen. Hoewel vaak gedacht wordt aan tabellen, views, stored procedures als source-control-data, dit hoort daar ook bij. De auteur geeft aan dat als data "niet wijzigbaar is", dan is het referentie-data. Omdat in een tabel "gewone data" kan staan, maar ook dergelijke "referentie-data", moet je die een flag kunnen geven zodat deze data niet verwijderd kan/mag worden.

Een losse database-instantie voor elke ontwikkelaar

De redenen die hiervoor gegeven worden zijn duidelijk, testen kunnen elkaar in de weg zitten en als je bepaalde wijzigingen uitvoert die niet "backward compatible" zijn, zou je met één instantie het werk van andere developers kunnen hinderen.

State-based ten opzichte van migration-based

In .NET heb je standaard de mogelijkheid om "migrations" toe te voegen. Mocht je in een andere tool iets met SQL e.d. moeten doen, het boek verwijst naar flywaydb.org (link) en liquibase.org (link). En fluentmigrator wordt genoemd (link), waarmee je vooruit kunt gaan, maar ook een rollback kunt doen.

Database Transactie Management

Het boek toont hoe je in productie-code transacties kunt toepassen. Als het goed is doen we dat al, sommige processen moeten volledig slagen of mislukken, niet "half". Het boek geeft het voorbeeld van een gebruiker en een bedrijf. We willen dat zaken goed uitgevoerd worden, omdat anders misschien het aantal gebruikers bij een bedrijf niet overeenkomen met het werkelijke aantal. Het boek komt met:

  • repositories, een class die toegang en aanpassing van objecten uit de database voor je afhandelt. In het voorbeeld van het boek komt er 1 voor de User en 1 voor de Company.
  • een transactie-class, deze gaan in combinatie met de mogelijkheden van de onderliggende database de transactie voor je afhandelen.


In plaats van directe acties op de database in de code, gebruik je nu de repositories om data op te vragen of bij te werken. De auteur gebruikt een TransactionScope, met een uiteindelijke "Commit()" markeer je de transactie als succesvol (je hebt geen fouten gezien, dus hij mag door), met een uiteindelijke "Dispose()" eindig je de transactie. Heb je Commit aangeroepen, dan worden de wijzigingen doorgevoerd. Zo niet, dan wordt een Rollback gedaan.

Hoewel dit al een goede actie is, is er volgens de auteur nog een betere optie, pas je Transactie-class aan naar een "Unit of Work". Een "Unit of Work" is een lijst van objecten die door een "business operatie" geraakt worden. Als de operatie uitgevoerd is, de unit of work zoekt uit wat allemaal aangepast moet worden om de database aan te passen en voert deze updates uit als "1 enkele actie". Wat schiet je hiermee op? Nou, al je acties op de database wordt op het einde uitgevoerd. Dit scheelt een hoop tussentijdse acties (locks?) en mogelijk ook extra database-calls. Database-transacties implementeren ook dit principe.

In .NET worden zaken gemakkelijk gemaakt met het DBContext-object, het Entity Framework waardoor je zelf niet allemaal SQL-statements hoeft uit te werken.

In de toelichting wordt nog even genoemd dat bij niet relationele databases die iets anders werkt (bijvoorbeeld bij MongoDB). Je mag hier maar 1 document per keer aanpassen. Maar omdat een document eigenlijk een soort "rij" is, kun je wel zorgen dat ook dat gaat werken.

Het beheren van transacties in testen

Hergebruik geen transacties of "units of work" tussen verschillende testen! We zien een voorbeeld van een test met een arrange, act en 2x een assert die dezelfde DBContext gebruiken. Maar in het "echte" programma zijn dat straks losse calls in een controller die hun eigen DBContext-instanties gebruiken. Je code is dus niet representatief, elke actie moet een eigen instantie gebruiken (want "onder water" kan die DBContext data cachen en vertekent het dus de resultaten).

Test-data lifecycle

Je moet testen sequentieel uitvoeren én je data die overblijft weer opruimen. Als testen parallel uitgevoerd worden, dezelfde test-set gebruiken en ook een zelfde soort acties uitvoeren, dan kan dat zorgen dat een test faalt (heeft één test-functie een record aangemaakt met jouw mailadres en kan jouw tweede test-functie dus niet nogmaals een record aanmaken omdat het e-mailadres al in gebruik is). Docker wordt nog genoemd om met containers zaken te testen, maar zoals de auteur al aangeeft: dat is veel werk.

Hoe kun je jouw troep weer opruimen?

  • voer voor elke test een database-restore uit van een eerder gemaakte back-up. De traagste optie.
  • ruim je rommel op aan het eind van de test. De snelste methode, maar het probleem: als je test halverwege afsterft, er stopt iets anders, kun je alsnog testdata over houden.
  • voer elke test binnen een transactie uit en voer nooit een commit door. Wel een stabiele oplossing, maar weer niet vergelijkbaar hoe je echte productie-code werkt. Dus niet representatief.
  • ruim de rotzooi aan het begin van een test op. Werkt snel, wordt niet overgeslagen en zorgt niet voor inconsistente data. De voorkeur van de auteur.


We krijgen nog een voorbeeld van een "base-class"  waarin de opruim SQL scripts uitgewerkt worden.

Voorkom het gebruik van "in-memory databases". Het kan een goede oplossing lijken (snel, zaken worden opgeruimd, voor elke test opgestart worden), maar ze zijn niet vergelijkbaar met jouw échte database, dus niet representatief. Gebruik daarom het type database wat je ook in productie gebruikt.

Hergebruik van code in test-secties

Hou je tests kort en leesbaar en zorg dat zaken niet afhankelijk van elkaar zijn. Als je 1 test wilt uitvoeren, maar vervolgens 3 andere stukken code moet doorlezen om te snappen wat er gebeurt, dan doe je het niet goed. We zien het test-voorbeeld van het boek waarbij nu elk deel van de code een eigen DBContext-object krijgt. Een flinke lap code dus. De zaken worden dus uit elkaar getrokken naar losse, private functies. Deze worden "Object Mothers" genoemd. Dat zijn classes of methodes die test fixtures aanmaken (objecten waartegen getest wordt). Een vergelijkbaar patroon is de Test Data Builder, alleen is dat op basis van een "fluent interface", dus dan krijg je in plaats van een losse functie "CreateUser" een new UserBuilder().WithEmail("test@test.nl").WithType(UserType.Employee). En we zien nog het voorbeeld waarmee een Delegate wordt meegestuurd en de test-code nog beknopter gemaakt kan worden.


private string Execute(
  Func<UserController, string> func,
  MessageBus messageBus,
  IDomainLogger logger)
  {
    using (var context = new CrmContext(ConnectionString))
    {
      var controller = new UserController(context, messageBus, logger);
      return func(controller);
    }
  }
}    

// aanroep:
string result = Execute(
x => x.ChangeEmail(user.UserId, "new@gmail.com"),
messageBus, loggerMock.Object);

En door extensions te maken, kun je ook "fluent controles" in je tests toepassen, dat maakt het een stuk leesbaarder.

Het boek kijkt nog naar de database-acties, dat waren er drie, dat zijn er nu vijf geworden. Dat is jammer, maar niet erg. De acties zijn niet heel zwaar en de structuur van de code klopt nu wel, dus dan neem je dit "op de koop toe".

Vragen over database-testen

In het boek worden nog een aantal vragen behandeld en ook nog even teruggekeken naar hoofdstuk 8 en 9.

De vraag of je "lees-acties" zou moeten testen? Eigenlijk niet, het gaat voornamelijk om "schrijf-acties". Op dat moment wijzigt data en je wilt testen dat het goed gaat of, wanneer het niet goed gaat, het op een correcte manier afgehandeld wordt. Pas als een lees-actie essentieel is, dan kun je deze testen.

Zou je "repositories" moeten testen? Ook hier is het antwoord nee. Je zult daar veel tijd aan onderhoud aan kwijt zijn, omdat deze in het kwadrant van de "controllers" vallen. En ook tegen regressie heeft het testen niet zoveel nut, je kunt beter de classes testen waar de business-logica in opgebouwd wordt.