Вступление

Всем привет. Меня зовут Ирек, и я в профессиональном IT с 2012 года. Прошел путь от специалиста службы поддержки до разработчика. На данный момент занимаюсь автоматизацией тестирования в компании РТК ИТ.

В статье хочу рассказать о своём опыте автоматизации тестирования websocket. О том какие грабли собрал и какой в итоге велосипед изобрёл.

На один из проектов разрабы завезли websoket и нужно было автоматизировать процесс тестирования. Бэк написан на Spring, фронт на React и оба они успешно используют библиотеку SockJS на которой и построена вся функциональность связанная с ws.

Автотесты мы пишем на нативной Java без использования Spring в отдельном от основного проекта репозитории.

Первое знакомство

Так получилось, что раньше эта тема меня обходила стороной. Поэтому для таких же как я постараюсь дать краткую вводную.

WebSocket — протокол связи поверх TCP-соединения, предназначенный для обмена сообщениями между браузером и веб-сервером, используя постоянное соединение.

Чуть подробнее вот тут.

STOMP (Simple Text Oriented Messaging Protocol) - текстово-ориентированный протокол, который может работать поверх websocket. Дело в том, что сам ws не определяет содержимое сообщений обмена и для согласованности принято использовать суб-протоколы.

Про STOMP понятно написано вот тут.

SockJS — это JavaScript библиотека, которая обеспечивает двусторонний междоменный канал связи между клиентом и сервером.

Про SockJS и как он работает в связке Spring, есть хорошая статья на хабре.

Как это выглядит в браузере

Открываем страничку, где используются ws.
Идем в DevTools или нажимаем F12, переходим во вкладку Network.
После ищем запрос со статусом 101.
Далее в самом запросе можно посмотреть на сообщения.

Ответы от сервера имеют определенные префиксы. На скрине они хорошо видны.

Из статьи приведенной выше мы узнаем, что для поддержания совместимости с Websocket Api SockJS использует кастомный протокол обмена сообщениями:
o — (open frame) отправляется каждый раз при открытии новой сессии.
c — (close frame) отправляется когда клиент запрашивает закрытие соединения.
h — (heartbeat frame) проверка доступности соединения.
a — (data frame) Массив json сообщений. К примеру: a["message"].

SockJS для тестирования

Если у нас на бэке и на фронте используется SockJS, то логично поискать и для тестирования подобную библиотеку.

На страничке в Github SockJS мы узнаем, что для Java существует клиент внутри Spring Framework, Atmosphere Framework и некая библиотека vert.x.
Потратил кучу времени на vert.x, но так и не смог заставить ее работать.

Решение №1. Нативный

Мы построим свой SockJS с асинхронкой и тестировщицами.

Немного покопался и нашел замечательное видео с которого и начал погружаться в ws. Сначала повторил все вслед за спикером и тестовым примером, а потом пробовал адаптировать под себя. Получилось не сразу, но всё же заработало.

Описание класса клиента будет выглядеть следующим образом

import org.java_websocket.client.WebSocketClient;
import org.java_websocket.drafts.Draft;
import org.java_websocket.handshake.ServerHandshake;

import java.net.URI;
import java.nio.ByteBuffer;

public class Client extends WebSocketClient {

    public Client(URI serverUri, Draft draft) {
        super(serverUri, draft);
    }

    public Client(URI serverURI) {
        super(serverURI);
    }

    @Override
    public void onOpen(ServerHandshake handshakedata) {
        System.out.println("new connection is opened");
    }

    @Override
    public void onClose(int code, String reason, boolean remote) {
        System.out.println("closed with exit code " + code + " additional info: " + reason);
    }

    @Override
    public void onMessage(String message) {
        System.out.println("received <- " + message);
    }

    @Override
    public void onMessage(ByteBuffer message) {
        System.out.println("received ByteBuffer");
    }

    @Override
    public void onError(Exception ex) {
        System.err.println("an error occurred:" + ex);
    }

    @Override
    public void send(String text) {
        System.out.println("send -> " + text);
        super.send(text);
    }
}

Вариант использования будет таким

// Создаем экземпляр клиента
WebSocketClient ws = new Client(new URI("wss://myhost.rt.ru/websocket/tracker/666/autotest/websocket"));

// Подключаемся к хосту
ws.connectBlocking();

// Отправляем сообщение о подключении
ws.send("[\"CONNECT\\naccept-version:1.2,1.1,1.0\\nheart-beat:10000,10000\\n\\n\\u0000\"]");
sleep(2000);

// Подписываемся на редактирование заголовка
ws.send("[\"SUBSCRIBE\\nid:23051/title/edit\\ndestination:/topic/articles/23051/title/edit\\n\\n\\u0000\"]");
sleep(2000);

// Отправляем сообщение с новым заголовком статьи
ws.send("[\"SEND\\ndestination:/kernel/articles/23051/title/edit\\ncontent-length:22\\n\\n{\\\"title\\\":\\\"Hello Habr\\\"}\\u0000\"]");
sleep(2000);

Соответственно в выводе увидим следующее

new connection is opened
send -> ["CONNECT\naccept-version:1.2,1.1,1.0\nheart-beat:10000,10000\n\n\u0000"]
received <- o
received <- a["CONNECTED\nversion:1.2\nheart-beat:0,0\n\n\u0000"]
send -> ["SUBSCRIBE\nid:23051/title/edit\ndestination:/topic/articles/23051/title/edit\n\n\u0000"]
send -> ["SEND\ndestination:/kernel/articles/23051/title/edit\ncontent-length:22\n\n{\"title\":\"Hello Habr\"}\u0000"]
received <- a["MESSAGE\ndestination:/topic/articles/23051/title/edit\ncontent-type:application/json\nsubscription:23051/title/edit\nmessage-id:autotest-79\ncontent-length:22\n\n{\"title\":\"Hello Habr\"}\u0000"]
received <- h

Немного про то откуда берется странная ссылка wss://myhost.rt.ru/websocket/tracker/666/autotest/websocket

Дело в том, что SockJs для формирования ссылки использует следующий шаблон wss://host:port/myApp/myEndpoint/{server-id}/{session-id}/{transport}
где
{server-id} — случайный параметр от 000 до 999, единственное назначение которого упростить балансировку на серверной стороне.
{session-id} — сопоставляет HTTP-запросы, принадлежащие сессии SockJs.
{transport} — указывает на транспортный протокол «websocket», «xhr-streaming», и т.д.

Поэтому в качестве server-id решили выбрать счастливое число 666, а в качестве session-id указали autotest вместо рандомного id, чтобы легче следить по логам.

Решение №2. Тащим Spring к себе в тесты

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

Для начала нужно определить класс управления сессией

import org.springframework.messaging.simp.stomp.StompHeaders;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;

public class StompSessionHandler extends StompSessionHandlerAdapter {
    // Описываем действия с подключением к вебсокету
    @Override
    public void afterConnected(StompSession session, StompHeaders connectedHeaders) {
        // Выводим в консоль, что подключение есть
        System.out.println("new connection is opened");
    }
}

Далее описываем хендлер для STOMP фреймов

import org.springframework.messaging.simp.stomp.StompFrameHandler;
import org.springframework.messaging.simp.stomp.StompHeaders;

import java.lang.reflect.Type;
import java.util.Map;
import java.util.Queue;

public class CustomStompFrameHandler implements StompFrameHandler {
    Queue<Map<String, Object>> queue;

    public CustomStompFrameHandler(Queue<Map<String, Object>> queue) {
        this.queue = queue;
    }

    @Override
    public Type getPayloadType(StompHeaders headers) {
        // WS запрашивает у нас тип для Payload
        return Map.class;
    }

    @Override
    @SuppressWarnings("unchecked")
    public void handleFrame(StompHeaders headers, Object payload) {
        // Получен ответ от WS
        if (payload != null) {
            // Выведем ответ в консоль для отладки
            System.out.println("received <- " + payload);
            System.out.println("  headers:");
            for (String key : headers.keySet()) {
                System.out.println("     " + key + ":" + headers.get(key));
            }
        }
        if (payload instanceof Map) {
            queue.add((Map<String, Object>) payload);
        }
    }
}

Теперь у нас есть всё, чтобы написать клиент

import org.json.JSONObject;
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.messaging.WebSocketStompClient;
import org.springframework.web.socket.sockjs.client.SockJsClient;
import org.springframework.web.socket.sockjs.client.WebSocketTransport;

import java.util.Collections;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;

import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

public class Client {

    private static final String TOPIC = "/topic";
    private static final String KERNEL = "/kernel";
    private WebSocketStompClient stompClient;
    private StompSession session = null;
    private Queue<Map<String, Object>> queue = new ConcurrentLinkedQueue<>();
    private String websocketURI;

    public Client(String websocketURI) {
        this.websocketURI = websocketURI;

        stompClient = new WebSocketStompClient(new SockJsClient(
                Collections.singletonList(new WebSocketTransport(new StandardWebSocketClient()))));
        stompClient.setMessageConverter(new MappingJackson2MessageConverter());
    }

    public void connect() {
        try {
            session = stompClient.connectAsync(
                            websocketURI,
                            new StompSessionHandler())
                    .get(1, SECONDS);
        } catch (InterruptedException | ExecutionException | TimeoutException e) {
            throw new RuntimeException(e);
        }
    }

    public void disconnect() {
        if (session.isConnected()) {
            session.disconnect();
        }
    }

    public void subscribe(String destination, String id) {
        if (!session.isConnected()) {
            connect();
        }
        session.subscribe(destination.formatted(TOPIC, id),
                new CustomStompFrameHandler(queue));
    }

    public void send(String destination, JSONObject json, String id) {

        if (!session.isConnected()) {
            connect();
        }

        Map<String, Object> payload = json.toMap();
        System.out.println("send -> " + payload);
        session.send(destination.formatted(KERNEL, id), payload);
        await().atMost(1, SECONDS)
                .untilAsserted(() -> assertThat(queue).contains(payload));
    }

}

Вариант использования будет таким

// Создаем экземпляр клиента
Client ws = new Client("wss://myhost.rt.ru/websocket/tracker");

// Подключаемся к хосту
ws.connect();

// Подписываемся на редактирование заголовка статьи
ws.subscribe("%s/articles/%s/title/edit", "23051");

// Отправляем сообщение с новым заголовком статьи
ws.send("%s/articles/%s/title/edit",
        new JSONObject().put("title", "Hello Habr"),
        "23051");

Вывод будет следующим

new connection is opened
send -> {title=Hello Habr}
received <- {title=Hello Habr}
    headers:
        destination:[/topic/articles/23051/title/edit]
        content-type:[application/json]
        subscription:[0]
        message-id:[6fe34d3531c14e3d8289168fcf0f6488-111]
        content-length:[22]

А если нужна нагрузка?

Для нагрузочных тестов использовал Gatling. Это было первое знакомство, поэтому мог нагородить лишнего. Если что поправьте в комментариях.

import io.gatling.http.Predef._
import io.gatling.core.Predef._
import io.gatling.core.structure.ChainBuilder

object ArticleCase {

  val subscribeTitle = "[\"SUBSCRIBE\\nid:173920/title/edit\\ndestination:/topic/articles/173920/title/edit\\n\\n\\u0000\"]"
  val sendTextTitle = "[\"SEND\\ndestination:/kernel/articles/173920/block/edit\\ncontent-length:170\\n\\n{\\\"blockId\\\":\\\"a5fb646f-8386-42bc-8070-1d40d135fc02\\\",\\\"currentUser\\\":\\\"80bc7774-e00a-4455-85dd-527499c5012a\\\",\\\"payload\\\":{\\\"type\\\":\\\"ROOT\\\",\\\"title\\\":\\\"WebSocket Load Testing is WORK\\\"}}\\u0000\"]"

  val updateArticleTitle: ChainBuilder = exec(
    ws("Подключение к Websocket").connect("/websocket/tracker/666/autotest/websocket"),
    pause(2),
    ws("Подписка на события заголовка").sendText(subscribeTitle),
    pause(1),
    ws("Отправка сообщения с новым заголовком").sendText(sendTextTitle),
    pause(2),
    ws("Закрытие канала Websocket").close
  )
}

Подведем итоги

На этом всё. Надеюсь кто-то найдет себе что-то новое, а кто-то сэкономит немножко времени, когда столкнётся с подобным случаем на практике.

В целом работа с ws довольно приятная и интересная, особенно в череде однотипных задач по автоматизации rest api.

Еще немного полезных ссылок, если нужно чуть глубже погрузиться:

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


  1. undex73
    11.09.2024 08:44

    В ws есть messages. Можно ли автотестами проверять статусы там?
    Например, есть плеер на фронте который общается с видеобекендом по ws. И в messages есть статусы текущей ситуации с видео (play, pause и тд).


    1. ErikNas Автор
      11.09.2024 08:44

      Вообще мы именно сообщения перехватываем автотестами. Загвоздка может быть в том, что сообщения будут бинарные и не факт, что получится легко получить информацию. Недостаточно опыта работы с видеотрактом. Статус ведь в этом случае в хэдерах передается, а не в теле сообщения?