Современное приложение на Java с использованием Spring Boot, включающее множество клиентов (веб, десктоп, мобильные), может столкнуться с проблемами в тестировании по мере его роста. Даже при хорошем покрытии тестами (80%+), увеличение объема интеграционных и приемочных тестов может привести к значительным задержкам в процессе разработки. Тесты могут занимать до 24 часов для выполнения, что снижает эффективность и увеличивает риск багов в продакшене. "Баги как костяшки домино, важно их расставлять так чтобы не упали все вместе", не знаю кто сказал, но вполне четко описывает процесс разработки.
Что делать
... кроме написания резюме в поисках более интересно проекта?
Пробуем понять, где в авто тестах тормоза. И понимаем, что все интеграционные и приемочные тесты зависят либо от скорости тестировщика, либо от скорости отклика клиента во время авто-тестов. И то, и то в сотни и тысячи раз медленнее вашего api.
После анализа ситуации остается одно: как-то все это автоматизировать, причем быстро и дешево.
Ускорить тестировщика без незаконных препаратов мы не можем, как и сделать UI, который будет работать без задержек, на скорости api (можно постараться, но дорого...).
Основные шаги для решения:
Ускорить тестирование, убрав фронтенд из автоматических интеграционных и приемочных тестов.
Автоматизировать ручные тест-сценарии.
Для этого необходимо разработать два процесса: процесс записи тестов и процесс воспроизведения тестов.
Процесс записи тестов
Восстановление бэкапа базы данных с тестовыми данными (он у вас есть, 100% вы на чем-то гоняете ваши автотесты).
Сохранение API вызовов: Записываем request и response для каждого вызова.
Сохранение данных: Сохраняем все это либо на файловую систему, либо в Elasticsearch/MongoDB и т.д. в реальном проекте вообще использовался AWS S3
Для тестировщика запись это просто рутинная ручная проверка скрипта.
Процесс воспроизведения тестов
Восстановление бэкапа базы данных с тестовыми данными.
Выполнение сохраненных API вызовов: вызываем сервис с записанными request.
Сравнение response: сравниваем ответ от сервиса с записанным response.
Пример имплементации
Опишем сперва сам вызов:
@Data
@Document(indexName = "apicalls")
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class ApiCall {
private UUID id = UUID.randomUUID();
private Date date = new Date();
private String path;
private CallType callType;
private Request request;
private Response response;
}
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Request {
private CallType callType;
private Map<String, String[]> arguments;
private String body;
}
@AllArgsConstructor
@NoArgsConstructor
@Data
@Getter
@Setter
public class Response {
private Object result;
private Integer statusCode;
private String body;
}
Для записи вызовов воспользуемся OncePerRequestFilter из Spring, пример имплементации:
@Component
@Order(1)
@AllArgsConstructor
@ConditionalOnProperty(name = "recorder.filter.enabled", havingValue = "true", matchIfMissing = true)
@Slf4j
public class RequestResponseLoggingFilter extends OncePerRequestFilter {
private final ElasticServices elasticServices;//TODO use for save calls
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
ApiCall apiCall = new ApiCall();
apiCall.setPath(requestWrapper.getRequestURI());
// Log request details
filterChain.doFilter(requestWrapper, responseWrapper);
// Log response details
logResponse(apiCall, responseWrapper);
logRequest(apiCall, requestWrapper);
objectMapper.writeValueAsString(apiCall);
responseWrapper.copyBodyToResponse();
}
private void logRequest(ApiCall apiCall, ContentCachingRequestWrapper requestWrapper) throws IOException {
Request request = new Request();
request.setCallType(CallType.valueOf(requestWrapper.getMethod()));
request.setArguments(requestWrapper.getParameterMap());
request.setBody(new String(requestWrapper.getContentAsByteArray()));
requestWrapper.getReader();
apiCall.setRequest(request);
}
private void logResponse(ApiCall apiCall, ContentCachingResponseWrapper responseWrapper) throws IOException {
Response response = new Response();
response.setStatusCode(responseWrapper.getStatus());
response.setBody(new String(responseWrapper.getContentAsByteArray()));
response.setResult(new String(responseWrapper.getContentAsByteArray()));
apiCall.setResponse(response);
}
}
Соответственно на выходе из doFilterInternal нужно куда-то сохранить вызов, тут уже что ваша душа пожелает.
Пример кода воспроизведения:
@Component
@AllArgsConstructor
public class Player {
private final RestTemplate restTemplate;
public void makeApiCall(ApiCall apiCall) throws IOException {
if (apiCall.getPath() != null && apiCall.getRequest() != null) {
String url = "http://localhost:8080" + apiCall.getPath();
Request request = apiCall.getRequest();
String method = request.getCallType().name();
Object result = switch (request.getCallType()) {
case GET -> restTemplate.getForObject(url, Object.class, request.getArguments());
case POST -> restTemplate.postForObject(url, request.getBody(), Object.class, request.getArguments());
case PUT -> {
restTemplate.put(url, request.getBody(), request.getArguments());
yield "PUT request sent successfully";
}
case PATCH -> {
restTemplate.patchForObject(url, request.getBody(), Object.class, request.getArguments());
yield "PATCH request sent successfully";
}
default -> throw new IllegalArgumentException("Unsupported method: " + method);
};
apiCall.getResponse().setResult(result);
apiCall.getResponse().setBody(result.toString());
if (!compareJsonObjects(result, apiCall)) {
throw new RuntimeException("response is wrong");
}
}
}
public boolean compareJsonObjects(Object obj1, ApiCall apiCall) throws IOException {
ObjectMapper mapper = new ObjectMapper();
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
String json1 = mapper.writeValueAsString(obj1);
String json2 = mapper.writeValueAsString(apiCall);
JsonNode node1 = mapper.readTree(json1);
JsonNode node2 = mapper.readTree(json2);
removeFields(node1);
removeFields(node2);
return node1.equals(node2);
}
private void removeFields(JsonNode node) {
if (node.isObject()) {
ObjectNode objectNode = (ObjectNode) node;
objectNode.fields().forEachRemaining(entry -> {
JsonNode value = entry.getValue();
if (value.isTextual()) {
String textValue = value.asText();
if (isUUID(textValue) || isDate(textValue)) {
objectNode.remove(entry.getKey());
}
}
removeFields(value);
});
} else if (node.isArray()) {
ArrayNode arrayNode = (ArrayNode) node;
arrayNode.forEach(this::removeFields);
}
}
private boolean isUUID(String value) {
try {
UUID.fromString(value);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
private boolean isDate(String value) {
try {
ObjectMapper mapper = new ObjectMapper();
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.readValue("\"" + value + "\"", java.util.Date.class);
return true;
} catch (Exception e) {
return false;
}
}
}
Основная проблема, что вам придется фильтровать некоторые типы (UUID например), подменять даты и т.п., достаточно не тривиальная задача с точки зрения «что вообще нужно сделать», но крайне простая в имплементации.
В прочем не буду дальше вас задерживать. А да линк на репо с примером примерного кода, приведенного тут только как пример https://github.com/kain64/apirecorder/