Idiomy z Java 8 III
Na początek cytat z lekcji o testach ze strony Elixir School:
If you follow good design principles the resulting code will be easy to test as individual components.
Fajna sprawa, bo jak patrzę na aplikacje, które pisałem to wszędzie tam prędzej czy później powstawały testy, które zaczynały się litanią mocków, albo co gorsze same testy były oznaczone jako @Ignore, z informacją, że nie da się czegoś zamockować. I dlatego nie uruchamiamy tego testu na CI. Klasycznym przykładem kodu, który bardzo ciężko się testuje jest kod działający według schematu
- Wczytaj wszystkie bloki/linie z pliku.
- Zrób magię.
- Zapisz do innego pliku
Można to zilustrować takim oto kodem:
Listing 1. Przykładowy kod
public class Alchemist {
public void transmute(File plumbum, File gold) throws IOException {
List<String> plumbumOre = Files.readAllLines(plumbum.toPath());
List<String> nuggets = magic(plumbumOre);
Files.write(gold.toPath(), nuggets, StandardOpenOption.APPEND);
}
private List<String> magic(List<String> plumbumOre) {
return plumbumOre; // serio ;)
}
}
i to już jest całkiem nowoczesny kod, ponieważ używa Files. Znacznie częściej otrzymamy kod, który będzie korzystał z FileReadera, czy z InputStream. Ten kod będzie paskudny do przetestowania, jeżeli chcemy zrobić to zgodnie ze sztuką. Mockowanie metod statycznych, w dodatku z klas standardowego API jest problematyczne. Po prostu w ich przypadku nie wiemy kto jeszcze korzysta z tych metod.
Pomyślmy, jeżeli chcemy zamockować write, która MOŻE być wykorzystana przez np. mechanizm raportowania w testach. Co wtedy? Można oczywiście sprawę zbadać i odpowiednio skonfigurować test… tia… tylko po co? Szczególnie, że tym co nas interesuje jest przetestowanie metody magic. Jest prywatna, bo nie chcemy się dzielić wiedzą jak zamienić ołów w złoto ze światem 😉 Zatem tu nic nie zwojujemy przy obecnym stanie kodu.
Get, map, accept
Zamiast mockować możemy lekko zrefaktoryzować nasz kod tak by stał się łatwiejszy do przetestowania. W tym celu użyjemy trzech narzędzi:
- Supplier – który dostarczy nam rudę ołowiu.
- Consumer – który przetopi nam samorodki złota na sztaby.
- Magia – to będzie nasza wiedza tajemna.
Listing 2. Alchemia inaczej
public class Alchemist {
public void transmute(Supplier<Collection<String>> plumbum, Consumer<Collection<String>> gold) {
gold.accept(magic(plumbum.get()));
}
private List<String> magic(Collection<String> plumbumOre) {
return plumbumOre; // serio ;)
}
}
I ten kod można już przetestować bez użycia mocków. Nadal jednak nie jest to coś co jest fajne. Należało by jeszcze zaczarować linijkę gold.accept(magic(plumbum.get()));, tak by zamiast zagnieżdżania było wywołanie step by step. W przypadku Scali czy Elixira jest to proste. Jest sobie operator strumienia i można by napisać:
Listing 3. Wykorzystanie operatora strumienia a la Elixir
plumbum.get |> magic |> gold.accept
W Javie musimy się troszeczkę nagimnastykować by stworzyć generyczne rozwiązanie. Wykorzystamy do tego kompozycję funkcji. Dodatkowo wydzielimy generyczny interfejs, który będzie ogarniał flow:
Listing 4. Interfejs GMA
interface GMA {
default <IN, OUT> void gma(Supplier<IN> s, Function<IN, OUT> f, Consumer<OUT> c) {
Function<Supplier<IN>, IN> sf = s -> s.get();
Function<OUT, Void> cf = out -> {
c.accept(out);
return null;
};
sf.andThen(f).andThen(cf).apply(s);
// cf.compose(f).compose(sf).apply(s);
}
}
Co tu się dzieje? Na początek definiujemy dwie funkcje, które opakowują nam Supplier i Consumet w Function. Następnie budujemy i wywołujemy funkcję złożoną. Wykomentowania lina pokazuje inny sposób budowy, ale efekt będzie taki sam. Funkcje inline możemy też zastąpić własnymi implementacjami funkcji:
Listing 5. Funkcje Function0 i VoidFunction
interface Function0<R> extends Function<Void, R> {
R apply();
@Override
default R apply(Void aVoid) {
return apply();
}
}
interface VoidFunction<T> extends Function<T, Void> {
void applyVoid(T t);
@Override
default Void apply(T t) {
applyVoid(t);
return null;
}
}
które zredukują nasz kod do:
Listing 6. Interfejs GMA z wykorzystaniem funkcji Function0 i VoidFunction
interface GMA {
default <IN, OUT> void gma(Supplier<IN> s, Function<IN, OUT> f, Consumer<OUT> c) {
Function0<IN> sf = () -> s.get();
VoidFunction<OUT> cf = out -> c.accept(out);
sf.andThen(f).andThen(cf).apply(null);
}
}
Podsumowanie
Pytanie brzmi po co tak kombinować? Otóż w testach zamiast mocków możemy wykorzystać proste implementacje Supplier i Consumer. Szczególnie ten ostatni będzie fajny, bo może być jednocześnie asercją.
Dodatkowym bonusem jest odseparowanie naszego alchemika od tego jak ma współpracować z systemem plików. Pierwotna wersja narzucała użytkownikowi pewne ograniczenia poprzez jawne użycie opcji zapisu pliku. Odczyt też musiał być dokonany z określonego źródła. W finalnej wersji kodu to użytkownik kontroluje w pełni otoczenie, a alchemik nie narzuca żadnego rozwiązania.
A jak wygląda wywołanie naszego kodu?
Listing 7. Wywołanie metody transmute w kodzie produkcyjnym
alchemist.transmute(()-> Files.readAllLines(plumbum.toPath()), nuggets-> Files.write(gold.toPath(), nuggets, StandardOpenOption.APPEND));
Jednocześnie test może wyglądać:
Listing 8. Wywołanie metody transmute w testach
public class AlchemistTest {
private Alchemist alchemist = new Alchemist();
@Test
public void shouldTransmuteWithEmpty() throws Exception {
alchemist.transmute(Collections::emptyList, c-> Assertions.assertThat(c).isEmpty());
}
}
Jak zatem widać udało się nam wyeliminować mocki z testów, a jednocześnie kod produkcyjny wyższego rzędu też może zostać łatwo przetestowany jeżeli tylko wprowadzimy klasy w rodzaju FileLineSupplier czy SaveFileConsumer 🙂 Je trzeba będzie co prawda przetestować już wykorzystując Files, ale tu zależność od środowiska będzie oczywista i będzie wiadomo, że testy w tym miejscu wchodzą w interakcję z otoczeniem.