Современное приложение на Java с использованием Spring Boot, включающее множество клиентов (веб, десктоп, мобильные), может столкнуться с проблемами в тестировании по мере его роста. Даже при хорошем покрытии тестами (80%+), увеличение объема интеграционных и приемочных тестов может привести к значительным задержкам в процессе разработки. Тесты могут занимать до 24 часов для выполнения, что снижает эффективность и увеличивает риск багов в продакшене. "Баги как костяшки домино, важно их расставлять так чтобы не упали все вместе", не знаю кто сказал, но вполне четко описывает процесс разработки.

Что делать

... кроме написания резюме в поисках более интересно проекта?

Пробуем понять, где в авто тестах тормоза. И понимаем, что все интеграционные и приемочные тесты зависят либо от скорости тестировщика, либо от скорости отклика клиента во время авто-тестов. И то, и то в сотни и тысячи раз медленнее вашего api.

После анализа ситуации остается одно: как-то все это автоматизировать, причем быстро и дешево.

Ускорить тестировщика без незаконных препаратов мы не можем, как и сделать UI, который будет работать без задержек, на скорости api (можно постараться, но дорого...).

Основные шаги для решения:

  1. Ускорить тестирование, убрав фронтенд из автоматических интеграционных и приемочных тестов.

  2. Автоматизировать ручные тест-сценарии.

Для этого необходимо разработать два процесса: процесс записи тестов и процесс воспроизведения тестов.

Процесс записи тестов

  1. Восстановление бэкапа базы данных с тестовыми данными (он у вас есть, 100% вы на чем-то гоняете ваши автотесты).

  2. Сохранение API вызовов: Записываем request и response для каждого вызова.

  3. Сохранение данных: Сохраняем все это либо на файловую систему, либо в Elasticsearch/MongoDB и т.д. в реальном проекте вообще использовался AWS S3

Для тестировщика запись это просто рутинная ручная проверка скрипта.

Процесс воспроизведения тестов

  1. Восстановление бэкапа базы данных с тестовыми данными.

  2. Выполнение сохраненных API вызовов: вызываем сервис с записанными request.

  3. Сравнение 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/

Комментарии (0)