Jak zarządzać wieloma mockami w testach jednostkowych?

0

Cześć,
mam takie przykładowe testy jednostkowe wraz z metodami pomocniczymi w osobnej klasie. Jak widzicie poniżej, te metody mają jakieś argumenty, które muszę potem w testach dodać, przez co test, może wydawać się trudniejszy do zrozumienia. No bo co, jeśli będę miał tych mocków z 20. Macie jakieś propozycje dla mnie, jak mogę uporządkować lub uprościć te testy?

Dzięki za pomoc.

Klasa z testami:

@ExtendWith(MockitoExtension.class)
public class LoginServiceImplTest {
    @InjectMocks
    private LoginServiceImpl login;
    @Mock
    private JwtTokenProvider tokenProvider;
    @Mock
    private AuthenticationManager authManager;
    @Mock
    private Authentication auth;
    @Mock
    private ApplicationMessageService message;
    @Test
    void whenLoginSuccess_ReturnToken() {
        String token = validLogin(authManager, auth, tokenProvider, login, "User", "Password#3");
        assertEquals("token", token);
    }
    @Test
    void whenLoginFailure_NoGenerateToken() {
        assertNoTokenGeneration(authManager, auth, tokenProvider, login, "invalid", "invalid");
    }
}

Klasa pomocnicza:

public class LoginServiceImplTestHelper {
    public static String validLogin(AuthenticationManager authManager, Authentication auth,
                                    JwtTokenProvider tokenProvider, LoginService login,
                                    String username, String password) {

        LoginRequest loginRequest = new LoginRequest(username, password);
        when(authManager.authenticate(any(UsernamePasswordAuthenticationToken.class)))
                .thenReturn(auth);
        when(tokenProvider.generateToken(auth)).thenReturn("token");
        return login.login(loginRequest);
    }
    public static void assertNoTokenGeneration(AuthenticationManager authManager, Authentication auth,
                                               JwtTokenProvider tokenProvider, LoginService login,
                                               String username, String password) {

        LoginRequest loginRequest = new LoginRequest(username, password);
        when(authManager.authenticate(any())).thenThrow(
                new BadCredentialsException("Incorrect credentials"));

        assertThrows(BadCredentialsException.class, () -> login.login(loginRequest));

        verify(tokenProvider, never()).generateToken(auth);
    }
}

4

IMO takie metody pomocnicze za bardzo nie mają sensu. Polecam poczytać https://mtlynch.io/good-developers-bad-tests/ odnośnie jak pisać testy, żeby było czytelnie (subiektywna opinia oczywiście). W tym wypadku nie ma opcji, żeby przyszły czytelnik wiedział o co chodzi, bo "token" jest zawarty w LoginServiceImplTestHelper a asercja sprawdzająca "token" jest na zewnątrz tj. w teście. Analiza polega na skakaniu do klasy pomocniczej i do testu, co jest bez sensu z punktu widzenia czytelności.

Do kodu powinieneś skakać jak nie wiesz jak działa implementacja. Jak cię to nie obchodzi to tak czy owak z wywołań powinieneś wyłapać flow:

  void whenLoginSuccess_ReturnToken() {
        // inicializacja mocków
        when(authManager.authenticate(any(UsernamePasswordAuthenticationToken.class)))
                .thenReturn(auth);
        when(tokenProvider.generateToken(auth)).thenReturn("token");

        // stworzenie testowanego obiektu
        var login = new LoginServiceImpl(authManager, tokenProvider)

        // testowanie
        LoginRequest loginRequest = new LoginRequest("User", "Password#3");
        assertEquals("token", login.login(loginRequest));
    }

Inny syf jaki widzę to te adnotacje. Z tego kodu nie wiem co jest zaleznością LoginServiceImpl a co nie. Przykładowo z kodu wynika, że auth wcale nie jest zależnością, tylko obiektem przepychanym pomiędzy wywołaniami metod. Żeby do tego dojść znowuż muszę przeczytać cały kod co nie powinno mieć miejsca.

Jak chcesz zostać przy @Mock (nie wiem jak to się robi inaczej, nie ogarniam Javy) to chociaż wywal @InjectMocks i twórz login w teście o tak:

new LoginServiceImpl(authManager, tokenProvider)

Od razu widać które mocki są wewnątrz obiektu


Ostatnia sprawa: testy pokazują ci jak syfny masz kod. Jeśli masz za dużo zależności i obiektu używa się niewygodnie to warto pomyśleć o alternatywie np. rozbiciu serwisu na dwa w taki sposób, żeby mieć większą kohezję (tj. każde pole w klasie jest używane przez każdą metodę)

11

Najlepiej nie zarządzać wieloma mockami w testach.
Idealna liczba mocków to zero, przeważnie nie potrzeba więcej.

2

Ten test wygląda jakbyś sam nie wiedział do końca co chcesz przetestować.

Z tego co rozumiem, to chcesz mieć case w którym jeśli ktoś poda odpowiedni login i hasło to dostaniesz token, a jak ktoś poda zły to...? No własnie, co wtedy? Rozumiem że nie chcesz zwrócić tokena, ale musisz coś innego - np null albo wyjątek. Bo teraz Twój test sprawdza czy po prostu metoda generateToken() nie została wywołana, a to jest bardzo słaby test. Powinieneś napisać asercję na wynik logowania. Czyli albo czy zwróci null, albo wyjątek, albo jakiś status code, albo coś innego (ale na pewno nie verify()).

Moim zdaniem Twój test powinien wyglądać tak:

public class LoginServiceTest {
    @Test
    void successLogin() {
        assertEquals("token", loginToken("User", "Password#3"));
    }

    @Test
    void loginFailure() {
        assertNull(loginToken("invalid", "invalid"));
    }
}

Chyba że chcesz żeby loginToken() rzucał wyjątek dla niepoprawnego tokenu - to wtedy zamiast assertNull daj assertThrows().

Całe mockowanie (jeśli jakieś) powinno być schowane w funkcji loginToken(). Ale posłuchałbym @jarekr000000 i ograniczył mocki do minimum. Wszędzie gdzie się da użyłbym prawdziwej implementacji. Mockowałbym tylko rzeczy które koniecznie muszą być zamockowane.

Przykład kodu zaprezentowany przez @slsy zbyt bardzo polega na szczegółach implementacyjnych, przez co z biegem czasu ten test zacznie Ci przeszkadzać.

0
jarekr000000 napisał(a):

Najlepiej nie zarządzać wieloma mockami w testach.
Idealna liczba mocków to zero, przeważnie nie potrzeba więcej.

Jak miałbym przetestować taką metodę bez użycia mocka ??? Prosty test - jak user z podanym emailem istnieje, chcę sprawdzić czy rzucą wyjątek.

public void checkForExistingEmail(RegisterRequest registerRequest) {
        if (userDAO.existsByEmail(registerRequest.email())) {
            throw new CredentialValidationException(messageService.getMessage("email.exist"));
        }
    }
2

Jak miałbym przetestować taką metodę bez użycia mocka ??? Prosty test - jak user z podanym emailem istnieje, chcę sprawdzić czy rzucą wyjątek.

Są przynajmniej 3 sposoby:

  1. Użyj np. testcontainers i niech testy stawiają bazę danych pod spodem. userDAO będzie pobierać z prawdziwej bazy danych.

  2. Pisz kod tak, żeby userDAO było interfacem, a w teście jako implementację wskaż jakieś InMemoryUserDAO działające na hashmapie.

  3. Użyj inmemory db, np. H2.

    Ja polecam zazwyczaj sposób nr 1. Pomaga to przetestowaniu appki od A do Z.

0
Grzyboo napisał(a):

Jak miałbym przetestować taką metodę bez użycia mocka ??? Prosty test - jak user z podanym emailem istnieje, chcę sprawdzić czy rzucą wyjątek.

Są przynajmniej 3 sposoby:

  1. Użyj np. testcontainers i niech testy stawiają bazę danych pod spodem. userDAO będzie pobierać z prawdziwej bazy danych.

  2. Pisz kod tak, żeby userDAO było interfacem, a w teście jako implementację wskaż jakieś InMemoryUserDAO działające na hashmapie.

  3. Użyj inmemory db, np. H2.

    Ja polecam zazwyczaj sposób nr 1. Pomaga to przetestowaniu appki od A do Z.

Tylko jak skorzystam z bazy danych, to już to chyba nie będzie test jednostkowy. Czy się mylę ?

4
Ornstein napisał(a):

Tylko jak skorzystam z bazy danych, to już to chyba nie będzie test jednostkowy. Czy się mylę ?

To będzie zwykły dobry test i wystarczy. Ode mnie dostaniesz medal, a to chyba najważniejsze.

0

To zamiast bawić się w mocki, lepiej pisać testy z użyciem @SpringBootTest ? Dobrze rozumiem ? Takie testy będą miały większą wartość ?

2
Ornstein napisał(a):

To zamiast bawić się w mocki, lepiej pisać testy z użyciem @SpringBootTest ? Dobrze rozumiem ? Takie testy będą miały większą wartość ?

Generalnie tak. Z mojego doświadczenia w webowych aplikacjach i tak zdecydowana większość kodu opiera się o I/O, więc najlepiej pisać testy "integracyjne" z faktyczną bazą danych zamiast z mockami/pseudoimplementacjami.

3
Ornstein napisał(a):

To zamiast bawić się w mocki, lepiej pisać testy z użyciem @SpringBootTest ? Dobrze rozumiem ? Takie testy będą miały większą wartość ?

Testy jednostkowe (odseparowane od zewnętrznych zależności i technologii) mają dość wąskie zastosowanie, ograniczone właściwie do weryfikacji jakichś konkretnych algorytmów/obliczeń, gdy masz znane wejście i wyjście oraz nie masz skutków ubocznych.

Gdy chcesz sprawdzić, czy coś istnieje w bazie, i napiszesz do tego test jednostkowy z użyciem mocków, to tak naprawdę nie sprawdzisz niczego, bo test przejdzie na zielono nawet gdy komunikacja z bazą nie będzie działała, czyli wynik testu będzie fałszywie negatywny. A więc po co taki test?

0
somekind napisał(a):

Testy jednostkowe (odseparowane od zewnętrznych zależności i technologii) mają dość wąskie zastosowanie, ograniczone właściwie do weryfikacji jakichś konkretnych algorytmów/obliczeń, gdy masz znane wejście i wyjście oraz nie masz skutków ubocznych.

Testy jednostkowe (odseparowane od zewnętrznych zależności i technologii) - nigdy mi nie pasowała taka definicja testów jednostkowych. Takie testy praktycznie nigdy nie mają żadnej wartości.

Może inaczej powiem.

somekind napisał(a):

Gdy chcesz sprawdzić, czy coś istnieje w bazie, i napiszesz do tego test jednostkowy z użyciem mocków, to tak naprawdę nie sprawdzisz niczego, bo test przejdzie na zielono nawet gdy komunikacja z bazą nie będzie działała, czyli wynik testu będzie fałszywie negatywny. A więc po co taki test?

Zgadzam się ze taki test jest słaby. I zgadzam się że lepiej zrobić test o szerszym zakresie.

Tylko chciałem dodać, że jak zrobisz test o szerszym zakresie - czyli bez mocków, ale taki którzy strzela do bazy - to taki test nie przestaje być jednostkowy. Chodzi o to że teraz wielu ludzi się możę obudzić i powiedzieć "ooo, skoro strzela do bazy to teraz jest integracyjny". I chciałem dodać że, no nie jest - nadal jest jednostkowy. I w sumie tylko tyle chciałem dodać.

0

Mam jedno pytanie dotyczące tego wątku. Zobrazujmy to sobie na przykładzie. Posiadam metodę, która wyłapuje pewien wyjątek – przykład znajduje się poniżej. Napisałem test, w którym symulowałem wyjątek za pomocą mocka, aby sprawdzić, czy metoda go przechwyci. Nie chcąc korzystać z mocków, wychodzi na to, że muszę zrezygnować z takich testów, tak ?

@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest request, HttpServletResponse response) {
    try {
        cookieDeleter.deleteCookie(request, response, "auth_token");
        return ResponseEntity.ok(new ApiResponse(messageService.getMessage("logout.success")));
    } catch (RuntimeException e) {
        log.error("Error while logging out: " + e.getMessage(), e);
        throw new InternalErrorException(messageService.getMessage("logout.failure"));
    }
}

test:

@Test
void test() {
    doThrow(new RuntimeException("Exception")).when(cookieDeleter).deleteCookie(request, response, "auth_token");
        
    assertThrows(InternalErrorException.class, () -> logoutController.logout(request, response));
}
1
Ornstein napisał(a):

Mam jedno pytanie dotyczące tego wątku. Zobrazujmy to sobie na przykładzie. Posiadam metodę, która wyłapuje pewien wyjątek – przykład znajduje się poniżej. Napisałem test, w którym symulowałem wyjątek za pomocą mocka, aby sprawdzić, czy metoda go przechwyci. Nie chcąc korzystać z mocków, wychodzi na to, że muszę zrezygnować z takich testów, tak ?

Raczej: musisz przemyśleć te wyjątki i jak je obsługujesz.

  1. w jakiej sytuacji cookieDeleter.deleteCookie rzuca wyjątkiem ( i po co)?
  2. Co w zasadzie daje Ci robienie catch na RuntimeException w tej metodzie, skoro i tak rzucasz kolejnym generycznym wyjątkiem, który i tak będzie złapany (i faktycznie obsłużony) przez jakiś springowy ExceptionHandler....
  3. Jeśli ta obsługa ma jakiś sens to wtedy jeśli już mockować to chyba najlepiej httpRequest, i httpResponse - tak żeby ten wyjątek spowodować. (ale to raczej bardzo nietypowy scenariusz w tym przypadku).
1
Ornstein napisał(a):

Mam jedno pytanie dotyczące tego wątku. Zobrazujmy to sobie na przykładzie. Posiadam metodę, która wyłapuje pewien wyjątek – przykład znajduje się poniżej. Napisałem test, w którym symulowałem wyjątek za pomocą mocka, aby sprawdzić, czy metoda go przechwyci. Nie chcąc korzystać z mocków, wychodzi na to, że muszę zrezygnować z takich testów, tak ?

Tak i nie.

Co na plus:

  • To że chcesz przetestować co się stanie w takiej sytuacji jest bardzo dobre - jeśli aplikacja coś robi, to należy pod to napisać test. Także tutaj pochwała za to że chcesz napisać test pod to.
  • Kod kontrolera i kod testów jest krótki - to jest bardzo dobrze.

Co na minus:

  • Test cierpi na chorobę, która objawia się tym że za dużo wie o implementacji, a zbyt mało o faktycznym działaniu aplikacji.

To co powinieneś zrobić, to tak:

  1. Zastanów się w jaki sposób użytkownik aplikacji mógłby wywołać taką sytuację, że cookieDeleter rzuci ten RuntimeException - co użytkownik musiałby zrobić, jaki request wysłać żeby taka sytuacja się wydarzyła? Ewentualnie co musiałoby się stać na serverze żeby taka sytuacja zaistniała.
  2. Jak już dojdziesz do tego co użytkownik musiałby zrobić (albo jaka sytuacja musiałaby się wydarzyć na serverze) żeby taki wyjątek poleciał, to po prostu wykonaj to w teście.

Dla przykładu - załóżmy że cookieDeleter ma rzucić wyjątek, jak cookie auth_token będzie puste. Oczywiście ja nie wiem dokładnie kiedy ten Twój cookieDeleter rzuca wyjątek - musisz sam ustalić kiedy to się ma stać, to taki test mógłby wyglądać tak:

@Test
void test() {
    request.setCookie("auth_token", "");
    assertThrows(InternalErrorException.class, () -> logoutController.logout(request, response));
}

Może też dojść do sytuacji w które ani użytkownik ani żadna sytuacja nie doprowadzi do tego że cookieDelete rzuca taki wyjątek - że nie jesteś w stanie w żaden sposób tego wywołać - to jest sygnał że skoro ani użytkownik ani stan servera nie jest w stanie wywołać takiej sytuacji, to można założyć że ta sytuacja się nigdy nie stanie i nie ma sensu tego ani testować ani obsługiwać. No chyba że widzisz jakiś potencjalny przypadek kiedy by się to wydarzyło - to w takim wypadku po prostu zasymuluj ten przypadek w teście.

PS2: Oczywiście to że test sprawdza czy poleciał konkretnie wyjątek InternalErrorException też jest słabe. Test powinien sprawdzić odpowiedź HTTP, czyli mógłby wyglądać np tak:

@Test
void shouldRespondWithServerErrorForUndeletableCookie() {
    request.setCookie("auth_token", "");
    response = logoutController.logout(request, response);
    assertEquals(500, response.getStatusCode());
}
jarekr000000 napisał(a):

Raczej: musisz przemyśleć te wyjątki i jak je obsługujesz.

  1. w jakiej sytuacji cookieDeleter.deleteCookie rzuca wyjątkiem ( i po co)?

+1. Dobra rada.

  1. Co w zasadzie daje Ci robienie catch na RuntimeException w tej metodzie, skoro i tak rzucasz kolejnym generycznym wyjątkiem, który i tak będzie złapany (i faktycznie obsłużony) przez jakiś springowy ExceptionHandler....

No chyba wiadomo, że po to żeby odseparować interfejs kontrolera i cookieDeletera. To jest akurat dobre.

  1. Jeśli ta obsługa ma jakiś sens to wtedy jeśli już mockować to chyba najlepiej httpRequest, i httpResponse - tak żeby ten wyjątek spowodować. (ale to raczej bardzo nietypowy scenariusz w tym przypadku).

- tak żeby ten wyjątek spowodować. - dobra rada żeby spowodować wyjątek zamiast wsadzić mocka który go rzuca.

jeśli już mockować to chyba najlepiej `httpRequest`, i `httpResponse` - Zamiast mockować httpRequest, lepiej jest stworzyć odpowiedni httpRequest z odpowiednimi wartościami. O mockowaniu httpResponse nie wspomnę, bo to szaleństwo.

0

Potrzebuję porady. Zgodnie z Waszymi sugestiami w testach zrezygnowałem z mocków i używam prawdziwej implementacji. I teraz mam taką sytuację - piszę testy dla metody validateToken, której zadaniem jest sprawdzać poprawność tokenu użytkownika przed wykonaniem operacji. Te testy wyglądają tak:

@SpringBootTest
public class TokenValidatorTest {
    @Autowired
    private TokenValidator validator;
    @Test
    void whenExpiredToken_ThrowException() {
        // given
        String token = TokenGeneratorHelper.generateExpiredToken();

        // when & then
        assertThrows(TokenException.class, () -> validator.validateToken(token));
    }
    @Test
    void whenMalformedToken_ThrowException() {
        // given
        String token = TokenGeneratorHelper.generateMalformedToken();

        // when & then
        assertThrows(TokenException.class, () -> validator.validateToken(token));
    }
    @Test
    void whenTokenHasUnsupportedSignature_ThrowException() {
        // given
        String token = TokenGeneratorHelper.generateTokenWithUnsupportedSignature();

        // when & then
        assertThrows(TokenException.class, () -> validator.validateToken(token));
    }

    @Test
    void whenTokenHasInvalidSignature_ThrowException() {
        // given
        String token = TokenGeneratorHelper.generateTokenWithInvalidSignature();

        // when & then
        assertThrows(TokenException.class, () -> validator.validateToken(token));
    }

    @Test
    void whenTokenHasEmptyClaims_ThrowException() {
        // given
        String token = TokenGeneratorHelper.generateTokenWithEmptyClaims();

        // when & then
        assertThrows(TokenException.class, () -> validator.validateToken(token));
    }
}

W klasie helper tworzę fałszywe tokeny:

public class TokenGeneratorHelper {
    private static final long EXPIRATION_MS = 1000L;
    private static final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
    private static Date getCurrentDate() {
        return new Date();
    }
    private static Date getExpirationDate(boolean expired) {
        Date now = getCurrentDate();
        return new Date(now.getTime() + (expired ? -EXPIRATION_MS : EXPIRATION_MS));
    }
    private static JwtBuilder createBaseBuilder(Date now, Date expirationDate) {
        return Jwts.builder()
                .setIssuedAt(now)
                .setExpiration(expirationDate)
                .signWith(key);
    }
    public static String generateMalformedToken() {
        String token = createBaseBuilder(getCurrentDate(), getExpirationDate(false))
                .setSubject("User")
                .compact();
        return token.substring(0, token.length() / 2);
    }
    public static String generateTokenWithUnsupportedSignature() {
        Key strongerKey = Keys.secretKeyFor(SignatureAlgorithm.HS512);

        return createBaseBuilder(getCurrentDate(), getExpirationDate(false))
                .setSubject("User")
                .signWith(strongerKey, SignatureAlgorithm.HS512)
                .compact();
    }
    public static String generateTokenWithEmptyClaims() {
        return createBaseBuilder(getCurrentDate(), getExpirationDate(false))
                .setClaims(new HashMap<>())
                .compact();
    }
    public static String generateExpiredToken() {
        return createBaseBuilder(getCurrentDate(), getExpirationDate(true))
                .setSubject("User")
                .compact();
    }
    public static String generateTokenWithInvalidSignature() {
        String token = createBaseBuilder(getCurrentDate(), getExpirationDate(false))
                .setSubject("User")
                .compact();
        return token + "invalid";
    }
}


Potrzebuję jeszcze napisać test, aby upewnić się, czy metoda zwróci true dla poprawnego tokena. I tutaj zastanawiam się, jak to poprawnie zrobić.

Czy mogę do klasy testowej wstrzyknąć klasę, która odpowiada za logowanie i je wykonać? Wyglądałoby to tak:

@SpringBootTest
public class TokenValidatorTest extends BaseIT {
    @Autowired
    private TokenValidator validator;
    @Autowired
    private LoginService login;

    @Test
    void whenValidToken_ReturnTrue() {
        // given
        String token = loginAndGetToken();

        // when & then
        assertTrue(validator.validateToken(token));

    }
    private String loginAndGetToken() {
        return login.login(new LoginRequest("User", "Password#3"));
    }
}

Tutaj zastanawiam się, czy test nie będzie zbyt złożony, z uwagi, że korzysta z dodatkowych zależności.

Co myślicie? Macie jakieś rady?

1
Ornstein napisał(a):

Potrzebuję jeszcze napisać test, aby upewnić się, czy metoda zwróci true dla poprawnego tokena.

Czyli ta metoda albo zwraca true albo rzuca wyjątek? Przecież to bez sensu. Jeśli rzuca wyjątek, to nie musi mieć zwracanego typu w ogóle. A jeśli w tym wyjątku nie ma żadnych szczegółów, to równie dobrze mogłaby zwrócić true na sukces, i false jak token jest niepoprawny.

Ornstein napisał(a):

Czy mogę do klasy testowej wstrzyknąć klasę, która odpowiada za logowanie i je wykonać? Wyglądałoby to tak:

@SpringBootTest
public class TokenValidatorTest extends BaseIT {
    @Autowired
    private TokenValidator validator;
    @Autowired
    private LoginService login;

    @Test
    void whenValidToken_ReturnTrue() {
        // given
        String token = loginAndGetToken();

        // when & then
        assertTrue(validator.validateToken(token));

    }
    private String loginAndGetToken() {
        return login.login(new LoginRequest("User", "Password#3"));
    }
}

Tutaj zastanawiam się, czy test nie będzie zbyt złożony, z uwagi, że korzysta z dodatkowych zależności.

No to od początku.

  • czy test nie będzie zbyt złożony, z uwagi, że korzysta z dodatkowych zależności. samo to w sobie problemem nie jest, możesz użyć klasy LoginService w teście jak chcesz, mało tego możesz nawet użyć LoginController. Nie przeszkadza żeby ten kod się wykonał w teście, tak długo jak coupling z tymi klasami będzie mały.

Natomiast co do Twojego pytania, to czemu robisz String token = loginAndGetToken(); zamiast TokenGeneratorHelper.generateCorrectToken();? Test pod sprawdzenie poprawności tokena nie powinien wiedzieć skąd ten poprawny token się wziął.

0
Riddle napisał(a):
Ornstein napisał(a):

Czy mogę do klasy testowej wstrzyknąć klasę, która odpowiada za logowanie i je wykonać? Wyglądałoby to tak:

@SpringBootTest
public class TokenValidatorTest extends BaseIT {
    @Autowired
    private TokenValidator validator;
    @Autowired
    private LoginService login;

    @Test
    void whenValidToken_ReturnTrue() {
        // given
        String token = loginAndGetToken();

        // when & then
        assertTrue(validator.validateToken(token));

    }
    private String loginAndGetToken() {
        return login.login(new LoginRequest("User", "Password#3"));
    }
}

Tutaj zastanawiam się, czy test nie będzie zbyt złożony, z uwagi, że korzysta z dodatkowych zależności.

No to od początku.

  • czy test nie będzie zbyt złożony, z uwagi, że korzysta z dodatkowych zależności. samo to w sobie problemem nie jest, możesz użyć klasy LoginService w teście jak chcesz, mało tego możesz nawet użyć LoginController. Nie przeszkadza żeby ten kod się wykonał w teście, tak długo jak coupling z tymi klasami będzie mały.

Dobrze wiedzieć. Chyba za dużo się naczytałem o izolacji testów, stąd moja niepewność.

Riddle napisał(a):

Natomiast co do Twojego pytania, to czemu robisz String token = loginAndGetToken(); zamiast TokenGeneratorHelper.generateCorrectToken();? Test pod sprawdzenie poprawności tokena nie powinien wiedzieć skąd ten poprawny token się wziął.

Ale to chyba bym musiał zrobić coś takiego:

@Component
public class TokenGenerator {

    @Autowired
    private LoginService login;

    public String generateCorrectToken() {
        return login.login(new LoginRequest("User", "Password#3"));
    }
}

Test:

public class TokenValidatorTest extends BaseIT{
    @Autowired
    private TokenValidator validator;
    @Autowired
    private TokenGenerator tokenGenerator;
    @Test
     void whenValidToken_ReturnTrue() {
         // given
        String token = tokenGenerator.generateCorrectToken();

        // when & then
        assertTrue(validator.validateToken(token));
    }
}

No bo gdybym chciał tą metodę zrobić statyczną, to będzie problem ze wstrzyknięciem LoginService.

1
Ornstein napisał(a):

No bo gdybym chciał tą metodę zrobić statyczną, to będzie problem ze wstrzyknięciem LoginService.

No i tutaj są dwa problemy.

  • Po pierwsze, napotkałeś właśnie problem ciasnego dowiązania (tight-coupling) do frameworka. Tzn. spring narzuca Ci jakiś styl (np te wstrzykiwanie zależności z @Autowired), przez co nie możesz zbudować swoich testów tak jakbyś chciał - tylko musisz tańczyć dookoła tego @Autowired. Frameworki niestety bardzo lubią zmuszać swoich użytkowników do ustrukturyzowania projektu pod nie.
  • Po drugie, korzystanie z metod statycznych do takich rzeczy też szczerze mówiąc jest słabe, więc nawet jakbyś nie miał springa, to ja raczej nie poszedłbym w takie coś.

Zdecyduj sam czy chcesz mieć testy przywiązane do springa, czy chcesz włożyć wysiłek i czas w to żeby je odseparować. Odseparować je się da, ale nie jest to proste, bo autorom frameworków jest na rękę to że przywiążesz swój projekt do ich frameworka. Autor springa chce żebyś wszędzi używał springa, więc po co miałby Ci ułatwić odseparowanie się od niego.

Ornstein napisał(a):

Dobrze wiedzieć. Chyba za dużo się naczytałem o izolacji testów, stąd moja niepewność.

W pojęciu "izolacji testów" chodzi o:

  • testy nie powinny wpływać na siebie nawzajem podczas uruchomienia - co to znaczy. To znaczy to że nie ważne ile testów uruchomisz (jeden, dwa, 5 wszystkie), nie ważne ile razy, i nie ważne w jakiej kolejności, to ich wynik ma być taki sam.
  • testy nie powinny wpływać na siebie podczas edycji kodu - co to znaczy, że edytując kod jakiegoś testu, nie powinienem być zmuszony do edycji innych testów.
  • testy nie powinny łamać enkapsulacji kodu - co to znaczy, że edytując pole prywatne klasy lub niepubliczną klasę modułu, nie powinienem musieć zmienić testów

Natomiast testy powinny wywoływać w runtime'ie kod programu, najlepiej cały, i najlepiej o szerokim zakresie. Nigdzie nie ma zasady która mówi że nie możesz wywołać kontrolera w teście, albo czegoś innego - tak długo jak nie robisz tight-coupling i nie łamiesz enkapsulacji.

Ornstein napisał(a):

Ale to chyba bym musiał zrobić coś takiego:
[...]

public class TokenValidatorTest extends BaseIT{
    @Autowired
    private TokenValidator validator;
    @Autowired
    private TokenGenerator tokenGenerator;
    @Test
     void whenValidToken_ReturnTrue() {
         // given
        String token = tokenGenerator.generateCorrectToken();

        // when & then
        assertTrue(validator.validateToken(token));
    }
}

Może być.

@Ornstein Tylko że ten test nie jest też taki super, bo wie o validator, a nie powinien (Bo jakbyś chciał zrefaktorować albo usunąć klasę validator, to test też musiałby się zmienić). Lepszy test byłby np:

@Test
void acceptValidToken() {
  request.setCookie("auth_token", tokenGenerator.correctToken());
  response = profileController.getProfile(request);
  assertEquals(200, response.getStatusCode());
}

co mógłbyś przerobić np w:

@Test
void acceptValidToken() {
  assertValidToken(tokenGenerator.correctToken());
}

void assertValidToken(String token) {
  request.setCookie("auth_token", token);
  assertEquals(200, profileController.getProfile(request).getStatusCode());
}

I to by był super test.

PS: Aczkolwiek to // given i // when & then też jest trochę dziwne. Ten pomysł z given/when/then się wziął z BDD, gdzie "given" to miał być początkowy stan systemu, when to miała być akcja na systemie, a then to miał być końcowy stan systemu. W Twoim przypadku nie ma ani początkowego ani końcowego stanu, więc zgodnie z tą konwencją given/when/then, to powinieneś mieć tylko when:

// when
assertTrue(validator.validToken(tokenGenerator.correctToken()));

Więc równie dobrze możesz pominąć to // when. To "given" w BDD się odnosi do stanu systemu, nie do parametrów.

Przykładem gdzie given/when/then w stylu BDD ma sens byłby np:

// given
userWithEmptyCart();                           // <!-- początkowy stan
// when
userAddItemToCart(new Item(2));                // <!-- akcja
// then
assertFalse(userCartEmpty());                  // <!-- końcowy stan
0
Riddle napisał(a):

Zdecyduj sam czy chcesz mieć testy przywiązane do springa, czy chcesz włożyć wysiłek i czas w to żeby je odseparować. Odseparować je się da, ale nie jest to proste, bo autorom frameworków jest na rękę to że przywiążesz swój projekt do ich frameworka. Autor springa chce żebyś wszędzi używał springa, więc po co miałby Ci ułatwić odseparowanie się od niego.

Na ten moment zostanę chyba przy Springu. Z moją aktualną wiedzą, gdybym chciał te testy odseparować od Springa, nie wiem, czy nie będę się porywał z motyką na księżyc.

Edit.
@Riddle w odniesieniu do:

Ornstein napisał(a):

Ale to chyba bym musiał zrobić coś takiego:

@Component
public class TokenGenerator {

    @Autowired
    private LoginService login;

    public String generateCorrectToken() {
        return login.login(new LoginRequest("User", "Password#3"));
    }
}

Test:

public class TokenValidatorTest extends BaseIT{
    @Autowired
    private TokenValidator validator;
    @Autowired
    private TokenGenerator tokenGenerator;
    @Test
     void whenValidToken_ReturnTrue() {
         // given
        String token = tokenGenerator.generateCorrectToken();

        // when & then
        assertTrue(validator.validateToken(token));
    }
}

No bo gdybym chciał tą metodę zrobić statyczną, to będzie problem ze wstrzyknięciem LoginService.

Napisałeś:

Riddle napisał(a):
  • Po drugie, korzystanie z metod statycznych do takich rzeczy też szczerze mówiąc jest słabe, więc nawet jakbyś nie miał springa, to ja raczej nie poszedłbym w takie coś.

To radzisz zrezygnować z metod statycznych w helperach? Lepiej, jak to będzie normalna metoda i będę ją wstrzykiwał do klasy testowej za pomocą @Autowired ?

1
Ornstein napisał(a):
Riddle napisał(a):
  • Po drugie, korzystanie z metod statycznych do takich rzeczy też szczerze mówiąc jest słabe, więc nawet jakbyś nie miał springa, to ja raczej nie poszedłbym w takie coś.

To radzisz zrezygnować z metod statycznych w helperach? Lepiej, jak to będzie normalna metoda i będę ją wstrzykiwał do klasy testowej za pomocą @Autowired ?

Nie ma na to twardej zasady - po prostu trzeba zrobić taki rachunek - na ile jakieś rozwiązanie Ci pomoże, a ile zaszkodzi.

Z mojego doświadczenia wynika że korzystanie z @Autowired w testach prędzej czy później się odbijało, więc nie korzystam z tego. W miarę możliwości ja osobiście staram się odizolować testy od springa. (nie znaczy to że testy nie wywołują springa - wołają go, tylko w kodzie testów nie ma do nich odniesień).

Druga rzecz, to ja nie wiem czy nazywałbym to co masz w swoich klasach "helper". Np ta klasa która generuje Ci niepoprawne tokeny to jest bardziej Fixture niż Helper (dane testowe).

Natomiast co się tyczy staticów - ja się staram w testach używać "normalnych" obiektów, tych z new, i ewentualnie chowam je w statycznych metodach jako takie "szybkie wywołanie". Nie trzymam żadnej logiki ani danych testowych w staticach.

0
Riddle napisał(a):

Z mojego doświadczenia wynika że korzystanie z @Autowired w testach prędzej czy później się odbijało, więc nie korzystam z tego. W miarę możliwości ja osobiście staram się odizolować testy od springa. (nie znaczy to że testy nie wywołują springa - wołają go, tylko w kodzie testów nie ma do nich odniesień).

To robisz coś takiego ?

Helper:

public class TokenGeneratorHelper {

    private final LoginService login;

    public TokenGeneratorHelper(LoginService login) {
        this.login = login;
    }
    public String generateCorrectToken() {
        return login.login(new LoginRequest("mail@mail.com", "Password#3"));
    }
}

Test:

public class TokenValidatorTest extends BaseIT {

    private TokenValidator validator;
    private KeyManager key;


    private TokenGenerator tokenGenerator;
    private DateProvider dateProvider;
    private Long jwtExpirationDate;


    private LoginService login;
    private ApplicationMessageService message;
    private UserAuthenticationService userAuth;
    @Autowired
    private AuthenticationManager authManager;

    private TokenGeneratorHelper tokenHelper;

    @BeforeEach
    void setUp() {
        // Validator
        key = new KeyManagerImpl("123456sdasdasdadadhuery8328t67rgyby3asdadas67tfgeyyg"); // jwtSecret
        validator = new TokenValidatorImpl(key);

        // TokenGenerator
        dateProvider = new DateProvider();
        jwtExpirationDate = 1000L;
        tokenGenerator = new TokenGeneratorImpl(jwtExpirationDate, dateProvider, key);

        // ApplicationMessageService
        message = new ApplicationMessageServiceImpl();

        //UserAuthService
        userAuth = new UserAuthenticationServiceImpl(authManager);

        // LoginService
        login = new LoginServiceImpl(tokenGenerator, message, userAuth);

        // TokenGeneratorHelper
        tokenHelper = new TokenGeneratorHelper(login);
    }
    @Test
    void whenValidToken_ValidationReturnTrue() {
        String token = tokenHelper.generateCorrectToken();

        boolean result = validator.validateToken(token);

        assertTrue(result);
    }
}

Edit.
Mam cztery testy, które korzystają z klasy generującej fałszywe tokeny. Jeden test korzysta z klasy tworzącej token poprzez logowanie – łączy się z bazą danych. Czy powinienem te testy, które się nie łączą z bazą danych, trzymać w osobnej klasie testowej?

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.