Heutzutage hat sich das Schreiben von automatisierten Tests weitestgehend durchgesetzt. Die meisten Frameworks bieten deshalb bereits von Hause aus Support an, um eine mit ihnen geschriebene Anwendung zu testen.

Auch Spring Boot bietet uns eine Menge an Konzepten an, um Tests auf allen Ebenen der Testpyramide schreiben zu können. Vom Unit- bis zum Integrationstest finden sich kleine Helferklassen oder Annotationen, die uns eine Menge an Arbeit abnehmen und so dazu beitragen, dass wir uns in Tests auf das Notwendigste konzentrieren können: Aufsetzen von Zustand/Testdaten, Ausführen der zu testenden Teile, Überprüfen des Ergebnisses.

Natürlich kann Spring Boot dabei nicht für jeden Anwendungsfall bereits eine fertige Lösung mitbringen. Bei diesen Fällen sind nun wir gefragt, die vorhandenen Mittel so zu nutzen, dass wir auch diese Anwendungsfälle abdecken können. Wie dies, vor allem in Zusammenarbeit mit JUnit 5, aussehen kann, wollen wir uns im Folgenden an drei Anwendungsfällen aus meinen letzten Projekten anschauen.

Testen von und mit Zeit

Beim Arbeiten mit Zeit gibt es immer Herausforderungen. Es gibt alleine eine Vielzahl an Annahmen, die wir über Zeit haben, die sich leider als falsch herausstellen. Die beiden Posts „Falsehoods programmers believe about time“ und „More falsehoods programmers believe about time; ‚wisdom of the crowd’ edition” alleine liefern hierzu bereits insgesamt über 100 Beispiele.

Unser erster Anwendungsfall ist jedoch weitaus weniger komplex. Wir möchten beim Aufruf der URI /time die aktuelle Zeit, in welcher Zeitzone auch immer, als formatierten Text ausgeben. Die Webanwendung bauen wir mit Spring Boot und der Controller, siehe Listing 1, ist, vorausgesetzt Spring Boot ist bekannt, schnell geschrieben. Starten wir die Anwendung nun und rufen http://localhost:8080/time auf, erhalten wir als Antwort die aktuelle Zeit passend formatiert.

@RestController
public class TimeController {
    static DateTimeFormatter DATE_TIME_FORMAT =
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    @GetMapping("/time")
    public String now() {
        LocalDateTime now = LocalDateTime.now();
        return now.format(DATE_TIME_FORMAT);
    }
}
Listing 1: Spring Controller für die Zeitausgabe

Wir wollen nun nicht nach jeder Änderung erneut manuell testen müssen, dass wir nichts kaputt gemacht haben. Deswegen schreiben wir einen Test mit JUnit 5 und nutzen den von Spring Boot bereitgestellten Web-MVC-Test-Support. Listing 2 zeigt den fertigen Test. Dieser Test schlägt leider fehl, denn selbst bei einem in der Zukunft gewählten Datum als Assertion ist die Wahrscheinlichkeit, den Test genau in diesem Augenblick auszuführen, nur sehr gering.

@WebMvcTest
public class TimeControllerTests {
    @Autowired
    MockMvc mvc;

    @Test
    void now_shouldRenderCurrentTime() throws Exception {
        mvc.perform(get("/time"))
            .andExpect(status().isOk())
            .andExpect(content().string(
                is(equalTo("2019-07-29 14:10:53"))));
    }
}
Listing 2: Web-MVC Test für den TimeController

Was können wir also tun, um doch einen zuverlässig laufenden Test zu erhalten, ohne die Aussagekraft des Tests zu verringern. Wir könnten nicht auf ein konkretes Datum prüfen, sondern nur prüfen, ob das Ergebnis im korrekten Format ausgegeben wird. Dies würde funktionieren, weicht allerdings das Kriterium auf, dass es sich bei dem Datum um die aktuelle Zeit handelt.

Die, meiner Meinung nach, bessere Lösung besteht demnach darin, dafür zu sorgen, dass wir innerhalb der Anwendung eine fixe Zeit setzen. Glücklicherweise hat das Java Datetime API genau diesen Anwendungsfall bereits vorgesehen und stellt uns hierzu die Abstraktion Clock zur Verfügung. Wir nutzen diese nun innerhalb des Controllers, siehe Listing 3, um den aktuellen Zeitpunkt zu erhalten.

@RestController
public class TimeController {
    static DateTimeFormatter DATE_TIME_FORMAT = ...;

    private final Clock clock;

    public TimeController(Clock clock) {
        this.clock = clock;
    }

    @GetMapping("/time")
    public String now() {
        LocalDateTime now = LocalDateTime.now(clock);
        return now.format(DATE_TIME_FORMAT);
    }
}
Listing 3: Benutzung von Clock im TimeController

Nun müssen wir zusätzlich dafür sorgen, dass es innerhalb des Spring-Contexts eine Bean des Typs Clock gibt. Listing 4 fügt diese hierzu programmatisch innerhalb der mit @SpringBootApplication annotierten Klasse Application hinzu. Anschließend können wir durch die Verwendung einer @TestConfiguration innerhalb unseres Tests, siehe Listing 5, die definierte Clock durch eine, nur für den Test gültige, Clock überschreiben, welche ein fixes Datum zurückgibt.

@SpringBootApplication
public class Application {
    ...
    @Bean
    public Clock clock() {
        return Clock.systemUTC();
    }
}
Listing 4: Definition der Spring Bean vom Typ Clock
@WebMvcTest
@TestPropertySource(properties =
    "spring.main.allow-bean-definition-overriding=true")
public class TimeControllerTests {
    ...
    @TestConfiguration
    static class TestClockConfiguration {
        @Bean
        public Clock clock() {
            LocalDateTime localDateTime =
                LocalDateTime.of(2019, 7, 29, 14, 10, 53);
            return Clock.fixed(
                localDateTime.toInstant(ZoneOffset.UTC),
                ZoneOffset.UTC);
        }
    }
}
Listing 5: Überschreiben der Zeit in TimeControllerTests

Nach dieser Änderung kann der Test jederzeit erfolgreich ausgeführt werden. Gibt es allerdings mehrere Tests, die unter Umständen auch noch verschiedene feste Zeitpunkte benötigen, reicht diese Lösung nicht ganz aus. Zu diesem Zweck schaffen wir uns die eigene Abstraktion WithLocalDateTime. Der Test kann nun wie in Listing 6 gezeigt formuliert werden.

@WebMvcTest
@WithLocalDateTime(date = "2019-07-29", time = "14:10:53")
public class TimeControllerTests {
    @Autowired
    MockMvc mvc;

    @Test
    void now_shouldRenderCurrentTime() throws Exception {
        mvc.perform(get("/time"))
            .andExpect(status().isOk())
            .andExpect(content().string(
                is(equalTo("2019-07-29 14:10:53"))));
    }
}
Listing 6: Web-MVC Test mit WithLocalDateTime Abstraktion

Die eigene Annotation WithLocalDateTime hat dabei zwei Aufgaben. Zum einen sorgt sie dafür, dass wie bisher auch die von der Anwendung definierte Bean vom Typ Clock mit einer eigenen Implementierung überschrieben wird. Zum anderen registriert sie eine zusätzliche JUnit5-Erweiterung, die vor jedem Test dafür sorgt, die Zeit auf das angegebene Datum zu setzen und nach dem Test die originale Uhr wiederherstellt. Listing 7 zeigt ausgewählte relevante Stellen aus der Implementierung.

...
@ExtendWith(WithLocalDateTime.WithLocalDateTimeExtension.class)
@TestPropertySource(...)
@ImportAutoConfiguration(
    WithLocalDateTime.ClockConfiguration.class)
public @interface WithLocalDateTime {
    String date();
    String time();

    class WithLocalDateTimeExtension
            implements BeforeEachCallback, AfterEachCallback {
        ...
        @Override
        public void beforeEach(ExtensionContext ctx) throws Exception {
            findAnnotation(ctx.getTestClass(), WithLocalDateTime.class)
                .ifPresent((annotation) -> setClockTo(ctx, annotation));
        }

        @Override
        public void afterEach(ExtensionContext ctx) throws Exception {
            findAnnotation(ctx.getTestClass(), WithLocalDateTime.class)
                .ifPresent((annotation) -> resetClockFrom(ctx));
        }

        private static void setClockTo(
                ExtensionContext ctx, WithLocalDateTime withLocalDateTime) {
            DelegatingClock delegatingClock =
                delegatingClockFrom(extensionContext);
            ...
            LocalDateTime localDateTime =
                localDateTimeFrom(withLocalDateTime);
            delegatingClock.setDelegate(fixedClock(localDateTime));
        }
        ...
        private static DelegatingClock delegatingClockFrom(
                ExtensionContext ctx) {
            ...
        }
        ...
    }

    @TestConfiguration
    class ClockConfiguration {
        @Bean
        public Clock clock() {
            return new DelegatingClock(Clock.systemUTC());
        }
    }

    class DelegatingClock extends Clock {
        private Clock delegate;
        // Konstruktor, Getter, Setter und delegierende Methoden
        ...
    }
}
Listing 7: Auszug der WithLocalDateTime-Annotation

Über @ImportAutoConfiguration(WithLocalDateTime.ClockConfiguration.class) wird sichergestellt, dass Spring die Konfiguration findet. Innerhalb der Konfiguration definieren wir erneut eine Definition des Typs Clock. In diesem Fall nutzen wir allerdings keine fixe Uhr, sondern eine eigene Implementierung, bei der wir die Uhr, die genutzt werden soll, zur Laufzeit austauschen können. Dazu bietet die Klasse DelegatingClock einen Getter und Setter für die zugrunde liegende Clock an. Alle in Clock definierten Methoden delegiert unsere Klasse an die gesetzte Clock.

Weil wir auch hier die in der Anwendung definierte Clock überschreiben, brauchen wir genau wie vorher das Property spring.main.allow-bean-definition-overriding=true, welches wir über die @TestPropertySource-Annotation spezifizieren. Durch die @ExtendWith-Annotation sorgen wir zudem dafür, dass die Klasse WithLocalDateTimeExtension als JUnit-Erweiterung registriert wird. Da diese die beiden Interfaces BeforeEach- und AfterEachCallback implementiert, können wir Code vor und nach jedem Test ausführen. Hierzu gucken wir, ob der Test wirklich mit der Annotation WithLocalDateTime versehen wurde, werten beide Felder der Annotation aus und manipulieren die im Spring-Context vorhandene Uhr, welche nun vom Typ DelegatingClock ist.

Die hier gezeigte Lösung funktioniert und hat sich im Projekt als hilfreich erwiesen. Es gibt zudem noch zwei mögliche Erweiterungen. Zum einen wäre es möglich, auch zu erlauben, einzelne Testmethoden zu annotieren, um dort eine andere Zeit zu erhalten. Außerdem wäre es noch praktisch, sich das so gesetzte Datum oder die ausgetauschte Uhr auch als Parameter in die Testmethode reinreichen zu lassen.

Wenn In-Memory-Datenbanken nicht mehr ausreichen

Spring Boot macht es uns einfach, in Tests eine H2, Derby oder HSQL In-Memory-Datenbank zu verwenden. Dies hat den Vorteil, dass die Tests schnell laufen und wenig Setup notwendig ist. Wird allerdings spezifische SQL-Syntax für die in Produktion genutzte Datenbank eingesetzt, ist dieser Weg nicht mehr möglich und ein anderer muss her.

Unsere Webanwendung soll eine zweite URI /person unterstützen. Unter dieser sollen voneinander separiert die Namen von Personen ausgegeben werden. Die Namen sollen in einer PostgreSQL-Datenbank verwaltet werden. Um die Struktur der Datenbank aufzusetzen, nutzen wir Flyway. Die initiale Migrationen ist in Listing 8 zu sehen.

CREATE TABLE person (
  id SERIAL PRIMARY KEY,
  name VARCHAR NOT NULL
);
Listing 8: Migration V1__create_person_table.sql zum Anlegen der person Tabelle

Um die Namen anzuzeigen, nutzen wir direkt innerhalb des Controllers das von Spring zur Verfügung gestellte JdbcTemplate und die Namen trennen wir durch ein Komma voneinander. Listing 9 zeigt die erste Implementierung des Controllers.

@RestController
public class PersonController {
    private final JdbcTemplate jdbcTemplate;

    public PersonController(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @GetMapping("/person")
    public String list() {
        List<String> persons =
            jdbcTemplate.query("SELECT * FROM person",
                (rs, rowNum) -> rs.getString("name"));
        return persons.stream().collect(joining(", "));
    }
}
Listing 9: Spring Controller zur Ausgabe der Namen von Personen

Auch hierzu schreiben wir wieder einen Test, siehe Listing 10. Dieser fährt die Anwendung im Gegensatz zum Web-MVC-Test jedoch wirklich hoch und führt einen richtigen HTTP-Request gegen die Anwendung aus. Als Datenbank nutzen wir die In-Memory-Variante von H2.

@SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureTestDatabase
public class PersonControllerTests {
    @LocalServerPort
    int port;

    @Autowired
    JdbcTemplate jdbcTemplate;

    @Autowired
    TestRestTemplate restTemplate;

    @Test
    void list_shouldRenderListOfKnownPersonNames() {
        jdbcTemplate.execute(
            "INSERT INTO person " +
            "(name) VALUES " +
            "('Ein Test'), ('Hallo Welt')");
        String content = restTemplate.getForObject(
            "http://localhost:" + port + "/person",
            String.class);
        assertThat(content)
            .isEqualTo("Ein Test, Hallo Welt");
    }
}
Listing 10: Test für den PersonController

Die so vorhandene und getestete Lösung soll nun erweitert werden. Obwohl wir es besser wissen, siehe, wollen wir die Namen nun im Format Nachname, Vorname und durch Semikolon getrennt ausgeben. Hierzu soll sich die Struktur der existierenden Tabelle ändern. Es soll eine Spalte für den Vor- und eine für den Nachnamen geben. Existierende Personen sollen dabei migriert werden. Zu diesem Zweck schreiben wir eine zweite, in Listing 11 zu sehende Migration.

ALTER TABLE person
  ADD COLUMN firstname VARCHAR,
  ADD COLUMN lastname VARCHAR;

UPDATE person p
SET
  firstname = split_part(p.name, ' ', 1),
  lastname = split_part(p.name, ' ', 2)
FROM person po
WHERE p.name = po.name;

ALTER TABLE person
  ALTER COLUMN firstname SET NOT NULL,
  ALTER COLUMN lastname SET NOT NULL,
  DROP COLUMN name;
Listing 11: Migration V2__add_first_and_lastname_to_person_table.sql zum Ändern der person Tabelle

Nachdem wir in PersonController und PersonControllerTests die SQL-Ausdrücke so geändert haben, dass wir die beiden neuen Spalten firstname und lastname lesen/schreiben, stellen wir fest, dass der Test nicht mehr läuft. Der in Listing 12 zu sehende Auszug aus dem Stacktrace lässt darauf schließen, dass H2 die Syntax unserer neuen Migration nicht versteht.

...
Migration V2__add_first_and_lastname_to_person_table.sql failed
---------------------------------------------------------------
SQL State  : 42000
Error Code : 42000
Message    : Syntax error in SQL statement "ALTER TABLE PERSON
...
Listing 12: Auszug aus dem Stacktrace des Tests nach Hinzufügen der neuen Migration

Wir müssen nun entweder die Migration so ändern, dass diese auch innerhalb einer H2-Datenbank ausführbar ist, oder wir sorgen dafür, im Test eine richtige PostgreSQL-Datenbank zur Verfügung zu haben.

An dieser Stelle entscheiden wir uns für einen Mittelweg und setzen auf das Projekt Embedded PostgreSQL. Um dies in den Tests nutzen zu können, gehen wir erneut den Weg über eine eigene Annotation WithEmbeddedPostgres. Diese ersetzt im Test die von Spring Boot bereitgestellte @AutoConfigureTestDatabase-Annotation. Um zu verstehen, wie wir dort eine Instanz der Embedded-PostgreSQL-Datenbank starten, schauen wir uns die Implementierung dieser Annotation in Listing 13 an.

...
@ImportAutoConfiguration(
    WithEmbeddedPostgres.EmbeddedPostgresConfiguration.class)
public @interface WithEmbeddedPostgres {
    @Configuration
    @AutoConfigureBefore(DataSourceAutoConfiguration.class)
    class EmbeddedPostgresConfiguration {
        @Bean(destroyMethod = "stop")
        EmbeddedPostgres embeddedPostgres() throws IOException {
            EmbeddedPostgres embeddedPostgres =
                new EmbeddedPostgres(() -> "9.6.8-1");
            int port = findAvailableTcpPort(10000);
            embeddedPostgres.start(
                "localhost", port, "database", "username", "password");
            return embeddedPostgres;
        }

        @Bean
        BeanFactoryPostProcessor propsOverrider(
                EmbeddedPostgres db) {
            return (beanFactory) -> {
                Map<String, Object> p = new HashMap<>();
                p.put("spring.datasource.url", db.getConnectionUrl().get());
                ...
                ConfigurableEnvironment e =
                    beanFactory.getBean(ConfigurableEnvironment.class);
                e.getPropertySources().addFirst(
                    new MapPropertySource("propsOverrider", p));
            };
        }
    }

    @Configuration
    protected static class EmbeddedPostgresDependencyConfiguration
            extends AbstractDependsOnBeanFactoryPostProcessor {
        public EmbeddedPostgresDependencyConfiguration() {
            super(DataSource.class, "embeddedPostgres");
        }
    }
}
Listing 13: Implementierung der WithEmbeddedPostgres Annotation

Die Annotation sorgt dafür, dass eine zusätzliche Konfiguration EmbeddedPostgresConfiguration von Spring erkannt wird. Zudem wird sichergestellt, dass diese Konfiguration vor der eigentlichen DataSourceAutoConfiguration ausgeführt wird. Innerhalb der Konfiguration wird eine Bean des Typs EmbeddedPostgres definiert und zusätzlich über einen BeanFactoryPostProcessor dafür gesorgt, dass die Datenbank relevanten Properties von Spring Boot durch die zur Embedded-PostgreSQL-Datenbank passenden ersetzt werden.

Die zweite Konfiguration EmbeddedPostgresDependencyConfiguration sorgt zusätzlich dafür, dass die DataSource-Bean von der EmbeddedPostgres-Bean abhängt und somit in jedem Fall erst nach dieser erzeugt wird. Dies ist notwendig, damit der PostProcessor die Properties setzen kann, bevor eine DataSource erzeugt wird.

Migrationen testen

Wie im letzten Abschnitt zu sehen, können auch Migrationen relativ komplex werden. Es wäre demnach super, wenn wir diese auch einzeln testen können. Hierzu müssten wir erst auf den Stand vor der zu testenden Migration migrieren, Daten einfügen, die zu testende Migration ausführen und anschließend die nun migrierten Daten validieren.

Auch dies lässt sich mit ein wenig Code und einer eigenen JUnit-Erweiterung lösen. Listing 14 zeigt einen Test für die zweite Migration unter Verwendung der eigenen MigrationTest-Annotation. Innerhalb der Annotation kann spezifiziert werden, welche Migrationen getestet werden. Die Erweiterung sorgt anschließend dafür, dass der Test als Methodenparameter ein MigrationTestTemplate übergeben bekommt. Dieses bietet die beiden Methoden beforeMigration und afterMigration an.

@MigrationTest(fromVersion = 1, toVersion = 2)
public class V2AddFirstAndLastnameToPersonTableTests {
    @Autowired
    JdbcTemplate jdbcTemplate;

    @Test
    void migration_shouldSplitNameIntoFirstAndLastname(
            MigrationTestTemplate template) {
        template.beforeMigration(() -> jdbcTemplate.execute(
            "INSERT INTO person (name) VALUES ('Test Fixture')"));
        template.afterMigration(() -> {
            String person = jdbcTemplate.queryForObject(
                "SELECT * FROM person",
                (rs, rowNum) -> {
                    return rs.getString("lastname") +
                        ", " +
                        rs.getString("firstname");
                }
            );
            assertThat(person).isEqualTo("Fixture, Test");
        });
    }
}
Listing 14: Test für unsere zweite Migration

Beim Aufruf von beforeMigration wird die Datenbank auf den Stand von fromVersion gesetzt und anschließend der übergebene Lambda-Ausdruck aufgerufen. In diesem können wir einen initialen Datenbestand aufsetzen. Rufen wir anschließend afterMigration auf, werden die zu testenden Migrationen ausgeführt, indem die Datenbank auf den Stand von toVersion migriert wird. Auch hier wird wieder ein Lambda-Ausdruck übergeben, um den Stand nach der Migration zu testen. Listing 15 zeigt Auszüge aus der Implementierung der MigrationTest-Annotation.

...
@SpringBootTest
@WithEmbeddedPostgres
@ImportAutoConfiguration(exclude = FlywayAutoConfiguration. class)
@ExtendWith(MigrationTest.FlywayMigrationTestExtension.class)
public @interface MigrationTest {
    int fromVersion();
    int toVersion();

    class FlywayMigrationTestExtension implements
            BeforeEachCallback, AfterEachCallback, ParameterResolver {

        @Override
        public void beforeEach(ExtensionContext ctx) throws Exception {
            JdbcTemplate jdbcTemplate = ...;
            dropAllTables(jdbcTemplate);
        }

        @Override
        public void afterEach(ExtensionContext ctx) throws Exception {
            JdbcTemplate jdbcTemplate = ...;
            dropAllTables(jdbcTemplate);
            DataSource dataSource = jdbcTemplate.getDataSource();
            Flyway flyway = Flyway.configure()
                .dataSource(dataSource)
                .locations("/db/migration")
                .load();
            flyway.migrate();
        }

        ...

        @Override
        public Object resolveParameter(
                ParameterContext pCtx, ExtensionContext eCtx)
                throws ParameterResolutionException {
            return findAnnotation(eCtx.getTestClass(),
                    MigrationTest.class)
                .map((migrationTest) -> {
                    DataSource dataSource = ...;
                    int fromVersion = migrationTest.fromVersion();
                    int toVersion = migrationTest.toVersion();
                    return new MigrationTestTemplate(
                        dataSource, fromVersion, toVersion);
                })
                .orElseThrow(() -> new IllegalStateException(...));
        }
    }
    ...
    class MigrationTestTemplate {
        ...
    }
}
Listing 15: Implementierung der MigrationTest Annotation

Zusätzlich zu den bereits bekannten Interfaces Before- und AfterEachCallback wird hier zusätzlich ParameterResolver implementiert. Über dieses Interface ist es möglich, den Testmethoden zusätzliche Objekte als Parameter zu übergeben. In diesem konkreten Fall setzen wir in beforeEach die gesamte Datenbank auf einen initialen leeren Zustand zurück. Dies ist notwendig, weil Spring bereits vorher die Migrationen bis zum aktuellsten Zustand durchgeführt hat. Genau aus diesem Grunde stellen wir in afterEach auch wieder diesen Zustand her. Hierzu löschen wir erneut alle Datenbankobjekte und führen eine Migration auf den aktuellsten Zustand durch.

Mittels resolveParameter erzeugen wir zudem für jede Testmethode eine neue Instanz von MigrationTestTemplate, damit der Test dieses nutzen kann, um die Migrationen zu testen.

Fazit

Nicht alle Anforderungen, die beim Testen von mit Spring Boot umgesetzten Webanwendungen entstehen, sind von Hause aus gelöst. Spring Boot gibt uns jedoch bereits eine Menge von Bestandteilen an die Hand, um auch diese Probleme zu lösen. In Verbindung mit eigenen JUnit5-Erweiterungen entstehen hierbei Abstraktionen, die gut wiederverwendbar sind und in den Tests klar verständlich ausdrücken, was erwartet wird.

Um dies zu veranschaulichen, haben wir die drei konkreten Anwendungsfälle „Testen von Zeit“, „Testen mit eingebetteter PostgreSQL-Datenbank“ und „Testen von Flyway-Migrationen“ betrachtet und jeweils eine mögliche Lösung gesehen, wie dies konkret umsetzbar ist.

Natürlich gibt es noch viele weitere Anwendungsfälle, die sich mit dieser Kombination lösen lassen. Mir fällt zum Beispiel noch das Aufräumen der Datenbank nach einem Test, das Starten anderer Systeme oder das Aufzeichnen von geloggten Nachrichten ein. Ich bin mir aber sicher, dass es noch viele weitere Möglichkeiten gibt.

Der vollständige Quellcode zu allen Listings kann auf GitHub angesehen oder heruntergeladen werden. Ich freue mich zudem über sämtliche Fragen oder Anmerkungen.