Java >> Java-zelfstudie >  >> Tag >> JUnit

JUnit in een notendop:testisolatie

Als consultant ontmoet ik nog steeds heel vaak programmeurs, die hoogstens een vaag begrip hebben van JUnit en het juiste gebruik ervan. Dit bracht me op het idee om een ​​meerdelige tutorial te schrijven om de essentie vanuit mijn oogpunt uit te leggen.

Ondanks het bestaan ​​van een aantal goede boeken en artikelen over testen met de tool, is de praktische benadering van deze miniserie misschien geschikt om een ​​of twee extra ontwikkelaars te interesseren voor unit testing - wat de moeite de moeite waard zou maken.

Merk op dat de focus van dit hoofdstuk ligt op fundamentele unit-testtechnieken in plaats van op JUnit-functies of API. Meer van de laatste zal worden behandeld in de volgende berichten. De nomenclatuur die wordt gebruikt om de technieken te beschrijven, is gebaseerd op de definities die worden gepresenteerd in de xUnit Test Patterns [MES] van Meszaros.

Eerder op JUnit in een notendop

De tutorial begon met een Hello World-hoofdstuk, waarin de basisprincipes van een test werden geïntroduceerd:hoe deze wordt geschreven, uitgevoerd en geëvalueerd. Het ging verder met de post-teststructuur, waarin de vier fasen (instellen, oefenen, verifiëren en afbreken) werden uitgelegd die gewoonlijk worden gebruikt om eenheidstests te structureren.

De lessen gingen vergezeld van een consistent voorbeeld om de abstracte concepten begrijpelijker te maken. Er werd gedemonstreerd hoe een testcase beetje bij beetje groeit - beginnend met een gelukkig pad tot en met corner case-tests, inclusief verwachte uitzonderingen.

Over het algemeen werd benadrukt dat een test meer is dan een eenvoudige verificatiemachine en ook kan dienen als een soort specificatie op laag niveau. Daarom moet het worden ontwikkeld met de hoogst mogelijke coderingsstandaarden die je maar kunt bedenken.

Afhankelijkheden

It takes two to tango
Spreuk

Het voorbeeld dat in deze zelfstudie wordt gebruikt, gaat over het schrijven van een eenvoudige nummerbereikteller, die een bepaald aantal opeenvolgende gehele getallen levert, beginnend bij een bepaalde waarde. Een testcase waarin het gedrag van het apparaat wordt gespecificeerd, kan er in fragmenten als volgt uitzien:

public class NumberRangeCounterTest {
  
  private static final int LOWER_BOUND = 1000;
  private static final int RANGE = 1000;
  private static final int ZERO_RANGE = 0;
  
  private NumberRangeCounter counter
    = new NumberRangeCounter( LOWER_BOUND, RANGE );
  
  @Test
  public void subsequentNumber() {
    int first = counter.next();
    int second = counter.next();
    
    assertEquals( first + 1, second );
  }
  
  @Test
  public void lowerBound() {
    int actual = counter.next();
    
    assertEquals( LOWER_BOUND, actual );
  }
  
  @Test( expected = IllegalStateException.class )
  public void exeedsRange() {
    new NumberRangeCounter( LOWER_BOUND, ZERO_RANGE ).next();
  }

  [...]
}

Merk op dat ik hier met een vrij compacte testcase ga om ruimte te besparen, bijvoorbeeld met behulp van impliciete armatuurconfiguratie en uitzonderingsverificatie. Zie het vorige hoofdstuk voor een gedetailleerde bespreking van teststructureringspatronen.

Merk ook op dat ik voor verificatie bij de ingebouwde JUnit-functionaliteit blijf. Ik zal de voor- en nadelen van bepaalde matcherbibliotheken (Hamcrest, AssertJ) in een apart bericht behandelen.

Terwijl de NumberRangeCounter Hoewel de aanvankelijke beschrijving voldoende was om deze tutorial op gang te brengen, is het de oplettende lezer misschien opgevallen dat de aanpak weliswaar een beetje naïef was. Bedenk bijvoorbeeld dat het proces van een programma kan worden beëindigd. Om de teller correct te kunnen herinitialiseren bij het opnieuw opstarten van het systeem, moet deze ten minste de laatste status behouden hebben.

Het aanhouden van de status van de teller omvat echter toegang tot bronnen (database, bestandssysteem en dergelijke) via softwarecomponenten (databasestuurprogramma, bestandssysteem-API enz.) die geen deel uitmaken van de eenheid, oftewel het te testen systeem (SUT). Dit betekent dat de eenheid afhankelijk op dergelijke componenten, die Meszaros beschrijft met de term depended-on component (DOC) .

Helaas brengt dit in veel opzichten testgerelateerde problemen met zich mee:

  1. Afhankelijk van de componenten die we niet kunnen controleren, kan dit een behoorlijke verificatie van een testspecificatie in de weg staan. Denk maar aan een echte webservice die soms niet beschikbaar kan zijn. Dit kan de oorzaak zijn van een testfout, hoewel de SUT zelf goed werkt.
  2. DOC's kunnen ook de uitvoering van tests vertragen. Om ervoor te zorgen dat unit-tests fungeren als vangnet de complete testsuite van een systeem in ontwikkeling moet heel vaak worden uitgevoerd. Dit is alleen mogelijk als elke test ongelooflijk snel verloopt. Denk opnieuw aan het voorbeeld van de webservice.
  3. Last but not least kan het gedrag van een DOC onverwacht veranderen, bijvoorbeeld door het gebruik van een nieuwere versie van een bibliotheek van derden. Dit laat zien hoe direct afhankelijk van componenten die we niet kunnen controleren een test fragiel maakt .

Dus wat kunnen we doen om deze problemen te omzeilen?

Isolatie – het SEP-veld van een unittester

Een SEP is iets dat we niet kunnen zien, of niet zien, of ons brein laat ons niet zien, omdat we denken dat het S is iemand E lse's P roblem….
Ford Prefect

Omdat we niet willen dat onze unit-tests afhankelijk zijn van het gedrag van een DOC, en ook niet dat ze traag of kwetsbaar zijn, streven we ernaar om onze unit zoveel mogelijk af te schermen van alle andere delen van de software. Luchthartig gesproken maken we deze specifieke problemen de zorg van andere testtypes - vandaar het grapje SEP Field citaat.

In het algemeen staat dit principe bekend als Isolatie van de SUT en spreekt de ambitie uit om problemen afzonderlijk te testen en houd tests onafhankelijk van elkaar. In de praktijk houdt dit in dat een unit zo moet worden ontworpen dat elke DOC kan worden vervangen door een zogenaamde Test Double , wat een lichtgewicht stand-in component is voor de DOC [MES1].

Met betrekking tot ons voorbeeld kunnen we besluiten om geen toegang te krijgen tot een database, bestandssysteem of iets dergelijks rechtstreeks vanuit de unit zelf. In plaats daarvan kunnen we ervoor kiezen om deze zorg op te splitsen in een afschermingsinterfacetype, zonder geïnteresseerd te zijn in hoe een concrete implementatie eruit zou zien.

Hoewel deze keuze zeker ook redelijk is vanuit een low-level ontwerpoogpunt, legt het niet uit hoe het testdubbel wordt gemaakt, geïnstalleerd en gebruikt tijdens een test. Maar voordat we ingaan op het gebruik van dubbels, is er nog een onderwerp dat moet worden besproken.

Indirecte in- en uitgangen

Tot dusver hebben onze testinspanningen ons geconfronteerd met directe alleen in- en uitgangen van de SUT. D.w.z. elk exemplaar van NumberRangeCounter is voorzien van een ondergrens en een bereikwaarde (directe invoer). En na elke oproep naar next() de SUT retourneert een waarde of genereert een uitzondering (directe uitvoer) die wordt gebruikt om het verwachte gedrag van de SUT te verifiëren.

Maar nu wordt de situatie een beetje ingewikkelder. Aangezien de DOC de laatste tellerwaarde voor SUT-initialisatie levert, is het resultaat van next() hangt af van deze waarde. Als een DOC op deze manier de SUT-invoer levert, spreken we van indirecte invoer .

Omgekeerd ervan uitgaande dat elke aanroep van next() de huidige status van de teller zou behouden, hebben we geen kans om dit te verifiëren via directe uitgangen van de SUT. Maar we kunnen controleren of de status van de teller is gedelegeerd aan het DOC. Dit soort delegatie wordt aangeduid als indirecte output .

Met deze nieuwe kennis zouden we voorbereid moeten zijn om door te gaan met de NumberRangeCounter voorbeeld.

Indirecte invoer regelen met stubs

Van wat we hebben geleerd, zou het waarschijnlijk een goed idee zijn om het staatsbehoud van de balie op te splitsen in een eigen soort. Dit type zou de SUT isoleren van de daadwerkelijke opslagimplementatie, aangezien we vanuit het oogpunt van de SUT niet geïnteresseerd zijn in hoe het probleem van bewaring daadwerkelijk is opgelost. Om die reden introduceren we de interface CounterStorage .

Hoewel er tot nu toe geen echte opslagimplementatie is, kunnen we in plaats daarvan doorgaan met een testdubbel. Het is triviaal om op dit moment een dubbel testtype te maken, aangezien de interface nog geen methoden heeft.

public class CounterStorageDouble implements CounterStorage {
}

Om de opslagruimte te bieden voor een NumberRangeCounter op een losjes gekoppelde manier kunnen we afhankelijkheidsinjectie . gebruiken . Het verbeteren van de impliciete armatuurconfiguratie met een opslagtestdubbel en het injecteren ervan in de SUT kan er als volgt uitzien:

private CounterStorage storage;

  @Before
  public void setUp() {
    storage = new CounterStorageDouble();
    counter = new NumberRangeCounter( storage, LOWER_BOUND, RANGE );
  }

Nadat de compileerfouten zijn verholpen en alle tests zijn uitgevoerd, zou de balk groen moeten blijven, omdat we nog geen gedrag hebben gewijzigd. Maar nu willen we de eerste aanroep van NumberRangeCounter#next() om de staat van de opslag te respecteren. Als de opslag een waarde biedt n binnen het gedefinieerde bereik van de teller, de eerste aanroep van next() moet ook n teruggeven , wat wordt uitgedrukt door de volgende test:

private static final int IN_RANGE_NUMBER = LOWER_BOUND + RANGE / 2;

  [...]

  @Test
  public void initialNumberFromStorage() {
    storage.setNumber( IN_RANGE_NUMBER );
    
    int actual = counter.next();
    
    assertEquals( IN_RANGE_NUMBER, actual );
  }

Onze testdubbel moet een deterministische indirecte invoer geven, in ons geval de IN_RANGE_NUMBER . Hierdoor is het uitgerust met de waarde met behulp van setNumber(int) . Maar omdat de opslag nog niet wordt gebruikt, mislukt de test. Om dit te veranderen wordt het tijd om de CounterStorage . te declareren 's eerste methode:

public interface CounterStorage {
  int getNumber();
}

Wat ons in staat stelt om de test dubbel als volgt te implementeren:

public class CounterStorageDouble implements CounterStorage {

  private int number;

  public void setNumber( int number ) {
    this.number = number;
  }

  @Override  
  public int getNumber() {
    return number;
  }
}

Zoals je kunt zien zijn de dubbele werktuigen getNumber() door een configuratiewaarde te retourneren die is ingevoerd door setNumber(int) . Een testdubbel die op deze manier indirecte invoer geeft, wordt een stub . genoemd . Nu zouden we het verwachte gedrag van NumberRangeCounter . kunnen implementeren en slaag voor de test.

Als je denkt dat get/setNumber slechte namen geeft om het gedrag van een opslag te beschrijven, ben ik het daarmee eens. Maar het vergemakkelijkt de evolutie van de post. Voel je uitgenodigd om goed doordachte refactoring voorstellen te doen...

Indirecte uitvoerverificatie met spionnen

Om een ​​NumberRangeCounter . te kunnen herstellen instantie na het opnieuw opstarten van het systeem, verwachten we dat elke statuswijziging van een teller wordt voortgezet. Dit kan worden bereikt door de huidige status naar de opslag te verzenden telkens wanneer een oproep naar next() komt voor. Daarom voegen we een methode toe setNumber(int) naar ons DOC-type:

public interface CounterStorage {
  int getNumber();
  void setNumber( int number );
}

Wat een vreemd toeval dat de nieuwe methode dezelfde signatuur heeft als die waarmee onze stub is geconfigureerd! Na het wijzigen van die methode met @Override het is gemakkelijk om onze armatuurconfiguratie opnieuw te gebruiken voor de volgende test:

@Test
  public void storageOfStateChange() {
    counter.next();
    
    assertEquals( LOWER_BOUND + 1, storage.getNumber() );
  }

Vergeleken met de initiële status verwachten we dat de nieuwe status van de teller met één wordt verhoogd na een oproep naar next() . Belangrijker is dat we verwachten dat deze nieuwe status wordt doorgegeven aan de opslag-DOC als een indirecte uitvoer. Helaas zijn we niet getuige de daadwerkelijke aanroep, dus we opnemen het resultaat van de aanroep in de lokale variabele van onze double.

De verificatiefase leidt hieruit af dat de juiste indirecte output is doorgegeven aan het DOC, als de geregistreerde waarde overeenkomt met de verwachte. Het vastleggen van de staat en/of het gedrag voor latere verificatie, hierboven beschreven op de eenvoudigste manier, wordt ook aangeduid als spionage. Een testdubbel waarbij deze techniek wordt gebruikt, wordt daarom een ​​spion genoemd .

Hoe zit het met spotten?

Er is nog een mogelijkheid om de indirecte uitvoer van next() . te verifiëren door een schijn . te gebruiken . Het belangrijkste kenmerk van dit type doublet is dat de indirecte outputverificatie wordt uitgevoerd binnen de delegatiemethode. Bovendien zorgt het ervoor dat de verwachte methode daadwerkelijk is aangeroepen:

public class CounterStorageMock implements CounterStorage {

  private int expectedNumber;
  private boolean done;

  public CounterStorageMock( int expectedNumber ) {
    this.expectedNumber = expectedNumber;
  }

  @Override
  public void setNumber( int actualNumber ) {
    assertEquals( expectedNumber, actualNumber );
    done = true;
  }

  public void verify() {
    assertTrue( done );
  }

  @Override
  public int getNumber() {
    return 0;
  }
}

Een CounterStorageMock instantie is geconfigureerd met de verwachte waarde door een constructorparameter. Als setNumber(int) wordt aangeroepen, wordt direct gecontroleerd of de opgegeven waarde overeenkomt met de verwachte waarde. Een vlag slaat de informatie op dat de methode is aangeroepen. Dit maakt het mogelijk om de daadwerkelijke aanroep te controleren met behulp van de verify() methode.

En dit is hoe de storageOfStateChange test kan eruit zien als een mock:

@Test
  public void storageOfStateChange() {
    CounterStorageMock storage
      = new CounterStorageMock( LOWER_BOUND + 1 );
    NumberRangeCounter counter
      = new NumberRangeCounter( storage, LOWER_BOUND, RANGE );

    counter.next();
    
    storage.verify();
  }

Zoals u kunt zien, is er geen specificatieverificatie meer in de test. En het lijkt vreemd dat de gebruikelijke teststructuur een beetje is verdraaid. Dit komt omdat de verificatievoorwaarde wordt gespecificeerd voorafgaand aan de trainingsfase in het midden van de inrichting van de armatuur. Alleen de schijnaanroepcontrole blijft over in de verificatiefase.

Maar in ruil daarvoor biedt een mock een nauwkeurige stacktrace in het geval dat gedragsverificatie mislukt, wat de probleemanalyse kan vergemakkelijken. Als u nogmaals naar de spionageoplossing kijkt, zult u zien dat een storingsspoor alleen zou verwijzen naar het verificatiegedeelte van de test. Er zou geen informatie zijn over de regel productiecode die de test daadwerkelijk heeft doen mislukken.

Bij een mock is dit heel anders. De tracering zou ons precies de positie laten identificeren waar setNumber(int) heette. Met deze informatie kunnen we gemakkelijk een breekpunt instellen en de problematische kwestie debuggen.

Vanwege de reikwijdte van dit bericht heb ik de dubbele introductie van tests beperkt tot stubs, spionnen en mocks. Voor een korte uitleg over de andere typen kun je kijken in Martin Fowler's post TestDouble, maar de diepgaande uitleg van alle typen en hun variaties is te vinden in Meszaros' xUnit Test Patterns boek [MES].

Een goede vergelijking van mock versus spion op basis van dubbele testframeworks (zie volgende sectie) is te vinden in Tomek Kaczanowski's boek Practical Unit Testing with JUnit and Mockito [KAC].

Na het lezen van dit gedeelte heb je misschien de indruk dat het schrijven van al die testdubbels vervelend werk is. Het is niet erg verrassend dat er bibliotheken zijn geschreven om dubbele verwerking aanzienlijk te vereenvoudigen.

Dubbele kaders testen – Het beloofde land?

Als je alleen maar een hamer hebt, lijkt alles op een spijker
Spreuk

Er zijn een aantal frameworks ontwikkeld om het gebruik van test doubles te vergemakkelijken. Helaas doen deze bibliotheken het niet altijd goed met betrekking tot een nauwkeurige dubbele testterminologie. Terwijl bijv. JMock en EasyMock richten zich op spot, Mockito is ondanks zijn naam spiongericht. Misschien praten de meeste mensen daarom over spotten , ongeacht wat voor soort dubbel ze daadwerkelijk gebruiken.

Desalniettemin zijn er aanwijzingen dat Mockito op dit moment het geprefereerde testdubbelgereedschap is. Ik denk dat dit komt omdat het een goed leesbare, vloeiende interface-API biedt en het nadeel van de hierboven genoemde spionnen een beetje compenseert, door gedetailleerde verificatiefoutberichten te leveren.

Zonder in detail te treden geef ik een versie van de storageOfStateChange() test, die Mockito gebruikt voor het maken van spionage en testverificatie. Merk op dat mock en verify zijn statische methoden van het type Mockito . Het is gebruikelijk om statische import te gebruiken met Mockito-expressies om de leesbaarheid te verbeteren:

@Test
  public void storageOfStateChange() {
    CounterStorage storage = mock( CounterStorage.class );
    NumberRangeCounter counter 
      = new NumberRangeCounter( storage, LOWER_BOUND, RANGE );
    
    counter.next();

    verify( storage ).setNumber( LOWER_BOUND + 1 );
  }

Er is veel geschreven over het al dan niet gebruiken van dergelijke tools. Robert C. Martin geeft bijvoorbeeld de voorkeur aan handgeschreven dubbels en Michael Boldischar overweegt zelfs om te spottend kaders schadelijk. De laatste beschrijft gewoon misbruik naar mijn mening en voor een keer ben ik het niet eens met Martin die zegt:'Die spots schrijven' is triviaal.’

Ik gebruik al jaren zelfgeschreven dubbels voordat ik Mockito ontdekte. Ik was meteen verkocht aan de vloeiende syntaxis van stubbing, de intuïtieve manier van verifiëren en ik vond het een verbetering om van die krabbenige dubbeltypes af te komen. Maar dit is zeker in het oog van de toeschouwer.

Ik heb echter ervaren dat test-double-tools ontwikkelaars verleiden om dingen te overdrijven. Het is bijvoorbeeld heel eenvoudig om componenten van derden, die anders duur zouden kunnen zijn om te maken, te vervangen door dubbele componenten. Maar dit wordt als een slechte gewoonte beschouwd en Steve Freeman en Nat Pryce leggen in detail uit waarom je alleen moet spotten met typen die je bezit [FRE_PRY].

Code-aanroepen van derden voor integratietests en een abstracte adapterlaag . Dit laatste is eigenlijk wat we in ons voorbeeld hebben aangegeven door de CounterStorage . te introduceren . En zoals we eigen de adapter, kunnen we deze veilig vervangen door een dubbele.

De tweede valkuil waar men gemakkelijk in loopt, is het schrijven van tests, waarbij een testdubbel een andere testdubbel oplevert. Als je op dit punt komt, moet je het ontwerp van de code waarmee je werkt heroverwegen. Het overtreedt waarschijnlijk de wet van demeter, wat betekent dat er iets mis kan zijn met de manier waarop uw objecten aan elkaar zijn gekoppeld.

Last but not least, als u overweegt om met een dubbel testraamwerk te gaan, moet u er rekening mee houden dat dit meestal een langetermijnbeslissing is die een heel team aangaat. Het is waarschijnlijk niet het beste idee om verschillende frameworks te mixen vanwege een coherente codeerstijl en zelfs als je er maar één gebruikt, moet elk (nieuw) lid de toolspecifieke API leren.

Voordat je testdubbels uitgebreid gaat gebruiken, kun je overwegen om Martin Fowler's Mocks Are not Stubs te lezen, waarin klassieke versus mockist-tests worden vergeleken, of Robert C. Martin's When to Mock, dat enkele heuristieken introduceert om de gulden snede tussen geen dubbels en te veel te vinden verdubbelt. Of zoals Tomek Kaczanowski het zegt:

'Enthousiast dat je overal de spot mee kunt drijven, hè? Vertraag en zorg ervoor dat u interacties echt moet verifiëren. De kans is groot dat je dat niet doet.' [KAC1]

Conclusie

Dit hoofdstuk van JUnit in een notendop besprak de implicaties van eenheidsafhankelijkheden voor testen. Het illustreerde het principe van isolatie en liet zien hoe het in de praktijk kan worden gebracht door DOC's te vervangen door testdubbels. In deze context werd het concept van indirecte in- en outputs gepresenteerd en werd de relevantie ervan voor testen beschreven.

Het voorbeeld verdiepte de kennis met praktische voorbeelden en introduceerde verschillende testdubbeltypen en hun gebruiksdoel. Tenslotte maakte een korte uitleg van test double frameworks en hun voor- en nadelen dit hoofdstuk af. Het was hopelijk evenwichtig genoeg om een ​​begrijpelijk overzicht van het onderwerp te geven zonder triviaal te zijn. Suggesties voor verbeteringen worden uiteraard zeer op prijs gesteld.

Het volgende bericht van de tutorial gaat over JUnit-functies zoals Lopers en regels en laat zien hoe u ze kunt gebruiken aan de hand van het lopende voorbeeld.

Referenties

[MES] xUnit-testpatronen, Gerard Meszaros, 2007
[MES1] xUnit-testpatronen, hoofdstuk 5, Principe:isoleer de SUT, Gerard Meszaros, 2007
[KAC] Praktische eenheidstests met JUnit en Mockito, Bijlage C. Test Spy vs. Mock, Tomek Kaczanowski, 2013
[KAC1] Slechte tests, goede tests, Hoofdstuk 4, Onderhoudbaarheid, Tomek Kaczanowski, 2013
[FRE_PRY] Groeiende objectgeoriënteerde software, geleid door Tests, Hoofdstuk 8, Steve Freeman, Nat Pryce, 2010
Java-tag