jaypee
jaypee

Reputation: 2147

How to mock socket.io-client using jest/react-testing-library

I am building a chat app and would like to write integration tests using react-testing-library and can not figure out how to mock socket.io-client's socket.on, socket.emit, etc.

I tried follow this article and tried using mock-socket.io and socket.io-mock all with no luck.

This is the component I am trying to test:

import React, { useEffect, useState } from 'react';
import io from 'socket.io-client';
import 'dotenv/config';
import ChatInput from './ChatInput';
import Messages from './Messages';

function App() {
  const [messages, setMessages] = useState([]);

  const port = process.env.REACT_APP_SERVER_PORT;
  const socket = io(`http://localhost:${port}`);

  useEffect(() => {
    socket
      .emit('app:load', messageData => {
        setMessages(messages => [...messages, ...messageData]);
      })
      .on('message:new', newMessage => {
        setMessages(messages => [...messages, newMessage]);
      });
  }, []);

  const postMessage = input => {
    socket.emit('message:post', input);
  };

  return (
    <div className="App">
      <Messages messages={messages} />
      <ChatInput postMessage={postMessage} />
    </div>
  );
}

export default App;

Upvotes: 5

Views: 11681

Answers (5)

Kz_3
Kz_3

Reputation: 1287

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.

Upvotes: 0

Ty Riviere
Ty Riviere

Reputation: 1

I was not able to find any luck with socket.io-mock. My assumption is that it might have become deprecated with some newer version of socket.io, having been last updated 3 years ago.

I found much more success following this article:

https://www.bomberbot.com/testing/testing-socket-io-client-apps-a-comprehensive-guide/

Mock Setup

They define the socket.io-client mock in a jest.setup.js file. This is how I got it to work, however I am not 100% sure that putting the mock in the jest.setup.js is required.

jest.setup.js:

jest.mock('socket.io-client', () => {
    const emit = jest.fn();
    const on = jest.fn();
    const off = jest.fn();
    const socket = { emit, on, off };
    return {
        io: jest.fn(() => socket),
    }
});

jest.config.js:

module.exports = {
  ...
  setupFilesAfterEnv: [‘<rootDir>/jest-setup.js‘],
  ...
};

Test Incoming Messages

MyComponent.test.tsx:

import { render, screen, waitFor } from ‘@testing-library/react‘;
import { io as mockIo } from ‘socket.io-client‘;
import MyComponent from ‘./MyComponent‘;

describe(‘MyComponent‘, () => {
  it(‘should render incoming messages‘, async () => {
    render(<MyComponent />);

    // wait for io() to be called: client initialized
    await waitFor(() => {
      expect(mockIo).toHaveBeenCalled();
    });

    const socket = mockIo.mock.results[0].value;

    // mock an incoming message and add it to the queue
    act(() => {
      socket.on.mock.calls.find(([event]) => event === ‘message‘)?.[1]({
        id: ‘1‘,
        text: ‘Hello world!‘,  
        sender: ‘Alice‘,
        timestamp: Date.now(),
      });  
    });

    // test that message content is displayed
    expect(screen.getByText(‘Hello world!‘)).toBeInTheDocument();
    expect(screen.getByText(‘Alice‘)).toBeInTheDocument();
  });
});

Test Outgoing Messages

MyComponent.test.tsx ...continued:

...

it(‘should send messages and display delivery status‘, async () => {
  render(<MyComponent />);

  const messageInput = screen.getByPlaceholderText(‘Enter message‘);
  const sendButton = screen.getByRole(‘button‘, { name: /send/i });

  userEvent.type(messageInput, ‘Hello!‘);
  userEvent.click(sendButton);

  expect(screen.getByText(‘Hello!‘)).toBeInTheDocument();

  expect(screen.getByLabelText(‘Sending‘)).toBeInTheDocument();

  // look for calls to socket.emit() and test call arguments
  const socket = mockIo.mock.results[0].value;
  const emitCalls = socket.emit.mock.calls;  
  const [topic, msg] = emitCalls[0];

  expect(topic).toEqual("message");
  expect(msg).toEqual(expect.objectContaining({ 
    text: ‘Hello!‘ 
  }));

  // mock a server response message to confirm that the initial message was delivered
  act(() => {
    socket.on.mock.calls.find(([event]) => event === ‘message-delivered‘)?.[1]({
      id: ‘2‘,  
    });
  });

  await waitFor(() => {
    expect(screen.queryByLabelText(‘Sending‘)).not.toBeInTheDocument();
  });

  expect(screen.getByLabelText(‘Delivered‘)).toBeInTheDocument();
});
...

Upvotes: 0

cup
cup

Reputation: 1

I have been trying to implement Jest mocks for socket-io.client and followed @Ty Riviere way of doing it. But the line import {io as mockIo} from 'socket-io.client was not being treated as mocks by Jest, even though the jest.setup.js and jest.config.js was set up as mentioned.

Manually Mocking Socket-io.client

something.test.tsx:

...

let mockIo: jest.Mock;

jest.mock(`socket.io-client`, () => {
    const emit = jest.fn();
    const on = jest.fn();
    const off = jest.fn();
    const socket = {emit, on, off};
    mockIo = jest.fn(() => socket);
    return {io: mockIo};
})

... // can reference mockIo later on for instance, mockIo.mock.results[0]

Upvotes: 0

peresleguine
peresleguine

Reputation: 2403

I also tried a couple of 3rd party libraries which were outdated or didn't work. Then I realized that mocking client interface using jest tools isn't that hard.

The following code snippet describes the idea and should work for your case. Or it might provide some clue for others looking for ways to mock socket.io client.

The trick is to memoize on handlers on mount and later trigger them per test case.

import { io } from 'socket.io-client';

const eventHandlers = {};
const mockSocketConnect = jest.fn();
const mockSocketDisconnect = jest.fn();
const mockSocketEmit = jest.fn();

jest.mock('socket.io-client', () => ({
  __esModule: true,
  io: () => ({
    connect: mockSocketConnect,
    disconnect: mockSocketDisconnect,
    emit: mockSocketEmit,
    on: (event, handler) => {
      eventHandlers[event] = handler;
    },
  }),
}));

const triggerSocketEvent = (event, data) => {
  if (eventHandlers[event]) {
    eventHandlers[event](data);
  }
};

describe('App', () => {
  it('should set messages on new message event', () => {
    // render App component
    triggerSocketEvent('message:new', 'Hello World');
    // expect messages to be updated
  });

  it('should emit message post event', () => {
    // render App component
    // emulate message input change
    expect(io().emit).toHaveBeenCalledWith('message:post', 'Hello World');
  });
});

Upvotes: 0

Vicky Gonsalves
Vicky Gonsalves

Reputation: 11717

This is a late answer but maybe useful for others:

to mock socket.io-client library I used jest mock function and used a third party library socket.io-mock https://www.npmjs.com/package/socket.io-mock

You need to modify your connection function as follows in order to work with mocked socket:

const url= process.env.NODE_ENV==='test'?'':`http://localhost:${port}`;
const socket = io(url);

Implementation:

import socketIOClient from 'socket.io-client';
import MockedSocket from 'socket.io-mock';

jest.mock('socket.io-client');

describe('Testing connection', () => {
  let socket;

  beforeEach(() => {
    socket = new MockedSocket();
    socketIOClient.mockReturnValue(socket);
  });

  afterEach(() => {
    jest.restoreAllMocks();
  });

  it('should dispatch connect event', () => {
    /*socket should connect in App and 
    Note that the url should be dummy string 
    for test environment e.g.(const socket = io('', options);)*/
    const wrapper = (
      <Provider store={store}>
        <App />
      </Provider>
    );

    expect(socketIOClient.connect).toHaveBeenCalled();
  });

  it('should emit message:new', done  => {
    const wrapper = (
      <Provider store={store}>
        <App />
      </Provider>
    );
    ...
    socket.on('message:new', (data)=>{
        expect(data).toEqual(['message1', 'message2']);
        done();
    });

    socket.socketClient.emit('message:new', ['message1', 'message2']);
    ...
  });
});

Upvotes: 11

Related Questions