Testing Concurrency in Go
Testing concurrent programs requires careful design. Unlike sequential programs, concurrent programs may produce results in different execution orders, making some tests more complex.
Go provides strong built-in support for testing through the testing package.
In this section we demonstrate how to write tests for the concurrency exercises.
Running Tests
Run tests using the Go testing tool:
go test ./...
To detect race conditions:
go test -race ./...
The race detector identifies unsafe access to shared variables.
Test Example – Exercise 3 (Channel Communication)
Suppose we have the following function that sends numbers to a channel.
func SendNumbers(ch chan int) {
for i := 1; i <= 5; i++ {
ch <- i
}
close(ch)
}
Test
package concurrency
import "testing"
func TestSendNumbers(t *testing.T) {
ch := make(chan int)
go SendNumbers(ch)
expected := []int{1,2,3,4,5}
i := 0
for num := range ch {
if num != expected[i] {
t.Fatalf("expected %d but got %d", expected[i], num)
}
i++
}
}
This test verifies that the channel produces the expected sequence.
Test Example – Exercise 5 (Parallel Data Processing)
Function under test:
func Square(n int) int {
return n * n
}
Test
func TestSquare(t *testing.T) {
tests := []struct{
input int
expected int
}{
{1,1},
{2,4},
{3,9},
{4,16},
}
for _, tt := range tests {
result := Square(tt.input)
if result != tt.expected {
t.Fatalf("expected %d got %d", tt.expected, result)
}
}
}
This uses table-driven testing, a common Go testing pattern.
Test Example – Worker Pool
Suppose we have a worker pool function.
func Worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
results <- job * 2
}
}
Test
func TestWorkerPool(t *testing.T) {
jobs := make(chan int, 5)
results := make(chan int, 5)
for w := 1; w <= 2; w++ {
go Worker(w, jobs, results)
}
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
for i := 0; i < 5; i++ {
result := <-results
if result % 2 != 0 {
t.Fatalf("expected even number but got %d", result)
}
}
}
Since execution order is not guaranteed, the test checks correctness of results rather than ordering.
Testing Fan-Out / Fan-In Pipelines
Fan-in pipelines combine multiple producers.
Example generator:
func Generator(start int, ch chan int) {
for i := start; i < start+3; i++ {
ch <- i
}
}
Test
func TestGenerator(t *testing.T) {
ch := make(chan int)
go Generator(1, ch)
count := 0
for i := 0; i < 3; i++ {
<-ch
count++
}
if count != 3 {
t.Fatalf("expected 3 values but got %d", count)
}
}
Testing with Timeouts
Sometimes tests must ensure goroutines do not block forever.
Example:
func TestChannelTimeout(t *testing.T) {
ch := make(chan int)
select {
case ch <- 1:
t.Fatal("unexpected send")
case <-time.After(time.Second):
// expected timeout
}
}
Timeout tests help detect deadlocks.
Using the Race Detector
Race conditions occur when multiple goroutines access shared memory unsafely.
Example race-prone code:
var counter int
func increment() {
counter++
}
Test:
go test -race
The race detector will report concurrent writes.
Best Practices for Testing Concurrent Code
When testing concurrent programs:
- Avoid relying on execution order.
- Prefer channels for synchronization.
- Use WaitGroups when necessary.
- Avoid unnecessary sleep statements.
- Always run tests with race detection.
Recommended Testing Workflow
- Write the concurrent function.
- Write deterministic unit tests.
- Run tests with the race detector.
go test -race ./...
- Refactor if race conditions are detected.
Next Step
After running these tests:
- Review the exercise solutions
- Discuss Week 5 questions
- Begin implementing the mini-project
Testing concurrent systems is a critical skill for building reliable Go applications.