Buduj z ufnością: przewodnik po testach JUnit

Opublikowany: 2022-03-11

Wraz z postępem technologii i przemysłu przechodzącym z modelu kaskadowego do Agile, a teraz do DevOps, zmiany i ulepszenia w aplikacji są wdrażane w środowisku produkcyjnym od razu po ich wprowadzeniu. Ponieważ kod jest tak szybko wdrażany w środowisku produkcyjnym, musimy mieć pewność, że nasze zmiany działają, a także, że nie psują żadnej istniejącej wcześniej funkcjonalności.

Aby zbudować tę pewność, musimy mieć ramy do automatycznego testowania regresji. W przypadku testowania regresji istnieje wiele testów, które należy przeprowadzić z punktu widzenia poziomu interfejsu API, ale tutaj omówimy dwa główne typy testów:

  • Testowanie jednostkowe , gdzie każdy test obejmuje najmniejszą jednostkę programu (funkcję lub procedurę). Może, ale nie musi, przyjmować niektóre parametry wejściowe i może zwracać niektóre wartości lub nie.
  • Testowanie integracyjne , w ramach którego poszczególne jednostki są testowane razem, aby sprawdzić, czy wszystkie jednostki współdziałają ze sobą zgodnie z oczekiwaniami.

Istnieje wiele frameworków dostępnych dla każdego języka programowania. Skoncentrujemy się na pisaniu testów jednostkowych i integracyjnych dla aplikacji internetowej napisanej w frameworku Java Spring.

Przez większość czasu piszemy metody w klasie, a te z kolei wchodzą w interakcję z metodami innej klasy. W dzisiejszym świecie — zwłaszcza w aplikacjach korporacyjnych — złożoność aplikacji jest taka, że ​​pojedyncza metoda może wywołać więcej niż jedną metodę wielu klas. Tak więc pisząc test jednostkowy dla takiej metody, potrzebujemy sposobu na zwrócenie zafałszowanych danych z tych wywołań. Dzieje się tak, ponieważ intencją tego testu jednostkowego jest przetestowanie tylko jednej metody, a nie wszystkich wywołań, które wykonuje ta konkretna metoda.


Przejdźmy do testów jednostkowych Java w Springu przy użyciu frameworka JUnit. Zaczniemy od czegoś, o czym być może słyszałeś: kpiny.

Co to jest kpina i kiedy pojawia się na obrazie?

Załóżmy, że masz klasę CalculateArea , która ma funkcję calculateArea(Type type, Double... args) , która oblicza obszar kształtu danego typu (koła, kwadratu lub prostokąta).

Kod wygląda mniej więcej tak w normalnej aplikacji, która nie używa wstrzykiwania zależności:

 public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; CalculateArea(SquareService squareService, RectangleService rectangeService, CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { switch (type) { case RECTANGLE: if(r.length >=2) return rectangleService.area(r[0],r[1]); else throw new RuntimeException("Missing required params"); case SQUARE: if(r.length >=1) return squareService.area(r[0]); else throw new RuntimeException("Missing required param"); case CIRCLE: if(r.length >=1) return circleService.area(r[0]); else throw new RuntimeException("Missing required param"); default: throw new RuntimeException("Operation not supported"); } } }
 public class SquareService { public Double area(double r) { return r * r; } }
 public class RectangleService { public Double area(Double r, Double h) { return r * h; } }
 public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
 public enum Type { RECTANGLE,SQUARE,CIRCLE; }

Teraz, jeśli chcemy przetestować jednostkę funkcję calculateArea() klasy CalculateArea , naszym motywem powinno być sprawdzenie, czy przypadki switch i warunki wyjątków działają. Nie powinniśmy testować, czy usługi kształtu zwracają prawidłowe wartości, ponieważ, jak wspomniano wcześniej, motywem testowania jednostkowego funkcji jest testowanie logiki funkcji, a nie logiki wywołań, które wykonuje funkcja.

Dlatego będziemy mockować wartości zwracane przez poszczególne funkcje usługowe (np. rectangleService.area() i testować funkcję wywołującą (np. CalculateArea.calculateArea() ) w oparciu o te zakłamane wartości.

Prosty przypadek testowy dla usługi rectangle — testowanie tego, że funkcja solveArea calculateArea() rzeczywiście wywołuje rectangleService.area() z poprawnymi parametrami — wyglądałaby tak:

 import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; public class CalculateAreaTest { RectangleService rectangleService; SquareService squareService; CircleService circleService; CalculateArea calculateArea; @Before public void init() { rectangleService = Mockito.mock(RectangleService.class); squareService = Mockito.mock(SquareService.class); circleService = Mockito.mock(CircleService.class); calculateArea = new CalculateArea(squareService,rectangleService,circleService); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }

Dwie główne linie, na które należy zwrócić uwagę, to:

  • rectangleService = Mockito.mock(RectangleService.class); — To tworzy kpinę, która nie jest rzeczywistym obiektem, ale kpiną.
  • Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); — Oznacza to, że w przypadku wykpiwania i wywołania metody area obiektu rectangleService z określonymi parametrami zwracana jest wartość 20d .

Co się dzieje, gdy powyższy kod jest częścią aplikacji Spring?

 import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; public CalculateArea(@Autowired SquareService squareService, @Autowired RectangleService rectangeService, @Autowired CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }

Tutaj mamy dwie adnotacje dla bazowego frameworka Spring do wykrycia w momencie inicjalizacji kontekstu:

  • @Component : Tworzy ziarno typu CalculateArea
  • @Autowired : wyszukuje ziarna rectangleService , squareService i circleService i wstrzykuje je do obszaru ziarna calculatedArea

Podobnie tworzymy fasolki dla innych klas:

 import org.springframework.stereotype.Service; @Service public class SquareService { public Double area(double r) { return r*r; } }
 import org.springframework.stereotype.Service; @Service public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
 import org.springframework.stereotype.Service; @Service public class RectangleService { public Double area(Double r, Double h) { return r*h; } }

Teraz, jeśli przeprowadzimy testy, wyniki są takie same. Użyliśmy tutaj wstrzykiwania konstruktora i na szczęście nie zmieniamy naszego przypadku testowego.

Istnieje jednak inny sposób wstrzykiwania ziaren usług w postaci kwadratu, koła i prostokąta: wstrzykiwanie pola. Jeśli tego użyjemy, nasz przypadek testowy będzie wymagał drobnych zmian.

Nie będziemy wchodzić w dyskusję o tym, który mechanizm wtrysku jest lepszy, ponieważ nie jest to objęte zakresem artykułu. Ale możemy powiedzieć tak: bez względu na rodzaj mechanizmu używanego do wstrzykiwania ziaren, zawsze istnieje sposób na napisanie dla niego testów JUnit.

W przypadku wstrzykiwania pola kod wygląda mniej więcej tak:

 @Component public class CalculateArea { @Autowired SquareService squareService; @Autowired RectangleService rectangleService; @Autowired CircleService circleService; public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }

Uwaga: Ponieważ używamy wstrzykiwania pól, nie ma potrzeby stosowania konstruktora sparametryzowanego, więc obiekt jest tworzony przy użyciu domyślnego, a wartości są ustawiane za pomocą mechanizmu wstrzykiwania pól.

Kod dla naszych klas usług pozostaje taki sam jak powyżej, ale kod dla klasy testowej wygląda następująco:

 public class CalculateAreaTest { @Mock RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Before public void init() { MockitoAnnotations.initMocks(this); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }

Kilka rzeczy wygląda tu inaczej: nie podstawy, ale sposób, w jaki to osiągamy.

Po pierwsze, sposób, w jaki kpimy z naszych obiektów: używamy adnotacji @Mock wraz z initMocks() do tworzenia makiet. Po drugie, wstawiamy mocki do rzeczywistego obiektu za pomocą @InjectMocks wraz z initMocks() .

Robi się to po prostu w celu zmniejszenia liczby linii kodu.

Kim są biegacze testowi i jakie są typy biegaczy?

W powyższym przykładzie podstawowym elementem uruchamiającym używanym do uruchamiania wszystkich testów jest BlockJUnit4ClassRunner , który wykrywa wszystkie adnotacje i odpowiednio uruchamia wszystkie testy.

Jeśli chcemy mieć więcej funkcji, możemy napisać niestandardowego biegacza. Na przykład w powyższej klasie testowej, jeśli chcemy pominąć linię MockitoAnnotations.initMocks(this); wtedy moglibyśmy użyć innego runnera, który jest zbudowany na bazie BlockJUnit4ClassRunner , np. MockitoJUnitRunner .

Używając MockitoJUnitRunner , nie musimy nawet inicjować mocków i wstrzykiwać ich. Zrobi to sam MockitoJUnitRunner , po prostu czytając adnotacje.

(Istnieje również SpringJUnit4ClassRunner , który inicjuje ApplicationContext potrzebny do testowania integracji ze Springiem — podobnie jak ApplicationContext jest tworzony podczas uruchamiania aplikacji Spring. Omówimy to później).

Częściowe kpiny

Jeśli chcemy, aby obiekt w klasie testowej zakpił niektóre metody, ale także wywołał niektóre rzeczywiste metody, wtedy potrzebujemy częściowego zakłamania. Osiąga się to poprzez @Spy w JUnit.

W przeciwieństwie do @Mock , w @Spy jest prawdziwy obiekt, ale metody tego obiektu mogą być wyśmiewane lub mogą być faktycznie wywoływane — cokolwiek potrzebujemy.

Na przykład, jeśli metoda area w klasie RectangleService wywołuje dodatkową metodę log() i faktycznie chcemy wydrukować ten dziennik, kod zmieni się na mniej więcej taki jak poniżej:

 @Service public class RectangleService { public Double area(Double r, Double h) { log(); return r*h; } public void log() { System.out.println("skip this"); } }

Jeśli zmienimy adnotację @Mock w rectangleService na @Spy , a także wprowadzimy pewne zmiany w kodzie, jak pokazano poniżej, w wynikach faktycznie zobaczylibyśmy drukowane logi, ale metoda area() zostanie wyśmiewana. Oznacza to, że oryginalna funkcja jest uruchamiana wyłącznie ze względu na jej skutki uboczne; jego zwracane wartości są zastępowane przez fałszywą.

 @RunWith(MockitoJUnitRunner.class) public class CalculateAreaTest { @Spy RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Test public void calculateRectangleAreaTest() { Mockito.doCallRealMethod().when(rectangleService).log(); Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }

Jak podchodzimy do testowania Controller lub RequestHandler ?

Z tego, czego dowiedzieliśmy się powyżej, kod testowy kontrolera dla naszego przykładu będzie podobny do poniższego:

 import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @Controller public class AreaController { @Autowired CalculateArea calculateArea; @RequestMapping(value = "api/area", method = RequestMethod.GET) @ResponseBody public ResponseEntity calculateArea( @RequestParam("type") String type, @RequestParam("param1") String param1, @RequestParam(value = "param2", required = false) String param2 ) { try { Double area = calculateArea.calculateArea( Type.valueOf(type), Double.parseDouble(param1), Double.parseDouble(param2) ); return new ResponseEntity(area, HttpStatus.OK); } catch (Exception e) { return new ResponseEntity(e.getCause(), HttpStatus.INTERNAL_SERVER_ERROR); } } }
 import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @RunWith(MockitoJUnitRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; @Test public void calculateAreaTest() { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); ResponseEntity responseEntity = areaController.calculateArea("RECTANGLE", "5", "4"); Assert.assertEquals(HttpStatus.OK,responseEntity.getStatusCode()); Assert.assertEquals(20d,responseEntity.getBody()); } }

Patrząc na powyższy kod testowy kontrolera, działa on dobrze, ale ma jeden podstawowy problem: testuje tylko wywołanie metody, a nie faktyczne wywołanie API. Brakuje wszystkich tych przypadków testowych, w których parametry API i stan wywołań API muszą zostać przetestowane pod kątem różnych danych wejściowych.

Ten kod jest lepszy:

 import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE&param1=5&param2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }

Tutaj możemy zobaczyć, jak MockMvc przejmuje zadanie wykonywania rzeczywistych wywołań API. Ma również kilka specjalnych elementów dopasowujących, takich jak status() i content() , które ułatwiają sprawdzanie poprawności zawartości.

Testowanie integracji Java za pomocą JUnit i Mocks

Teraz, gdy wiemy, że poszczególne jednostki kodu działają, upewnijmy się, że współdziałają one ze sobą zgodnie z oczekiwaniami.

Najpierw musimy utworzyć instancję wszystkich ziaren, to samo, co dzieje się podczas inicjalizacji kontekstu Spring podczas uruchamiania aplikacji.

W tym celu definiujemy wszystkie ziarna w klasie, powiedzmy TestConfig.java :

 import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class TestConfig { @Bean public AreaController areaController() { return new AreaController(); } @Bean public CalculateArea calculateArea() { return new CalculateArea(); } @Bean public RectangleService rectangleService() { return new RectangleService(); } @Bean public SquareService squareService() { return new SquareService(); } @Bean public CircleService circleService() { return new CircleService(); } }

Zobaczmy teraz, jak używamy tej klasy i piszemy test integracyjny:

 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = {TestConfig.class}) public class AreaControllerIntegrationTest { @Autowired AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE&param1=5&param2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }

Tutaj zmienia się kilka rzeczy:

  • @ContextConfiguration(classes = {TestConfig.class}) — informuje o przypadku testowym, w którym znajdują się wszystkie definicje ziaren.
  • Teraz zamiast @InjectMocks używamy:
 @Autowired AreaController areaController;

Cała reszta pozostaje taka sama. Jeśli debugujemy test, zobaczymy, że kod faktycznie działa do ostatniego wiersza metody area() w RectangleService , gdzie obliczany jest return r*h . Innymi słowy, działa właściwa logika biznesowa.

Nie oznacza to, że w testach integracyjnych nie ma możliwości maskowania wywołań metod ani wywołań baz danych. W powyższym przykładzie nie użyto żadnej usługi ani bazy danych innej firmy, stąd nie musieliśmy używać mocków. W prawdziwym życiu takie aplikacje są rzadkie i często trafiamy na bazę danych lub API innej firmy, albo na jedno i drugie. W takim przypadku, tworząc ziarno w klasie TestConfig , nie tworzymy rzeczywistego obiektu, ale fikcyjny i używamy go wszędzie tam, gdzie jest to potrzebne.

Bonus: Jak tworzyć dane testowe dużego obiektu

Często tym, co powstrzymuje back-endowych programistów przed pisaniem testów jednostkowych lub integracyjnych, są dane testowe, które musimy przygotować dla każdego testu.

Zwykle, jeśli dane są wystarczająco małe, posiadają jedną lub dwie zmienne, łatwo jest po prostu utworzyć obiekt klasy danych testowych i przypisać pewne wartości.

Na przykład, jeśli oczekujemy, że obiekt naśladowany zwróci inny obiekt, gdy funkcja zostanie wywołana na obiekcie naśladowanym, zrobimy coś takiego:

 Class1 object = new Class1(); object.setVariable1(1); object.setVariable2(2);

A potem, aby użyć tego obiektu, zrobilibyśmy coś takiego:

 Mockito.when(service.method(arguments...)).thenReturn(object);

Jest to w porządku w powyższych przykładach JUnit, ale gdy zmienne składowe w powyższej klasie Class1 stale rosną, ustawianie poszczególnych pól staje się dość uciążliwe. Czasami może się nawet zdarzyć, że klasa ma zdefiniowany inny niepierwotny element klasy. Następnie stworzenie obiektu tej klasy i ustawienie poszczególnych wymaganych pól dodatkowo zwiększa wysiłek programistyczny, aby osiągnąć pewien schemat.

Rozwiązaniem jest wygenerowanie schematu JSON powyższej klasy i jednokrotne dodanie odpowiednich danych w pliku JSON. Teraz w klasie testowej, w której tworzymy obiekt Class1 , nie musimy tworzyć obiektu ręcznie. Zamiast tego czytamy plik JSON i za pomocą ObjectMapper go na wymaganą klasę Class1 :

 ObjectMapper objectMapper = new ObjectMapper(); Class1 object = objectMapper.readValue( new String(Files.readAllBytes( Paths.get("src/test/resources/"+fileName)) ), Class1.class );

Jest to jednorazowy wysiłek polegający na utworzeniu pliku JSON i dodaniu do niego wartości. Wszelkie nowe testy po tym mogą używać kopii tego pliku JSON z polami zmienionymi zgodnie z potrzebami nowego testu.

Podstawy JUnit: wiele podejść i umiejętności, które można przenosić

Oczywiste jest, że istnieje wiele sposobów pisania testów jednostkowych Javy w zależności od tego, jak zdecydujemy się wstrzyknąć ziarna. Niestety, większość artykułów na ten temat zakłada, że ​​jest tylko jeden sposób, więc łatwo się pomylić, zwłaszcza podczas pracy z kodem, który został napisany przy innych założeniach. Mamy nadzieję, że nasze podejście tutaj zaoszczędzi programistom czasu na szukanie właściwego sposobu na kpinę i tego, którego testera użyć.

Bez względu na język lub framework, którego używamy — być może nawet każdą nową wersję Springa lub JUnit — podstawa koncepcyjna pozostaje taka sama, jak wyjaśniono w powyższym samouczku JUnit. Miłego testowania!