В статье речь пойдет об интеграции веб-приложений, написанных с помощью Spring и работающих по HTTP. Название Spring Cloud Contract, на мой взгляд, вводит в заблуждение, так как не имеет ничего общего с cloud.
Речь пойдет об API контрактах.
Для юнит-тестирования контроллеров достаточно часто используются mockMCV или RestAssured. Для моков на стороне фронтэнда используются моск-серверы, например Wiremock или Pact. Но зачастую, юнит-тесты пишут одни люди, а моки другие.
Это может привести к пролбемам при интеграции.
Например, сервер при отсутствии данных может возвращать 204 NO_CONTENT, а клиент может ожидать 200 OK и пустой json.
Тут не важно, кто из них прав. Проблема в том, что кто-то сделал ошибку и она будет найдена не раньше этапа интеграции.
Вот эту проблему и призван решить spring cloud contract.
Что такое spring cloud contract
Это файл, в котором на yaml или groovyDSL диалекте описано, как должны выглядеть запрос и ответ. По умолчанию все контракты лежат в папке /src/test/resources/contracts/*
.
Для примера протестируем простейший GET-endpoint
@GetMapping("/bets/{userId}")
public ResponseEntity<List<Bet>> getBets(@PathVariable("userId") String userId) {
List<Bet> bets = service.getByUserId(userId);
if (bets.isEmpty()) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.ok(bets);
}
Опишем контракт
org.springframework.cloud.contract.spec.Contract.make {
request {
method 'GET'
urlPath '/bets/2'
}
response {
status 200
headers {
header('Content-Type', 'application/json')
}
body('''
{
"sport":"football",
"amount": 1
}
'''
)
}
}
Далее из этого файла с помощью maven или gradle плагина генерируются юнит-тесты и json’ы для wiremock.
JSON описание мока для примера выше будет выглядеть так:
{
"id" : "df8f7b73-c242-4664-add3-7214ac6356ff",
"request" : {
"urlPath" : "/bets/2",
"method" : "GET"
},
"response" : {
"status" : 200,
"body" : "{"amount":1,"sport":"football"}",
"headers" : {
"Content-Type" : "application/json"
},
"transformers" : [ "response-template" ]
},
"uuid" : "df8f7b73-c242-4664-add3-7214ac6356ff"
}
Wiremock можно запустить локально, надо только скачать jar отсюда. По умолчанию json-моки надо положить папку mappings
.
$java -jar wiremock-standalone-2.18.0.jar
Ниже показан сгеренированный тест. По умолчанию использована библиотека RestAssured, но могут быть использоаваны mockMVC или spockframework.
public class UserControllerTest extends ContractBae {
@Test
public void validate_get_200() throws Exception {
// given:
MockMvcRequestSpecification request = given();
// when:
ResponseOptions response = given().spec(request)
.get("/bets/2");
// then:
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.header("Content-Type")).isEqualTo("application/json");
// and:
DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
assertThatJson(parsedJson).field("['amount']").isEqualTo(1);
assertThatJson(parsedJson).field("['sport']").isEqualTo("football");
}
}
Следует отметить, что все сгенерированные классы наследуют какой-нибудь базовый класс (базовых классов может быть несколько), в котором инициализируются все необходимые параметры для тестов. Путь к базовому классу описывается в настройках плагина.
Для данного примера базовый класс может выглядеть так:
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = SccDemoApplication.class)
public abstract class ContractBae {
@LocalServerPort
int port;
@Autowired
protected WebApplicationContext webApplicationContext;
@Before
public void configureRestAssured() {
RestAssured.port = port;
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
.build();
RestAssuredMockMvc.mockMvc(mockMvc);
}
}
В итоге получаем, что и тесты и моки получены из одного источника. Если юнит-тесты проходят и условный фронтэнд работает на моках, то и с интеграцией проблем не будет.
Но это еще не все
Моки может использовать не только фронтэнд, но само приложение для интеграции с другим приложением. Спринг умеет запускать моск-сервер, надо только сгенерировать jar с моками и передать путь к нему аннотации @AutoConfigureStubRunner
Допустим что наш контроллер делает HTTP к другому приложению:
@GetMapping("/bets/{userId}")
public ResponseEntity<List<Bet>> getBets(@PathVariable("userId") String userId) {
if(!isUsetExists(userId)) {
return ResponseEntity.notFound().build();
}
List<Bet> bets = service.getByUserId(userId);
if (bets.isEmpty()) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.ok(bets);
}
private boolean isUsetExists(String userId) {
try {
restTemplate.getForObject("/exists/" + userId, Void.class);
return true;
} catch (HttpStatusCodeException ignore) {
return false;
}
}
Тогда надо просто описать пусть к jar с моками в базовом классе
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = SccDemoApplication.class)
@AutoConfigureStubRunner(ids = {"me.dehasi.contracts.demo:sub-service-stubs:+:stubs:8090"}, stubsMode = StubRunnerProperties.StubsMode.LOCAL)
public abstract class ContractBase {
Т.к. это тесты контроллера, то из этих же тестов можно сгенерировать ascii-doc сниппеты (полноценная статья про rest-docs уже есть на хабре) .
Что мы имеем
Получается, что у нас есть один источник API контракта, который описан на человекопонятном языке, и из него мы генерируем юнит-тесты (теоретически еще и документацию), и из него же моки. Данный подход снижает риски ошибок интеграции между веб-приложениями.
Примеры можно посмотреть на официальном сайте например.
Примеры кода в статье взяты из простейшего проекта тут.
Автор: dehasi