Detta är den sista 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.

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

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

TestLink är ett open source-verktyg där du kan samla ett projekts krav och testfall. Testfallen kan sedan grupperas i olika testplaner inför exempelvis en ny release.

TestLink har ett XML/RPC-gränssnitt som kan användas för att hämta information om testprojekt, testplaner, testresult mm, men som även kan användas för att uppdatera TestLink med resultatet från en testkörning. Implementationer av detta gränssnitt finns i flera språk såsom Ruby, Python och Java. En javaimplementation – TestLink Java API – finns här.

Utvecklarna av TestLink Java API har som ambition är att vara synkade med den senaste version av TestLinks XML/RPC-gränssnitt. Senaste version när detta skrivs är 1.9.4-0. För att använda TestLink Java API måste du lägga till följande beroende i din Maven-POM.

<dependency>
    <groupId>br.eti.kinoshita</groupId> 
    <artifactId>testlink-java-api</artifactId> 
    <version>1.9.4-0</version>
</dependency>

Följande hjälpklass implementerar stöd för att hämta projekt, testplan mm från TestLink samt även uppdatera ett exekverat testfall med resultatet.

package se.cygni.testlink;

import java.net.URL;
import java.util.HashMap;
import java.util.Map;

import br.eti.kinoshita.testlinkjavaapi.TestLinkAPI;
import br.eti.kinoshita.testlinkjavaapi.constants.ExecutionStatus;
import br.eti.kinoshita.testlinkjavaapi.model.Build;
import br.eti.kinoshita.testlinkjavaapi.model.Platform;
import br.eti.kinoshita.testlinkjavaapi.model.TestCase;
import br.eti.kinoshita.testlinkjavaapi.model.TestPlan;
import br.eti.kinoshita.testlinkjavaapi.model.TestProject;

public class TestLinkAPIHelper {

    private static TestLinkAPIHelper INSTANCE = new TestLinkAPIHelper();
    private TestLinkAPI m_api;

    private TestLinkAPIHelper() {
        try {
            URL url = new URL("/lib/api/xmlrpc.php");
            m_api = new TestLinkAPI(url, "");
        } catch (Exception e) {
            throw new RuntimeException("Unable to create TestLinkApi");
        }
    }

    public static TestLinkAPIHelper getInstance() {
        return INSTANCE;
    }

    public TestProject getProject(String projectName) {
        return m_api.getTestProjectByName(projectName);
    }

    public TestPlan getTestPlan(TestProject project, String testPlanName) {
        return m_api.getTestPlanByName(testPlanName, project.getName());
    }

    public Platform getPlatform(TestPlan testPlan, String platformName) {
        Platform[] platforms = m_api.getTestPlanPlatforms(testPlan.getId());
        for (Platform platform : platforms) {
            if (platformName.equals(platform.getName())) {
                return platform;
            }
        }
        return null;
    }

    public Build getBuild(TestPlan plan, String buildName) {
        Build[] builds = m_api.getBuildsForTestPlan(plan.getId());
        for (Build build : builds) {
            if (buildName.equals(build.getName())) {
                return build;
            }
        }
        return null;
    }

    public TestCase[] getTestCases(TestPlan plan, Build build) {
        return m_api.getTestCasesForTestPlan(
                plan.getId(), null, build.getId(), null, null, null, null, null, null, false, null);
    }

    public void reportResult(TestPlan plan, Build build, Platform platform, TestCase testCase, ExecutionStatus status, String notes) {
        System.out.println(String.format("[TestLink] Reporting status %s for test case SP-%s", status.name(), testCase.getFullExternalId()));
        m_api.reportTCResult(
                testCase.getId(), null, plan.getId(), status, build.getId(), null, notes, false, null, platform.getId(), null, null, false);
    }

    public TestCase addTestCaseToTestPlan(TestProject project, TestPlan plan, String fullExternalId, Platform platform) {
        TestCase testCase = getTestCase(fullExternalId);
        m_api.addTestCaseToTestPlan(project.getId(), plan.getId(), testCase.getId(), testCase.getVersion(), platform.getId(), null, null);
        return testCase;
    }

    public TestCase getTestCase(String fullExternalId) {
        return m_api.getTestCaseByExternalId(fullExternalId, null);
    }
}

Andra argumentet i konstruktionen av TestLinkAPI på rad 23 skall vara din developer-key, ett hexadecimalt tal som kan genereras när du skapar den testanvändare som skall ”köra” de automatiska integrationstesterna. Hur du gör för att skapa denna nyckel finns beskrivet här.

Nu är vi klara att för att uppdatera TestLink med resultatet från våra testkörningar, men hur ser vi till att vår hjälpklass anropas?

Ett antal saker krävs:

  1. Vi måste bli notifierade när ett testfall har körts klarts med uppgift om i fall testen gick bra eller inte
  2. Vi måste när detta sker kunna anropa vår hjälpklass med korrekt TestLink-projekt, testplan, testfallsid mm

En testrunner exekverar ett enskilt testfall genom ett anrop till metoden runChild(final FrameworkMethod method, RunNotifier notifier) där instansen av RunNotifier blir notifierad varje gång ett testfall har körts klart. Notifiern ansvarar för att uppdatera alla som har registrerat sig som lyssnare på testfallskörningar. Detta kan vi utnyttja för att lösa punkt 1 ovan genom att överrida runChild i en egen testrunner, och där registrera en egen lyssnare . Denna lyssnare skall när testfallet har kört klart rapportera resultatet.

Låt oss först definiera ett interface för de klasser som skall ansvara för att rapportera testresult till exempelvis ett externt system såsom TestLink:

package se.cygni.testlink;

import org.junit.runner.Description;
import org.junit.runner.notification.Failure;

public interface TestReporter {
    void reportTestFailure(Failure failure);

    void reportTestSuccess(Description description);

    boolean testShouldBeRun(Description description);
}

Nu till vår testrunner som överrider runChild:

package se.cygni.testlink;

import org.junit.Ignore;
import org.junit.runner.Description;
import org.junit.runner.notification.Failure;
import org.junit.runner.notification.RunListener;
import org.junit.runner.notification.RunNotifier;
import org.junit.runners.model.FrameworkMethod;

import se.cygni.fibonaccitest.ConcurrentJUnitRunner;

/**
 * Abstract test runner with support for reporting the test results
 * to an external target, i.e. a test management tool such as TestLink.
 */
public abstract class AbstractReportingTestRunner extends ConcurrentJUnitRunner {

    public AbstractReportingTestRunner(Class<?> klass) throws Throwable {
        super(klass);
    }

    @Override
    protected void runChild(final FrameworkMethod method, RunNotifier notifier) {
        Description description = describeChild(method);
        if (method.getAnnotation(Ignore.class) != null) {
            notifier.fireTestIgnored(description);
        } else {
            if (testShouldBeRun(description)) {
                RunListener listener = new ResultListener(description);
                notifier.addListener(listener);
                try {
                    runLeaf(methodBlock(method), description, notifier);
                } catch (Exception e) {
                    handleTestFailure(new Failure(description, e));
                } finally {
                    notifier.removeListener(listener);
                }
            }
        }
    }

    protected void handleTestFailure(Failure failure) {
        getTestReporter().reportTestFailure(failure);
    }

    protected void handleTestSuccess(Description description) {
        getTestReporter().reportTestSuccess(description);
    }

    protected boolean shouldTestBeRun(Description description) {
        return getTestReporter().shouldTestBeRun(description);
    }

    protected abstract TestReporter getTestReporter();

    /**
     * RunListener, listening for the execution result of
     * a test case.
     */
    private class ResultListener extends RunListener {

        private boolean m_failure = false;
        private Description m_description;

        public ResultListener(Description description) {
            m_description = description;
        }

        @Override
        public void testFailure(Failure failure) throws Exception {
            if (isForMe(failure.getDescription())) {
                handleTestFailure(failure);
                m_failure = true;
            }
        }

        @Override
        public void testFinished(Description description) throws Exception {
            if (isForMe(description)) {
                if (!m_failure) {
                    handleTestSuccess(m_description);
                }
            }
        }

        @Override
        public void testAssumptionFailure(Failure failure) {
            if (isForMe(failure.getDescription())) {
                handleTestFailure(failure);
                m_failure = true;
            }
        }

        private boolean isForMe(Description description) {
            return m_description.getDisplayName().equals(description.getDisplayName());
        }
    }
}

Ovanstående är en abstrakt klass där konkreta subklasser är tänkt att implementera getTestReporter och svara med en egen test reporter.

Ett (trivialt) exempel som använder sig av en implementation av TestReporter som loggar till konsolen:

public class LogToConsoleTestRunner extends AbstractReportingTestRunner {
    public LogToConsoleTestRunner(Class<?> klass) {
        super(klass);
    }

    @Override
    protected TestReporter getTestReporter() {
        return new TestReporter() {

            @Override
            public void reportTestFailure(Failure failure) {
                Description description = failure.getDescription();
                System.out.println(String.format("%s FAILED. Reason %s", description.getDisplayName(), failure.getMessage()));
            }

            @Override
            public void reportTestSuccess(Description description) {
                System.out.println(String.format("%s PASSED.", description.getDisplayName()));
            }

            @Override
            public boolean testShouldBeRun(Description description) {
                return true;
            }

        };
    }
}

Om vi använder LogToConsoleTestRunner för att exekvera följande testklass som testar en fiktiv Calculator (observera felet i test_subtraction).

package se.cygni.testreport;

import static org.junit.Assert.*;

import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(LogToConsoleTestRunner.class)
public class CalculatorTest {

    @Test
    public void test_addition() {
        assertEquals(5, Calculator.add(2, 3));
    }

    @Test
    public void test_subtraction() {
        assertEquals(5, Calculator.subtract(8, 2));
    }

}

så kommer följande att loggas i konsolen

test_subtraction(se.cygni.testreport.CalculatorTest) FAILED. Reason expected:<5> but was:<6> 
test_addition(se.cygni.testreport.CalculatorTest) PASSED.

För att slutligen knyta ihop exekveringen av vårt testfall med en uppdatering av TestLink behövs ytterligare några byggstenar.

Först en implementation av TestReporter som använder TestLinkAPIHelper för att uppdatera TestLink:

package se.cygni.testlink;

import br.eti.kinoshita.testlinkjavaapi.constants.ExecutionStatus;
import br.eti.kinoshita.testlinkjavaapi.model.*;
import org.junit.runner.Description;
import org.junit.runner.notification.Failure;
import se.cygni.testreport.TestReporter;

import java.util.HashMap;
import java.util.Map;

/**
 * Implementation of {@link TestReporter}, responsible for reporting the
 * test result to the test plan in TestLink as the test cases are executed.
 * <p>
 * There is only one instance of this class per test execution and
 * it's configured with project, test plan, platform and build, read
 * from an external configuration.
 */
public class TestLinkReporter implements TestReporter {

    // In TestLink it is possible to define a prefix that
    // will be added to all test case ids.
    // getFullExternalId will return id w/o this prefix though.
    private static String TEST_ID_PREFIX = "";

    private TestLinkAPIHelper m_helper;
    private TestProject m_project;
    private TestPlan m_plan;
    private Platform m_platform;
    private Build m_build;
    private Map m_testCases = new HashMap();

    public TestLinkReporter(String project, String testPlan, String platform, String build) {
        System.out.println(String.format("Running tests updating project %s, test plan %s, platform%s and build %s", project, testPlan, platform, build));
        m_helper = TestLinkAPIHelper.getInstance();
        init(project, testPlan, platform, build);
    }

    private void init(String project, String testPlan, String platform, String build) {
        m_project = m_helper.getProject(project);
        m_plan = m_helper.getTestPlan(m_project, testPlan);
        m_platform = m_helper.getPlatform(m_plan, platform);
        m_build = m_helper.getBuild(m_plan, build);
        TestCase[] testCases = m_helper.getTestCases(m_plan, m_build);
        for (TestCase testCase : testCases) {
            m_testCases.put(TEST_ID_PREFIX + testCase.getFullExternalId(), testCase);
        }
    }

    public void reportTestFailure(Failure failure) {
        TestId[] testIds = getTestIds(failure.getDescription());
        for (TestId testId : testIds) {
            reportTestResult(testId, new Failed(testId, failure));
        }
    }

    public void reportTestSuccess(Description description) {
        TestId[] testIds = getTestIds(description);
        for (TestId testId : testIds) {
            reportTestResult(testId, new Passed(testId, description));
        }
    }

    @Override
    public boolean testShouldBeRun(Description description) {
        return getTestLinkAnnotation(description) != null;
    }

    private TestLink getTestLinkAnnotation(Description description) {
        return description.getAnnotation(TestLink.class);
    }

    private TestId[] getTestIds(Description description) {
        TestLink testLink = getTestLinkAnnotation(description);
        if (testLink != null) {
            String[] ids = testLink.id().split(",");
            TestId[] testIds = new TestId[ids.length];
            for (int i = 0; i < ids.length; i++) {
                testIds[i] = new TestId(ids[i].trim());
            }
            return testIds;
        } else {
            return new TestId[0];
        }
    }

    private void reportTestResult(TestId testId, TestResult testResult) {
        if (testId == null) {
            System.out.println(String.format("Invalid test id format'%s', unable to update TestLink.", testId));
            return;
        }
        TestCase testCase = getOrAddTestCase(testId);
        if (testCase != null) {
            m_helper.reportResult(m_plan, m_build, m_platform, testCase, testResult.getExecutionStatus(), testResult.getNotes());
        } else {
            System.out.println(String.format("Test case '%s' is not in test plan and not able to add it!", testId));
        }
    }

    private TestCase getOrAddTestCase(TestId testId) {
        TestCase testCase = getTestCase(testId.id);
        if (testCase == null) {
            System.out.println(String.format("Test case '%s' is not in test plan, will be added.", testId.id));
            testCase = m_helper.addTestCaseToTestPlan(m_project, m_plan, testId.id, m_platform);
            testCase.setFullExternalId(testId.id);
            m_testCases.put(testId.id, testCase);
        }
        return testCase;
    }

    private TestCase getTestCase(String testId) {
        return m_testCases.get(testId);
    }

    /**
     * Utility class holding the internal test id,
     * i.e. the actual test case id, such as 'SP-472'
     */
    private static class TestId {
        public String id;

        public TestId(String id) {
            this.id = id;
        }

        public String toString() {
            return id;
        }
    }

    /**
     * Internal classes representing the different test results
     * for a test case execution.
     */
    private static abstract class TestResult {
        public TestId testId;
        public String testDescription;

        protected TestResult(TestId testId, Description description) {
            this.testId = testId;
            testDescription = getTestDescription(description);
        }

        public abstract ExecutionStatus getExecutionStatus();

        public abstract String getNotes();

        public abstract boolean passed();

        public abstract boolean failed();

        public abstract String getTestLinkNotes();

        public String toString() {
            return testId.toString() + " " + getExecutionStatus().name();
        }

        private String getTestDescription(Description description) {
            TestLink testLink = description != null ? description.getAnnotation(TestLink.class) : null;
            return testLink != null ? testLink.desc() : "No description";
        }

    }

    private static class Passed extends TestResult {

        Passed(TestId testId, Description description) {
            super(testId, description);
        }

        @Override
        public ExecutionStatus getExecutionStatus() {
            return ExecutionStatus.PASSED;
        }

        @Override
        public String getNotes() {
            return null;
        }

        @Override
        public boolean passed() {
            return true;
        }

        @Override
        public boolean failed() {
            return false;
        }

        @Override
        public String getTestLinkNotes() {
            StringBuilder sb = new StringBuilder(testId.id);
            sb.append(" '").append(testDescription).append("' PASSED.\n");
            return sb.toString();
        }

    }

    private static class Failed extends TestResult {
        public String reason;

        Failed(TestId testId, Failure failure) {
            super(testId, failure.getDescription());
            this.reason = failure.getMessage();
        }

        @Override
        public ExecutionStatus getExecutionStatus() {
            return ExecutionStatus.FAILED;
        }

        @Override
        public String getNotes() {
            return reason;
        }

        @Override
        public boolean passed() {
            return false;
        }

        @Override
        public boolean failed() {
            return true;
        }

        @Override
        public String getTestLinkNotes() {
            StringBuilder sb = new StringBuilder(testId.id);
            sb.append(" '").append(testDescription).append("' FAILED.\n");
            sb.append(reason);
            return sb.toString();
        }
    }
}

Det behövs också en testrunner som använder TestLinkReporter

package se.cygni.testlink;

import org.junit.runner.Description;
import org.junit.runner.Runner;
import org.junit.runner.manipulation.Filter;
import org.junit.runner.manipulation.NoTestsRemainException;
import org.junit.runner.notification.Failure;
import org.junit.runners.Suite;
import org.junit.runners.model.InitializationError;
import org.junit.runners.model.RunnerBuilder;
import se.cygni.testreport.AbstractReportingTestRunner;
import se.cygni.testreport.TestReporter;

import java.util.LinkedList;
import java.util.List;

public class TestLinkSuite extends Suite {

    private static TestReporter s_testReporter = new TestReporter() {

        @Override
        public void reportTestFailure(Failure failure) {
        }

        @Override
        public void reportTestSuccess(Description description) {
        }

        @Override
        public boolean testShouldBeRun(Description description) {
            return true;
        }
    };

    public TestLinkSuite(Class<?> klass, RunnerBuilder builder) throws Throwable {
        super(klass, getRunners(getAnnotatedClasses(klass)));
        System.out.println(String.format("TestLinkSuite created for running test suite %s", klass.getSimpleName()));
        try {
            filter(new TestLinkFilter());
        } catch (NoTestsRemainException e) {
            System.out.println("No TestLink tests found in " + klass.getSimpleName());
        }
    }

    public static void setTestLinkReporter(TestLinkReporter testLinkReporter) {
        s_testReporter = testReporter;
    }

    private static Class<?>[] getAnnotatedClasses(Class<?> klass) throws InitializationError {
        Suite.SuiteClasses annotation = klass.getAnnotation(Suite.SuiteClasses.class);
        if (annotation != null) {
            return annotation.value();
        }
        throw new InitializationError(String.format("class '%s' must have a SuiteClasses annotation", klass.getName()));
    }

    private static List getRunners(Class<?>[] classes) throws Throwable {
        List runners = new LinkedList();
        for (Class<?> klazz : classes) {
            runners.add(createRunner(klazz));
        }
        return runners;
    }

    private static Runner createRunner(Class<?> klazz) throws Throwable {
        return isSuite(klazz) ? new TestLinkSuite(klazz, null) : new TestLinkRunner(klazz);
    }

    public static boolean isSuite(Class<?> klazz) {
        return klazz.getAnnotation(Suite.SuiteClasses.class) != null;
    }

    /**
     * Internal runner implementation
     */
    private static class TestLinkRunner extends AbstractReportingTestRunner {

        public TestLinkRunner(Class<?> klass) throws Throwable {
            super(klass);
            System.out.println(String.format("TestLinkRunner created for running test class %s", klass.getSimpleName()));
            try {
                filter(new TestLinkFilter());
            } catch (NoTestsRemainException e) {
                System.out.println("No TestLink tests found in " + klass.getSimpleName());
            }
        }

        @Override
        protected TestReporter getTestReporter() {
            return s_testReporter;
        }
    }

    /**
     * Filter implementation that filters out
     * 1. test methods missing the TestLink annotation
     * 2. test classes that has no methods annotated with TestLink
     * 3. test suites that has no test classes containing methods annotated with TestLink
     */
    private static class TestLinkFilter extends Filter {

        @Override
        public boolean shouldRun(Description description) {
            if (isTestLinkMethod(description)) {
                return true;
            }
            for (Description each : description.getChildren()) {
                if (shouldRun(each)) {
                    return true;
                }
            }
            return false;
        }

        private boolean isTestLinkMethod(Description description) {
            return description.isTest() && description.getAnnotation(TestLink.class) != null;
        }

        @Override
        public String describe() {
            return "TestLink";
        }
    }
}

Observera att TestLinkSuite ärver Suite, dvs skall användas för att exekvera en testsvit och inte enbart en enskild testklass. Denna begränsning är i praktiken inget problemet, om du mot all förmodan endast har en testklass som exekverar alla dina integrationstester så är det en smal sak att skapa en testsvit som inkluderar din enda testklass.

Annoteringen @TestLink används för att annotera en testmetod, för vilken resultatet skall importeras in i TestLink. Utöver att fungera som ”metodmarkör”, innehåller den också testfallsid och testfallsbeskrivning. Id används för att mappa testmetod mot testfall i TestLink medan beskrivningen används för att uppdatera noteringsfältet i TestLink.

package se.cygni.testlink;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.METHOD;

@Target({METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface TestLink {
    String id();
    String desc() default "";
}

Nu återstår bara att fixa till en testsvit som exekveras av TestLinkSuite. Din testsvit måste också implementera en @BeforeClassmetod för att där skapa den ”globala” TestLinkReporter med uppgifter om projekt, testplan mm som skall uppdateras med testresultatet och injektas i TestLinkSuite.

Först en testklass med @TestLink-annoteringar för att indikera att testerna skall köras och att resultatet skall importeras in i TestLink

package se.cygni.testlink;

import static org.junit.Assert.*;

import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;

import se.cygni.testreport.Calculator;

public class TestLinkCalculatorTest {

    @Test
    @TestLink(id="421", desc="Test addition")
    public void test_addition() {
        assertEquals(5,Calculator.add(2,3));
    }

    @Test
    @TestLink(id="422", desc="Test substraction")
    public void test_subtraction() {
        assertEquals(5,Calculator.subtract(8,2));
    }
}

Slutligen en testsvit som inkluderar ovanstående test och som exekveras av TestLinkSuite.

package se.cygni.testlink;

import org.junit.BeforeClass;
import org.junit.runner.RunWith;
import org.junit.runners.Suite.SuiteClasses;

@RunWith(TestLinkSuite.class)
@SuiteClasses(TestLinkCalculatorTest.class)
public class CalculatorTestSuite {
    @BeforeClass
    public static void beforeClass() {
        TestLinkReporter testLinkReporter = new TestLinkReporter(Configuration.getTestLinkProject(),Configuration.getTestPlan(),Configuration.getTestPlatform(),Configuration.getTestBuild());
        TestLinkSuite.setTestLinkReporter(testLinkReporter);
    }
}

Även om implementation ovan är specifikt riktad mot TestLink så är själva grundidén, dvs hur du designar dina egna testrunners och testklasser för att få till en rapportering av testresultatet generell och tillämplig även för andra testhanteringssystem än TestLink. Under förutsättning förstås att det finns ett användbart java-API!

Detta avslutar denna artikelserie om automatiserade integrationstester – kolla gärna in del 1 som handlar om parallelliserade tester eller del 2 som handlar om parametriserade tester .