Centric connect.engage.succeed

3 Testautomationpatronen in C#

Geschreven door Michiel Abel - 25 juli 2019

Michiel Abel
Hoe je het ook wendt of keert, bij testautomation komt softwareontwikkeling kijken. Hierbij horen ook de standaardgewoontes, zoals het schrijven van unit-testen, het houden van code-reviews en het gebruik van design-patterns.

Met het toepassen van design-patterns verhoog je in de meeste gevallen de kwaliteit en leesbaarheid van je code. Bovendien zijn ze door ontwikkelaars makkelijk te herkennen. Toch is het gebruik van design-patterns geen must. Laat de situatie het niet toe, gebruik het template dan niet.

In deze blog ga ik dieper in op drie patterns die handig kunnen zijn voor testautomation:

  1. factory-pattern
  2. command-pattern
  3. page-objectmodel

Factory-pattern

Stel je voor: je hebt 1.000 testcases geschreven met de tool Selenium en jouw team besluit om de applicatie ook mobiel beschikbaar te maken. Dan moet je goed hebben nagedacht over de structuur van je framework. Heb je dat niet, dan kun je met een nieuwe tool dezelfde 1.000 testcases gaan schrijven. Dit probleem kun je makkelijk tackelen als je goed nadenkt over de structuur van je framework. De oplossing? Je testdata en testuitvoering in losse lagen definiëren. Het factory-pattern kan je hierbij helpen. Hieronder zie je drie aparte lagen van testautomation.

Layers of testautomation

Testcases in DSTL

Als eerste wil je ervoor zorgen dat je je testcases in een Domain-Specific Test language (DSTL) schrijft. Dit is een taal die jij met jouw team bedenkt om specifieke acties te definiëren. Denk hierbij aan een loginactie. Of aan een bepaald menu dat je een eigen naam geeft. De loginactie bestaat uit meerdere subacties, maar wordt onder één naam gedefinieerd om de testcases leesbaar te houden.

Een testcase die wordt geschreven in DSTL, zou er op deze manier uit kunnen zien:

Abstract definition of keywords

Als tweede laag wil je een abstracte afhandeling definiëren van je keywords. Hierbij mogen geen directe referenties zijn met je testtool.

In deze laag komt het factory-pattern goed van pas. Een factory-pattern maakt onderscheid tussen een factory- en een product-class. Het patroon encapsuleert het creëren van objecten. Ook weet de factory hoe een bepaald product object moet worden gemaakt. Een andere implementatie van de factory-class kan dan ook een ander object opleveren. In onderstaand voorbeeld wordt de factory bepaald door de gebruikte testtool, Selenium. Als je een andere testtool wilt gebruiken, hoef je alleen een nieuwe factory aan te maken.

Bron:  https://nl.wikipedia.org/wiki/Factory_(ontwerppatroon)

Testcase

Hier wordt een IAgent-object gemaakt die in dit geval een Selenium-agent wordt. De IAgent is in ons voorbeeld de factory. Zodra we functies van deze IAgent-interface aanroepen, weet de interface al welk type testtool het moet gebruiken. Op deze manier kun je een onbeperkt aantal testtools in je framework aanmaken. Je hoeft alleen maar de generieke functies van de IAgent-interface te implementeren.

KeyWord-class

Zoals je hier ziet, wordt er alleen abstract gedefinieerd wat dit keyword moet doen. We vragen aan de ‘factory’ om een nieuwe control aan te maken, die we later nodig hebben bij het zoeken van elementen. Dit gebeurt in de methode CreateUiControl();. De agent weet welk type control die moet aanmaken, omdat we in de vorige stap gedefinieerd hebben dat de IAgent een Selenium-agent is. Hetzelfde gebeurt met de IAgentAction. De CreateUiControl()-functie ziet er dan als volgt uit:

Selenium Agent class

Dit patroon kan je helpen bij het onderscheiden van je generieke functies en de functies die gericht zijn op je test-tool. Als test-engineer wil je onafhankelijk zijn van je test-tool en door dit patroon toe te passen, hoef je maar een van de drie lagen aan te passen bij veranderingen in de applicatie.

Command-pattern

Het zou mogelijk moeten zijn om zonder testers test-cases te draaien. Het heet test-automation met een reden. Het is daarom essentieel een goede log te genereren, waardoor je kunt zien wat er eventueel fout gaat. Bovendien is een degelijke log met screenshots en leesbare teststappen handig voor je ontwikkelaars bij het optreden van bevindingen. Je ontwikkelaars kunnen op deze manier makkelijk stap voor stap de bevinding naspelen, zonder hulp van een tester.

Als je te veel tijd spendeert aan het debuggen van je test-cases die fout gaan, heb je waarschijnlijk een logging-probleem. Je moet op elk moment weten wat er gedaan is met welke actie. Mijn eigen vuistregel hiervoor, is dat als ik langer dan vijftien minuten aan het debuggen ben, er een fundamenteel log-probleem is. Een patroon dat je kan helpen met zo’n log is het command-pattern.

Het idee achter dit patroon is dat je meerdere commands of acties als losse objecten definieert. Een commando kan in ons voorbeeld een klikactie of edit-fieldactie zijn. Deze commando’s staan los van de invoker class. Deze invoker vraagt aan een command om zichzelf uit te voeren via de functie execute in het command. In de invoker-class zou je eventueel meerdere parameters mee kunnen geven aan een command. Een via de execute-functie aangeroepen command gaat altijd eerst naar een abstracte class of interface, de concrete-command. In de concrete-command-class kunnen herhaaldelijke stappen die bij elk command nodig zijn, worden uitgevoerd. Denk bijvoorbeeld aan het wegschrijven van een log. Als laatste roept het command eventueel een receiver aan. De receiver weet op welke manier hij het commando moet uitvoeren. In ons voorbeeld gaan we hier een retry- controller gebruiken.

Bron:  https://en.wikipedia.org/wiki/Command_pattern

Deze afbeelding geeft abstract weer hoe dit patroon eruitziet.

De structuur van een normaal command-pattern: Client -> Invoker -> Concrete command -> Command -> Receiver.

De structuur die voor dit artikel is gebruikt, is: Client -> Invoker -> Receiver -> Concrete command -> Command.

De structuur wijkt dus af van het originele formaat. Dit is gedaan om het wat leesbaarder te maken en om te laten zien dat er prima afwijkingen kunnen plaatsvinden bij het toepassen van design-patterns. Het is een bonus om de receiver in dit patroon te gebruiken. Maak je dan ook geen zorgen als je hem niet nodig hebt. Het laat alleen zien dat je command zo moet definiëren, dat het afgehandeld kan worden door een ander object. In ons voorbeeld gebruiken we hiervoor een retry-controller.

Dit patroon wordt vaak gebruikt wanneer er behoefte is om commando’s met een vertraging uit te voeren. Denk bijvoorbeeld aan het in de wachtrij zetten van opdrachten of het parallel uitvoeren van opdrachten. In het geval van testautomation willen we deze commando’s via een retry-controller voor een bepaalde tijd laten uitvoeren. Je kunt het command via de receiver net zolang iets proberen, totdat een command gelukt is. Op deze manier vermijd je eventuele netwerkissues.

Om je een voorbeeld te geven van deze structuur, laat ik je enkele code-samples zien, waarbij dit patroon terugkomt met use-cases voor testautomation.

  • Command (AgentAction): Dit is een abstracte class waar elke ConcreteCommand van overerft.
  • ConcreteCommand  (AgentClick): De specifieke commands waar de acties daadwerkelijk gebeuren.
  • Client – MSTestvs2 (TestRunner): De testrunner die de testcases runt, in ons voorbeeld is dit MSTest (wordt niet genoemd in het voorbeeld).
  • Invoker (IKeyWord): Definieert welke AgentAction aangeroepen moet worden.
  • Receiver (IAgent): Zorgt voor het verloop van het uitvoeren van de commands.

Invoker

De Invoker-class is een class waar de data gedefinieerd wordt, maar nog niks concreets gedaan wordt.

In het voorbeeld zie je dat er twee objecten gedefinieerd worden: IUiControl en IAgentAction. De IUiControl is een beschrijving van wat voor element we nodig hebben bij de actie. In het voorbeeld zijn we op zoek naar een input-element met een bepaald ButtonId. Daarnaast creëren we via een factory-pattern een nieuwe klikactie. Dit is alleen nog de definitie, en nog niet daadwerkelijk de uitvoering.

Als laatste triggeren we hier de ExecuteCommand() van de IAgent-class. Normaal gesproken laat je de receiver de execute() van een command aanroepen. In ons geval triggeren wij de ExecuterCommand() van de receiver, die daarna pas het command gaat uitvoeren. Je ziet met dit voorbeeld dat design-patterns alleen als template gebruikt hoeven worden en dat je er waar nodig van kunt afwijken, mits de lagenstructuur hetzelfde blijft.

Receiver

De receiver-class heeft de verantwoordelijkheid om de uitvoer van het command juist te laten verlopen. Hierin staat dus geen directe reference met het command zelf. In ons geval hebben we hier een retry-controller gemaakt. Deze probeert een command in een try-/catch-functie net zolang uit te voeren totdat het goed gaat.

In dit voorbeeld zie je een while-loop die gedurende vijfentwintig seconden probeert de action.ExecuteAction aan te roepen. Als deze actie geslaagd is, dan stopt de loop. Deze maximale wachttijden kun je per test aanpassen. Je ziet dat deze laag alleen verantwoordelijk is voor het verloop van uitvoer, en niet voor het daadwerkelijk uitvoeren. Dit benadrukt de essentie van dat patroon, omdat er duidelijk onderscheid wordt gemaakt tussen hoe het command wordt uitgevoerd en het command zelf.

De thread.sleep() is een hele minimale sleep die nodig is om je testtool niet te snel te laten verlopen. Hierover hoor je vaak dat het bad-practice is. Daar ben ik het enigszins mee eens, aangezien je de CPU helemaal blokkeert. Maar je moet als testautomation-engineer altijd kiezen voor stabiele testcases. Je testcases moeten in 100% van de keren slagen en als een thread.sleep-functie hiervoor nodig is, dan is het geen probleem om het te gebruiken. De testen runnen meestal in de nacht, waardoor er genoeg tijd is om te testen.

Een functie die wacht totdat het element geladen is, werkt in de meeste gevallen wel, maar heel af en toe ook niet, omdat het te snel wordt uitgevoerd. Deze functie zie je in ons voorbeeld terug in de abstracte class-command.

Command

Het is hierbij belangrijk dat dit een interface of abstracte class is. Je voert ook hier de actie nog steeds niet uit. Wel kun je hier logic inzetten, die generiek is voor elke actie. In mijn voorbeeld wil ik dat bij elke actie deze stappen gedaan worden:

  • Er moet gewacht worden totdat de elementen zichtbaar en klikbaar zijn.
  • Er moet een rode highlight getekend worden om een element.
  • Er moet een screenshot gemaakt worden.

Hier kun je nog meer logica inzetten, zoals het resetten van de DOM bij IFrames of het switchen van tabbladen.

AgentAction is een abstract-class die van een interface overerft. Uiteindelijk roep je in deze method als laatste de Execute() aan. Deze Execute definieer je in de ConcreteClasses.

Voorbeeld rood omcirkelen element

Concrete-class

Na alle abstracte lagen zijn we eindelijk aangekomen bij de daadwerkelijke call. De concrete-class heeft als parent de abstracte class AgentAction. Hij moet de functie Execute() nog definiëren.

Bij dit patroon kun je op veel plekken logs inbouwen voor wat essentieel is bij jouw UI-testautomation. Omdat je de concrete acties los definieert, voorkom je dat je code dubbel gaat schrijven.

Page-objectmodel

Het page-objectmodel is een absolute musthave voor je framework. Dit patroon zorgt niet alleen voor beter leesbare testcases, maar ook voor minder onderhoud. Een page-objectmodel is een patroon waar er een vertaling wordt gemaakt tussen de functionele naam en de technische naam van elementen in een applicatie. Zo wordt bijvoorbeeld de functionele naam: “inlognaam” en de technische naam: “User_Login”. Je houdt door dit patroon de testcases en de locaties van de elementen gescheiden.

Voordelen:

  1. leesbare testcases
  2. onderhoudbaarheid van grote hoeveelheden testcases
  3. collega’s kunnen helpen met schrijven zonder technische kennis
  4. duidelijk gescheiden structuur tussen testcases en locaties van elementen

Hieronder staat een voorbeeld van een van de 3.000+ pagina’s van onze applicatie. Dit wordt automatisch als C#-class gegenereerd. De lastige naam, Scherm_CLI_CLI_C1, van de pagina is juist een van de redenen waarom je een page-objectmodel wilt gebruiken.

Als je in een testcase een referentie doet naar een van de elementen, dan doe je dit via de functionele naam. Daarna wordt in het page-objectmodel gekeken wat de technische naam is. Als je een applicatie hebt zonder juiste technische benamingen, kun je een page-objectmodel maken waarin deze manier van zoeken ook beschreven is. De test-ability van een applicatie is in zo’n situatie niet voldoende en je hebt als QA-engineer het recht om dit aan te laten passen door je ontwikkelaars. Lees deze blog voor meer informatie hierover: https://www.centric.eu/NL/Default/Craft/Blogs/2017/06/28/Als-tester-opkomen-voor-de-testability-hoe-dan.

De functionele naam is leesbaar en beter te begrijpen. Hierdoor kun je als team meerdere mensen mee laten helpen met het schrijven van testscripts.

Een page-objectmodel hoort een read-onlycollectie te zijn die onafhankelijk van een tool gelezen kan worden. Ook wil je een page-objectmodel het liefst genereren, aangezien het onmogelijk is om bij een grote applicatie het werk van alle ontwikkelaars bij te houden.

Een link met Selenium By() is niet aan te raden, aangezien je dan een afhankelijkheid creëert van Selenium. Wel kun je een eigen gemaakte by class gebruiken. Gebruik in C# liever een collectie van IEnumerable, dan een list<t>. De performance van de IEnumerable is, mits je deze juist gebruikt, sneller dan die van de list. Ook zorgt de IEnumerable ervoor dat je geen objecten in de collectie kan bewerken. Hierdoor kunnen er geen onverwachte problemen ontstaan, omdat een ander testscript het page-objectmodel veranderd heeft.

Samenvatting

Dit zijn voorbeelden van hoe je design-patterns kunt toevoegen bij testautomation. Ze dienen als beschrijving, niet als holy grail. Je kunt er in sommige gevallen een klein beetje van afwijken om voor elkaar te krijgen wat je zelf wilt.

Nadenken over de structuur van je framework en het gebruiken van design-patterns kun je in de toekomst maanden tijd schelen als er veranderingen optreden.

Een design-pattern is een generiek opgezette structuur die dient als template om veelvoorkomende softwareproblemen op te lossen en als beschrijving van een oplossing van bekende softwareproblemen.

Over Michiel

Craft expert Michiel Abel is onderdeel van het Team Testing binnen Craft, hét groeiprogramma voor IT'ers (powered by Centric). Wil je zijn blogs volgen? Schrijf je in voor de Craft-update.

Wil je meer weten over Craft, hét groeiprogramma voor IT'ers? Neem een kijkje op de website

Tags:Testing

     
Reacties
  • Centric
    Renzo Veldkamp
    02 augustus 2019
    Leuk artikel, Michiel! De tips zijn absoluut bruikbaar (natuurlijk ook voor andere toepassingen dan testautomatisering ;-) )
    Wees zorgvuldig met de casing van je code-statements: C# is case-sensitive.
Schrijf een reactie
  • Captcha image
  • Verzenden