
Современное приложение на 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/

