С напредването на технологиите и индустрията, преминаващи от модела на водопада към Agile и сега към DevOps, промените и подобренията в приложението се внедряват в производството в момента, в който са направени. С бързото внедряване на кода в производството, трябва да сме уверени, че промените ни работят, както и че те не нарушават никаква съществуваща функционалност.
За да изградим тази увереност, трябва да имаме рамка за автоматично регресивно тестване. За извършване на регресионно тестване има много тестове, които трябва да се извършват от гледна точка на ниво API, но тук ще разгледаме два основни типа тестове:
Налични са множество рамки за всеки език за програмиране. Ще се съсредоточим върху писането на модули и тестване на интеграция за уеб приложение, написано на Java Пролетна рамка.
симулацията може да използва всяко разпределение на вероятностите, което потребителят дефинира.
По-голямата част от времето пишем методи в клас, а те от своя страна си взаимодействат с методи от някой друг клас. В днешния свят - особено в корпоративни приложения —Сложността на приложенията е такава, че един метод може да извика повече от един метод от множество класове. Така че, когато пишем единичен тест за такъв метод, ние се нуждаем от начин да върнем подигравани данни от тези повиквания. Това е така, защото целта на този единичен тест е да тества само един метод, а не всички повиквания, които този конкретен метод прави.
Нека да преминем към Java модулното тестване през пролетта, използвайки JUnit framework. Ще започнем с нещо, за което може би сте чували: подигравка.
Да предположим, че имате клас, CalculateArea
, който има функция calculateArea(Type type, Double... args)
което изчислява площта на фигура от дадения тип (кръг, квадрат или правоъгълник.)
Кодът е нещо подобно в нормално приложение, което не използва инжектиране на зависимости:
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; }
Сега, ако искаме да тестваме модулно функцията calculateArea()
на класа CalculateArea
, тогава мотивът ни трябва да бъде да проверим дали switch
случаи и условия за изключение работят. Не бива да проверяваме дали услугите за фигури връщат правилните стойности, тъй като, както беше споменато по-рано, мотивът за единично тестване на функция е да се тества логиката на функцията, а не логиката на извикванията, които функцията прави.
Така че ще се подиграваме със стойностите, върнати от отделни сервизни функции (напр. rectangleService.area()
И ще тестваме извикващата функция (напр. CalculateArea.calculateArea()
) Въз основа на тези подигравани стойности.
Един прост тестов случай за услугата за правоъгълник - тестване, че calculateArea()
наистина извиква rectangleService.area()
с правилните параметри - ще изглежда така:
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); } }
Тук се отбелязват два основни реда:
rectangleService = Mockito.mock(RectangleService.class);
—Това създава макет, който не е действителен обект, а подиграван.Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d);
—Това казва, че когато се подиграва и rectangleService
object’s area
метод се извиква с посочените параметри, след което връща 20d
.Сега, какво се случва, когато горният код е част от приложение 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) } }
Тук имаме две анотации за основната Spring рамка, които да бъдат открити по време на инициализация на контекста:
@Component
: Създава боб от тип CalculateArea
@Autowired
: Търси зърната rectangleService
, squareService
и circleService
и ги инжектира в зърното calculatedArea
По същия начин създаваме боб и за други класове:
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; } }
Сега, ако пуснем тестовете, резултатите са същите. Използвахме инжектор на конструктор тук и за щастие не променяме тестовия си случай.
Но има и друг начин за инжектиране на зърната на услугите за квадрат, кръг и правоъгълник: инжектиране на поле. Ако използваме това, тогава нашият тестов случай ще се нуждае от някои незначителни промени.
Няма да навлизаме в дискусията кой механизъм за инжектиране е по-добър, тъй като това не е в обхвата на статията. Но можем да кажем следното: Без значение какъв тип механизъм използвате, за да инжектирате боб, винаги има начин да напишете JUnit тестове за него.
В случай на инжектиране на поле, кодът е нещо подобно:
@Component public class CalculateArea { @Autowired SquareService squareService; @Autowired RectangleService rectangleService; @Autowired CircleService circleService; public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }
Забележка: Тъй като използваме инжектиране на поле, няма нужда от параметризиран конструктор, така че обектът се създава с помощта на стандартния и стойностите се задават с помощта на механизма за инжектиране на поле.
Кодът за нашите класове на обслужване остава същият както по-горе, но кодът за тестовия клас е следният:
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); } }
Няколко неща протичат по различен начин тук: не основите, а начинът, по който го постигаме.
Първо, начинът, по който се подиграваме с нашите обекти: Използваме @Mock
анотации заедно с initMocks()
за създаване на подигравки. Второ, инжектираме подигравки в действителния обект, използвайки @InjectMocks
заедно с initMocks()
.
Това е направено само за намаляване на броя на редовете код.
В горната проба основният бегач, който се използва за провеждане на всички тестове, е BlockJUnit4ClassRunner
който открива всички анотации и съответно изпълнява всички тестове.
Ако искаме повече функционалност, тогава можем да напишем персонализиран бегач. Например в горния тестов клас, ако искаме да пропуснем реда MockitoAnnotations.initMocks(this);
тогава бихме могли да използваме различен бегач, който е изграден върху BlockJUnit4ClassRunner
, напр. MockitoJUnitRunner
.
Използвайки MockitoJUnitRunner
, дори не е необходимо да инициализираме макети и да ги инжектираме. Това ще бъде направено от MockitoJUnitRunner
само чрез четене на анотации.
(Има и SpringJUnit4ClassRunner
, което инициализира ApplicationContext
, необходимо за тестване за интегриране на Spring - точно както ApplicationContext
се създава при стартиране на приложение Spring. Това ще разгледаме по-късно.)
Когато искаме обект от тестовия клас да се подиграва с някакъв метод (и), но също така да извика някакъв действителен метод (и), тогава се нуждаем от частично подиграване. Това се постига чрез @Spy
в JUnit.
За разлика от използването на @Mock
, с @Spy
се създава реален обект, но методите на този обект могат да бъдат подигравани или всъщност да бъдат извикани - каквото ни трябва.
Например, ако area
метод в класа RectangleService
извиква допълнителен метод log()
и всъщност искаме да отпечатаме този дневник, тогава кодът се променя на нещо като по-долу:
@Service public class RectangleService { public Double area(Double r, Double h) { log(); return r*h; } public void log() { System.out.println('skip this'); } }
Ако сменим @Mock
анотация на rectangleService
до @Spy
, и също така направете някои промени в кода, както е показано по-долу, тогава в резултатите всъщност ще видим отпечатване на регистрационните файлове, но методът area()
ще бъде подиграван. Тоест, оригиналната функция се изпълнява единствено заради нейните странични ефекти; връщаните му стойности се заменят с подигравани.
@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); } }
Controller
или RequestHandler
?От това, което научихме по-горе, тестовият код на контролер за нашия пример ще бъде нещо като по-долу:
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()); } }
Разглеждайки горния тестов код на контролера, той работи добре, но има един основен проблем: Той тества само извикването на метода, а не действителното извикване на API. Липсват всички онези тестови случаи, при които API параметрите и състоянието на API повикванията трябва да бъдат тествани за различни входове.
Този код е по-добър:
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¶m1=5¶m2=4') ) .andExpect(status().isOk()) .andExpect(content().string('20.0')); } }
Тук можем да видим как MockMvc
поема работата по извършване на действителни API повиквания. Той също така има някои специални съвпадения като status()
и content()
които улесняват проверката на съдържанието.
Сега, когато знаем, че отделните единици от кода работят, нека се уверим, че те също взаимодействат помежду си, както очакваме.
Първо, трябва да създадем екземпляр на всички зърна, едни и същи неща, които се случват по време на инициализацията на Spring Spring по време на стартиране на приложението.
За това дефинираме всички зърна в клас, да кажем 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(); } }
Сега да видим как използваме този клас и да напишем тест за интеграция:
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¶m1=5¶m2=4') ) .andExpect(status().isOk()) .andExpect(content().string('20.0')); } }
Тук се променят няколко неща:
@ContextConfiguration(classes = {TestConfig.class})
- това разказва тестовия случай, в който се намират всички дефиниции на боб.@InjectMocks
ние използваме: @Autowired AreaController areaController;
Всичко останало остава същото. Ако отстраним грешката на теста, ще видим, че кодът действително работи до последния ред на area()
метод в RectangleService
където return r*h
се изчислява. С други думи, действителната бизнес логика работи.
Това не означава, че при интеграционното тестване няма подигравки на извиквания на методи или извиквания на база данни. В горния пример не е използвана услуга или база данни на трета страна, поради което не е необходимо да използваме макети. В реалния живот такива приложения са рядкост и често ще ударим база данни или API на трети страни или и двете. В този случай, когато създаваме боб в TestConfig
клас, ние не създаваме действителния обект, а подиграван и го използваме навсякъде, където е необходимо.
Често това, което спира back-end разработчиците при писането на модулни или интеграционни тестове, са тестовите данни, които трябва да подготвим за всеки тест.
Обикновено, ако данните са достатъчно малки, имащи една или две променливи, тогава е лесно просто да създадете обект от тестов клас данни и да присвоите някои стойности.
Например, ако очакваме подиграван обект да върне друг обект, когато се извика функция на подигравания обект, ще направим нещо подобно:
Class1 object = new Class1(); object.setVariable1(1); object.setVariable2(2);
И тогава, за да използваме този обект, ще направим нещо подобно:
Mockito.when(service.method(arguments...)).thenReturn(object);
Това е добре в горните примери за JUnit, но когато променливите-членове в горните Class1
клас продължава да се увеличава, а след това задаването на отделни полета става доста мъка. Понякога може дори да се случи, че даден клас има дефиниран друг непримитивен член на класа. След това създаването на обект от този клас и задаването на отделни задължителни полета допълнително увеличава усилията за разработка, само за да се постигне някакъв пример.
Решението е да се генерира JSON схема от горния клас и да се добавят съответните данни в JSON файла веднъж. Сега в тестовия клас, където създаваме Class1
обект, не е нужно да създаваме обекта ръчно. Вместо това четем файла JSON и, използвайки ObjectMapper
, го преобразуваме в необходимия Class1
клас:
ObjectMapper objectMapper = new ObjectMapper(); Class1 object = objectMapper.readValue( new String(Files.readAllBytes( Paths.get('src/test/resources/'+fileName)) ), Class1.class );
Това е еднократно усилие за създаване на JSON файл и добавяне на стойности към него. Всички нови тестове след това могат да използват копие на този JSON файл с полета, променени според нуждите на новия тест.
Ясно е, че има много начини за писане на модулни тестове на Java в зависимост от това как решим да инжектираме боб. За съжаление повечето статии по темата са склонни да приемат, че има само един начин, така че е лесно да се объркате, особено когато работите с код, написан под друго предположение. Надяваме се, че нашият подход тук спестява време на разработчиците да измислят правилния начин за подигравка и кой тестов бегач да използва.
Независимо от езика или рамката, които използваме - може би дори всяка нова версия на Spring или JUnit - концептуалната основа остава същата, както е обяснено в горния урок за JUnit. Приятно тестване!
JUnit е най-известната рамка за писане на модулни тестове в Java. Пишете тестови методи, които извикват действителните методи за тестване. Тестовият случай проверява поведението на кода, като утвърждава връщаната стойност спрямо очакваната стойност, като се имат предвид предадените параметри.
По-голямата част от разработчиците на Java са съгласни, че JUnit е най-добрата рамка за модулно тестване. Той е де факто стандарт от 1997 г. и със сигурност има най-голям обем поддръжка в сравнение с други рамки за модулно тестване на Java.
При модулно тестване отделни единици (често обектните методи се считат за „единица“) се тестват по автоматизиран начин.
JUnit тестването се използва за тестване на поведението на методите в класовете, които сме написали. Тестваме метод за очакваните резултати и понякога случаи на хвърляне на изключения - дали методът е в състояние да се справи с изключенията по начина, по който искаме.
Извадка от проектен документ на високо ниво
JUnit е рамка, която предоставя много различни класове и методи за лесно писане на модулни тестове.
Да, JUnit е проект с отворен код, поддържан от много активни разработчици.
JUnit намалява шаблона, който разработчиците трябва да използват, когато пишат модулни тестове.
Кент Бек и Ерих Гама първоначално създадоха JUnit. Днес проектът с отворен код има над сто участници.