Or press ESC to close.

Testing Video Streaming Apps Under Stress

Nov 24th 2024 10 min read
medium
performance
javascriptES6
integration
chai5.1.2

In today's world of instant communication and remote collaboration, video streaming apps play a critical role. But how can we ensure these apps remain reliable under real-world conditions, such as heavy traffic or fluctuating network connections? In this blog post, we'll explore the process of stress testing a real-time video streaming app built with Socket.io and PeerJS. From understanding the app's structure to crafting tests that simulate chaotic user behavior, we'll dive into the key steps for validating performance and identifying potential bottlenecks. Let's get started!

Explaining the Testing App

The testing app for this blog post is a real-time video streaming application built using React for the client, Socket.io for WebSocket-based communication, and PeerJS for peer-to-peer video calls. Its purpose is to establish connections between users, enabling seamless video and audio streaming. Here's how the app is structured:

1. Core Functionalities

The app enables two users to connect and stream video in real time. The flow begins when a user starts searching for a connection. The app uses Socket.io to manage communication between the client and the server, ensuring that users can find each other. Once matched, PeerJS establishes a peer-to-peer connection to handle video and audio streaming directly.

2. Client-Side Components

The React-based client consists of:

The client also handles edge cases such as:

3. Server-Side Logic

The backend, powered by Express and Socket.io, manages user matchmaking. It tracks connected users, identifies those waiting for a partner, and matches them when possible. Once two users are matched, the server exchanges their peer IDs so they can establish a direct connection via PeerJS.

Key server tasks include:

4. Environment Setup

The app uses a local development setup with the server running on localhost:3000 and the React client on localhost:3001. Environment variables, defined in a .env file, ensure flexibility in configuring server URLs and ports.

5. Why This App is Ideal for Testing

This app provides a perfect playground for stress testing real-time video streaming. Its reliance on peer-to-peer communication, WebSocket matchmaking, and real-time media streaming simulates the challenges faced by production-level applications. By testing its limits, we can uncover potential bottlenecks and ensure the app's robustness under load.

Putting the App to the Test

To evaluate the performance and reliability of the video streaming app, we designed and executed integration tests that simulate real-world usage and extreme conditions. These tests focus on connection stability, matchmaking under load, and error handling when users rapidly connect and disconnect. Here's a breakdown of the tests and how they were implemented.

Verifying Server Readiness

The first test ensures that the server is operational and ready to accept client connections before running any other tests. It serves as a foundational step to avoid debugging failures caused by server unavailability.

To implement this test, we create a single client instance using the socket.io-client library. The client attempts to connect to the server defined by the TEST_SERVER_URL. The "force new connection" option ensures that each connection is treated as independent, preventing cached connections from interfering with the test. Additionally, we specify the transports option to use WebSocket connections explicitly, ensuring stable communication with the server.

                
const client = io(TEST_SERVER_URL, {
  "force new connection": true,
  transports: ["websocket"],
});
                

Once the client instance is created, we listen for the connect event, which is emitted when the connection to the server is successfully established. If this event occurs, we can confirm that the server is up and functioning. At this point, we immediately disconnect the client using the disconnect() method, ensuring the server is free for subsequent tests. We then call the done() callback to signal that the test has passed.

                
client.on("connect", () => {
  client.disconnect();
  done();
});
                

On the other hand, if the server is unreachable or unable to establish a connection, the connect_error event is triggered. This event captures details about the connection failure, such as the error type or message. In this case, the test is marked as failed by passing the error object to the done() callback, allowing the test framework to report the issue.

                
client.on("connect_error", (error) => {
  done(error);
});
                

If the server fails to respond at this stage, it indicates an underlying issue that needs to be addressed before any additional testing is performed. This foundational step saves time and effort by verifying that the testing environment is correctly set up and ready.

Simulating Multiple Connections

The Simulating Multiple Connections test is designed to stress-test the server by simulating the behavior of multiple clients connecting, searching for peers, and disconnecting. This test ensures that the server can handle concurrent client activity without errors or inconsistencies.

We define a constant, TOTAL_CLIENTS, which determines the number of client instances to create. For this test, TOTAL_CLIENTS is set to 10. This value can be adjusted to test the server under varying loads. The results object is initialized to track test metrics, including the total number of connections, disconnections, and any errors that occur.

                
const TOTAL_CLIENTS = 10;

const results = {
  maxConcurrentWaiting: 0,
  totalConnections: 0,
  errors: [],
  disconnections: 0,
};
                

A for loop iterates TOTAL_CLIENTS times to create client instances. Each client is instantiated using the io function from the socket.io-client library. The same options as in the server readiness test - "force new connection" and transports: ["websocket"] — are used to ensure independent and reliable connections. Each client is added to the clients array for later cleanup.

                
for (let i = 0; i < TOTAL_CLIENTS; i++) {
  const client = io(TEST_SERVER_URL, {
    "force new connection": true,
    transports: ["websocket"],
  });
                
  clients.push(client);
}
                

When a client successfully connects to the server, indicated by the connect event, we increment the totalConnections counter. To simulate real-world user behavior, the client emits a startSearching event after a random delay between 0 and 500 milliseconds. This randomness prevents artificial synchronization of all clients, mimicking a more realistic scenario.

                
client.on("connect", () => {
  results.totalConnections++;
              
  setTimeout(() => {
    client.emit("startSearching");
                

After emitting the startSearching event, the client waits for another random delay, this time between 500 and 1500 milliseconds, before disconnecting from the server. This simulates users leaving the app after a short session. When the client disconnects, the disconnections counter is incremented, and the test checks if all clients have completed their connection lifecycle.

                
setTimeout(() => {
  client.disconnect();
  results.disconnections++;
          
  connectionsCompleted++;
  if (connectionsCompleted === TOTAL_CLIENTS) {
    try {
      expect(results.errors).to.be.empty;
      expect(results.totalConnections).to.equal(TOTAL_CLIENTS);
      expect(results.disconnections).to.equal(TOTAL_CLIENTS);
      done();
    } catch (error) {
      done(error);
    }
  }
}, Math.random() * 1000 + 500);
                

To ensure the test is robust, listeners are attached to each client to catch errors. If the error or connect_error events are triggered, the details are added to the results.errors array for debugging purposes.

                
client.on("error", (error) => {
  results.errors.push(error);
});
              
client.on("connect_error", (error) => {
  results.errors.push(
    `Connection error for client ${i}: ${error.message}`
  );
});
                

After all clients have connected, searched, and disconnected, the test validates the metrics. It checks that no errors occurred, the total number of connections matches TOTAL_CLIENTS, and all clients were disconnected successfully. Any discrepancy in these metrics indicates a potential issue with the server's ability to handle simultaneous connections.

                
try {
  expect(results.errors).to.be.empty;
  expect(results.totalConnections).to.equal(TOTAL_CLIENTS);
  expect(results.disconnections).to.equal(TOTAL_CLIENTS);
  done();
} catch (error) {
  done(error);
}
                
Cleanup After Each Test

The afterEach function is used to clean up the clients array. The first thing it does is check if there are any clients to clean up. If the array is empty, the done callback is invoked immediately to signify the cleanup is complete.

                
afterEach(function (done) {
  let disconnected = 0;
  const totalClients = clients.length;
                    
  if (totalClients === 0) {
    done();
    return;
  }
                

Here, disconnected is initialized to track how many clients have been successfully disconnected. The totalClients variable stores the total number of clients in the clients array. This allows the code to compare the number of disconnected clients against the total to ensure all have been handled. If there are no clients, the cleanup completes immediately by calling done().

If there are clients to handle, the code iterates over each client in the clients array. For each client, it checks whether the client is connected.

                
clients.forEach((client) => {
  if (client.connected) {
    client.on("disconnect", () => {
      disconnected++;
      if (disconnected === totalClients) {
        done();
      }
    });
    client.disconnect();
  } else {
    disconnected++;
    if (disconnected === totalClients) {
      done();
    }
  }
});
                

If the client is connected, an event listener is attached to the client's disconnect event. This ensures that when the client successfully disconnects, the disconnected counter is incremented. If the number of disconnected clients matches the total number of clients, the done callback is called, signaling that the cleanup process is complete.

For clients that are already disconnected, the code increments the disconnected counter directly. Again, if this counter matches the total number of clients, the done callback is invoked.

Finally, the after hook clears the clients array.

                
after(function () {
  clients = [];
});
                

This step resets the global clients variable, ensuring that it is empty before the next batch of tests begins. By clearing this array, the code avoids accidentally reusing stale client objects.

Together, the afterEach and after hooks ensure a robust and reliable cleanup process. The test environment is returned to a clean slate after each test, preventing state leakage and ensuring consistent behavior across all tests.

Conclusion

Stress testing a real-time video streaming app, such as the one built with Socket.io and PeerJS, is essential for ensuring its performance under varying loads and challenging conditions. By simulating multiple client connections, matchmaking, and disconnections, we can identify bottlenecks and areas for improvement, such as server capacity or error handling. The tests discussed in this blog serve as an effective way to validate the app's reliability and scalability, offering insights into how it can perform in real-world scenarios. With robust testing, we can ensure that users have a seamless and uninterrupted streaming experience, even under the most demanding circumstances.

The testing app and the complete set of tests featured in this blog post are available on our GitHub page.