Dzisiaj zapoznamy się z innym sposobem tworzenia beanów w Springu. Zamiast korzystać z adnotacji @Component i jej pochodnych, skorzystamy z @Configuration oraz @Bean. Będzie to nawiązanie do jednego z poprzednich wpisów, gdzie dodaliśmy dodatkową konfigurację na rzecz testów. Przy okazji ten wpis jest nawiązaniem do mojego dawnego artykułu sprzed czterech lat (ale ten czas leci…)!

Zastana sytuacja

Sytuacja jest następująca. Mamy klasę MainService, która ma zależności do ServiceA, ServiceB oraz ExamplePort. Wykorzystuje ich dostępne metody, aby wykonać zadanie umiejscowione w metodzie calculate. Jedyną implementacją dla ExamplePort jest RandomNumberGenerator, która, jak nazwa wskazuje, losuje, dowolną liczę z zakresu integera.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Component
@RequiredArgsConstructor
public class MainService {

    private final ServiceA serviceA;
    private final ServiceB serviceB;
    private final ExamplePort examplePort;

    public int calculate() {
        return serviceA.returnSomething() + serviceB.returnSomething() + examplePort.returnNumber();
    }

}

@Component
class ServiceA {

    int returnSomething() {
        return 1;
    }

}

@Component
class ServiceB {

    int returnSomething() {
        return 2;
    }

}

public interface ExamplePort {

    int returnNumber();

}

@Component
public class RandomNumberGenerator implements ExamplePort {

    @Override
    public int returnNumber() {
        return new Random().nextInt();
    }

}

Warto zauważyć, że ServiceA i ServiceB mają dostęp pakietowy. Można wyjść z założenia, że znajdują się one w tym pakiecie raczej ze względu na rozbicie funkcjonalności z powodu czytelności. Nie chcemy ich udostępniać na zewnątrz, bo to są tylko (i aż) szczegóły implementacyjne danego modułu. Tutaj mały protip. Zajrzyj sobie do sekcji “podsumowanie” w tym artykule. Jest tam wskazówka jak zwizualizować sobie w IntelliJ co ma dostęp pakietowy, a co nie.

Wracając na chwilę do RandomNumberGenerator. Jest to jedyna zewnętrzna zależność do naszego modułu, oddzielona od niego interfejsem w postaci ExamplePort. Jest to też istotna informacja, którą możemy wskazać explicite w kodzie. A w jaki sposób? No właśnie korzystając z @Configuration oraz @Bean.

Przejście na inny sposób rejestracji beanów

Na start napiszmy sobie test, który będzie nam pomagał weryfikować nasze nadchodzące zmiany. Tylko tym razem nie chcemy uruchamiać całej machinerii Springa. Skorzystamy sobie z szybkiego testu jednostkowego.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class MainServiceTest {

    MainService mainService = new MainService(
        new ServiceA(),
        new ServiceB(),
        new RandomNumberGenerator(),
    );

    @Test
    void test() {
        int result = mainService.calculate();

        assertEquals(0, result); // failed...
    }

}

Na ten moment naiwnie skorzystaliśmy z niedeterministycznej, jedynej implementacji interfejsu ExamplePort. Asercja oczywiście nie przejdzie. Nawet nie wiadomo, jakbyśmy się nie starali, to ciężko to osiągnąć przy takim stanie rzeczy. Dlatego na ten moment skorzystamy z mocka, jak to miało miejsce w jednym z poprzednich wpisów. Tam z zaślepki skorzystaliśmy na poziomie testów Springa, natomiast idea jest ta sama.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MainServiceTest {

    ExamplePort examplePort = mock(ExamplePort.class);

    MainService mainService = new MainService(
        new ServiceA(),
        new ServiceB(),
        examplePort,
    );

    @Test
    void test() {
        when(examplePort.returnNumber()).thenReturn(10);

        int result = mainService.calculate();

        assertEquals(13, result);
    }

}

Test przechodzi. Dobra, ale załóżmy, że z jakiegoś powodu chcielibyśmy dodać nowy serwis pakietowy. Może z pobudek zwiększenia czytelności albo doszła po prostu nowa funkcjonalność. Z czym to się wiążę?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Component
class ServiceC {

    int returnSomething() {
        return 3;
    }

}

@Component
@RequiredArgsConstructor
public class MainService {

    private final ServiceA serviceA;
    private final ServiceB serviceB;
    private final ServiceC serviceC;
    private final ExamplePort examplePort;

    public int calculate() {
        return serviceA.returnSomething() + serviceB.returnSomething() + serviceC.returnSomething()
                + examplePort.returnNumber();
    }

}

class MainServiceTest {

    ExamplePort examplePort = mock(ExamplePort.class);

    MainService mainService = new MainService(
        new ServiceA(),
        new ServiceB(),
        new ServiceC(),
        examplePort,
    );

    @Test
    void test() {
        when(examplePort.returnNumber()).thenReturn(10);

        int result = mainService.calculate();

        assertEquals(16, result);
    }

}

Musieliśmy dokonać zmiany w dwóch miejscach - MainService oraz teście MainServiceTest. Jednak załóżmy, że klas testowych korzystających z MainService byłoby znacznie więcej. Wtedy musielibyśmy wejść do każdej takiej klasy i dokonać podobnej poprawki. Jak możemy sobie z tym poradzić? Już pokazuję.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Configuration
public class MainConfig {

    @Bean
    MainService mainService(ExamplePort examplePort) {
        return new MainService(
                new ServiceA(),
                new ServiceB(),
                new ServiceC(),
                examplePort
        );
    }

}

class MainServiceTest {

    ExamplePort examplePort = mock(ExamplePort.class);

    MainService mainService = new MainConfig().mainService(examplePort);

    @Test
    void test() {
        when(examplePort.returnNumber()).thenReturn(10);

        int result = mainService.calculate();

        assertEquals(16, result);
    }

}

Oczywiście trzeba pamiętać o usunięciu @Component ze wszystkich serwisów (z pobudek czysto praktycznych). Co nam to dało? Jeśli wykonywany byłby refactoring w tym module, bo np. zamiast klas ServiceA i ServiceB chcielibyśmy mieć jedną klasę ServiceX to testy by się o tym nie dowiedziały. Byłyby to tylko szczegóły implementacyjne modułu.

Dodatkowo nie oddajemy całkowicie władzy frameworkowi. Wykonaliśmy trochę krok w tył, bo sami korzystamy ze słowa kluczowego new, ale robimy to świadomie. Rejestrujemy w kontekście Springa tylko to, co jest konieczne. Plus komunikujemy, że dany moduł zależy tylko od wybranych zależności jak ExamplePort. Cała reszta jest detalem, szczegółem wewnątrz modułu.

Sprawdźmy teraz, co się stanie, jak dojdzie kolejna klasa pakietowa - ServiceD.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class ServiceD {

    int returnSomething() {
        return 4;
    }

}

@RequiredArgsConstructor
public class MainService {

    private final ServiceA serviceA;
    private final ServiceB serviceB;
    private final ServiceC serviceC;
    private final ServiceD serviceD;
    private final ExamplePort examplePort;

    public int calculate() {
        return serviceA.returnSomething() + serviceB.returnSomething() + serviceC.returnSomething()
                + serviceD.returnSomething()
                + examplePort.returnNumber();
    }

}

class MainServiceTest {

    ExamplePort examplePort = mock(ExamplePort.class);

    MainService mainService = new MainConfig().mainService(examplePort);

    @Test
    void test() {
        when(examplePort.returnNumber()).thenReturn(10);

        int result = mainService.calculate();

        assertEquals(20, result);
    }

}

W teście nic się nie zmieniło poza asercją, którą musieliśmy dostosować do nowego wyniku. I tyle! Test tak naprawdę nie jest świadomy istnienia nowej klasy ServiceD. Do pełni szczęścia zamieńmy jeszcze mocka na fake, w postaci wyrażenia lambda.

1
2
3
4
5
6
7
8
9
10
11
12
class MainServiceTest {

    MainService mainService = new MainConfig().mainService(() -> 10);

    @Test
    void test() {
        int result = mainService.calculate();

        assertEquals(20, result);
    }

}

Test jest czytelniejszy (subiektywna opinia) i odporny na perturbacje klas w module.

Podsumowanie

Czy takie podejście jest dla Ciebie odpowiednie? Nie wiem, to zależy od Twoich preferencji. Z mojego punktu widzenia i doświadczenia wychodzi na to, że to jest dużo lepsze podejście, ponieważ:

  • trochę odchudzamy kontekst Springa
  • łatwiej możemy tworzyć testy, bo nie musimy się teraz zastanawiać, jak te wszystkie obiekty ze sobą posklejać
  • jeśli powstałaby potrzeba refactoringu, żeby coś rozbić ze względu na odpowiedzialności wewnątrz modułu, to nie musielibyśmy propagować tych wszystkich zmian w testach

Także to by było na tyle, co chciałem Ci przekazać w tym wpisie. Mam nadzieję, że to faktycznie będzie dla Ciebie przydatne, a jeśli z tego nie skorzystasz, to chociaż będziesz świadomy tego rozwiązania. Dziękuję Ci za Twój czas, na razie i cześć!