Skip to main content

Command Palette

Search for a command to run...

Testing a component that uses WebSockets, without a real server

Updated
4 min readView as Markdown
T
3 years of experience building production SaaS platforms, enterprise web apps, and cross-platform mobile apps using React.js, Next.js, and TypeScript.

A component that talks to a REST API is easy to test. You mock the HTTP call, resolve it with fixture data, assert on the render. A component that talks to a WebSocket is a different animal, and most people's first attempt is to spin up a real WebSocket server in a test setup file, which is slow, flaky in CI, and tests your server as much as your component.

Here's how to test WebSocket-driven UI without a real server anywhere in the loop.

The trap: testing through a real connection

A WebSocket isn't a request-response call you can intercept the way you'd intercept fetch. It's a long-lived connection, and your component probably reacts to messages arriving over time, connection drops, reconnect attempts, and a readyState that changes independently of anything your test code does. If your test opens a real socket to a real (even local) server, you've coupled the test to timing, port availability, and server startup, and every flaky CI run traces back to one of those.

The fix: replace the WebSocket constructor itself

The trick is to mock window.WebSocket at the constructor level, not the network layer underneath it. Your component doesn't care what protocol carried the bytes, it only cares about the object it got back from new WebSocket(url) and the events that object fires: open, message, close, error.

class MockWebSocket {
  static instances = []

  constructor(url) {
    this.url = url
    this.readyState = 0 // CONNECTING
    this.listeners = {}
    MockWebSocket.instances.push(this)
  }

  addEventListener(type, cb) {
    this.listeners[type] ??= []
    this.listeners[type].push(cb)
  }

  send(data) {
    this.lastSent = data
  }

  close() {
    this.readyState = 3 // CLOSED
    this._emit('close', {})
  }

  _open() {
    this.readyState = 1 // OPEN
    this._emit('open', {})
  }

  _receive(data) {
    this._emit('message', { data: JSON.stringify(data) })
  }

  _emit(type, event) {
    (this.listeners[type] || []).forEach((cb) => cb(event))
  }
}

Swap it in before your component mounts:

beforeEach(() => {
  MockWebSocket.instances = []
  global.WebSocket = MockWebSocket
})

Now every new WebSocket(...) your component code calls creates a MockWebSocket instead, and your test controls exactly when it opens, what messages arrive, and when it closes, no network involved.

Writing the actual test

test('renders a live price update when a message arrives', async () => {
  render(<PriceTicker symbol="BTC" />)

  const socket = MockWebSocket.instances[0]
  socket._open()

  socket._receive({ symbol: 'BTC', price: 61234.5 })

  expect(await screen.findByText('$61,234.50')).toBeInTheDocument()
})

This reads almost like a script: connect, push a message, assert. No waitFor fighting a real network round trip, no server process to start and tear down, no port collisions when tests run in parallel.

Where this actually pays off: reconnect logic

The real value shows up once you test the unhappy paths that are painful to trigger against a real server on purpose:

test('shows a reconnecting indicator after an unexpected close', () => {
  render(<PriceTicker symbol="BTC" />)

  const socket = MockWebSocket.instances[0]
  socket._open()
  socket.close()

  expect(screen.getByText(/reconnecting/i)).toBeInTheDocument()
})

Try reproducing the server dropping the connection mid-session against a real server reliably, on demand, in CI. With the constructor mocked, it's one line.

When you'd reach for a real socket instead

This approach is for component and unit tests, where you're testing your app's reaction to socket events. If you need to test the actual wire protocol, real reconnection timing against network conditions, or server-side broadcast logic, that's integration or E2E territory, and tools like Cypress with a real test server, or a library like mock-socket that implements more of the real WebSocket surface, are the better fit there. Don't use a constructor mock to avoid writing the E2E test your reconnect logic actually needs eventually, use it to make the other ninety percent of your component tests fast and deterministic.

What's the messiest WebSocket edge case you've had to reproduce in tests, reconnect storms, out of order messages, something else. Tell me in the comments.