I ran into this question for the same problem.
After reading your solutions, including the links, I came up with this one.
First, I didn't want to install no additional package, and I have different instances in different screens, so I created a reusable utility function that returns the mocked socket object.
UTILITY FUNCTION:
export interface SocketMockedInstance {
connect: jest.Mock<any, any, any>;
disconnect: jest.Mock<any, any, any>;
emit: jest.Mock<any, any, any>;
on: jest.Mock<void, [event: string, handler: (...args: any[]) => void], any>;
triggerEvent: (event: string, ...args: any[]) => void;
_reset: () => void;
}
export const createSocketMock = () => {
const eventHandlers: Record<string, ((...args: any[]) => void)[]> = {};
const socketMock = {
connect: jest.fn(),
disconnect: jest.fn(),
emit: jest.fn(),
on: jest.fn((event: string, handler: (...args: any[]) => void) => {
if (!eventHandlers[event]) {
eventHandlers[event] = [];
}
eventHandlers[event].push(handler);
}),
// Helper method to trigger events in tests
triggerEvent: (event: string, ...args: any[]) => {
if (eventHandlers[event]) {
eventHandlers[event].forEach((handler) => handler(...args));
}
},
// Helper to clear handlers between tests
_reset: () => {
Object.keys(eventHandlers).forEach((key) => delete eventHandlers[key]);
socketMock.connect.mockClear();
socketMock.disconnect.mockClear();
socketMock.emit.mockClear();
socketMock.on.mockClear();
},
};
// Setup the mock
jest.mock("socket.io-client", () => ({
__esModule: true,
io: () => socketMock,
}));
return socketMock;
};
Then, in my case, the connection is subscribed to a component, and the connection lives through the component life cycle, rather than being connected or disconnected by an user event. The suites come like this: (I added comments to explain each section on the code and keep this post from being longer):
import { useCustomHook } from "../[YOUR-FILE-PATH]";
import { AllWrappers } from "#/test.utils";
import { act, renderHook, waitFor } from "@testing-library/react";
import { createSocketMock } from "#/mocked-socket.utils";
describe("useCustomHook", () => {
let socketMock: any;
// No beforeEach because we want the connection to live throughout the hook lifecycle.
beforeAll(() => {
socketMock = createSocketMock();
});
// Clear the emit to clean up past states on event triggering.
beforeEach(() => {
socketMock.emit.mockClear();
});
// Clean up function after all for coherence with the first statement.
afterAll(() => {
socketMock._reset();
});
it("Connection to sockets is established on component mount", async () => {
renderHook(() => useCustomHook(), { wrapper: AllWrappers });
// The connection is established on mount.
socketMock.on("connect", () => {
expect(socketMock.connect).toHaveBeenCalled();
});
});
it("Message on socket is emitted", async () => {
renderHook(() => useCustomHook(), { wrapper: AllWrappers });
// The timer helps the hook to be mounted and the connection to be re-established. This is due to the asynchronous nature of the socket connection.
await act(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
});
// Ping-pong mocked event to trigger the message emit.
await act(async () => {
socketMock.triggerEvent("[NAME_OF_EVENT]", { message: "Mocked Socket connected" });
await waitFor(() => {
expect(socketMock.emit).toHaveBeenCalledWith("[NAME_OF_EVENT]", { message: "Mocked Socket connected" });
});
});
// After the message has been emitted, we persist the connection.
expect(socketMock.on).toHaveBeenCalledWith("connect", expect.any(Function));
});
});
It worked for me.
The only case missing is when the component unmounts and the connection is interrupted, but based on these cases, we can figure out how to do it.