Модульные и интеграционные тесты для Spring Boot

Узнайте, как использовать инструменты, представленные в этом руководстве, и писать модульные и интеграционные тесты в среде Spring Boot.

1. Обзор

В этой статье мы увидим, как писать модульные тесты и интегрировать их вSpring BootОкружающая обстановка. Вы можете найти множество руководств по этой теме в Интернете, но бывает сложно найти всю необходимую информацию на одной странице. Я часто замечаю, что младшие разработчики путают концепции модульного и интеграционного тестирования, особенно когда речь идет об экосистеме Spring. Я постараюсь пояснить, как разные аннотации используются в разных контекстах.

2. Модульное тестирование против интеграционного тестирования

Википедия так говоритмодульный тестиз:

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

Интеграционное тестирование:

«Интеграционное тестирование (иногда называемое интеграцией и тестированием, сокращенно I&T) — это этап тестирования программного обеспечения, на котором для тестирования объединяются отдельные программные модули».

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

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

3. Модульное тестирование Java

Все Java-разработчики знаютJUnitЯвляется основным фреймворком для выполнения модульных тестов. Он предоставляет множество аннотаций для утверждения ожиданий.

Hamcrestявляется дополнительной структурой для тестирования программного обеспечения. Hamcrest позволяет использовать существующие классы сопоставления для проверки условий в коде, а также собственные реализации сопоставления. Чтобы использовать сопоставитель Hamcrest с JUnit, вы должны использоватьassertThatоператор, за которым следует один или несколько сопоставителей.

Здесь вы можете увидеть простые тесты с использованием обоих фреймворков:

import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.CoreMatchers.anyOf;
import static org.hamcrest.CoreMatchers.both;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.everyItem;
import static org.hamcrest.CoreMatchers.hasItems;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.CoreMatchers.sameInstance;
import static org.hamcrest.CoreMatchers.startsWith;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;

import java.util.Arrays;

import org.hamcrest.core.CombinableMatcher;
import org.junit.Test;

public class AssertTests {
  @Test
  public void testAssertArrayEquals() {
    byte[] expected = "trial".getBytes();
    byte[] actual = "trial".getBytes();
    assertArrayEquals("failure - byte arrays not same", expected, actual);
  }

  @Test
  public void testAssertEquals() {
    assertEquals("failure - strings are not equal", "text", "text");
  }

  @Test
  public void testAssertFalse() {
    assertFalse("failure - should be false", false);
  }

  @Test
  public void testAssertNotNull() {
    assertNotNull("should not be null", new Object());
  }

  @Test
  public void testAssertNotSame() {
    assertNotSame("should not be same Object", new Object(), new Object());
  }

  @Test
  public void testAssertNull() {
    assertNull("should be null", null);
  }

  @Test
  public void testAssertSame() {
    Integer aNumber = Integer.valueOf(768);
    assertSame("should be same", aNumber, aNumber);
  }

  // JUnit Matchers assertThat
  @Test
  public void testAssertThatBothContainsString() {
    assertThat("albumen", both(containsString("a")).and(containsString("b")));
  }

  @Test
  public void testAssertThatHasItems() {
    assertThat(Arrays.asList("one", "two", "three"), hasItems("one", "three"));
  }

  @Test
  public void testAssertThatEveryItemContainsString() {
    assertThat(Arrays.asList(new String[] { "fun", "ban", "net" }), everyItem(containsString("n")));
  }

  // Core Hamcrest Matchers with assertThat
  @Test
  public void testAssertThatHamcrestCoreMatchers() {
    assertThat("good", allOf(equalTo("good"), startsWith("good")));
    assertThat("good", not(allOf(equalTo("bad"), equalTo("good"))));
    assertThat("good", anyOf(equalTo("bad"), equalTo("good")));
    assertThat(7, not(CombinableMatcher.<Integer> either(equalTo(3)).or(equalTo(4))));
    assertThat(new Object(), not(sameInstance(new Object())));
  }

  @Test
  public void testAssertTrue() {
    assertTrue("failure - should be true", true);
  }
}

4. Представьте наш кейс

Напишем простую программу. Его цель - предоставить базовую поисковую систему для манги.

Kenshiro vs Roul

4.1 Зависимости Maven

Во-первых, нам нужно добавить некоторые зависимости в наш проект.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
  <groupId>org.projectlombok</groupId>
  <artifactId>lombok</artifactId>
  <version>1.16.20</version>
  <scope>provided</scope>
</dependency>

4.2 Определение модели

Наша модель очень проста, состоит всего из двух классов: Manga и MangaResult.

4.2.1. Класс манги

Класс Manga представляет экземпляр Manga, полученный системой. использоватьLombokдля сокращения шаблонного кода.

package com.mgiglione.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class Manga {
    private String title;
    private String description;
    private Integer volumes;
    private Double score;
}

4.2.2. MangaResult

Класс MangaResult — это класс-оболочка, содержащий список манги.

package com.mgiglione.model;

import java.util.List;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter @Setter @NoArgsConstructor
public class MangaResult {
    private List<Manga> result;
}

4.3. Внедрение службы

Для реализации этого Сервиса мы будем использоватьJikan MoeПредоставляется бесплатный интерфейс API.

RestTemplate — это класс Spring, используемый для вызовов REST к API.

package com.mgiglione.service;

import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import com.mgiglione.model.Manga;
import com.mgiglione.model.MangaResult;

@Service
public class MangaService {

    Logger logger = LoggerFactory.getLogger(MangaService.class);
    private static final String MANGA_SEARCH_URL="http://api.jikan.moe/search/manga/";
    
    @Autowired
    RestTemplate restTemplate;
    
    public List<Manga> getMangasByTitle(String title) {
        return restTemplate.getForEntity(MANGA_SEARCH_URL+title, MangaResult.class).getBody().getResult();
    }

}

4.4 Реализация контроллера

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

package com.mgiglione.controller;

import java.util.List;
import java.util.concurrent.CompletableFuture;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

import com.mgiglione.model.Manga;
import com.mgiglione.service.MangaService;

@RestController
@RequestMapping(value = "/manga")
public class MangaController {

    Logger logger = LoggerFactory.getLogger(MangaController.class);
    
    @Autowired
    private MangaService mangaService;   
    
    @RequestMapping(value = "/async/{title}", method = RequestMethod.GET)
    @Async
    public CompletableFuture<List<Manga>> searchASync(@PathVariable(name = "title") String title) {
        return CompletableFuture.completedFuture(mangaService.getMangasByTitle(title));
    }
    
    @RequestMapping(value = "/sync/{title}", method = RequestMethod.GET)
    public @ResponseBody <List<Manga>> searchSync(@PathVariable(name = "title") String title) {
        return mangaService.getMangasByTitle(title);
    }

}

4.5 Запустите и протестируйте систему

mvn spring-boot:run

Тогда давайте попробуем:

curl http://localhost:8080/manga/async/ken
curl http://localhost:8080/manga/sync/ken

Пример вывода:

{  
   "title":"Rurouni Kenshin: Meiji Kenkaku Romantan",
   "description":"Ten years have passed since the end of Bakumatsu, an era of war that saw the uprising of citizens against the Tokugawa shogunate. The revolutionaries wanted to create a time of peace, and a thriving c...",
   "volumes":28,
   "score":8.69
},
{  
   "title":"Sun-Ken Rock",
   "description":"The story revolves around Ken, a man from an upper-class family that was orphaned young due to his family's involvement with the Yakuza; he became a high school delinquent known for fighting. The only...",
   "volumes":25,
   "score":8.12
},
{  
   "title":"Yumekui Kenbun",
   "description":"For those who suffer nightmares, help awaits at the Ginseikan Tea House, where patrons can order much more than just Darjeeling. Hiruko is a special kind of a private investigator. He's a dream eater....",
   "volumes":9,
   "score":7.97
}

5. Модульное тестирование приложений Spring Boot

Spring Boot предоставляет мощный класс, облегчающий тестирование:@SpringBootTestаннотация

Эту аннотацию можно указать в тестовых классах, работающих в Spring Boot.

В дополнение к обычному Spring TestContext Framework он предоставляет следующие возможности:

  • SpringBootContextLoader используется как ContextLoader по умолчанию, когда @ContextConfiguration (loader=…) специально не объявлен.
  • Автоматически ищет @SpringBootConfiguration, когда вложенная аннотация @Configuration не используется и связанный класс не указан явно.
  • Позволяет использовать свойства для настройки свойств среды.
  • Обеспечивает поддержку различных режимов веб-среды, включая возможность запуска полнофункционального веб-сервера на определенном или произвольном порту.
  • Зарегистрируйте bean-компоненты TestRestTemplate и/или WebTestClient для использования в веб-тестах, которые полностью выполняются на веб-сервере.

Здесь у нас есть только два компонента для тестирования: MangaService и MangaController.

5.1 Модульное тестирование MangaService

Чтобы протестировать MangaService, нам нужно изолировать его от внешних компонентов. В этом примере требуется только один внешний компонент:RestTemplate, который мы используем для вызова удаленного API.

Что нам нужно сделать, так это имитировать RestTemplate Bean и заставить его всегда отвечать фиксированным заданным ответом. Spring Test объединяет и расширяет библиотеку Mockito, а с помощью аннотации @MockBean мы можем настроить фиктивные bean-компоненты.

package com.mgiglione.service.test.unit;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

import java.io.IOException;
import java.util.List;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.web.client.RestTemplate;
import static org.assertj.core.api.Assertions.assertThat;

import com.mgiglione.model.Manga;
import com.mgiglione.model.MangaResult;
import com.mgiglione.service.MangaService;
import com.mgiglione.utils.JsonUtils;

@RunWith(SpringRunner.class)
@SpringBootTest
public class MangaServiceUnitTest {
    
    @Autowired
    private MangaService mangaService;
    
    // MockBean is the annotation provided by Spring that wraps mockito one
    // Annotation that can be used to add mocks to a Spring ApplicationContext.
    // If any existing single bean of the same type defined in the context will be replaced by the mock, if no existing bean is defined a new one will be added.
    @MockBean
    private RestTemplate template;
    
    @Test
    public void testGetMangasByTitle() throws IOException {
        // Parsing mock file
        MangaResult mRs = JsonUtils.jsonFile2Object("ken.json", MangaResult.class);
        // Mocking remote service
        when(template.getForEntity(any(String.class), any(Class.class))).thenReturn(new ResponseEntity(mRs, HttpStatus.OK));
        // I search for goku but system will use mocked response containing only ken, so I can check that mock is used.
        List<Manga> mangasByTitle = mangaService.getMangasByTitle("goku");
        assertThat(mangasByTitle).isNotNull()
            .isNotEmpty()
            .allMatch(p -> p.getTitle()
                .toLowerCase()
                .contains("ken"));
    }
    
}

5.2 Модульное тестирование MangaController

Как и в модульном тесте MangaService, нам нужно изолировать компоненты. В этом случае нам нужно смоделироватьMangaServiceБоб.

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

MockMvcэто класс Spring, который делает это. Его можно установить по-разному:

  1. Использование автономного контекста
  2. Использование контекста веб-приложения
  3. Позвольте Spring загрузить все контексты для автоматического связывания с помощью аннотаций @SpringBootTest, @AutoConfigureMockMvc в тестовом классе.
  4. Позвольте Spring загрузить контекст веб-уровня, используя аннотацию @WebMvcTest в тестовом классе для автоматического связывания.
package com.mgiglione.service.test.unit;

import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;

import java.util.ArrayList;
import java.util.List;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.web.context.WebApplicationContext;

import com.mgiglione.controller.MangaController;
import com.mgiglione.model.Manga;
import com.mgiglione.service.MangaService;

@SpringBootTest
@RunWith(SpringRunner.class)
public class MangaControllerUnitTest {

    MockMvc mockMvc;
    
    @Autowired
    protected WebApplicationContext wac;
    
    @Autowired
    MangaController mangaController;
    
    @MockBean
    MangaService mangaService;
    
    /**
     * List of samples mangas
     */
    private List<Manga> mangas;
    
    @Before
    public void setup() throws Exception {
        this.mockMvc = standaloneSetup(this.mangaController).build();// Standalone context
        // mockMvc = MockMvcBuilders.webAppContextSetup(wac)
        // .build();
        Manga manga1 = Manga.builder()
            .title("Hokuto no ken")
            .description("The year is 199X. The Earth has been devastated by nuclear war...")
            .build();
        Manga manga2 = Manga.builder()
            .title("Yumekui Kenbun")
            .description("For those who suffer nightmares, help awaits at the Ginseikan Tea House, where patrons can order much more than just Darjeeling. Hiruko is a special kind of a private investigator. He's a dream eater....")
            .build();
        mangas = new ArrayList<>();
        mangas.add(manga1);
        mangas.add(manga2);
    }
    
    @Test
    public void testSearchSync() throws Exception {
        // Mocking service
        when(mangaService.getMangasByTitle(any(String.class))).thenReturn(mangas);
        mockMvc.perform(get("/manga/sync/ken").contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$[0].title", is("Hokuto no ken")))
            .andExpect(jsonPath("$[1].title", is("Yumekui Kenbun")));
    }

    @Test
    public void testSearchASync() throws Exception {
        // Mocking service
        when(mangaService.getMangasByTitle(any(String.class))).thenReturn(mangas);
        MvcResult result = mockMvc.perform(get("/manga/async/ken").contentType(MediaType.APPLICATION_JSON))
            .andDo(print())
            .andExpect(request().asyncStarted())
            .andDo(print())
            // .andExpect(status().is2xxSuccessful()).andReturn();
            .andReturn();
        // result.getRequest().getAsyncContext().setTimeout(10000);
        mockMvc.perform(asyncDispatch(result))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(jsonPath("$[0].title", is("Hokuto no ken")));
    }
}

Как видно из кода, было выбрано первое решение, потому что оно самое легкое, и мы можем лучше управлять объектами, загружаемыми в контексте Spring.

В асинхронном тесте сначала нужно вызвать службуasyncDispatchметод имитации асинхронного поведения.

6. Интеграционное тестирование приложений Spring Boot

Для интеграционных тестов мы хотим обеспечить нисходящую связь для проверки наших основных компонентов.

6.1. Интеграционное тестирование MangaService

Этот тест тоже очень простой. Нам не нужно ничего имитировать, потому что наша цель — вызвать удаленный API Manga.

package com.mgiglione.service.test.integration;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import com.mgiglione.model.Manga;
import com.mgiglione.service.MangaService;

@RunWith(SpringRunner.class)
@SpringBootTest
public class MangaServiceIntegrationTest {

    @Autowired
    private MangaService mangaService;
    
    @Test
    public void testGetMangasByTitle() {
           List<Manga> mangasByTitle = mangaService.getMangasByTitle("ken");
           assertThat(mangasByTitle).isNotNull().isNotEmpty();
    }
    
}

6.2 Интеграционное тестирование MangaController

Этот тест очень похож на модульный тест, но в этом случае нам не нужно имитировать MangaService.

package com.mgiglione.service.test.integration;

import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.web.context.WebApplicationContext;

import com.mgiglione.controller.MangaController;

@SpringBootTest
@RunWith(SpringRunner.class)
public class MangaControllerIntegrationTest {

    // @Autowired
    MockMvc mockMvc;
    
    @Autowired
    protected WebApplicationContext wac;
    
    @Autowired
    MangaController mangaController;
    
    @Before
    public void setup() throws Exception {
        this.mockMvc = standaloneSetup(this.mangaController).build();// Standalone context
        // mockMvc = MockMvcBuilders.webAppContextSetup(wac)
        // .build();
    }
    
    @Test
    public void testSearchSync() throws Exception {
        mockMvc.perform(get("/manga/sync/ken").contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.*.title", hasItem(is("Hokuto no Ken"))));
    }
    
    @Test
    public void testSearchASync() throws Exception {
        MvcResult result = mockMvc.perform(get("/manga/async/ken").contentType(MediaType.APPLICATION_JSON))
            .andDo(print())
            .andExpect(request().asyncStarted())
            .andDo(print())
            .andReturn();
        mockMvc.perform(asyncDispatch(result))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.*.title", hasItem(is("Hokuto no Ken"))));
    }

}

7. Заключение

Мы увидели основные различия между модульным тестированием и интеграционным тестированием в среде Spring Boot, а также в такой среде, как Hamcrest, которая упрощает написание тестов. Конечно, и в моемРепозиторий GitHubНайдите все коды здесь.

Оригинал: https://dzone.com/articles/unit-and-integration-tests-in-spring-boot-2

автор:Marco Giglione

Переводчик: Ваньсян
------

Сентябрьские льготы, обратите внимание на официальный аккаунт Ответ Backstage: 004, получите основные моменты перевода за август! Ответ прошлых льгот: 001, 002, 003, чтобы получить!

img