Jak poprawnie zarządzać złożonymi zależnościami w testach?

0

Hej,

mam problem z Mockito podczas wstrzykiwania zależności w testach.
Chodzi o przypadek o przypadek bardziej złożonych zależności
Nie rozumiem dlaczego Mockito nie inicjalizuja poprawnie klas, niektóre są Nullem , choć są oznaczone przez adnotację Mockito

Poniżej przykład:

@RequiredArgsConstructor
@Service
public class CompetitionTagService {

  private final CompetitionRepository competitionRepository;
  private final VerifyMethodsForServices verifyMethodsForServices;
}
@Service
@RequiredArgsConstructor
public class VerifyMethodsForServices {

  private final CompetitionRepository competitionRepository;
  private final FindTeamService findTeamService;
}

Tutaj jak widzimy obie te klasy wykorzystują CompetitionRepository i mam wrażenie, że przez to Mockito się gubi.
Nie wiem tak naprawdę jak mogę to poprawić, tak aby zachować przejrzystość inicjalizacji w testach

Przy takiej konfiguracji:

@Mock
CompetitionRepository competitionRepository;

@Mock
FindTeamService findTeamService;

@InjectMocks
CompetitionTagService competitionTagService;

@InjectMocks
VerifyMethodsForServices verifyMethodsForServices;

Dostaję błąd, że VerifyMethodsForServices jest Nullem (pewnie przez InjectMocks)

Natomiast podczas takiej:

@Mock
CompetitionRepository competitionRepository;

@Mock
FindTeamService findTeamService;

@InjectMocks
CompetitionTagService competitionTagService;

@Mock
VerifyMethodsForServices verifyMethodsForServices;

VerifyMethodsForServices nie widzi CompetitionRepository bo użyłem Mock
Wychodzi takie masło maślane.

Finalnie skończyło się na takie konfiguracji:

@Mock
CompetitionRepository competitionRepository;

@Mock
FindTeamService findTeamService;

CompetitionTagService competitionTagService;

@InjectMocks
VerifyMethodsForServices verifyMethodsForServices;

I po prostu inicjalizuje ręcznie:

competitionTagService = new CompetitionTagService(competitionRepository, verifyMethodsForServices);

Czy da się to jakoś wszystko spiąć annotacjami ?
Czy ręczna inicjalizacja jest po prostu konieczna?

Generalnie nie skupiałbym się w tym przypadku pod względem "architektury" tego typu zależności, choć oczywiście wszelkie rady iw skazówki chętnie przyjmię.
W głównej mierze zależy mi na zrozumieniu mockowania wlaśnie takiego łańcucha zależności.

Za pomoc z góry dzięki.

5
pitagram napisał(a):

I po prostu inicjalizuje ręcznie:

        competitionTagService = new CompetitionTagService(competitionRepository, verifyMethodsForServices);

No i gratulacje. Nie można było tak od początku?

Czy da się to jakoś wszystko spiąć annotacjami ?

Po co próbujesz na siłę uzyskać nieczytelne adnotacje, skoro, jak sam widzisz, wychodzą z tego same problemy? Nie ma co się bać słówka kluczowego new, tak naprawdę w testach jest to jedyny sensowny sposób tworzenia komponentów.

0

Znaczy można tak było, ale jakoś tak miałem obawy trochę ze względu na to, że korzystam z Mock, InjectMocks , a tu nagle pojawiam się z operatorem new przy inicjalizacji testu.
Ta rozbieżność jest tak naprawdę jedynym powodem mojej konsternacji na ten temat.

0
pitagram napisał(a):

Znaczy można tak było, ale jakoś tak miałem obawy trochę ze względu na to, że korzystam z Mock, InjectMocks , a tu nagle pojawiam się z operatorem new przy inicjalizacji testu.
Ta rozbieżność jest tak naprawdę jedynym powodem mojej konsternacji na ten temat.

Zupełnie niepotrzebna ta obawa.

1

oki, dzięki. Nie byłem pewien, a chciałem dopytać bardziej doświadczonych kolegów.

2
pitagram napisał(a):

oki, dzięki. Nie byłem pewien, a chciałem dopytać bardziej doświadczonych kolegów.

Ja na Twoim miejscu zastanowiłbym się czy w ogóle używać mockito do testów? 🤔 Ja piszę normalnie testy bez tej biblioteki i są super.

0

@Riddle: bez tej biblioteki, ale rozumiem, że nie korzystasz też z zamiennika? Nie dodaje to tak naprawdę pisania dodatkowego kodu, który już masz w tej bibliotece? Przez co tak naprawdę dewelopment się wydłuża?

0
pitagram napisał(a):

@Riddle: bez tej biblioteki, ale rozumiem, że nie korzystasz też z zamiennika? Nie dodaje to tak naprawdę pisania dodatkowego kodu, który już masz w tej bibliotece? Przez co tak naprawdę dewelopment się wydłuża?

Pokaż Twoje testy z mockami, to ja Ci pokażę jak ja bym to napisał i sam ocenisz.

0

@Riddle

W sumie to spoko pomysł. Jestem ciekawy i chętnie się czegoś dowiem, poniżej wrzucam klasę testową.

@ExtendWith(MockitoExtension.class)
public class CompetitionTagServiceTest {

    @Mock
    CompetitionRepository competitionRepository;

    CompetitionTagService competitionTagService;

    @InjectMocks
    VerifyMethodsForServices verifyMethodsForServices;


    @Mock
    private UserPrincipal userPrincipal;

    @Spy
    EventMapper eventMapper = Mappers.getMapper(EventMapper.class);

    private static final String userName = "someUserName";

    Competition competition;
    Tag competitionTag;

    @BeforeEach
    public void setUp() {

        competitionTagService = new CompetitionTagService(competitionRepository, verifyMethodsForServices);

        when(userPrincipal.getName()).thenReturn(userName);

        competition = Competition.builder()
                .id(1L)
                .eventOwner(userName)
                .eventName("zawody1")
                .eventStartDate(Timestamp.valueOf("2020-05-01 12:30:00").toLocalDateTime())
                .eventEndDate(Timestamp.valueOf("2020-05-02 12:30:00").toLocalDateTime())
                .city("Gdynia")
                .maxAmountOfTeams(10)
                .tags(Sets.newHashSet())
                .build();

        competitionTag = Tag.builder().tag("Tag").id(1L).competitions(Sets.newHashSet()).build();
    }

    @Test
    public void shouldAddTags() {

        when(competitionRepository.findByEventName(competition.getEventName())).thenReturn(
            Optional.ofNullable(competition));

        Set<String> tags = new HashSet<>(Set.of("someTag", "someNextTag"));
        ResponseEntity<?> response = competitionTagService.addCompetitionTag(tags, competition.getEventName(), userPrincipal.getName());

        verify(competitionRepository, times(1)).save(competition);

        EventTagsDto returnedEventTagsDto = (EventTagsDto) response.getBody();

        assertEquals(response.getStatusCode(), HttpStatus.CREATED);
        assert returnedEventTagsDto != null;
        assertEquals(competition.getEventName(), returnedEventTagsDto.getEventName());
        assertTrue(returnedEventTagsDto.getTags().containsAll(tags));
    }

    @Test
    public void shouldUpdateTag() {

        when(competitionRepository.findByEventName(competition.getEventName())).thenReturn(
            Optional.ofNullable(competition));

        when(competitionRepository.save(competition)).thenReturn(competition);

        String competitionTag = "updatedTag";
        ResponseEntity<?> response = competitionTagService.updateCompetitionTag(competitionTag, competition.getEventName(), userPrincipal.getName());

        verify(competitionRepository, times(1)).save(competition);

        EventTagsDto returnedEventTagsDto = (EventTagsDto) response.getBody();

        assertEquals(response.getStatusCode(), HttpStatus.CREATED);
        assert returnedEventTagsDto != null;
        assertEquals(competition.getEventName(), returnedEventTagsDto.getEventName());
        assertTrue(returnedEventTagsDto.getTags().contains(competitionTag));

    }

    @Test
    public void shouldThrowExceptionCompetitionNotExistsWhenAddTag() {

        Set<String> tags = Set.of("someTag");

        ResponseStatusException exception = assertThrows(
            ResponseStatusException.class,
            () ->  competitionTagService.addCompetitionTag(tags, competition.getEventName(), userPrincipal.getName()),
            "Expected doThing() to throw, but it didn't");

        verify(competitionRepository, times(1)).findByEventName(competition.getEventName());
        assertEquals(HttpStatus.NOT_FOUND, exception.getStatusCode());
        assertEquals("Competition not exists, Name: "+ competition.getEventName(), exception.getReason());
    }


    @Test
    public void shouldThrowExceptionCompetitionNotExistsWhenUpdateTag() {

        String competitionTag = "updatedTag";

        ResponseStatusException exception = assertThrows(
            ResponseStatusException.class,
            () ->  competitionTagService.updateCompetitionTag(competitionTag, competition.getEventName(), userPrincipal.getName()),
            "Expected doThing() to throw, but it didn't");

        verify(competitionRepository, times(1)).findByEventName(competition.getEventName());
        assertEquals(HttpStatus.NOT_FOUND, exception.getStatusCode());
        assertEquals("Competition not exists, Name: "+ competition.getEventName(), exception.getReason());
    }

}
1
pitagram napisał(a):

@Riddle: bez tej biblioteki, ale rozumiem, że nie korzystasz też z zamiennika? Nie dodaje to tak naprawdę pisania dodatkowego kodu, który już masz w tej bibliotece? Przez co tak naprawdę dewelopment się wydłuża?

Odpowiadając na Twoje pytanie: nie, i to z conajmniej 4ech powodów:

  • Po pierwsze - nie mierz czasu pracy na podstawie tego ile piszesz. Zmierz kiedyś stoperem ile czasu piszesz, ile czytasz kod, ile analizujesz, ile szukasz rozwiązań, ile debugujesz. Pisanie to będzie 10%. Ważniejsze jest umiejętne rozumowanie nad kodem.
  • Po drugie - biblioteki "do mocków" z reguły mają podobną ilość kodu jak pisanie samych klas - ilośc informacji w kodzie jest podobna, zwłaszcza jak dodamy stubowanie tego jak metody mają działać i te wszystkie verify() i inne takie.
  • Po trzecie - kod z mockito bardzo ciężko się czyta (przynajmniej mi), przez to że bardzo trudno nim wyrazić intencje. Kod z mockito mówi jaka mogłaby być przekładowa implementacja, ale nie mówi nic o intencji.

No i po czwarte i najważniejsze - kod powstały z takim agresywnym mockowaniem bardzo często ma zły design i jest przywiązany do implementacji, zwłaszcza jak używamy wspomnianych już verify()'ów.

Co do Twojego kodu, jasno widać że te testy powstały po kodzie, przez co są przywiązane do implementacji tak jak mówiłem. Dobrze napisane testy mogłyby wyglądać jakoś tak:

public class CompetitionTagServiceTest {
    CompetitionTagService tags;

    @BeforeEach
    public void setUp() {
        competitionTagService = new CompetitionTagService(competitionRepository, verifyMethodsForServices);
    }

    @Test
    public void shouldAddManyTags() {
        existingCompetition("zawody1");
        tags.addCompetitionTag(hashSet("someTag", "someNextTag")), "zawody1", "someUserName");
        assertTagExists("someTag");
        assertTagExists("someNextTag");
    }

    @Test
    public void shouldUpdateTag() {
        existingCompetition("zawody1");
        tags.updateCompetitionTag("updatedTag", "zawody1", "user");
        assertTagUpdated("updatedTag");
    }

    @Test
    public void addingMissingTags_throwsException() {
        ResponseStatusException exception = assertThrows(
            ResponseStatusException.class,
            () -> tags.addCompetitionTag("someTag", "zawody1", "someUserName"));

        assertEquals(HttpStatus.NOT_FOUND, exception.getStatusCode());
        assertEquals("Competition not exists, Name: zawody1", exception.getReason());
    }

    @Test
    public void updatingMissingTags_throwsException() {
        ResponseStatusException exception = assertThrows(
            ResponseStatusException.class,
            () -> tags.updateCompetitionTag("updatedTag", "zawody1", "someUserName"));

        assertEquals(HttpStatus.NOT_FOUND, exception.getStatusCode());
        assertEquals("Competition not exists, Name: zawody1", exception.getReason());
    }
}

Metody assertTagExists(), hashSet() oraz existingCompetition() to metody pomocnicze które musisz zaimplementować sam, tak żeby testy pozostały czyste i wyrażające intencje.

Widziałem że niektóre Twoje testy jednocześnie testują odpowiedź i działanie (np. jednocześnie sprawdzasz success-code oraz wynik działania). To jest ogólnie słabe, i powinieneś mieć dwa testy pod to - jeden na odpowiedź update'a (status code), i osobny na działanie update'a (czy tag jest zaktualizowany).

0

@Riddle: dzięki bardzo za wyjaśnienie. Chyba wszystkie rozumiem, prócz tego co napisałeś W ostatnim akapicie. Jeśli rozdzielę do jednego testu weryfikowania status code, a do drugiego, czy tag został zaaktualizowany to finalnie nie doprowadzę do sytuacji, gdzie testy będą tak naprawdę uruchamiany dwukrotnie przez co zwiększy się czas uruchamiania testów oraz uzyskam trochę "zabrudzenie" kodu z racji tego, że rozdzielam to na dwie osobne metody, choć wszystko mógłbym sprawdzić w jednej? Czy się mylę i nie do końca zrozumiałem, co miałeś na myśli

1
pitagram napisał(a):

@Riddle: dzięki bardzo za wyjaśnienie. Chyba wszystkie rozumiem, prócz tego co napisałeś W ostatnim akapicie. Jeśli rozdzielę do jednego testu weryfikowania status code, a do drugiego, czy tag został zaaktualizowany to finalnie nie doprowadzę do sytuacji, gdzie testy będą tak naprawdę uruchamiany dwukrotnie przez co zwiększy się czas uruchamiania testów oraz uzyskam trochę "zabrudzenie" kodu z racji tego, że rozdzielam to na dwie osobne metody, choć wszystko mógłbym sprawdzić w jednej? Czy się mylę i nie do końca zrozumiałem, co miałeś na myśli

Owszem, kod uruchomi się dwa razy, ale to żaden problem.

Pamiętaj po co są testy - po to żebyś mógł do woli refaktorować i usprawniać kod i użyć testów żeby sprawdzić czy Twój kod nadal działa tak jak powinien. Jeśli coś zepsujesz (dodasz buga) - jakiś test powinien nie przejść.

Mając dwa osobne testy:

  • Jeśli zepsujesz odpowiedź HTTP, ale logika działa - wtedy nie przejdzie jeden test, a drugi tak (wiesz gdzie szukać błędu).
  • Jeśli zepsujesz logikę, ale odpowiedź działa - wtedy pierwszy test przejdzie, drugi nie (też wiesz gdzie szukać błędu)
  • Jeśli zepsujesz obie te rzeczy, wtedy oba nie przejdą - na prawdę coś bardzo zepsułeś
0

Czym jest verifyMethodsForServices?

Z zasady Mockito stosuje się do testów jednej, pojedyńczej klasy. Klasa ma zależności, które są mockami. @InjectMock jednocześnie inicjalizuje mocki i wskazuje którą klasę będziesz testował, tzw. under test.
W ten sposób każda klasa i każda metoda mogą być bardzo prosto przetestowane przez każdy wymarzony case. Można testować jak kto chce - implementacje lub zachowanie.

Niektóre klasy łatwiej zrobić konstruktorem, niektóre klasy łatwiej się testuje z postawieniem kontekstu. Różnie to idzie, zależy co masz do testowania, bo nie ma jednego idealnego sposobu testowania. Testujesz w taki sposób, jaki potrzebujesz, żeby upewnić się, że coś działa w taki sposób jaki oczekujesz.

1

Tak jak @Riddle napisał: widać, że testy były pisane po zaimplementowaniu a nie drajwowały implementacji.

Moment gdy siadasz do napisania testów i zaczynasz się zastanawiać what's the fuck to właśnie ten moment by sobie uświadomić, że ktoś spierniczył design 🙂

W sumie jak sobie przypomnę, że prawie rok spędziłem w projekcie gdzie było podobnie:

  • kupa starych testów które jeśli były aktualizowane t otylko by się świecić na zielono a nie by zweryfikować, że ficzer działa
  • jedno wielkie spaghetti pisane na kolanie bo estymaty robione przez nietechnicznego PM'a i zaakceptowane przez klienta mają sie zgadzać
  • zespół któremu wydawało się, że umie pisac testy po czym robili unity z 30 asercjami (jeśl iw ogóle robili)

aż się łezka w oku kręci 😅

1
pitagram napisał(a):

@Riddle

W sumie to spoko pomysł. Jestem ciekawy i chętnie się czegoś dowiem, poniżej wrzucam klasę testową.

O panocku.

@ExtendWith(MockitoExtension.class)
public class CompetitionTagServiceTest {

    @Mock
    CompetitionRepository competitionRepository;

    CompetitionTagService competitionTagService;

    @InjectMocks
    VerifyMethodsForServices verifyMethodsForServices;


    @Mock
    private UserPrincipal userPrincipal;

    @Spy
    EventMapper eventMapper = Mappers.getMapper(EventMapper.class);

    private static final String userName = "someUserName";

    Competition competition;
    Tag competitionTag;

Pomijając kwestię używania / nieużywania mocków - masz tutaj niezły bałagan. Trudno powiedzieć, co kierowało taką, a nie inną kolejnością i poziomami enkapsulacji.

Ale tak, zacząłbym od nieużywania @Mock, @Spy i @InjectMocks, i spróbował ograniczyć liczbę potrzebnych abstrakcji. Prawdopodobnie nie potrzebujesz tego aż tyle. @Spy na mapperze też jest chyba tylko po to, by mieć kontrolę nad kodem, który się i tak wykona. Mappers.getMapper(EventMapper.class) brzmi jak coś w stylu service locatora.

    @BeforeEach
    public void setUp() {

        competitionTagService = new CompetitionTagService(competitionRepository, verifyMethodsForServices);

        when(userPrincipal.getName()).thenReturn(userName);

        competition = Competition.builder()
                .id(1L)
                .eventOwner(userName)
                .eventName("zawody1")
                .eventStartDate(Timestamp.valueOf("2020-05-01 12:30:00").toLocalDateTime())
                .eventEndDate(Timestamp.valueOf("2020-05-02 12:30:00").toLocalDateTime())
                .city("Gdynia")
                .maxAmountOfTeams(10)
                .tags(Sets.newHashSet())
                .build();

        competitionTag = Tag.builder().tag("Tag").id(1L).competitions(Sets.newHashSet()).build();
    }

Po co właściwie ten mock na UserPrincipal? Nawet nie mogę znaleźć w tym kodzie, do czego UserPrincipal jest właściwie zależnością. Więc pewnie jast w jakiś ciekawy sposób schowany. Ale jak już go masz i potrzebujesz fejkowej implementacji gettera... może po prostu stwórz sobie prawdziwy obiekt z fejkowymi danymi? A jeśli bardzo chcesz mockować, konfigurowanie tego mocka raz po raz w @BeforeEach raczej nic nie wnosi.

Nie do końca też rozumiem, po co ta dana testowa jest tworzona w setUp, ani dlaczego ta metoda w ogóle jest publiczna. Dana się nie zmienia, może być stałą. Jeśli jest mutowalna i jednak się zmienia, można wydzielić metodę która zwróci nową daną w miejscu użycia, nie trzeba tego robić w setUp. Podobnie tworzenie instancji CompetitionTagService w tej metodzie jest IMO dziwne - co to wnosi do testów?

    @Test
    public void shouldAddTags() {

Nie ma potrzeby by te metody były publiczne.


        when(competitionRepository.findByEventName(competition.getEventName())).thenReturn(
            Optional.ofNullable(competition));

Ten setup powtarza się w kilku testach. W dodatku ujawnia, że mocki prawdopodobnie są zbędne - skoro mock instruuje competitionRepository, by wyszukiwało sobie competition po jego właściwości eventName to równie dobrze możesz napisać wprost trywialny test double jako in-memory repository i używać go w testach bez mocków:

class InMemoryCompetitionRepository implements CompetitionRepository {
  private final Set<Competition> competitions = new HashSet<>();

  @Override
  public void add(Competition competition) {
    competitions.add(competition);
  }

  @Override
  public Optional<Competition> findByEventTag(String eventTag) {
    return competitions.stream()
      .filter(competition -> competition.eventTag().equals(eventTag))
      .findAny();
  }
}

No i w zasadzie pozostałe mocki prawdopodobnie wymagają podobnego nakładu pracy, by mockozy i skomplikowanych setupów pozbyc się całkowicie. Zauważ, że to jest może 20% objętości kodu, jaki poświęcasz w jednej tylko klasie testowej na setup mocków.


        Set<String> tags = new HashSet<>(Set.of("someTag", "someNextTag"));
        ResponseEntity<?> response = competitionTagService.addCompetitionTag(tags, competition.getEventName(), userPrincipal.getName());

Dlaczego ...Service, czyli zakładam że coś domenowego, zwraca tutaj ResponseEntity i w dodatku z nieokreślonym typem? Potem i tak rzutujesz zawartość. ResponseEntity jest czymś związanym z konkretnym frameworkiem (Spring) i konkretnym rodzajem API, który może być zintegrowany z domenowym serwisem (HTTP). To bardzo mocny coupling to bardzo konkretnych rozwiązań, który uniemożliwia reużywanie i testowanie kodu w pełnej izolacji od założenia, że robimy sobie API HTTP w Springu.

        verify(competitionRepository, times(1)).save(competition);

Wartością tego testu jest weryfikacja, że .save(...) zostało zawołane dokładnie raz z określonym parametrem. Czyli zasadniczo niczego to nie testuje, poza pożądanymi wywołaniami. Nie testuje efektu końcowego. Używając in-memory repo lub innego sensowengo test double, mógłbyś po prostu zweryfikować w teście faktyczne rezultaty i nie używać Mockito.verify(...).


        EventTagsDto returnedEventTagsDto = (EventTagsDto) response.getBody();

        assertEquals(response.getStatusCode(), HttpStatus.CREATED);
        assert returnedEventTagsDto != null;
        assertEquals(competition.getEventName(), returnedEventTagsDto.getEventName());
        assertTrue(returnedEventTagsDto.getTags().containsAll(tags));
    }

Jeżeli już używasz jakiejś biblioteki do asercji, skąd nagle ten assert x != null;? Poza tym, na liście asercji null check powinien raczej poprzedzać wszelkie inne weryfikacje. No i możesz przy okazji użyć assertAll, nie pamiętam już czy z JUnitpowych asercji, z AssertJ, czy z obu:

Assertions.assertAll(
  () -> assertCośtam(),
  () -> assertCośInnego(),
  // ...
);

Poza tym, te testy zupełnie mieszają przeróżne poziomy abstrakcji. Dlaczego jeden test weryfikuje wywołania, kody HTTP, zwrotki z jakiegoś mockowego repository i pieron wie co jeszcze?

Dodatkowo, w bibliotekach do asercji najczęściej jest konwencja assertCośtam(expected, actual), tutaj mieszasz i raz actual jest na pierwszym, raz na drugim miejscu. W dodatku asercje są napisane w mocno niskopoziomowy sposób - chyba tylko asercja na status code jest w miarę klarowna, pozostałe to trochę taka makarena.

I jeszcze odnośnie sposobu robienia asercji na zawartościach setów:

assertTrue(returnedEventTagsDto.getTags().containsAll(tags));

Odgadywanie, dlaczego taka asercja się wysypała, to będzie masakra i debugowanie. Opcje widzę dwie:

  • biblioteka do asercji która dostarcza asercje na kolekcjach
  assertThat(collection).containsAll(expected);
  • zrobienie sobie samemu odpowiednika takiej asercji, np. załatwienie tego różnicą na zbiorach dowolną metodą, np. z Guavy
  var missing = Sets.difference(expected, actual);
  assertTrue(missing.isEmpty(), "Following missing elements are expected: %s".format(missing));

Pozostałych testów już nie będę komentował, bo problemy są zasadniczo takie same jak w pierwszym.

Zarejestruj się i dołącz do największej społeczności programistów w Polsce.

Otrzymaj wsparcie, dziel się wiedzą i rozwijaj swoje umiejętności z najlepszymi.