Ostatnio w pracy spędziłem sporo czasu nad jednym zadaniem przez… test jednostkowy. Pomimo tego, że lubię pisać testy to nie dziwię się innym programistom, że w takiej sytuacji zaprzestają ich pisania. Jednak to nie jest wina koncepcji testów tylko tworzenia kodu produkcyjnego, który po prostu nie chce dać się przetestować. Zarysuję Ci teraz jak wyglądała mniej więcej sytuacja.

Zdefiniowanie problemu

Zacznijmy od bazowej klasy abstrakcyjnej jaką zastałem. Oczywiście kod jest zupełnie inny niż w mojej pracy jeśli chodzi o przypadek biznesowy. Ja tylko chcę zarysować problem, który napotkałem.

1
2
3
4
5
6
7
8
9
10
11
import org.springframework.beans.factory.annotation.Autowired;

public abstract class UserVerification {

  @Autowired
  private UserService userService;

  public void verifyUser(User user) {
    userService.checkDept(user);
  }
}

Od razu powinno zapalić się światełko w głowie… Już nie wiem od jak dawna nie powinno robić się wstrzykiwania zależności przez pole. Jednak załóżmy, że tego nie zauważyłem i brnąłem w to dalej. Utworzyłem, więc swoją klasę rozszerzającą to bazowe rozwiązanie.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.springframework.stereotype.Component;

@Component
public class VipUserVerification extends UserVerification {

  private final VipService vipService;

  public VipUserVerification(final VipService vipService) {
    this.vipService = vipService;
  }

  public void verifyVip(Vip vip) {
    verifyUser(vip);
    vipService.checkExtraDept(vip);
  }
}

Rozszerzyłem UserVerification, dodałem konstruktor wstrzykujący jako parametr VipService i przypisujący je do odpowiedniego pola. Następnie w metodzie verifyVip wywołałem metodę verifyUser z naszej klasy bazowej i metodę checkExtraDept z serwisu. Wszystko jak do tej pory powinno działać jak należy. Oczywiście w tym przypadku nie jest ważne czym jest UserService, VipService, User czy Vip. No dobra to zabierzmy się teraz za clue, czyli nasz test jednostkowy.

Test jednostkowy

Załóżmy, że UserService i VipService to usługi zewnętrzne, więc wypada je zamockować. Zaraz do tego dojdziemy, najpierw zdefiniujmy test.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...
import org.junit.jupiter.api.Test;

import static org.mockito.Mockito.verify;

class VipVerificationTest {

  // ... set up

  @Test
  public void ourTestMethod() {
    Vip vip = new Vip();

    vipUserVerification.verifyVip(vip);

    verify(userService).checkDept(vip);
    verify(vipService).checkExtraDept(vip);
  }
}

Tworzymy nowego Vip’a, wywołujemy weryfikację i sprawdzamy czy nasze usługi zewnętrzne zostały wywołane z naszym obiektem. Prosty test. Wróćmy jednak do ustawień. Musimy utworzyć instancję naszej testowanej klasy i wstrzyknąć jej odpowiedniego mocka.

Pierwsze podejście

Spróbujmy na początek coś takiego.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
...
import org.junit.jupiter.api.BeforeEach;

import static org.mockito.Mockito.mock;

class VipVerificationTest {

  private final UserService userService = mock(UserService.class);
  private final VipService vipService = mock(VipService.class);

  private VipUserVerification vipUserVerification;

  @BeforeEach
  public void setUp() {
    vipUserVerification = new VipUserVerification(
        vipService
    );
  }

  // ... test
}

Pomimo tego, że VipUserVerification wymaga tylko VipService to i tak utworzyliśmy mocka UserService, aby sprawdzić czy jego metoda też została wywołana w teście. Dostajemy, więc kolejny alert, ale znowu go umyślnie nie zauważamy. Uruchamiamy test, iiii…. cyk!

java.lang.NullPointerException: Cannot invoke "... .UserService.checkDept(pl.devcezz.javadockerwar.User)" because "this.userService" is null

Dostajemy NullPointerException! Nasz UserService okazał się nullem pomimo tego, że go utworzyliśmy. No dobrze, pewnie to nasz błąd przy ustawieniach testu, spróbujmy czegoś innego.

Podejście drugie

Użyjmy adnotacji Mockito.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
...
import org.junit.jupiter.api.BeforeEach;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

class VipVerificationTest {

  @Mock
  private UserService userService;

  @Mock
  private VipService vipService;

  @InjectMocks
  private VipUserVerification vipUserVerification;

  @BeforeEach
  public void setUp() {
    MockitoAnnotations.openMocks(this);
  }

  // ... test
}

Podejście numer dwa, uruchamiamy test iii… znowu to samo!

java.lang.NullPointerException: Cannot invoke "... .UserService.checkDept(pl.devcezz.javadockerwar.User)" because "this.userService" is null

I teraz irytacja rośnie do granic możliwości. Usuwamy test, robimy commit, push i robota skończona! Tak pewnie zrobiłaby większość z nas. Jednak może warto się chwilę wstrzymać i przenalizować co może być nie tak. Dałem już na początku wskazówkę, więc podążmy ją i spójrzmy na kod produkcyjny.

Poprawka w kodzie produkcyjnym

Powiedziałem, że nie powinno się wstrzykiwać zależności przez pole. Zmieńmy zatem klasę UserVerification, aby odbywało się wstrzykiwanie przez konstruktor.

1
2
3
4
5
6
7
8
9
10
11
12
public abstract class UserVerification {

  private final UserService userService;

  UserVerification(final UserService userService) {
    this.userService = userService;
  }

  public void verifyUser(User user) {
    userService.checkDept(user);
  }
}

Przez tą zmianę wymagane jest poprawienia naszej klasy biznesowej.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import org.springframework.stereotype.Component;

@Component
public class VipUserVerification extends UserVerification {

  private final VipService vipService;

  public VipUserVerification(
      final UserService userService, 
      final VipService vipService
  ) {
    super(userService);
    this.vipService = vipService;
  }

  public void verifyVip(Vip vip) {
    verifyUser(vip);
    vipService.checkExtraDept(vip);
  }
}

Musieliśmy i w konstruktorze klasy VipUserVerification dodać zależność do UserService. Teraz uruchamiamy test i jak ręką odjął! Mamy zielony pasek!

Zakończenie mojej historii

W przypadku mojej pracy nie było happy end’u. Istniało bodajże osiem klas, które rozszerzały tą klasę bazową. Podjąłem się wyzwania i zmieniłem klasę bazową, aby zależności były wstrzykiwane przez konstruktor. To samo musiałem zrobić w klasach ją rozszerzających, ponieważ również zawierały ten błąd. Poprawiłem testy i przechodziły jak należy. Jednak podczas uruchamiania kontekstu aplikacji dostawałem wyjątek o zależności cyklicznej Spring’a. Szukałem błędu, ale deadline’y goniły i musiałem zaprzestać refaktoryzacji. Wróciłem, więc do rozwiązania, które znalazłem w innych klasach. Musiałem zastosować w swojej klasie… wstrzykiwanie przez pole.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class VipUserVerification extends UserVerification {

  @Autowired
  private VipService vipService;

  public void verifyVip(Vip vip) {
    verifyUser(vip);
    vipService.checkExtraDept(vip);
  }
}

Wtedy w test w takiej postaci przechodzi na zielono. Co prawda zostaje pewien niedosyt i niesmak.

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
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import static org.mockito.Mockito.verify;

class VipVerificationTest {

  @Mock
  private UserService userService;

  @Mock
  private VipService vipService;

  @InjectMocks
  private VipUserVerification vipUserVerification;

  @BeforeEach
  public void setUp() {
    MockitoAnnotations.openMocks(this);
  }

  @Test
  public void ourTestMethod() {
    Vip vip = new Vip();

    vipUserVerification.verifyVip(vip);

    verify(userService).checkDept(vip);
    verify(vipService).checkExtraDept(vip);
  }
}

Podsumowanie

W tym przypadku finał jest taki, że musiałem zastosować coś czego próbuję unikać jak ognia. Musiałem zrezygnować z własnych przekonań, aby dokończyć swoje zadanie. To rozwiązanie wydaje się nieintuicyjne i wręcz nie jest jasne. Wszystko dzieje się za naszymi plecami dzięki magii. Nie mamy nad niczym kontroli. Nie wiemy co dokładnie się dzieje, co od czego zależy. Nawet IDE w postaci IntelliJ jest z nami w tej walce mówiąc nam, że “Field injection is not recommended”. Dlatego ważne jest, aby chwilę się zastanowić nad pisanym przez siebie kodem i sprawdzić czy czasem komuś nie utrudnimy nim pracy.

Jakie jest Twoje zdanie na ten temat? Z chęcią dowiem się czy miewałeś/aś takie same przypadki i jak sobie z nimi radziłeś/aś.