Вступление
Всем привет. Меня зовут Ирек, и я в профессиональном 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.
Еще немного полезных ссылок, если нужно чуть глубже погрузиться: