DEV Community

Cover image for Concurrent Testing in Go: Taming My Netcat Broadcaster and Shared State
John Paul Nyunja
John Paul Nyunja

Posted on

Concurrent Testing in Go: Taming My Netcat Broadcaster and Shared State

Welcome back to our series on taming Go unit test timeouts! In Part 1, we tackled the frustrating "panic: test timed out" error, focusing on how SetReadDeadline and channels helped us fix hanging client connections. Now, we're diving into a more complex problem: concurrent testing, specifically how to reliably test the "broadcaster" part of my Netcat-like chat application and manage shared information.

Testing code that runs in parallel can introduce tricky problems like race conditions (where different parts of your code try to change the same thing at the same time) and subtle timing issues. My broadcaster tests were a prime example of this challenge.

The Broadcaster's Challenge: Goroutines...

In my chat application, the "broadcaster" is like the central hub. Its job is to take messages and send them out to all connected clients. This means it's constantly listening for new messages and then pushing them across multiple network connections, all happening at the same time (concurrently!).

Trying to test this reliably initially led to more timeouts and unexpected failures. Why? Because the test needed to:

  1. Send a message to the broadcaster.
  2. Wait for all active clients to receive that message.
  3. Ensure the broadcaster cleaned up correctly, even if a client disconnected.

Relying on time.Sleep() here would have been a disaster – completely unreliable for coordinating multiple active parts of the system.

Solution 1: sync.WaitGroup – Orchestrating Concurrent Tasks

You might remember from Part 1 that time.Sleep() is a no-go for reliable tests. For situations where you need to wait for multiple goroutines to complete their tasks, Go offers sync.WaitGroup. It's a fantastic tool for counting how many goroutines are running and waiting for them all to finish before your test moves on.

Here's how it works:

  • wg.Add(n): You tell the WaitGroup how many goroutines you're waiting for.
  • wg.Done(): Each goroutine calls this when it's finished.
  • wg.Wait(): Your main test goroutine calls this to block until all Add calls have been matched by Done calls.

In my TestBroadcaster, I needed to ensure that after sending a message, both simulated clients (client1 and client2) actually received it. I launched two goroutines to check this, each using wg.Add(1) and then defer wg.Done():

// Use WaitGroup to ensure all checks complete
var wg sync.WaitGroup
wg.Add(2) // We're waiting for 2 client checks

// ... (code for checkClientMessage function) ...

go checkClientMessage(client1, "User1")
go checkClientMessage(client2, "User2")

// Wait for all client checks to complete
wg.Wait() // The test waits here until both checkClientMessage goroutines call wg.Done()
Enter fullscreen mode Exit fullscreen mode

This pattern guarantees that the test won't try to check assertions about received messages until after the clients have actually had a chance to read them.

Solution 2: Graceful Goroutine Shutdown with Channels

Just like we used channels to signal HandleClient completion in Part 1, it's crucial to tell long-running goroutines like the Broadcaster when to stop during tests. If they keep running after the test finishes, they can cause resource leaks or interfere with subsequent tests.

The Broadcaster typically runs in a loop, listening for messages. To shut it down cleanly, you can close its input channel (models.Broadcast in my case) and use another "done" channel to wait for it to exit:

// Start broadcaster in goroutine with done channel
broadcasterDone := make(chan bool)
go func() {
    br.Broadcaster() // Your broadcaster function
    close(broadcasterDone) // Signal that the broadcaster has finished
}()

// ... send messages to models.Broadcast ...

// Clean up
close(models.Broadcast) // This signals the broadcaster to stop its loop

// Wait for broadcaster to finish with timeout
select {
case <-broadcasterDone:
    // Good, broadcaster finished
case <-time.After(2 * time.Second): // Give it a reasonable timeout
    t.Error("Broadcaster did not finish after channel close")
}
Enter fullscreen mode Exit fullscreen mode

By closing models.Broadcast, the Broadcaster's for range loop on that channel will eventually exit, allowing the br.Broadcaster() function to complete and close(broadcasterDone) to be called. The select statement then ensures our test waits for this graceful exit, but only for a maximum of 2 seconds to avoid a new timeout.

Solution 3: Testing Failure Scenarios (Simulating Disconnections)

A robust broadcaster needs to handle clients disconnecting. My TestBroadcasterWithFailedConnection simulates this by manually closing one end of a net.Pipe() connection (server1.Close()) before sending a message. The test then verifies two things:

  1. The working client (client2) still receives the message.
  2. The failed connection (server1) is correctly removed from the models.Clients map.

This type of test ensures your concurrent logic can gracefully recover from unexpected events, like a client losing internet connection.

// Close one server connection to simulate failure
server1.Close()

// ... send message ...

// Check that failed connection was removed from clients
models.Mu.Lock()
if _, exists := models.Clients[server1]; exists {
    t.Error("Failed connection should have been removed from clients map")
}
if _, exists := models.Clients[server2]; !exists {
    t.Error("Working connection should still exist in clients map")
}
models.Mu.Unlock()
Enter fullscreen mode Exit fullscreen mode

Solution 4: Managing Shared Global State

My Netcat application uses global variables like models.Clients (a map holding all active connections and usernames) and models.Broadcast (the channel for messages). When multiple goroutines (like the Broadcaster and HandleClients) access these, you must use synchronization to prevent "race conditions" – situations where the order of operations by different goroutines is unpredictable, leading to wrong results or crashes.

Go's sync.Mutex is perfect for this. It acts like a lock: only one goroutine can hold the lock at a time. When a goroutine needs to read from or write to a shared variable, it first Lock()s the mutex, performs its operation, and then Unlock()s it.

In tests, it's also absolutely critical to reset all global shared state before each test run. This ensures that every test starts with a clean slate and isn't affected by what a previous test did.

// Setup - at the beginning of each test
models.Mu.Lock() // models.Mu is a sync.Mutex
models.Clients = make(map[net.Conn]string) // Re-initialize the map
models.Broadcast = make(chan string, 10) // Re-initialize the channel
models.Mu.Unlock()
Enter fullscreen mode Exit fullscreen mode

This disciplined approach to managing shared state is non-negotiable for robust concurrent applications and their tests.

Case Study: Fixing My Broadcaster Tests

Let's look at the specific improvements in TestBroadcaster and TestBroadcasterWithFailedConnection:

broadcaster function

test a

test b

  • TestBroadcaster: The key fix here was replacing time.Sleep() with sync.WaitGroup to confidently wait for both client connections to receive the broadcast message. We also introduced the broadcasterDone channel to ensure the Broadcaster goroutine exited cleanly after we closed models.Broadcast. Read deadlines were also properly applied to client reads.

  • TestBroadcasterWithFailedConnection: Similar to the above, sync.WaitGroup ensured the test waited for the working client to receive its message. Crucially, the test now correctly verifies that the disconnected client is removed from the models.Clients map, ensuring the broadcaster's cleanup logic is sound.

These changes transformed the broadcaster tests from being a source of constant frustration into reliable checks for concurrent behavior.

Key Takeaways for Testing Go Concurrency

  • sync.WaitGroup is Your Friend: For synchronizing multiple goroutines and waiting for a set of tasks to complete, WaitGroup is vastly superior to time.Sleep().
  • Graceful Goroutine Shutdown: Always provide a mechanism (like closing a channel and using a "done" channel) for your long-running goroutines to exit cleanly during tests.
  • Test Failure Paths: Don't just test the happy path! Simulate disconnections, errors, and other adverse conditions to ensure your concurrent code handles them robustly.
  • Guard Shared State: Use sync.Mutex (or sync.RWMutex) for all access to shared variables across goroutines. And always reset global state before each test.
  • Increase Test Timeouts: While we avoid time.Sleep, ensure your SetReadDeadline and time.After values are generous enough for actual network or goroutine operations to complete, especially in CI environments. My tests often increased these to 2 seconds.

By applying these principles, you can build Go applications with confidence, knowing that your concurrent logic is thoroughly tested and reliable.

program files structure

In the final part of this series, we'll delve into testing more complex I/O patterns, file handling, and ensuring your server initializes correctly, using examples from the utils_test.go and server_test.go files. Stay tuned!

Top comments (0)