JUnit 5 – Fabryki testów
Czasami samo przekazanie parametrów do testów nie wystarcza. Nie są to sytuacje częste, ale mogą się zdarzyć. Jednym z przykładów niech będzie konieczność pobrania danych testowych z jakiejś bazy danych. Nie posiadamy do dyspozycji żadnego narzędzia, w rodzaju @CsvFileSource. Można takie narzędzie napisać. Dodać rozszerzenie do modułu testów parametryzowanych i będzie OK. Ma ono jednak pewne cechy, które parafrazując Twierdzenie GödlaW, można pisać w następujący sposób:
Mechanizm będzie prosty w użyciu np. @DataBaseSource(DbArgumentsProvider.class), ale wymagający wielu dodatkowych elementów do implementacji, albo będzie trudny w użyciu, ale nie będzie wymagać dodatkowych elementów do implementacji np. @DataBaseSource(„DataSourceName”, „QUERY”, ResultSetMapper.class).
I tak źle i tak niedobrze. W dodatku pytanie, co w przypadku innych źródeł danych (JSON, yaml, Excel, XML)? Jak zachowają się testy, gdy zmienimy bazę danych, co w przypadku gdy zapytanie zwraca wyniki w losowej kolejności? Rozwiązaniem są fabryki testów. Mechanizm ten jest obecny w TestNG:
Listing 1. @Factory w TestNG
public class FizzBuzzTestNGFactoryTest {
@Factory
public Object[] fizzBuzzTestFactory() {
return new Object[]{new DividedBy3(),new DividedBy5(), new DividedBy15(), new NotDividedBy3Or5()};
}
static class NotDividedBy3Or5 {
private FizzBuzz sut;
@BeforeTest
public void setup() {
sut = new FizzBuzz();
}
@Test
public void shouldReturnVal() throws Exception {
assertEquals("2", sut.fizzBuzz(2));
}
}
static class DividedBy5 {
private FizzBuzz sut;
@BeforeTest
public void setup() {
sut = new FizzBuzz();
}
@Test
public void shouldReturnBuzzIfDiv5() throws Exception {
assertEquals("Buzz", sut.fizzBuzz(5));
}
}
static class DividedBy3 {
private FizzBuzz sut;
@BeforeTest
public void setup() {
sut = new FizzBuzz();
}
@Test
public void shouldReturnFizzIfDiv3() throws Exception {
assertEquals("Fizz", sut.fizzBuzz(3));
}
}
static class DividedBy15 {
private FizzBuzz sut;
@BeforeTest
public void setup() {
sut = new FizzBuzz();
}
@Test
public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
assertEquals("FizzBuzz", sut.fizzBuzz(15));
}
}
}
Metoda oznaczona @Factory zwraca tablicę obiektów, które są testami. W tym konkretnym przypadku wykonujemy pracę, którą normalnie robi za nas biblioteka. Jednak można łatwo wyobrazić sobie sytuację, w której proces tworzenia testu jest znacznie bardziej skomplikowany. Chociażby w wyniku wykorzystania @DataProvider jako dostawcy dla fabryki. Powyższy kod jest oczywiście uproszczony. Fabryki przede wszystkim służą do tworzenia testów, gdzie trzeba użyć konstruktora z parametrami:
Listing 2. Klasy testowe z parametrami w konstruktorach w TestNG
public class FizzBuzzTestNGFactoryConstructorCallTest {
@Factory
public Object[] fizzBuzzTestFactory() {
FizzBuzz sut = new FizzBuzz();
return Stream.of(
IntStream.of(3, 6, 99).<Object>mapToObj(p -> new DividedBy3(sut, p)),
IntStream.of(5, 10, 50).<Object>mapToObj(p -> new DividedBy5(sut, p)),
IntStream.of(15, 30, 150).<Object>mapToObj(p -> new DividedBy15(sut, p)),
IntStream.of(2, 8, 11).<Object>mapToObj(p -> new NotDividedBy3Or5(sut, p))
).flatMap(Function.identity())
.collect(Collectors.toSet()).toArray();
}
static class NotDividedBy3Or5 {
private final FizzBuzz sut;
private final int param;
public NotDividedBy3Or5(FizzBuzz sut, int param) {
this.sut = sut;
this.param = param;
}
@Test
public void shouldReturnVal() throws Exception {
assertEquals(param + "", sut.fizzBuzz(param));
}
}
static class DividedBy5 {
private final FizzBuzz sut;
private final int param;
DividedBy5(FizzBuzz sut, int param) {
this.sut = sut;
this.param = param;
}
@Test
public void shouldReturnBuzzIfDiv5() throws Exception {
assertEquals("Buzz", sut.fizzBuzz(param));
}
}
static class DividedBy3 {
private final FizzBuzz sut;
private final int param;
DividedBy3(FizzBuzz sut, int param) {
this.sut = sut;
this.param = param;
}
@Test
public void shouldReturnFizzIfDiv3() throws Exception {
assertEquals("Fizz", sut.fizzBuzz(param));
}
}
static class DividedBy15 {
private final FizzBuzz sut;
private final int param;
DividedBy15(FizzBuzz sut, int param) {
this.sut = sut;
this.param = param;
}
@Test
public void shouldReturnFizzBuzzIfDiv3And5() throws Exception {
assertEquals("FizzBuzz", sut.fizzBuzz(param));
}
}
}
W dodatku rzeczywisty kod może być rozbity na wiele klas w osobnych plikach. To są jednak szczegóły. W JUnit 4 nie było możliwości zrobienia czegoś takiego. JUnit 5 wprowadza mechanizm testów dynamicznych.
Testy dynamiczne
Testy dynamiczne, to testy, które są tworzone w czasie wykonania programu. W połączeniu z mechanizmem fabryk pozwalają one na wygenerowanie testów w locie. Oczywiście coś za coś:
Listing 3. Testy dynamiczne w JUnit 5
public class FizzBuzzJUnit5DynamicTest {
private FizzBuzz sut = new FizzBuzz();
@Nested
class DividedBy15 {
@TestFactory
public Collection<DynamicTest> shouldReturnFizzBuzzIfDiv3And5() throws Exception {
return Arrays.asList(
dynamicTest("For 15", () -> assertEquals("FizzBuzz", sut.fizzBuzz(15))),
dynamicTest("For 30", () -> assertEquals("FizzBuzz", sut.fizzBuzz(30))),
dynamicTest("For 150", () -> assertEquals("FizzBuzz", sut.fizzBuzz(150)))
);
}
}
@Nested
class DividedBy5 {
@TestFactory
public Stream<DynamicTest> shouldReturnBuzzIfDiv5() throws Exception {
return Stream.of(5, 10, 50)
.map(
val -> dynamicTest(String.format("For %s", val)
, () -> assertEquals("Buzz", sut.fizzBuzz(val))
)
);
}
}
@Nested
class DividedBy3 {
@TestFactory
public Iterable<DynamicTest> shouldReturnFizzIfDiv3() throws Exception {
return Arrays.asList(
dynamicTest("for 3", () -> assertEquals("Fizz", sut.fizzBuzz(3))),
dynamicTest("for 6", () -> assertEquals("Fizz", sut.fizzBuzz(6))),
dynamicTest("for 99", () -> assertEquals("Fizz", sut.fizzBuzz(99)))
);
}
}
@Nested
class NotDividedBy3Or5 {
@TestFactory
public Iterator<DynamicTest> shouldReturnVal() throws Exception {
return Arrays.asList(
dynamicTest("for 2", () -> assertEquals("2", sut.fizzBuzz(2))),
dynamicTest("for 9", () -> assertEquals("8", sut.fizzBuzz(8))),
dynamicTest("for 11", () -> assertEquals("11", sut.fizzBuzz(11)))
).iterator();
}
}
}
Uwaga, używam mechanizmu testów zagnieżdżonych, by wszystko było w jednym miejscu.
Z metody oznaczonej jako @TestFactory, zwracamy kolekcję, iterator, strumień, etc. obiektów DynamicTest. Obiekty te reprezentują poszczególne testy. Wygląda to nieźle, biorąc pod uwagę, że możemy w łatwy sposób np. mapować ResultSet na Stream. To, co odróżnia testy dynamiczne od zwykłych testów, jest inny cykl życia. W przypadku testów dynamicznych nie są uruchamiane metody oznaczone adnotacjami @BeforeEach i @AfterEach. Jest tak, ponieważ klasa DynamicTest, jest tylko wrapperem na implementację Executable, które reprezentuje test jako taki. Executable, jest interfejsem oznaczonym jako @FunctionalInterface. Poza ograniczeniem do pojedynczej metody, nie ma możliwości przekazania zmiennych pomiędzy poszczególnymi lambdami, bez łamania kontraktu.
Jeżeli chcemy użyć @BeforeEach i @AfterEach, to możemy zrobić to na poziomie klasy, w której mamy zdefiniowaną metodę fabrykującą:
Listing 4. Użycie @BeforeEach i @AfterEach z testami dynamicznymi
public class FizzBuzzJUnit5DynamicBeforeTest {
@Nested
class DividedBy15 {
private FizzBuzz sut;
@BeforeEach
public void setUp() {
sut = new FizzBuzz();
}
@TestFactory
public Collection<DynamicTest> shouldReturnFizzBuzzIfDiv3And5() throws Exception {
return Arrays.asList(
dynamicTest("For 15", () -> assertEquals("FizzBuzz", sut.fizzBuzz(15))),
dynamicTest("For 30", () -> assertEquals("FizzBuzz", sut.fizzBuzz(30))),
dynamicTest("For 150", () -> assertEquals("FizzBuzz", sut.fizzBuzz(150)))
);
}
}
@Nested
class DividedBy5 {
private FizzBuzz sut;
@BeforeEach
public void setUp() {
sut = new FizzBuzz();
}
@TestFactory
public Stream<DynamicTest> shouldReturnBuzzIfDiv5() throws Exception {
return Stream.of(5, 10, 50)
.map(
val -> dynamicTest(String.format("For %s", val)
, () -> assertEquals("Buzz", sut.fizzBuzz(val))
)
);
}
}
@Nested
class DividedBy3 {
private FizzBuzz sut;
@BeforeEach
public void setUp() {
sut = new FizzBuzz();
}
@TestFactory
public Iterable<DynamicTest> shouldReturnFizzIfDiv3() throws Exception {
return Arrays.asList(
dynamicTest("for 3", () -> assertEquals("Fizz", sut.fizzBuzz(3))),
dynamicTest("for 6", () -> assertEquals("Fizz", sut.fizzBuzz(6))),
dynamicTest("for 99", () -> assertEquals("Fizz", sut.fizzBuzz(99)))
);
}
}
@Nested
class NotDividedBy3Or5 {
private FizzBuzz sut;
@BeforeEach
public void setUp() {
sut = new FizzBuzz();
}
@TestFactory
public Iterator<DynamicTest> shouldReturnVal() throws Exception {
return Arrays.asList(
dynamicTest("for 2", () -> assertEquals("2", sut.fizzBuzz(2))),
dynamicTest("for 9", () -> assertEquals("8", sut.fizzBuzz(8))),
dynamicTest("for 11", () -> assertEquals("11", sut.fizzBuzz(11)))
).iterator();
}
}
}
Przy czym metody te zostaną wywołane raz, przed wywołaniem metody fabrykującej.
Podsumowanie
Mechanizm testów dynamicznych w JUnit 5 działa inaczej niż fabryki z TestNG. Ma on pewne ograniczenia, ale nie są one na tyle duże, by całkowicie przekreślić ten mechanizm.