Follow Us on Twitter

Optimaal Unittesten

Mei 2012 – Het unittesten van software heeft o.a. door de opkomst van Agile software-ontwikkeling een enorme vlucht genomen. Bij Agile methoden zoals Scrum, Extreme Programming en Test Driven Development (zie ook onze Whitebook Test first, implement later) vormt unittesten een centraal onderdeel van software-ontwikkeling. Goede unittests zorgen er o.a. voor dat geïntroduceerde bugs als gevolg van changes eerder worden opgemerkt, waarmee tijd en geld kan worden bespaard, en leiden over het algemeen tot kwalitatief betere software.
Toch komt het nog vaak voor, met name bij complexere applicaties, dat de testcases zich beperken tot integratietestcases of zelfs enkel tot functionele testcases.
In dit Whitebook wordt ingegaan op het belang en de voordelen van het bouwen van goede testsets. Naast integratietests wordt ook het belang aangestipt van echte unittests. Aan de hand van concrete voorbeelden worden enkele technieken toegelicht voor het maken van goede unit- en integratietests. Het een en ander wordt gedemonstreerd aan de hand van enkele eenvoudige JEE klassen.

Soorten tests

Op het gebied van automatisch testen zijn er grofweg drie categorieën te onderscheiden in volgorde van fijn- naar grofmazig:

  1. Unittesten:
    Een unittest is de meest fijnmazige test die er is. Idealiter test je hiermee een klasse of methode in isolatie. De meeste klassen hebben echter afhankelijkheden met andere klassen. Om dit te ondervangen kan gebruik worden gemaakt van zgn. stubs en/of mockobjecten. Een Unittest framework als JUnit of TestNG, evt. in combinatie met een mock framework, is hiervoor voldoende;
  2. Integratietesten:
    Een integratietest test een klasse in samenhang met zijn afhankelijkheden. Hierbij kan een onderscheid worden gemaakt tussen een horizontale integratietest binnen een applicatielaag (bv. het testen van business logica in de EJB laag) of een verticale integratietest tussen applicatielagen (bv. het testen van een session bean die een database bevraagt). Bij het uitvoeren van een horizontale integratietest kan vaak ook volstaan worden met JUnit. Het uitvoeren van een verticale integratietest is ingewikkelder. Hier komt vaak de interactie met een JEE container om de hoek kijken. Een framework als Cactus biedt hiervoor bv. ondersteuning. Recentelijk wordt steeds meer gebruik gemaakt van een embedded container. Dit is een lightweight container die via een API o.a. gestart en gestopt kan worden, waarmee je binnen een integratietest direct kan beschikken over de functionaliteit van een JEE container en je deze dus niet apart op een server hoeft te hosten;
  3. Functionele unittesten:
    Hierbij automatiseer je het testen van een complete use case. Een onderdeel van de applicatie wordt doorgetest van voorkant (UI) tot achterkant (database). Bij een webapplicatie kan hiervoor bv. gebruikt worden gemaakt van frameworks als HTMLUnit, HTTPUnit en Selenium.

De rest van dit Whitebook zal dieper ingaan op unit- en integratietests. Functionele unittests vallen buiten de scope van dit Whitebook.

Codevoorbeelden

In dit Whitebook zullen enkele begrippen aan de hand van code voorbeelden worden verduidelijkt. Hierbij worden twee eenvoudige JEE klassen als leidraad gebruikt: een JSF managed bean die code delegeert naar een geïnjecteerde stateless session bean. Vanuit een JSF pagina wordt een name doorgegeven en de managed bean plakt daar "Hello" voor en een uitroepteken achter (via delegatie) en stopt dit tenslotte in een result variabele.
Imports en getter en setter methoden zijn weggelaten om de code compact te houden. Zie de zipfile voor de volledige code.

JSF Managed Bean

package nl.whitehorses.ui;
...
@ManagedBean
@RequestScoped
public class HelloWorldManagedBean {
    private String name;
    private String result;
    private HelloBean helloWorld;

    // delegate to Session Bean
    public String sayHello() {
        helloWorld.sayHello(name);
        result = helloWorld.getResult();
        return "success"; }

    ...getters and setters...

    @EJB
    public void setHelloWorld(HelloBean helloWorld) {
        this.helloWorld = helloWorld; }
}

Stateless Session Bean interface

package nl.whitehorses.session;
...
@Local
public interface HelloBean {
    public void sayHello(String name);
    public java.lang.String getResult();
}
Stateless Session Bean implementatie
package nl.whitehorses.session;
...
@Stateless
@LocalBean
public class HelloWorldSessionBean implements HelloBean {
    private String result;

    public void sayHello(String name) {
        this.result = "Hello " + name + "!"; }

    public String getResult() {
        return result; }
}

Unittesten

Een unittest test een klasse in isolatie. Hiermee kan zeer fijnmazig een klasse doorgetest worden, waarbij gedeeltes van de code getest worden die via integratie- of functionele tests niet of zeer moeilijk geraakt kunnen worden. Indien eenmaal een goede unittest voor een klasse is opgezet, is hiermee een stuk kwaliteitsbewaking gerealiseerd. Als aanpassingen in de klasse a.g.v. een change bugs introduceren, zal dit leiden tot een falende unittest. Goede unittests zijn tevens van belang bij code refactoring. Ook in dat geval zullen bugs door falende unittests gesignaleerd worden. Het geeft een programmeur daarnaast meer vertrouwen over de kwaliteit van de code. Andersom kan het schrijven van unittests ook weer tot refactoring leiden. Indien het bv. te complex blijkt om een unittest voor een klasse te schrijven, is dit vaak ook een teken dat de klasse eenvoudiger kan, ofwel dat er niet aan het OO principe van high cohesion is voldaan.

JUnit

De de facto manier om java klassen te unittesten is door gebruik te maken van JUnit(http://www.junit.org/). Een standaard methodiek is dat je per te testen java klasse een testklasse bouwt, waarin alle methoden getest worden. Testklassen in JUnit zijn te bundelen in een TestSuite, waar verderop nog een voorbeeld van wordt getoond.
Met de komst van JEE 5, waarin o.a. de EJB-specificatie grondig overhoop werd gehaald en waarbij de lessons learned van frameworks als Spring zijn toegepast, is het een stuk eenvoudiger geworden om JEE componenten te testen. Net als Spring Beans zijn ook EJB's vanaf versie 3 (de versie behorend bij de JEE 5 spec) POJO's (Plain Old Java Objects) geworden. POJO's zijn eenvoudige Java Beans die ook eenvoudig getest kunnen worden. De sayHello methode kan m.b.v. JUnit als volgt worden getest:

EJB Unittest

package nl.whitehorses.session.unittests;
...
public class HelloWorldSessionBeanTest {
    @Test
    public void testSayHello() throws Exception {
        HelloWorldSessionBean helloWorldSessionBean = new HelloWorldSessionBean();
        helloWorldSessionBean.sayHello("Roger");
        String result = helloWorldSessionBean.getResult();
        assertEquals(result, "Hello Roger!"); }
}

Best practices hierbij zijn om in de naam van de testklasse, of testcase, “Test” op te nemen. Indien als buildtool Maven wordt gebruikt, wordt de klasse automatisch meegenomen in de test fase en hoeft er niets extra's geconfigureerd te worden (configuration by exception principe).

De @Test annotatie zorgt ervoor dat de methode als een uit te voeren test wordt gezien. Andere veelgebruikte JUnit annotaties zijn @Before en @After (code die voor en na elke testmethode moet worden uitgevoerd) en @BeforeClass en @AfterClass (code die aan het begin en aan het eind van een testklasse moet worden uitgevoerd). Verderop worden nog enkele voorbeelden van het gebruik van deze annotaties gegeven.

M.b.v. de JUnit assertEquals methode wordt gecontroleerd of aan het verwachte resultaat wordt voldaan. JUnit biedt een scala aan controle methodes. Naast het controleren of een waarde gelijk is aan een verwachte waarde, kan er bv. ook gecontroleerd worden of een variabele gevuld is (AssertNotNull) of dat er een exceptie optreedt (via de @Test annotatie).

De JSF Managed Bean heeft een afhankelijkheid naar de Session Bean en is daarmee lastiger om te unittesten. Om deze klasse te testen as een single unit, moet iets verzonnen worden om de session bean te simuleren. Hiervoor zijn twee veelgebruikte mechanismen voorhanden: stubs en mocks.

Stubs

Met behulp van een stub wordt de state van een afhankelijk object, d.w.z. de waarde van de instance variabelen, gesimuleerd. Onderstaande unittest geeft een voorbeeld. De stub, opgenomen als een inner klasse binnen de testklasse, implementeert de interface van de Session Bean en geeft altijd het verwachte resultaat terug.

Managed Bean Unittest met een stub

package nl.whitehorses.ui.unittests;
...
public class HelloWorldManagedBeanStubTest {
    @Test
    public void testSayHello() {
        String name = "Roger";
        HelloWorldManagedBean instance = new HelloWorldManagedBean();
        instance.setHelloWorld(new HelloBeanStub());
        instance.setName(name);
        String outcome = instance.sayHello();
        assertEquals("success", outcome);
        String result = instance.getResult();
        assertEquals("Hello Roger!", result);
    }

    class HelloBeanStub implements nl.whitehorses.session.HelloBean {
        public void sayHello(String name) {}

        public String getResult() {
            return "Hello Roger!"; } }
}

Aangezien de Managed Bean ook weer gewoon een POJO is, kan deze m.b.v. een stub simpel via JUnit ge-unittest worden. Via een setter wordt de stub in de Managed Bean geïnjecteerd. De getResult() simuleert de verwachte state van de stub, waarmee dus enkel de Managed Bean getest wordt zonder afhankelijkheden.
Tijdens het bouwen van de voorbeelden voor het Whitebook, is de managed bean klasse ook daadwerkelijk gerefactord, een punt dat eerder al is aangekaart, om het unittestsen middels een stub mogelijk te maken. Hiervoor zijn de volgende aanpassingen verricht:

  • Injectie van de EJB is verplaatst van de instance variabele naar een toegevoegde setter methode;
  • In plaats van een Managed Bean direct afhankelijk te maken van de EJB klasse, is een interface geïntroduceerd, die de EJB implementeert en waarvan de Managed Bean nu afhankelijk is.

Beide aanpassingen getuigen van toepassingen van goede oo principes, te weten encapsulation, coding against interfaces en loose coupling.

Mocks

Mockobjecten gaan verder dan stubs. Met mockobjecten wordt ook het verwachte gedrag gesimuleerd (de aanroep van de methoden). Met mocks kan dus gecontroleerd worden of bepaalde methodes daadwerkelijk zijn aangeroepen en dat dit in de juiste volgorde is gebeurd. Denk hierbij bv. aan een controle op het sluiten van een stream of database connectie of het valideren van een complexe if-constructie.

Managed Bean Unittest met een mock

package nl.whitehorses.ui.unittests;
...
public class HelloWorldManagedBeanMockTest {
    private HelloBean mockHelloBean;

    @Before
    public void setUp() {
        mockHelloBean = createMock("mockHelloBean", HelloBean.class); }

    @After
    public void tearDown() {
        verify( mockHelloBean ); }

    @Test
    public void testSayHello() {
        String name = "Roger";
        
        // Defining expectations
        mockHelloBean.sayHello(name);
        expect(mockHelloBean.getResult()).andReturn("Hello Roger!");
        // Finished
        replay(mockHelloBean);

        HelloWorldManagedBean instance = new HelloWorldManagedBean();
        instance.setName(name);
        instance.setHelloWorld(mockHelloBean);

        String outcome = instance.sayHello();
        assertEquals("success", outcome);
        String result = instance.getResult();
        assertEquals("Hello Roger!", result); }
}

In bovenstaande code is gebruik gemaakt van het mocking framework EasyMock (http://www.easymock.org/). Een ander veelgebruikt framework is jMock (http://www.jmock.org/). Met behulp van de JUnit Before en After annotaties wordt pre-, resp. post-code uitgevoerd om de Test code heen.
In de Before code wordt de mock m.b.v. de EasyMock methode createMock gecreëerd. De basis van de de mock is de HelloBean interface. In de testcode wordt vervolgens eerst het verwachte gedrag van de mock uitgecodeerd, afgesloten met de aanroep van de EasyMock replay methode. Dit is een standaard opzet die afgedwongen wordt door EasyMock. Methoden die niets (void) retourneren, zoals de sayHello methode, worden rechtstreeks vanuit de mock aangeroepen. Voor methodes met een return waarde, zoals de getResult methode, moet ook het verwachte resultaat gedefinieerd worden. Dit gebeurt m.b.v. de EasyMock expect en andReturn methodes.

De mock wordt middels een setter aan de Managed Bean gekoppeld. Vervolgens worden analoog aan de stub test diverse unittests uitgevoerd, waarbij het verwachte gedrag van de session bean wordt gesimuleerd. Als laatste controle wordt in de After code nog geverifieerd of daadwerkelijk de verwachte methoden zijn aangeroepen en of dit in de juiste volgorde is gebeurt.
Het EasyMock framework ondersteunt naast gemockte interfaces ook het mocken van klassen, waarmee de eerder genoemde refactoring naar interfaces niet perse noodzakelijk is.

Een nadeel van mocks t.o.v. stubs is dat ze vaak tot complexere testcode leiden. Daar staat tegenover dat met mocks klassen meer fijnmazig getest kunnen worden. Een framework als EasyMock kan het werk daarnaast enorm verlichten.

Integratietests

Een integratietest is bedoeld om de samenhang tussen softwareonderdelen te testen. Ook de interactie met een JEE container valt hieronder. Een integratietest komt hiermee iets dichter bij een functionele test.
Net als unittests zorgen integratietests voor een stuk kwaliteitsbewaking. Aanpassingen in klassen die geraakt worden door een integratietest, worden door die test meteen gevalideerd.

In container testen

Sinds de JEE6 specificatie is het verplicht voor een JEE server implementatie (Glassfish, JBoss) om ook een embedded container aan te leveren. Hiermee kunnen JEE klassen getest worden in samenhang met een container, waarbij dus zaken als dependency injection en jndi lookups mogelijk zijn. In container testen valt onder de categorie integratietests, waarbij klassen in onderlinge samenhang (en/of dus in samenhang met een JEE container) worden getest.

In container test Session Bean, Testsuite

package nl.whitehorses.integrationtests;
…
@RunWith(value = Suite.class)
@Suite.SuiteClasses(value = {HelloWorldSessionBeanIT.class})
public class ITSuite {

    private static EJBContainer container;

    @BeforeClass
    public static void setUp() throws Exception {
        container = EJBContainer.createEJBContainer(); }

    @AfterClass
    public static void tearDownClass() throws Exception {
        container.close(); }
}

In container test Session Bean, Testcase

package nl.whitehorses.integrationtests;
…
public class HelloWorldSessionBeanIT {

    private static Context ctx;

    @BeforeClass
    public static void setUpClass() throws Exception {
        ctx = new InitialContext(); }

    @Test
    public void testSayHello() throws Exception {
        HelloBean instance = (HelloBean) ctx.lookup("java:global/classes/HelloWorldSessionBean!nl.whitehorses.session.HelloBean");
        instance.sayHello("Roger");
        String result = instance.getResult();
        assertEquals(result, "Hello Roger!"); }
}

Omdat het opstarten van een container tijd kost, is het aan te raden om met een Testsuite te werken die de container eenmalig start voor het uitvoeren van de bijbehorende testcases en uiteindelijk ook weer stopt. Zoals in de code te zien is, kan een JNDI lookup uitgevoerd worden om de Session Bean op te halen (dit in tegenstelling tot de unittest die deze bean mockt/stubt).

Om onderscheid te maken tussen unittests en integratietest, is in de naamgeving van deze klasse ipv "Test" "IT" gebruikt. Indien de maven-failsafe plugin (http://maven.apache.org/plugins/maven-failsafe-plugin/) wordt gebruikt (die per default de testen met “IT” in de naamgeving uitvoert), heeft dit als voordeel dat deze tests in de integratietest fase van de Maven build cycle worden uitgevoerd, waar ze ook thuishoren.

Continuous Integration

Veelgebruikte buildtools voor JEE projecten zijn Ant en Maven. Met behulp van Ant tasks of Maven plugins kunnen unit- en integratietesten automatisch onderdeel uitmaken van een build cycle. De volgende logische stap is dan het opzetten van een build server, waarmee builds continu en op de achtergrond kunnen worden uitgevoerd (zie ook: http://www.whitehorses.nl/whitebooks/2007/voortdurend-integreren-voortdurend-controle). Hudson en Jenkins zijn veelgebruikte buildservers, die naadloos aansluiten op Ant en Maven. Hiermee kan bv. bij elke codewijziging in het versiebeheer systeem, een automitische taak afgetrapt worden, die een volledige build inclusief alle testen uitvoert. Hiermee kan al heel vroeg gesignaleerd worden of een wijziging fouten heeft geïntroduceerd. Daarmee is ook meteen de oorzaak makkelijker op te sporen.

Conclusie

In dit Whitebook is het belang is van geautomatiseerd testen aangestipt:

  • Automatisch testen leidt tot kwalitatief betere software;
  • Een goede testset kan dienst doen als regressietestset bij refactoring en changes;
  • Het testbaar maken van code kan tevens tot betere code leiden;
  • Bugs worden over het algemeen in een vroeger stadium opgemerkt.

Een onderscheid kan worden gemaakt tussen geautomatiseerde unittests en integratietests. Unittests zijn fijnmazige tests en testen dus een klein gedeelte van een systeem, terwijl integratietests grofmazig zijn en meer de samenhang tussen onderdelen testen. Om een applicatie goed door te testen is een gebalanceerde mix van beide noodzakelijk.

Hoe meer onderdelen van een systeem automatisch getest worden, hoe lager het risico is dat aanpassingen onbedoelde fouten introduceren, hetgeen de kwaliteit van een systeem uiteraard ten goede komt. Het is goed om te realiseren dat testcode een wezenlijk onderdeel vormt van de codebase van een systeem en dus ook onderhouden zal moeten worden.

Het goed opzetten van unit- en integratietests is met de komst van nieuwe JEE specificaties een stuk eenvoudiger geworden. Met behulp van JUnit testklassen in combinatie met mocks of stubs kunnen goede unittests opgezet worden. Integratietesten is m.b.v. een embedded container ook een stuk eenvoudiger geworden.
Het opleveren van goed geteste software is enorm waardevol en er zijn ook nauwelijks meer redenen te bedenken om dit niet zoveel mogelijk geautomatiseerd te doen.

Referenties

Waardering:
 

Reacties

Nieuwe reactie inzenden

De inhoud van dit veld is privé en zal niet openbaar worden gemaakt.

Meer informatie over formaatmogelijkheden

CAPTCHA
Deze vraag is om te testen of u een persoon bent en om spam te voorkomen
Image CAPTCHA
Enter the characters shown in the image.