Como escrever testes de integração para WebSockets
WebSocket é um protocolo de comunicação que permite a troca bidirecional de dados em tempo real entre um cliente e um servidor. Ao contrário do HTTP tradicional, que segue um modelo de solicitação-resposta, o WebSocket estabelece uma conexão persistente, permitindo que os dados fluam de um lado para outro sem a sobrecarga de estabelecer conexões repetidamente para cada troca.
Esse paradigma de comunicação é comumente usado em aplicações web, jogos online, sistemas de chat e outros cenários onde a comunicação em tempo real e de baixa latência é essencial. Um teste de integração concentra-se na avaliação das interações e compatibilidade entre diferentes componentes ou módulos de um aplicativo de software. Neste contexto, testar aplicações baseadas em WebSocket pode ser um desafio por vários motivos:
- Natureza assíncrona: a comunicação WebSocket é inerentemente assíncrona, com dados sendo enviados e recebidos em tempo real. Essa assincronicidade pode dificultar a previsão da ordem dos eventos, o que é crucial nos cenários de teste.
- Tempo e sincronização: os testes de integração para aplicativos WebSocket precisam lidar com problemas de tempo. Em cenários do mundo real, as mensagens podem chegar em momentos diferentes e em sequências variadas, tornando um desafio projetar testes que levem em conta essas discrepâncias.
- Mock e stubbing: para controlar e isolar as interações WebSocket durante os testes, os desenvolvedores precisam criar servidores WebSocket simulados ou usar bibliotecas que lhes permitam fazer stub de conexões WebSocket. Essas camadas adicionais de abstração podem ser propensas a erros e aumentar a complexidade das configurações de teste.
Instalação
Neste exemplo, usaremos Vitest e Socket.io, mas outros executores de testes ou bibliotecas WebSocket podem ser usados. Os conceitos são os mesmos.
npm install -D vitest
npm install socket.io socket.io-client
Exemplo
Neste exemplo, implementaremos uma funcionalidade de multiplicação simples para mostrar como é feita a comunicação entre o cliente e o servidor. Primeiramente o cliente emite o evento “multiply-by-2”, passando o número 5 como parâmetro. No servidor, este evento é escutado, ocorre a multiplicação e é emitido o evento “multiplicado por dois”. Por último, no cliente, este evento é tratado, esperando receber 10 como saída.
Funções utilitárias
As funções utilitárias são importantes para organizar o código e garantir que tudo funcione da maneira que esperamos. Precisaremos de:
- Uma função auxiliar para manipular os eventos de soquete no servidor;
- Uma função responsável por aguardar os eventos esperados no soquete do cliente.
- Uma função para configurar o Servidor Web;
Tratamento de eventos de socket
Esta função é responsável por tratar os eventos de soquete no servidor
import { Server, Socket } from "socket.io";
export function handleSocketEvents(io: Server, socket: Socket) {
const socketsEvents: Record<string, (data: any) => void> = {
"multiply-by-2": (data) => {
const result = data * 2;
socket.emit("multiplied-by-2", result);
},
};
for (const event in socketsEvents) {
socket.on(event, socketsEvents[event]);
}
}
Neste código, temos um objeto que contém o evento a ser tratado como chave e a função manipuladora como valor. Se você tiver outros eventos, basta adicionar a este objeto. Além disso, você pode usar o “io” para outras opções de transmissão.
Esperar por evento
Esta função auxiliar é responsável por fazer o socket do cliente aguardar um evento vindo do servidor.
import { Socket as ClientSocket } from "socket.io-client";
export default function waitFor(emitter: ClientSocket, event: string) {
return new Promise<any>((resolve) => {
emitter.once(event, resolve);
});
}
Configurando o servidor web
Esta função usa as outras duas funções, inicia o servidor Web, configura o servidor de soquete e trata da conexão do soquete do cliente
import { Server, Socket } from "socket.io";
import { io as ioc } from "socket.io-client";
import { createServer } from "http";
import { handleSocketEvents } from "./handleSocketEvents";
import waitFor from "./waitForSocketEvent";
export default async function setupTestServer() {
const httpServer = createServer();
const io = new Server(httpServer);
httpServer.listen(PORT);
const clientSocket = ioc(`ws://localhost:${PORT}`, {
transports: ["websocket"],
});
let serverSocket: Socket | undefined = undefined;
io.on("connection", (connectedSocket) => {
serverSocket = connectedSocket;
handleSocketEvents(io, serverSocket);
});
await waitFor(clientSocket, "connect");
return { io, clientSocket, serverSocket };
}
Escrevendo os testes de integração
Depois de configurar seu executor de testes (Vitest neste caso), vamos escrever os testes. Primeiramente, no “beforeAll” precisamos iniciar o servidor, e no “afterAll” precisamos fechar as conexões.
import { Server, Socket } from "socket.io";
import { io as ioc } from "socket.io-client";
import waitFor from "./waitForSocketEvent";
describe("websocket integration test", () => {
let io: Server;
let serverSocket: Socket | undefined;
let clientSocket: ClientSocket;
beforeAll(async () => {
const response = await setupTestServer();
io = response.io;
clientSocket = response.clientSocket;
serverSocket = response.serverSocket;
});
afterAll(() => {
io.close();
clientSocket.close();
});
});
Por último, vamos adicionar o caso de teste:
import { Server, Socket } from "socket.io";
import { io as ioc } from "socket.io-client";
import waitFor from "./waitForSocketEvent";
describe("websocket integration test", () => {
let io: Server;
let serverSocket: Socket | undefined;
let clientSocket: ClientSocket;
beforeAll(async () => {
const response = await setupTestServer();
io = response.io;
clientSocket = response.clientSocket;
serverSocket = response.serverSocket;
});
afterAll(() => {
io.close();
clientSocket.close();
});
it("should multiply by 2", async () => {
clientSocket.emit("multiply-by-2", 5);
const promises = [
waitFor(clientSocket, "multiplied-by-2"),
];
const [response] = await Promise.all(promises);
expect(reponse).toBe(10);
});
});
Aqui await Promise.all(promises)
não é necessário, mas se você está esperando vários eventos no cliente, é uma abordagem interessante para evitar testes instáveis.
Conclusão
Obrigado por ler até aqui, tive bastante dificuldade para criar testes de integração envolvendo WebSockets, então senti que precisava compartilhar o que aprendi. Sinta-se à vontade para me perguntar qualquer dúvida;).
Edit
Criei um Pull Request com o meu exemplo, e agora ele faz parte da documentação oficial do Socket.io!