Автотесты на Java для websocket на SockJS

от автора

Вступление

Всем привет. Меня зовут Ирек, и я в профессиональном 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.

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


ссылка на оригинал статьи https://habr.com/ru/articles/842406/