Создавайте уверенно: руководство по тестам JUnit

Опубликовано: 2022-03-11

С развитием технологий и переходом отрасли от водопадной модели к Agile, а теперь и к DevOps, изменения и усовершенствования в приложении внедряются в производство в ту же минуту, когда они сделаны. Поскольку код развертывается в рабочей среде так быстро, мы должны быть уверены, что наши изменения работают, а также что они не нарушают какие-либо ранее существовавшие функции.

Чтобы укрепить эту уверенность, у нас должна быть основа для автоматического регрессионного тестирования. Для проведения регрессионного тестирования существует множество тестов, которые следует выполнять с точки зрения уровня API, но здесь мы рассмотрим два основных типа тестов:

  • Модульное тестирование , где любой заданный тест охватывает наименьшую единицу программы (функцию или процедуру). Он может принимать или не принимать некоторые входные параметры и может возвращать или не возвращать некоторые значения.
  • Интеграционное тестирование , при котором отдельные модули тестируются вместе, чтобы проверить, все ли модули взаимодействуют друг с другом, как ожидалось.

Для каждого языка программирования доступно множество фреймворков. Мы сосредоточимся на написании модульного и интеграционного тестирования для веб-приложения, написанного на платформе Java Spring.

Большую часть времени мы пишем методы в классе, а они, в свою очередь, взаимодействуют с методами какого-то другого класса. В современном мире, особенно в корпоративных приложениях, сложность приложений такова, что один метод может вызывать несколько методов нескольких классов. Поэтому при написании модульного теста для такого метода нам нужен способ вернуть имитированные данные из этих вызовов. Это связано с тем, что целью этого модульного теста является проверка только одного метода, а не всех вызовов, которые делает этот конкретный метод.


Давайте перейдем к модульному тестированию Java в Spring, используя среду JUnit. Мы начнем с того, о чем вы, возможно, слышали: насмешки.

Что такое насмешка и когда она проявляется?

Предположим, у вас есть класс 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); — Это говорит о том, что при имитировании и вызове метода area объекта rectangleService с указанными параметрами возвращается 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 : создает bean-компонент типа CalculateArea .
  • @Autowired : ищет бины rectangleService , squareService и circleService и внедряет их в бин calculatedArea .

Точно так же мы создаем bean-компоненты и для других классов:

 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; } }

Теперь, если мы запустим тесты, результаты будут такими же. Здесь мы использовали внедрение конструктора и, к счастью, не изменили наш тестовый пример.

Но есть и другой способ внедрить bean-компоненты сервисов Square, Circle и Rectangle: внедрение поля. Если мы воспользуемся этим, то нашему тестовому сценарию потребуются небольшие изменения.

Мы не будем вдаваться в обсуждение того, какой механизм впрыска лучше, так как это не входит в рамки статьи. Но мы можем сказать следующее: независимо от того, какой тип механизма вы используете для внедрения bean-компонентов, всегда есть способ написать для него 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&param1=5&param2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }

Здесь мы видим, как MockMvc выполняет работу по выполнению реальных вызовов API. Он также имеет некоторые специальные сопоставители, такие как status() и content() , которые упрощают проверку содержимого.

Интеграционное тестирование Java с использованием JUnit и Mocks

Теперь, когда мы знаем, что отдельные блоки кода работают, давайте удостоверимся, что они взаимодействуют друг с другом так, как мы ожидаем.

Во-первых, нам нужно создать экземпляры всех bean-компонентов, то же самое, что происходит во время инициализации контекста Spring во время запуска приложения.

Для этого мы определяем все bean-компоненты в классе, скажем, 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&param1=5&param2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }

Здесь меняется несколько вещей:

  • @ContextConfiguration(classes = {TestConfig.class}) — сообщает тестовому примеру, где находятся все определения bean-компонентов.
  • Теперь вместо @InjectMocks используем:
 @Autowired AreaController areaController;

Все остальное остается прежним. Если мы отладим тест, мы увидим, что код действительно работает до последней строки метода area() в RectangleService , где вычисляется return r*h . Другими словами, работает реальная бизнес-логика.

Это не означает, что в интеграционном тестировании нет насмешек над вызовами методов или вызовами базы данных. В приведенном выше примере не использовались сторонние службы или базы данных, поэтому нам не нужно было использовать моки. В реальной жизни такие приложения встречаются редко, и мы часто сталкиваемся с базой данных или сторонним API, или и тем, и другим. В этом случае, когда мы создаем bean-компонент в классе TestConfig , мы создаем не фактический объект, а фиктивный, и используем его везде, где это необходимо.

Бонус: как создать тестовые данные для больших объектов

Часто бэкенд-разработчикам при написании модульных или интеграционных тестов мешают тестовые данные, которые мы должны готовить для каждого теста.

Обычно, если данные достаточно малы, имеют одну или две переменные, то легко просто создать объект класса тестовых данных и присвоить некоторые значения.

Например, если мы ожидаем, что фиктивный объект вернет другой объект, когда функция вызывается для фиктивного объекта, мы должны сделать что-то вроде этого:

 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 с полями, измененными в соответствии с потребностями нового теста.

Основы JUnit: несколько подходов и передаваемые навыки

Понятно, что существует много способов написания модульных тестов Java в зависимости от того, как мы решили внедрять bean-компоненты. К сожалению, в большинстве статей на эту тему предполагается, что существует только один способ, поэтому легко запутаться, особенно при работе с кодом, который был написан с другим предположением. Будем надеяться, что наш подход сэкономит время разработчиков на выяснении правильного способа имитации и используемого средства запуска тестов.

Независимо от используемого языка или фреймворка — возможно, даже любой новой версии Spring или JUnit — концептуальная основа остается такой же, как описано в приведенном выше руководстве по JUnit. Удачного тестирования!