Testy integracyjne negatywnych scenariuszy – Mockito, Dynamic Proxy, JUnit, Spring

Testy integracyjne negatywnych scenariuszy – Mockito, Dynamic Proxy, JUnit, Spring

W ramach jednej z aplikacji, które ostatnio współtworzyłem, pojawiła się potrzeba sprawdzenia jej zachowania w niestabilnym ekosystemie. Mam tu na myśli upewnienie się, że aplikacja zachowa się poprawnie w takich sytuacjach jak np. wystąpienie wyjątku bazodanowego, czy awaria infrastruktury lub usługi sieciowej. Poprzez poprawne zachowanie rozumiemy tu odpowiednio wycofanie transakcji bazodanowej, lub ewentualną kompensację działań elementów nietransakcyjnych, takich jak np. wywołanie usług sieciowych. Po zaprojektowaniu i pozytywnym wdrożeniu testów integracyjnych w tejże aplikacji postanowiłem podzielić się przemyśleniami i sposobami ich realizacji.

Aby lepiej wyjaśnić tytułowe zagadnienie, pozwolę sobie zacząć od omówienia – kiedy i po co testować przypadki negatywne. Następnie posługując się prostym przykładem aplikacji symulującej bank przedstawię dwa sposoby realizacji testów integracyjnych uwzględniających awarię wywołania jednej z usług sieciowych. Przejdźmy zatem do tematu.

Po co testować negatywne scenariusze?

Myślę, że idea stojąca za testowaniem ścieżek pozytywnych jest dość intuicyjna. Z jednej strony, takimi testami, mamy zagwarantowaną regresję, a z drugiej strony opisane scenariusze biznesowe. Jakie korzyści zatem ma dodanie do tego zbioru ścieżek negatywnych?

Jeśli chodzi o przypadki negatywne to mogą one wynikać ze scenariuszy biznesowych, kiedy to zakładamy, że pewne operacje mogą się nie udać, ale jest dla nich przewidziana biznesowa ścieżka obsługi. Takim przykładem z bankowości może być np. test wypłaty pieniędzy z konta na którym brakuje środków na jej realizację. Jest to scenariusz negatywny, jednak dość częsty, przewidywalny i w dodatku jego obsługę definiuje domena biznesowa.

Innym przykładem negatywnych scenariuszy testów, są te związane z architekturą samej aplikacji, oraz przyjętymi założeniami. Mam tu przede wszystkim na myśli wspomnianą wcześniej transakcyjność i kompensację usług. I to na tej grupie chciałbym się skupić w dalszej części artykułu.

Studium przypadku: Aplikacja bankowa

Wyobraź sobie, że piszesz aplikację symulującą bank, która umożliwia takie operacje jak wpłata i wypłata środków, sprawdzenie salda, oraz przelew między rachunkami. Aplikacja podzielona jest na dwa komponenty: InternalBankService oraz BankService. Na potrzeby tego przykładu załóżmy, że metoda InternalBankService#addToAccount zachowuje się jak usługa sieciowa. Komponent BankService implementuje metody, które skomponowane są ze wspomnianej usługi addToAccount. Owe kompozycje zaznaczyłem w poniższym kodzie aplikacji.

package com.bettercoding.lab;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;

@SpringBootApplication
public class BankApp {
    public static void main(String[] args) {
        SpringApplication.run(BankApp.class, args);
    }

    @Component
    public static class InternalBankService {
        private Map<String, BigDecimal> accounts = new HashMap<>();

        void addToAccount(String accountNumber, BigDecimal amount) {
            BigDecimal newAmount = accounts.getOrDefault(accountNumber, BigDecimal.ZERO).add(amount);
            accounts.put(accountNumber, newAmount);
        }

        BigDecimal getAmount(String accountNumber) {
            return accounts.getOrDefault(accountNumber, BigDecimal.ZERO);
        }
    }

    @Component
    public static class BankService {
        private final InternalBankService internalBankService;

        @Autowired
        public BankService(InternalBankService internalBankService) {
            this.internalBankService = internalBankService;
        }

        public void credit(String accountNumber, BigDecimal amount) {
            this.internalBankService.addToAccount(accountNumber, amount);
        }


        public void debit(String accountNumber, BigDecimal amount) {
            this.internalBankService.addToAccount(accountNumber, amount.negate());
        }

        public void transfer(String debitAccount, String creditAccount, BigDecimal amount) {
            this.internalBankService.addToAccount(creditAccount, amount);
            this.internalBankService.addToAccount(debitAccount, amount.negate());
        }

        public BigDecimal getAmount(String accountNumber) {
            return this.internalBankService.getAmount(accountNumber);
        }
    }
}

Napisanie testów integracyjnych czy end2end pozytywnych ścieżek nie powinno stwarzać większego problemu. Poniżej znajduje się przykład jednego z takich testów:

package com.bettercoding.lab;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.assertEquals;


@SpringBootTest
class BankAppTest {
    @Autowired
    private BankApp.BankService bankService;

    @Test
    void happyPathTest() {
        bankService.credit("account_1", BigDecimal.valueOf(100));
        bankService.credit("account_1", BigDecimal.valueOf(50));
        bankService.credit("account_2", BigDecimal.valueOf(200));
        bankService.credit("account_2", BigDecimal.valueOf(300));

        assertEquals(150, bankService.getAmount("account_1").longValue());
        assertEquals(500, bankService.getAmount("account_2").longValue());
    }
}

Testy scenariuszy negatywnych

Inaczej ma się sprawa w przypadku scenariuszy sytuacji wyjątkowych i potencjalnych awarii. Zatrzymajmy się na moment i zastanówmy, w jaki sposób możemy przetestować również i takie przypadki. Idealnie byłoby móc kontrolować kiedy, w jakich warunkach, oraz w którym dokładnie momencie nastąpi taki wyjątek.

Kontrola sytuacji wyjątkowych

W takim razie jak kontrolować i symulować awarię pewnego komponentu? Otóż najczęstszym objawem awarii jest otrzymanie jakiegoś wyjątku z grupy RuntimeException. Stąd też najprostszym i zarazem bardzo dobrze sprawdzającym się sposobem na taką symulację będzie wyrzucenie wyjątku z rodziny RuntimeException w odpowiednim momencie. Zastanówmy się w takim razie jak można podejść do tego tematu. Czyli w jaki sposób kontrolować kiedy i gdzie wyrzucać wspomniane wyjątki.

W przypadku naszej przykładowej aplikacji, odpowiednim miejscem na taką symulację będzie komponent InternalBankService, który jak wspomniałem, odzwierciedla usługi sieciowe, z których to korzysta BankService. W dalszej części artykułu przedstawię Ci dwie metody, które umożliwiają zmianę zachowania komponentu InternalBankService w taki sposób, aby dało się kontrolować jego reakcję na kolejne wywołania metody addToAccount.

Obie przedstawione poniżej metody opierają się na podobnej koncepcji. Pierwsza z nich napisana jest od zera z wykorzystaniem dynamicznego proxy klas. Druga natomiast wykorzystuje framework Mockito. Dzięki takiemu zestawieniu łatwiej będzie Ci wyrobić sobie zdanie na temat – czy warto podobne mechanizmy pisać od początku, czy jednak wykorzystać do tego celu gotowe narzędzia.

Metoda #1: Wykorzystanie dynamicznego proxy

Jeśli temat tematy dynamicznego proxy’owania klas jest dla Ciebie obcy lub chciałbyś odświeżyć sobie nieco wiedzę, gorąco zachęcam do zapoznania się z moim poprzednim wpisem.

Jak już wspomniałem wcześniej, chcielibyśmy mieć kontrolę nad wywołaniem metody InternalBankService#addToAccount. Aby było to możliwe, możemy utworzyć dynamiczne proxy klasy InternalBankService, w którym zaimplementujemy mechanizm wyzwalania wyjątku RuntimeException. Tak przygotowaną klasę proxy możemy podmienić w kontekście Spring’a przygotowując odpowiednią konfigurację testową oznaczoną adnotacją @TestConfiguration. Ważne, aby nadpisywany bean miał tę samą nazwę co oryginalny. W naszym przypadku będzie to: public BankApp.InternalBankService internalBankService(). Kolejnym ważnym elementem jest użycie adnotacji @DirtiesContext w konfiguracji DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD, która powoduje, że po każdym teście kontekst Spring jest resetowany, co skutkuje również zresetowaniem proxy.

Poniżej przedstawiłem przykładową implementację negatywnej ścieżki, zaznaczając zmiany związane ze stworzeniem i użyciem dynamicznego proxy.

package com.bettercoding.lab;

import javassist.util.proxy.ProxyFactory;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.test.annotation.DirtiesContext;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

import static org.junit.jupiter.api.Assertions.assertEquals;


@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class BankAppDynamicProxyTest {

    @Autowired
    private BankApp.BankService bankService;

    @Test
    void happyPathTest() {
        bankService.credit("account_1", BigDecimal.valueOf(100));
        bankService.credit("account_1", BigDecimal.valueOf(50));
        bankService.credit("account_2", BigDecimal.valueOf(200));
        bankService.credit("account_2", BigDecimal.valueOf(300));

        assertEquals(150, bankService.getAmount("account_1").longValue());
        assertEquals(500, bankService.getAmount("account_2").longValue());
    }

    @Test
    void negativePathTest() {
        TestConfig.forceExceptionDuringInvocation("addToAccount", 4);

        Assertions.assertThrows(RuntimeException.class, () -> {
            bankService.transfer("account_1", "account_2", BigDecimal.valueOf(100));
            bankService.transfer("account_1", "account_2", BigDecimal.valueOf(50));
        });


        assertEquals(-100, bankService.getAmount("account_1").longValue());
        assertEquals(100, bankService.getAmount("account_2").longValue());
    }

    @TestConfiguration
    public static class TestConfig {
        private static Map<String, AtomicInteger> INTERNAL_BANK_SERVICE_METHOD_MAP = new HashMap<>();

        public static void forceExceptionDuringInvocation(String methodName, int invocationsNo) {
            INTERNAL_BANK_SERVICE_METHOD_MAP.put(methodName, new AtomicInteger(invocationsNo));
        }

        @Bean
        public BankApp.InternalBankService internalBankService() {
            ProxyFactory proxyFactory = new ProxyFactory();
            proxyFactory.setSuperclass(BankApp.InternalBankService.class);

            try {
                return (BankApp.InternalBankService) proxyFactory.create(new Class<?>[0], new Object[0], (self, thisMethod, proceed, args) -> {
                    System.out.println(">> Mock called before original method invocation: " + thisMethod.getName());

                    int value = INTERNAL_BANK_SERVICE_METHOD_MAP.getOrDefault(thisMethod.getName(), new AtomicInteger(Integer.MAX_VALUE)).decrementAndGet();
                    if (value <= 0) {
                        INTERNAL_BANK_SERVICE_METHOD_MAP.remove(thisMethod.getName());
                        throw new RuntimeException("Triggered exception");
                    }

                    Object result = proceed.invoke(self, args);


                    System.out.println("<< Mock called after original method invocation:" + thisMethod.getName());
                    return result;
                });
            } catch (Exception e) {
                throw new RuntimeException("An exception occurred during proxy creation", e);
            }
        }
    }
}

Metoda #2: SpyBean oraz stub’owanie z wykorzystaniem Mockito

Nim przejdziemy do przykładowej implementacji tytułowego problemu, myślę, że warto przypomnieć podstawowe informacje o Mockito oraz używanej nomenklaturze.

  • Mickito – biblioteka służąca do mock’owania obiektów. Strona projektu.
  • Mock – obiekt używany zamiast rzeczywistej implementacji. Mock służy jedynie do badania interakcji z nim, czyli np. ile razy dana metoda została zawołana.
  • Stub – jest również obiektem zastępującym rzeczywistą implementację, jednak używany w celu zwrócenia pewnych ustalonych wartości odpowiedzi na wywołania jego metod.
  • Spy – jest obiektem zastępczym rzeczywistego obiektu, na którym możemy zarówno zaprogramować konkretną odpowiedź jak w przypadku Stub’ów jak również wykonać rzeczywisty kod metody.
  • Więcej o mock’ach możesz przeczytać tu.

Najwyższa pora na zaprezentowanie głównego bohatera tego artykułu w akcji. Mowa oczywiście o Mockito. Zobaczmy zatem ile kodu potrzeba aby osiągnąć analogiczny efekt jak ten z użyciem dynamicznego proxy.

package com.bettercoding.lab;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Spy;
import org.springframework.boot.test.context.SpringBootTest;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doCallRealMethod;


@SpringBootTest
class BankAppMockitoTest {

    @Spy
    private BankApp.InternalBankService internalBankService;

    @InjectMocks
    private BankApp.BankService bankService;

    @Test
    void happyPathTest() {
        bankService.credit("account_1", BigDecimal.valueOf(100));
        bankService.credit("account_1", BigDecimal.valueOf(50));
        bankService.credit("account_2", BigDecimal.valueOf(200));
        bankService.credit("account_2", BigDecimal.valueOf(300));

        assertEquals(150, bankService.getAmount("account_1").longValue());
        assertEquals(500, bankService.getAmount("account_2").longValue());
    }

    @Test
    void negativePathTest() {

        Assertions.assertThrows(RuntimeException.class, () -> {
            doCallRealMethod()
                    .doCallRealMethod()
                    .doCallRealMethod()
                    .doThrow(RuntimeException.class)
                    .when(internalBankService).addToAccount(any(), any());

            bankService.transfer("account_1", "account_2", BigDecimal.valueOf(100));
            bankService.transfer("account_1", "account_2", BigDecimal.valueOf(50));
        });


        assertEquals(-100, bankService.getAmount("account_1").longValue());
        assertEquals(100, bankService.getAmount("account_2").longValue());
    }
}

Jak pewnie zauważyłeś w stosunku do oryginalnego kodu testu musieliśmy jedynie zaprogramować zachowanie Spy beana InternalBankService. W tym celu wstrzyknęliśmy go adnotacją @Spy(linie 19-20) oraz zaprogramowaliśmy go w liniach 40-44, tak aby w przypadku wywołań metody addToAccount dwukrotnie wykonał prawdziwy kod metod, a za trzecim razem wyrzucił wyjątek RuntimeException. Ostatnią zmianą, która musieliśmy wykonać była zmiana adnotacji @Autowired na @InjectMocks. Adnotacja ta wstrzykuje beany uwzględniając mock’i zdefiniowane przy pomocy frameworku Mockito

Podsumowanie

Wiadomo, że w programowaniu istnieje wiele sposobów na rozwiązanie tego samego problemu. I tak, zanim dowiedziałem się o istnieniu Mockito moje rozwiązania przypominały bardziej te z użyciem dynamicznego prox’y. Czy teraz, mając świadomość o istnieniu i zaletach takiego frameworku wybrałbym ponownie stare rozwiązanie? Nie sądzę.

A jakie są Twoje przemyślenia na ten temat? Czy poznałeś ostatnio coś, co zmieniło Twoje spojrzenie na pisanie kodu lub projektowanie aplikacji? Zostaw proszę komentarz a być może pomożesz zarówno mnie jaki innym czytelnikom rozwinąć się poznając coś nowego.

To byłoby na tyle jeśli chodzi o ten wpis. Więcej artykułów dostępnych jest w  angielskiej wersji bloga Jeśli podoba Ci się konwencja i chciałbyś więcej takich wpisów, zostaw mi komentarz z tematem, który Cię interesuje a ja postaram się go przygotować. Tymczasem możesz mi pomóc dotrzeć do większej liczby odbiorców udostępniając ten wpis.
Dziękuję

Leave a Reply

avatar
  Subscribe  
Notify of
Close Menu