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.

7 myśli na temat “Idiomy z Java 8 III

  1. A w Scala jakbyś widział rozwiązanie?

    Mi przychodzi do głowy po prostu coś takiego:

    def plumbum = 1 to 10 map (_.toString) toList
    def magic(x: List[String]) = x map (_ + „_XXX”)
    def gold(x: List[String]) = x map println

    magic _ andThen gold apply plumbum

    lecz wtedy plumbum jest na końcu zamiast na początku tak jak w wersji Elixirowej. Przeniesienie plumbum na początek już by chyba tak ładnie nie wyglądało. ?

  2. W Scali będzie podobnie jak w Elixirze, a jedynie wykorzystasz for competition for comprehension z tego co kojarzę. Ale rzecz godna uwagi, bo warto to porównać ze Scalą i Cloure.

  3. Miałeś na myśli „for comprehension”? Nie wiem jakby miało tutaj pomóc. Ale zaimplementowanie takiego operatora właściwie nie powinno być trudne.

  4. @Ekstrapolowany, taki mała „litrówka”, a chodzi to o możliwość budowania „flowu” i na końcu umieszczenia słówka yeld. Praktycznie lukier składniowy, ale przydatny.

  5. Hej czy mógłbyś napisać coś więcej na temat wywołania step by step zamiast zagnieżdżania. Dlaczego taka konstrukcja jest bardziej pożądana? Jakie daje korzyści? Co w przypadku gdy funkcja przyjmuje dwa argumenty?

  6. @Manski, zaletą tego rozwiązania jest oddzielenie logiki biznesowej od konkretnego sposobu przetwarzania. W tym konkretnym przypadku mogę przetestować oddzielnie elementy związane z wczytywaniem danych, magią i zapisem danych. Patrząc na to z punktu widzenia wzorców jest to mieszanka kompozycji z metodą szablonową.
    By odczuć zaletę tego typu podejścia należy spróbować napisać testy dla listingu 1, ale nie zmieniając tego kodu. Problem ten został ciekawie opisany w http://blog.plataformatec.com.br/2015/10/mocks-and-explicit-contracts/

Napisz odpowiedź

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *

To create code blocks or other preformatted text, indent by four spaces:

    This will be displayed in a monospaced font. The first four 
    spaces will be stripped off, but all other whitespace
    will be preserved.
    
    Markdown is turned off in code blocks:
     [This is not a link](http://example.com)

To create not a block, but an inline code span, use backticks:

Here is some inline `code`.

For more help see http://daringfireball.net/projects/markdown/syntax