Detta är den andra delen av tre i en artikelserie om automatiserade integrationstester. Den första delen ger en kort beskrivning av syftet med integrationstester och de utmaningar som ofta uppstår vid testning. Där visar jag också hur man kan parallellisera tester i JUnit för att minska exekveringstiden.

Denna del visar på användningen av parametriserade tester och hur man på ett bra sätt kan använda extern testdata (testfixtures).

Den sista delen visar hur man kan koppla ihop sina testsviter med open source-verktyget TestLink för att få fram trevliga rapporter.

Parametriserade tester

Tekniken med en egen testrunner kan också användas för att parametrisera ett testfall, dvs låta samma test köras flera gånger med olika testdata.

Det finns inbyggt stöd för parametriserade testfall i JUnit4 via testrunnerklassen org.junit.runners.Parameterized.

Designen av Parameterized förutsätter att testklassen implementerar en klassmetod som returnerar en lista av objektmatriser (”object arrays”) där varje element i en sådan matris motsvaras av ett argument i testklassens konstruktor. Parameterized ansvarar sedan för att skapa en instans av testklassen för varje objektmatris och därefter köra testmetoden.

Ett exempel på en trivial parametriserad test av en fiktiv Calculator:

package se.cygni.calculatortest;

import static org.junit.Assert.*;

import java.util.Arrays;
import java.util.List;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;

@RunWith(Parameterized.class)
public class ParameterizedCalculatorTest {

    public enum Operation {
        ADD("+"), SUB("-"), MULT("*"), DIV("/");
        private String operator;
        private Operation(String operator) {
            this.operator = operator;
        }
        public String operator() {
            return operator;
        }
    }
    @Parameters
    public static List<Object[]> data() {
        return Arrays.asList(new Object[][] {
                {"ADD",1, 0 , 1 }, {"ADD", 3, 4, 7 },
                {"SUB", 2, 1, 1 }, {"SUB",3, 6, -3},
                {"MULT",4, 3, 12 },{"MULT",5, -5, -25},
                {"DIV" 16, 8, 2 }, {"DIV" 16, -4, -4 }});
    }

    private Operation operation;
    private int num1;
    private int num2;
    private int expected;

    public ParameterizedCalculatorTest(String operation,int num1, int num2,int expected) {
        this.operation = Operation.value(operation);
        this.num1= num1;
        this.num2= num2;
        this.expected = expected;
    }

    @Test
    public void test() {
        switch (operation) {
            case ADD:
                assertEquals(expected, Calculator.add(num1, num2));
                break;
            case SUB:
                assertEquals(expected, Calculator.subtract(num1, num2));
                break;
            case MULT:
                assertEquals(expected, Calculator.multiply(num1, num2));
                break;
            case DIV:
                assertEquals(expected, Calculator.divide(num1, num2));
                break;
        }
    }
}

Exempel ute på nätet kan ge ett intryck av att data i objektmatrisen måste vara primitiver, dvs int, long etc, men så är inte fallet. I det triviala exemplet ovan kan du exempelvis låta ditt testdata representeras av en CalculatorTestFixture:

package se.cygni.calculatortest;

import se.cygni.calculatortest.ParameterizedCalculatorTest.Operation;

public class CalculatorTestFixture {
    Operation operation;
    int num1;
    int num2;
    int expected;

    public CalculatorTestFixture(String operation,int num1, int num2, int expected) {
        this.operation = Operation.valueOf(operation);
        this.num1 = num1;
        this.num2 = num2;
        this.expected = expected;
    }
    public Operation getOperation() {
        return operation;
    }
    public int getNum1() {
        return num1;
    }
    public int getNum2() {
        return num2;
    }
    public int getExpected() {
        return expected;
    }
}

Testklassen skulle då behöva ändras på följande sätt:

@RunWith(Parameterized.class)
public class ParameterizedCalculatorTest {
    public enum Operation {
        ADD("+"), SUB("-"), MULT("*"), DIV("/");
        private String operator;
        private Operation(String operator) {
            this.operator = operator;
        }
        public String operator() {
            return operator;
        }
    }

    @Parameters
    public static List<Object[]> data() {
        return Arrays.asList(new Object[][] {
                {new CalculatorTestFixture("ADD",1, 0 , 1) }, {new CalculatorTestFixture("ADD", 3, 4, 7) },
                {new CalculatorTestFixture("SUB", 2, 1, 1) }, {new CalculatorTestFixture("SUB",3, 6, -3)},
                {new CalculatorTestFixture("MULT",4, 3, 12) },{new CalculatorTestFixture("MULT",5, -5, -25)},
                {new CalculatorTestFixture("DIV", 16, 8, 2) }, {new CalculatorTestFixture("DIV", 16, -4, -4) }});
    }

    private CalculatorTestFixture testFixture;

    public ParameterizedCalculatorTest(CalculatorTestFixture testFixture) {
        this.testFixture = testFixture;
    }

    @Test
    public void test() {
        switch (testFixture.getOperation()) {
            case ADD:
                assertEquals(testFixture.getExpected(), Calculator.add(testFixture.getNum1(), testFixture.getNum2()));
                break;
            case SUB:
                assertEquals(testFixture.getExpected(), Calculator.subtract(testFixture.getNum1(), testFixture.getNum2()));
                break;
            case MULT:
                assertEquals(testFixture.getExpected(), Calculator.multiply(testFixture.getNum1(), testFixture.getNum2()));
                break;
            case DIV:
                assertEquals(testFixture.getExpected(), Calculator.divide(testFixture.getNum1(), testFixture.getNum2()));
                break;
        }
    }
}

Vid en första anblick ser vi ju inte ut att ha vunnit så mycket (om ens något) med denna omdesign men lugn, det kommer mera.

När du kör en ovanstående test exempelvis inne i Eclipse så kommer varje exekverad test att synas i JUnitfliken som följer:

Varje testinstans får endast sitt testdataindex som namn, själva testfallet likaså. Detta är inte en idealisk situation vad gäller felsökning mm. Nu kan vår objektifiering av testdata samt vår möjlighet att skapa en egen parametriserad testrunner komma till användning.

ParentRunner har en metod, getName som returnerar en sträng som är tänkt att ge ett beskrivande namn för testrunnerklassen, som default returneras testklassens namn. På liknande sätt så har basklassen till den testrunnerklass som internt används av Parameterized, BlockJUnit4ClassRunner, en liknande metod, testName som skall ge ett beskrivande namn för den exekverade testmetoden.

Tyvärr så måste vi göra en helt egen implementation av Parameterized för att kunna dra nytta av denna kunskap och implementera om dessa metoder

package se.cygni.calculatortest;

import java.lang.annotation.Annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import org.junit.runner.Runner;
import org.junit.runner.notification.RunNotifier;
import org.junit.runners.Suite;
import org.junit.runners.model.FrameworkMethod;
import org.junit.runners.model.InitializationError;
import org.junit.runners.model.Statement;
import org.junit.runners.model.TestClass;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

public class ParameterizedRunner extends Suite {
    /**
     * Annotation for a method which provides parameters to be injected into the
     * test class constructor by <code>ParameterizedRunner</code>
     */
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public static @interface Parameters {}

    private class TestClassRunnerForParameters extends SpringJUnit4ClassRunner {
        private final int fParameterSetNumber;
        private final List<Object[]> fParameterList;

        TestClassRunnerForParameters(Class<?> type, List<Object[]> parameterList, int i) throws InitializationError {
            super(type);
            fParameterList= parameterList;
            fParameterSetNumber= i;
        }

        @Override
        public Object createTest() throws Exception {
            return getTestClass().getOnlyConstructor().newInstance( computeParams());
        }

        private Object[] computeParams() throws Exception {
            try {
                return fParameterList.get(fParameterSetNumber);
            } catch (ClassCastException e) {
                throw new Exception(String.format( "%s.%s() must return a Collection of arrays.",
                        getTestClass().getName(), getParametersMethod(getTestClass()).getName()));
            }
        }

        private ParameterizedTestFixture getTestFixture() {
            try {
                Object[] parameters = computeParams();
                return (ParameterizedTestFixture)parameters[0];
            } catch (Exception e) {
                throw new IllegalArgumentException(e.getLocalizedMessage(),e);
            }
        }

        @Override
        protected String getName() {
            return String.format("%s",getTestFixture().getTestClassName());
        }

        @Override
        protected String testName(final FrameworkMethod method) {
            return String.format("%s",getTestFixture().getTestName());
        }

        @Override
        protected void validateConstructor(List<Throwable> errors) {
            validateOnlyOneConstructor(errors);
        }

        @Override
        protected Statement classBlock(RunNotifier notifier) {
            return childrenInvoker(notifier);
        }

        @Override
        protected Annotation[] getRunnerAnnotations() {
            return new Annotation[0];
        }
    }

    private final ArrayList<Runner> runners= new ArrayList<Runner>();

    /**
     * Only called reflectively. Do not use programmatically.
     */
    public ParameterizedRunner(Class<?> klass) throws Throwable {
        super(klass, Collections.<Runner>emptyList());
        List<Object[]> parametersList= getParametersList(getTestClass());
        for (int i= 0; i < parametersList.size(); i++)
            runners.add(new TestClassRunnerForParameters(getTestClass().getJavaClass(),parametersList, i));
    }

    @Override
    protected List<Runner> getChildren() {
        return runners;
    }

    @SuppressWarnings("unchecked")
    private List<Object[]> getParametersList(TestClass klass) throws Throwable {
        return (List<Object[]>) getParametersMethod(klass).invokeExplosively(null);
    }

    private FrameworkMethod getParametersMethod(TestClass testClass) throws Exception {
        List<FrameworkMethod> methods= testClass.getAnnotatedMethods(Parameters.class);
        for (FrameworkMethod each : methods) {
            int modifiers= each.getMethod().getModifiers();
            if (Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers))
                return each;
        }
        throw new Exception("No public static parameters method on class "+ testClass.getName());
    }
}

Notera att ParameterizedRunner ärver Suite och inte någon av de runnerklasser som vanligen används för att exekvera en testklass. Anledningen till detta är att exekveringen av en parametriserad testklass, som tidigare nämnts, i realiteten implementeras som en exekvering av flera instanser av testklassen i fråga, ett fall som just Suite är designad för att hantera.

Notera också att metoden getTextFixture förutsätter att den aktuella testklassen är parametriserad på ett sådan sätt att testdata levereras som en instans av en klass som implementerar ParameterizedTestFixture. Detta för att på ett generellt sätt kunna använda testfixturen, som ju har information om det specifika testfallet, för att ge en bättre visuell feedback av resultatet.

public interface ParameterizedTestFixture {
    String getTestClassName();
    String getTestName();
}

För att ge ett exempel hur detta kan användas skriver vi om vår CalculatorTestFixture:

public class CalculatorTestFixture implements ParameterizedTestFixture {
    ...
    ...

    public String getTestClassName() {
        return String.format("CalculatorTest[%s]", operation.name());
    }
    
    public String getTestName() {
        return String.format("%d %s %d -> %d", num1, operation.operator(), num2, expected);
    }
}

Slutligen byter vi testrunner för vår testklass

@RunWith(ParameterizedRunner.class)
public class ParameterizedCalculatorTest {
    ...
    ...
}

Vi får nu följande, mera beskrivande resultat när testerna körs från Eclipse:

Slutligen kan vi genom att kombinera våra två testrunnerklasser få en parametriserad test som kör testfallen parallellt.

public class ConcurrentParameterizedRunner extends ParameterizedRunner {
    public ConcurrentParameterizedRunner(final Class<?> klass) throws InitializationError {
        super(klass);
        if (klass.isAnnotationPresent(Concurrent.class)) {
            if (runTestsInParallell()) {
                setScheduler(getRunnerScheduler(klass));
            } else {
                System.out.println(String.format("Test class %s is annotated for concurrent run of tests but this test run has disabled concurrent tests.", klass.getSimpleName()));
            }
        }
    }

    private static boolean runTestsInParallell() {
        return Configuration.runTestsInParallell();
    }

    private static RunnerScheduler getRunnerScheduler(final Class<?> klass) {
        return new ConcurrentTestRunnerScheduler(klass);
    }
}

Modifiera slutligen din testklass så att den använder den nya testrunnerklassen. Glöm inte @Concurrent-annoteringen.

@RunWith(ConcurrentParameterizedRunner.class)
@Concurrent
public class ParameterizedCalculatorTest {
    ...
    ...

    @Test
    public void test() {
        switch (testFixture.getOperation()) {
            case ADD:
                assertEquals(testFixture.getExpected(), Calculator.add(testFixture.getNum1(), testFixture.getNum2()));
                break;
            case SUB:
                assertEquals(testFixture.getExpected(), Calculator.subtract(testFixture.getNum1(), testFixture.getNum2()));
                break;
            case MULT:
                assertEquals(testFixture.getExpected(), Calculator.multiply(testFixture.getNum1(), testFixture.getNum2()));
                break;
            case DIV:
                assertEquals(testFixture.getExpected(), Calculator.divide(testFixture.getNum1(), testFixture.getNum2()));
                break;
        }
        // Bromsa lite, så att vi kan se att flera
        // tester startar upp samtidigt
        try {
            Thread.sleep(3000L);
        } catch (InterruptedException e) {
        }
    }
}

När testen nu körs i Eclipse kan man se att 5 (defaultvärde för antal trådar i @Concurrent) av testfallen startar upp omedelbart, sedan startar de övriga vartefter tidigare testfall avslutas och trådar blir tillgängliga.

Externt testdata (testfixtures)

Om det finns behov av att administrera testdata utanför testkoden, är det en trivial övning att i stället för den hårdkodade varianten ovan implementera en klass som ansvarar för att skapa testfixtures genom att läsa testdata från exempelvis en excelfil med hjälp av Apache POI.

@Parametersmetoden i ParameterizedCalculatorTest skulle då kunna se ut ungefär så här:

...
   @Parameters
    public static List<Object[]> data() {
        List<Object[]> testData = new ArrayList<Object[]>();
        Collection<CalculatorTestFixture> testFixtures = new CalulatorTestFixtureReader().read("testdata.xls");
        for (CalculatorTestFixture testFixture : testFixtures) {
            testData.add(new Object[]{testFixture});
        }
        return testData;
    }
...

I nästa del visar jag hur man kan integrera integrationstester mot open source-verktyget TestLink.