Upgrade to Pro — share decks privately, control downloads, hide ads and more …

(Sync)testing Concurrent Code with Confidence

(Sync)testing Concurrent Code with Confidence

Testing concurrent code has always been a balance between tests being either hacky, flaky or slow. While the testing/synctest package made it possible for us to more effectively write idiomatic, easy-to-read tests for our concurrent programs that are both reliable and fast, it also came with a set of constraints.

In this talk, we’ll look at exactly what synctest can and cannot do, the underlying reasons behind its limitations, and how to avoid them. Finally, using a real-world example, we’ll see how proper use can push us towards robust system design we can feel confident about. In short, you’ll know precisely what to do to make the most of synctest, and why that will make your application code better.

Avatar for Alexander Baygeldin

Alexander Baygeldin

August 05, 2026

More Decks by Alexander Baygeldin

Other Decks in Programming

Transcript

  1. “Non-deterministic tests have two problems, rstly they are useless, secondly

    they are a virulent infection that can completely ruin your entire test suite.” fi — Martin Fowler
  2. func Greet(name string) string { greetings := []string{"Hello", "Hi"} idx

    := rand.Intn(len(greetings)) // Nondeterministic! return fmt.Sprintf("%s, %s!", greetings[idx], name) } func TestGreet(t *testing.T) { // Flaky! want := "Hi, Bob!" if got := Greet("Bob"); got != want { t.Fatalf("got %q, want %q", got, want) } } Why is my test nondeterministic? 1. Random number generation.
  3. func Greet(name string) string { if time.Now().Hour() < 12 {

    // Nondeterministic! return "Good morning, " + name + "!" } return "Good afternoon, " + name + "!" } func TestGreet(t *testing.T) { // Flaky! want := "Good morning, Alice!" if got := Greet("Alice"); got != want { t.Fatalf("got %q, want %q", got, want) } } Why is my test nondeterministic? 1. Random number generation. 2. Unpredictable outside world.
  4. func Greet(people map[string]struct{}) string { keys := []string{} for key

    := range people { // Nondeterministic! keys = append(keys, key) } return "Hi, " + strings.Join(keys, " & ") + "!" } func TestGreet(t *testing.T) { // Flaky! people := map[string]struct{}{ "Alice": {}, "Bob": {}, } want := "Hi, Alice & Bob!" fi if got := Greet(people); got != want { t.Fatalf("got %q, want %q", got, want) } } Why is my test nondeterministic? 1. Random number generation. 2. Unpredictable outside world. 3. Unspeci ed language behavior.
  5. “A programmer had a problem. He thought to himself, "I

    know, I'll solve it with threads!". has Now problems. two he” — Lao Tzu, probably
  6. func BatchGreeter(batchSize int) ( < chan string, func(int), ) {

    out := make(chan string) names := make(chan string) go func() { batch := make([]string, 0, batchSize) for name := range names { batch = append(batch, name) if len(batch) == batchSize { out < "Hi, " + strings.Join(batch, " & ") + "!" batch = batch[:0] } } }() - - fi - } return out, func(userID int) { go func() { names < fetchUserName(userID) }() } Why is my test nondeterministic? 1. Random number generation. 2. Unpredictable outside world. 3. Unspeci ed language behavior. 4. Unreliable order of execution.
  7. func BatchGreeter(batchSize int) ( < chan string, func(int), ) {

    out := make(chan string) names := make(chan string) go func() { batch := make([]string, 0, batchSize) for name := range names { batch = append(batch, name) if len(batch) == batchSize { out < "Hi, " + strings.Join(batch, " & ") + "!" batch = batch[:0] } } }() - - fi - } return out, func(userID int) { go func() { names < fetchUserName(userID) }() } Why is my test nondeterministic? 1. Random number generation. 2. Unpredictable outside world. 3. Unspeci ed language behavior. 4. Unreliable order of execution.
  8. func BatchGreeter(batchSize int) ( < chan string, func(int), ) {

    out := make(chan string) names := make(chan string) go func() { batch := make([]string, 0, batchSize) for name := range names { batch = append(batch, name) if len(batch) == batchSize { out < "Hi, " + strings.Join(batch, " & ") + "!" batch = batch[:0] } } }() return out, func(userID int) { go func() { names < fetchUserName(userID) }() - - fi - } } Why is my test nondeterministic? 1. Random number generation. 2. Unpredictable outside world. 3. Unspeci ed language behavior. 4. Unreliable order of execution.
  9. func BatchGreeter(batchSize int) ( < chan string, func(int), ) {

    out := make(chan string) names := make(chan string) go func() { batch := make([]string, 0, batchSize) for name := range names { batch = append(batch, name) if len(batch) == batchSize { out < "Hi, " + strings.Join(batch, " & ") + "!" batch = batch[:0] } } }() return out, func(userID int) { go func() { names < fetchUserName(userID) }() - - - - } } func TestBatchGreeter(t *testing.T) { // Flaky! greetings, greet := BatchGreeter(2) greet(1) // ID=1 is Alice greet(2) // ID=2 is Bob want := "Hi, Alice & Bob!" } if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) }
  10. flaky tests func TestBatchGreeter(t *testing.T) { greetings, greet := BatchGreeter(2)

    greet(1) // ID=1 is Alice time.Sleep(100 * time.Millisecond) greet(2) // ID=2 is Bob want := "Hi, Alice & Bob!" - time.Sleep if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) } }
  11. flaky tests func TestBatchGreeter(t *testing.T) { greetings, greet := BatchGreeter(2)

    greet(1) // ID=1 is Alice time.Sleep(100 * time.Millisecond) greet(2) // ID=2 is Bob want := "Hi, Alice & Bob!" time.Sleep if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) } } - eww! an ugly hack!
  12. func BatchGreeter(batchSize int) ( < chan string, func(int) < chan

    struct{}, ) { out := make(chan string) names := make(chan string) // ... return out, func(userID int) < chan struct{} { done := make(chan struct{}) go func() { defer close(done) names < }() - - - return done - - - - } } fetchUserName(userID) func TestBatchGreeter(t *testing.T) { greetings, greet := BatchGreeter(2) go func() { < greet(1) // ID=1 is Alice < greet(2) // ID=2 is Bob }() want := "Hi, Alice & Bob!" if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) } }
  13. func BatchGreeter(batchSize int) ( < chan string, func(int) < chan

    struct{}, ) { out := make(chan string) names := make(chan string) // ... return out, func(userID int) < chan struct{} { done := make(chan struct{}) go func() { defer close(done) names < fetchUserName(userID) }() - - - - - - } } return done func TestBatchGreeterDoesNotGreet( t *testing.T, ) { greetings, greet := BatchGreeter(2) < greet(1) // ID=1 is Alice select { case got := < greetings: t.Fatalf("unexpected greeting: %q", got) default: } }
  14. func BatchGreeter(batchSize int) ( < chan string, func(int) < chan

    struct{}, ) { out := make(chan string) names := make(chan string) // ... return out, func(userID int) < chan struct{} { done := make(chan struct{}) go func() { defer close(done) names < fetchUserName(userID) }() < greet(1) // ID=1 is Alice select { case got := < greetings: t.Fatalf("unexpected greeting: %q", got) default: } } how do you know that something did not happen? - - - - - - } } return done func TestBatchGreeterDoesNotGreet( t *testing.T, ) { greetings, greet := BatchGreeter(2)
  15. func BatchGreeter(batchSize int) ( < chan string, func(int) < chan

    struct{}, ) { out := make(chan string) names := make(chan string) // ... go func() { defer close(done) names < fetchUserName(userID) }() - - - return done - - - < greet(1) // ID=1 is Alice time.Sleep(100 * time.Millisecond) return out, func(userID int) < chan struct{} { done := make(chan struct{}) } } func TestBatchGreeterNotGreet(t *testing.T) { greetings, greet := BatchGreeter(2) select { case got := < greetings: t.Fatalf("unexpected greeting: %q", got) default: } }
  16. func BatchGreeter(batchSize int) ( < chan string, func(int) < chan

    struct{}, ) { out := make(chan string) names := make(chan string) // ... return out, func(userID int) < chan struct{} { done := make(chan struct{}) go func() { defer close(done) names < fetchUserName(userID) }() - - - - - - - } } return done func TestBatchGreeterNotGreet(t *testing.T) { greetings, greet := BatchGreeter(2) < greet(1) // ID=1 is Alice select { case got := < greetings: t.Fatalf("unexpected greeting: %q", got) case < time.After(100 * time.Millisecond): } }
  17. type request struct { name string done chan struct{} }

    func BatchGreeter(batchSize int) ( < chan string, func(int) < chan struct{}, ) { out := make(chan string) names := make(chan request) go func() { batch := make([]string, 0, batchSize) for request := range names { batch = append(batch, request.name) if len(batch) == batchSize { out < "Hi, " + strings.Join(batch, " & ") + "!" batch = batch[:0] } close(request.done) } }() return out, func(userID int) < chan struct{} { done := make(chan struct{}) go func() { names < request{ name: fetchUserName(userID), done: done, } }() - - - } - - - - } return done func TestBatchGreeterNotGreet(t *testing.T) { greetings, greet := BatchGreeter(2) < greet(1) // ID=1 is Alice select { case got := < greetings: t.Fatalf("unexpected greeting: %q", got) default: } }
  18. type request struct { name string done chan struct{} }

    func BatchGreeter(batchSize int) ( < chan string, func(int) < chan struct{}, ) { out := make(chan string) names := make(chan request) go func() { batch := make([]string, 0, batchSize) for request := range names { batch = append(batch, request.name) if len(batch) == batchSize { out < "Hi, " + strings.Join(batch, " & ") + "!" batch = batch[:0] } close(request.done) } }() return out, func(userID int) < chan struct{} { done := make(chan struct{}) go func() { names < request{ name: fetchUserName(userID), done: done, } }() - - - } - - - - } return done func TestBatchGreeterNotGreet(t *testing.T) { greetings, greet := BatchGreeter(2) < greet(1) // ID=1 is Alice select { case got := < greetings: t.Fatalf("unexpected greeting: %q", got) default: } } how do you know it actually works?
  19. type request struct { name string done chan struct{} }

    func BatchGreeter(batchSize int) ( < chan string, func(int) < chan struct{}, ) { out := make(chan string) names := make(chan request) go func() { batch := make([]string, 0, batchSize) for request := range names { batch = append(batch, request.name) if len(batch) == batchSize { out < "Hi, " + strings.Join(batch, " & ") + "!" batch = batch[:0] } close(request.done) } }() return out, func(userID int) < chan struct{} { done := make(chan struct{}) go func() { names < request{ name: fetchUserName(userID), done: done, } }() - - - - - - - } } - return done func TestBatchGreeterNotGreet(t *testing.T) { greetings, greet := BatchGreeter(2) select { case < greet(1): // ID=1 is Alice case < time.After(time.Second): t.Fatal("timed out waiting for Alice") } select { case got := < greetings: t.Fatalf("unexpected greeting: %q", got) default: } }
  20. type request struct { name string done chan struct{} }

    func BatchGreeter(batchSize int) ( < chan string, func(int), ) { out := make(chan string) names := make(chan string) func BatchGreeter(batchSize int) ( < chan string, func(int) < chan struct{}, ) { out := make(chan string) names := make(chan request) go func() { batch := make([]string, 0, batchSize) go func() { batch := make([]string, 0, batchSize) }() for request := range names { batch = append(batch, request.name) if len(batch) == batchSize { out < "Hi, " + strings.Join(batch, " & ") + "!" batch = batch[:0] } for name := range names { batch = append(batch, name) if len(batch) == batchSize { out < "Hi, " + strings.Join(batch, " & ") + "!" batch = batch[:0] } } }() return out, func(userID int) { go func() { names < fetchUserName(userID) }() } } return out, func(userID int) < chan struct{} { done := make(chan struct{}) go func() { names < request{ name: fetchUserName(userID), done: done, } }() - - - - - - - } - } close(request.done) } return done
  21. type request struct { name string done chan struct{} }

    func BatchGreeter(batchSize int) ( < chan string, func(int) < chan struct{}, ) { out := make(chan string) names := make(chan request) func BatchGreeter(batchSize int) ( < chan string, func(int), ) { out := make(chan string) names := make(chan string) go func() { batch := make([]string, 0, batchSize) }() for name := range names { batch = append(batch, name) if len(batch) == batchSize { out < "Hi, " + strings.Join(batch, " & ") + "!" batch = batch[:0] } } go func() { batch := make([]string, 0, batchSize) was it worth it? for request := range names { batch = append(batch, request.name) if len(batch) == batchSize { out < "Hi, " + strings.Join(batch, " & ") + "!" batch = batch[:0] } 🤔🤔🤔 }() return out, func(userID int) { go func() { names < fetchUserName(userID) }() } } return out, func(userID int) < chan struct{} { done := make(chan struct{}) go func() { names < request{ name: fetchUserName(userID), done: done, } }() - - - - - - - } - } close(request.done) } return done
  22. Quiesce nce [ k w i ˈ ɛ s ə

    n s ] n o u n The state of being at rest, quiet, still, inactive or motionless.
  23. func TestBatchGreeter(t *testing.T) { greetings, greet := BatchGreeter(2) func TestBatchGreeter(t

    *testing.T) { greetings, greet := BatchGreeter(2) greet(1) // ID=1 is Alice greet(1) // ID=1 is Alice time.Sleep(100 * time.Millisecond) synctest.Wait() want := "Hi, Alice & Bob!" want := "Hi, Alice & Bob!" if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) } } if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) } } - - greet(2) // ID=2 is Bob greet(2) // ID=2 is Bob
  24. func TestBatchGreeter(t *testing.T) { greetings, greet := BatchGreeter(2) greet(1) //

    ID=1 is Alice synctest.Wait() greet(2) // ID=2 is Bob want := "Hi, Alice & Bob!" - if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) } }
  25. func TestBatchGreeter(t *testing.T) { greetings, greet := BatchGreeter(2) greet(1) //

    ID=1 is Alice synctest.Wait() greet(2) // ID=2 is Bob want := "Hi, Alice & Bob!" - if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) } }
  26. func TestBatchGreeter(t *testing.T) { synctest.Test(t, func(t *testing.T) { greetings, greet

    := BatchGreeter(2) greet(1) // ID=1 is Alice synctest.Wait() greet(2) // ID=2 is Bob want := "Hi, Alice & Bob!" if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) } }) - }
  27. func TestBatchGreeter(t *testing.T) { synctest.Test(t, func(t *testing.T) { greetings, greet

    := BatchGreeter(2) greet(1) // ID=1 is Alice synctest.Wait() greet(2) // ID=2 is Bob want := "Hi, Alice & Bob!" if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) } }) - }
  28. Quiesce nce* * s y n c t e s

    t s t y l e The state when all goroutines in the bubble are durably blocked.
  29. Durably blocking operations • time.Sleep • sync.WaitGroup.Wait • sync.Cond.Wait •

    send/receive to/from a bubbled channel • send/receive to/from a nil channel • select{} • select where all cases are durably blocked synctest.Wait() returns only when all goroutines are blocked in one of these ways
  30. Durably blocking operations • time.Sleep • sync.WaitGroup.Wait • sync.Cond.Wait •

    send/receive to/from a bubbled channel • send/receive to/from a nil channel • select{} • select where all cases are durably blocked synctest.Wait() returns only when all goroutines are blocked in one of these ways
  31. Durably blocking operations • time.Sleep • sync.WaitGroup.Wait • sync.Cond.Wait •

    send/receive to/from a bubbled channel • send/receive to/from a nil channel • select{} • select where all cases are durably blocked synctest.Wait() returns only when all goroutines are blocked in one of these ways
  32. Durably blocking operations • time.Sleep • sync.WaitGroup.Wait • sync.Cond.Wait •

    send/receive to/from a bubbled channel • send/receive to/from a nil channel • select{} • select where all cases are durably blocked synctest.Wait() returns only when all goroutines are blocked in one of these ways
  33. Durably blocking operations • time.Sleep • sync.WaitGroup.Wait • sync.Cond.Wait •

    send/receive to/from a bubbled channel • send/receive to/from a nil channel • select{} • select where all cases are durably blocked synctest.Wait() returns only when all goroutines are blocked in one of these ways
  34. func Test(t *testing.T, f func(*testing.T)) { bubble := &synctestBubble{ done:

    false, // Whether the main goroutine has exited waiting: false, // Whether a synctest.Wait call is pending running: 1, // Number of goroutines not durably blocked } currentGoroutine.bubble = bubble defer func() { currentGoroutine.bubble = nil }() go f(t) // Start the main goroutine! // ... }
  35. func Test(t *testing.T, f func(*testing.T)) { bubble := &synctestBubble{ done:

    false, // Whether the main goroutine has exited waiting: false, // Whether a synctest.Wait call is pending running: 1, // Number of goroutines not durably blocked } currentGoroutine.bubble = bubble defer func() { currentGoroutine.bubble = nil }() go f(t) // Start the main goroutine! // ... }
  36. func Test(t *testing.T, f func(*testing.T)) { bubble := &synctestBubble{ done:

    false, // Whether the main goroutine has exited waiting: false, // Whether a synctest.Wait call is pending running: 1, // Number of goroutines not durably blocked } currentGoroutine.bubble = bubble defer func() { currentGoroutine.bubble = nil }() go f(t) // Start the main goroutine! // ... }
  37. When a durable operation happens, Go runtime updates the bubble!

    func Test(t *testing.T, f func(*testing.T)) { bubble := &synctestBubble{ done: false, // Whether the main goroutine has exited waiting: false, // Whether a synctest.Wait call is pending running: 1, // Number of goroutines not durably blocked } currentGoroutine.bubble = bubble defer func() { currentGoroutine.bubble = nil }() go f(t) // Start the main goroutine! // ... }
  38. Non-durably blocking operations • syscalls, cgo calls, anything that isn’t

    Go fi • I/O ( les, pipes, network connections, etc.)
  39. Proceed with caution ⚠ Using the network. ⚠ Interacting with

    external processes. func BatchGreeter(batchSize int) ( < chan string, func(int), ) { // ... return out, func(userID int) { go func() { - - } } names < }() fetchUserName(userID)
  40. Proceed with caution ⚠ Using the network. ⚠ Interacting with

    external processes. func BatchGreeter(batchSize int) ( < chan string, func(int), ) { // ... return out, func(userID int) { go func() { } } names < }() fetchUserName(userID) - - just make sure this returns
  41. func GreetServer(conn net.Conn) error { name, err := bufio.NewReader(conn).ReadString('!') if

    err != nil { return err } } _, err = fmt.Fprintf(conn, "Hi, %s!", name[:len(name)-1]) return err func GreetClient(conn net.Conn, name string) (string, error) { if _, err := fmt.Fprintf(conn, "%s!", name); err != nil { return "", err } } return bufio.NewReader(conn).ReadString('!')
  42. func GreetServer(conn net.Conn) error { name, err := bufio.NewReader(conn).ReadString('!') if

    err != nil { return err } } _, err = fmt.Fprintf(conn, "Hi, %s!", name[:len(name)-1]) return err func GreetClient(conn net.Conn, name string) (string, error) { if _, err := fmt.Fprintf(conn, "%s!", name); err != nil { return "", err } } return bufio.NewReader(conn).ReadString('!') func TestGreetServerClient(t *testing.T) { synctest.Test(t, func(*testing.T) { serverConn, clientConn := net.Pipe() defer clientConn.Close() defer serverConn.Close() }) } // ... use a fake network connection!
  43. Non-durably blocking operations • syscalls, cgo calls, anything that isn’t

    Go • I/O ( les, pipes, network connections, etc.) • sync.Mutex.Lock and sync.RWMutex.Lock ??? • Runtime instrumentation is not free. fi • Mutexes are not usually held for long periods of time.
  44. //go:build !fakemutex package fakemutex import "sync" type ThreadSafeGreeter struct {

    mu sync.Mutex } type Mutex = sync.Mutex type ThreadSafeGreeter struct { mu fakemutex.Mutex }
  45. //go:build !fakemutex package fakemutex import "sync" type ThreadSafeGreeter struct {

    mu sync.Mutex } type Mutex = sync.Mutex //go:build fakemutex package fakemutex type Mutex struct { /* ... */ } func (m *Mutex) Lock() { /* ... */ } func (m *Mutex) Unlock() { /* ... */ } func (m *Mutex) TryLock() bool { /* ... */ } type ThreadSafeGreeter struct { mu fakemutex.Mutex }
  46. type Mutex struct { once sync.Once ch chan struct{} }

    func (m *Mutex) init() { m.once.Do(func() { m.ch := make(chan struct{}, 1) m.ch < struct{}{} }) } YO DAWG I HEARD YOU LIKE MUTEXES func (m *Mutex) Lock() { m.init() < m.ch } func (m *Mutex) Unlock() { m.init() select { case m.ch < struct{}{}: default: panic("sync: unlock of unlocked mutex") } } - - - // ... SO I PUT CHANNELS IN YOUR MUTEXES SO YOU CAN USE MUTEXES WHILE YOU USE MUTEXES
  47. No-no's 🚫 Operating on a bubbled channel, timer, or ticker

    from outside the bubble. 🚫 Calling Add or Go on a bubbled sync.WaitGroup from outside the bubble. 🚫 Waking a bubbled goroutine blocked on Cond.Wait from outside the bubble.
  48. No-no's 🚫 Operating on a bubbled channel, timer, or ticker

    from outside the bubble. func TestBatchGreeter(t *testing.T) { synctest.Test(t, func(t *testing.T) { greetings, greet := BatchGreeter(2) 🚫 Calling Add or Go on a bubbled greet(1) // ID=1 is Alice synctest.Wait() sync.WaitGroup from outside the bubble. greet(2) // ID=2 is Bob 🚫 Waking a bubbled goroutine blocked on want := "Hi, Alice & Bob!" Cond.Wait from outside the bubble. if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) } }) - }
  49. No-no's 🚫 Operating on a bubbled channel, timer, or ticker

    from outside the bubble. func TestBatchGreeter(t *testing.T) { synctest.Test(t, func(t *testing.T) { greetings, greet := BatchGreeter(2) 🚫 Calling Add or Go on a bubbled greet(1) // ID=1 is Alice synctest.Wait() sync.WaitGroup from outside the bubble. greet(2) // ID=2 is Bob 🚫 Waking a bubbled goroutine blocked on want := "Hi, Alice & Bob!" Cond.Wait from outside the bubble. if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) } }) - }
  50. No-no's 🚫 Operating on a bubbled channel, timer, or ticker

    from outside the bubble. func TestBatchGreeter(t *testing.T) { synctest.Test(t, func(t *testing.T) { greetings, greet := BatchGreeter(2) 🚫 Calling Add or Go on a bubbled greet(1) // ID=1 is Alice synctest.Wait() sync.WaitGroup from outside the bubble. greet(2) // ID=2 is Bob 🚫 Waking a bubbled goroutine blocked on want := "Hi, Alice & Bob!" Cond.Wait from outside the bubble. - 🚫 Leaking goroutines inside the bubble. if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) } }) }
  51. func BatchGreeter(batchSize int) ( < chan string, func(int), ) {

    out := make(chan string) names := make(chan string) synctest.Test(t, func(t *testing.T) { greetings, greet := BatchGreeter(2) greet(1) // ID=1 is Alice go func() { batch := make([]string, 0, batchSize) synctest.Wait() for name := range names { // ... } }() want := "Hi, Alice & Bob!" - - return out, func(userID int) { go func() { names < fetchUserName(userID) }() } } - func TestBatchGreeter(t *testing.T) { greet(2) // ID=2 is Bob if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) } }) } panic: main bubble goroutine has exited but blocked goroutines remain! 😱 😱 😱
  52. func BatchGreeter(batchSize int) ( < chan string, func(int), ) {

    out := make(chan string) names := make(chan string) synctest.Test(t, func(t *testing.T) { greetings, greet := BatchGreeter(2) greet(1) // ID=1 is Alice go func() { batch := make([]string, 0, batchSize) synctest.Wait() for name := range names { // ... } }() want := "Hi, Alice & Bob!" - - return out, func(userID int) { go func() { names < fetchUserName(userID) }() } } - func TestBatchGreeter(t *testing.T) { greet(2) // ID=2 is Bob if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) } }) } panic: main bubble goroutine has exited but blocked goroutines remain! 😱 😱 😱
  53. func BatchGreeter(batchSize int) ( < chan string, func(int), func(), )

    { out := make(chan string) names := make(chan string) func TestBatchGreeter(t *testing.T) { synctest.Test(t, func(t *testing.T) { greetings, greet, stop := BatchGreeter(2) greet(1) // ID=1 is Alice synctest.Wait() greet(2) // ID=2 is Bob go func() { batch := make([]string, 0, batchSize) stop() // All good now! for name := range names { // ... } want := "Hi, Alice & Bob!" }() - - - return out, func(userID int) { go func() { names < fetchUserName(userID) }() }, func() { close(names) } } }) } if got := < greetings; got != want { t.Fatalf("got %q, want %q", got, want) }
  54. The Catch? 👍 Test isolation. 👍 Healthy test pyramid. 👍

    Using channels for communication. ⏱ TIME! >synctest's limitations >look inside >good practices
  55. func Test(t *testing.T, f func(*testing.T)) { // ... go f(t)

    for { bubble.waitUntilQuiescent() if bubble.waiting { bubble.resume() // synctest.Wait() returns continue } if !bubble.done && bubble.hasSomethingScheduled() { bubble.advanceTimeUntilSomethingHappens() continue } } break // ... }
  56. func Test(t *testing.T, f func(*testing.T)) { // ... go f(t)

    for { bubble.waitUntilQuiescent() if bubble.waiting { bubble.resume() // synctest.Wait() returns continue } if !bubble.done && bubble.hasSomethingScheduled() { bubble.advanceTimeUntilSomethingHappens() continue } } break // ... }
  57. func Test(t *testing.T, f func(*testing.T)) { // ... go f(t)

    for { bubble.waitUntilQuiescent() if bubble.waiting { bubble.resume() // synctest.Wait() returns continue } if !bubble.done && bubble.hasSomethingScheduled() { bubble.advanceTimeUntilSomethingHappens() continue } } break // ... }
  58. func Test(t *testing.T, f func(*testing.T)) { // ... go f(t)

    for { bubble.waitUntilQuiescent() if bubble.waiting { bubble.resume() // synctest.Wait() returns continue } if !bubble.done && bubble.hasSomethingScheduled() { bubble.advanceTimeUntilSomethingHappens() continue } } break // ... }
  59. func Test(t *testing.T, f func(*testing.T)) { // ... go f(t)

    for { bubble.waitUntilQuiescent() if bubble.waiting { bubble.resume() // synctest.Wait() returns continue } if !bubble.done && bubble.hasSomethingScheduled() { bubble.advanceTimeUntilSomethingHappens() continue } } break // ... }
  60. func Test(t *testing.T, f func(*testing.T)) { // ... go f(t)

    for { bubble.waitUntilQuiescent() if bubble.waiting { bubble.resume() // synctest.Wait() returns continue } if !bubble.done && bubble.hasSomethingScheduled() { bubble.advanceTimeUntilSomethingHappens() continue } } break // ... }
  61. “You can think of the bubble as simulating an in

    nitely fast computer: Any amount of computation takes no time.” fi — Damien Neil, author of synctest
  62. func TestSynctestUnderstanding(t *testing.T) { var mu sync.Mutex go func() {

    mu.Lock() defer mu.Unlock() time.Sleep(10 * time.Millisecond) }() mu.Lock() // OK! }) }
  63. func TestSynctestUnderstanding(t *testing.T) { synctest.Test(t, func(t *testing.T) { var mu

    sync.Mutex go func() { mu.Lock() defer mu.Unlock() time.Sleep(10 * time.Millisecond) }() synctest.Wait() mu.Lock() // Deadlock! }) }
  64. func TestSynctestUnderstanding(t *testing.T) { synctest.Test(t, func(t *testing.T) { var mu

    sync.Mutex go func() { mu.Lock() defer mu.Unlock() time.Sleep(10 * time.Millisecond) }() synctest.Wait() mu.Lock() // Deadlock! }) }
  65. func TestSynctestUnderstanding(t *testing.T) { synctest.Test(t, func(t *testing.T) { var mu

    sync.Mutex go func() { mu.Lock() defer mu.Unlock() time.Sleep(10 * time.Millisecond) }() synctest.Wait() mu.Lock() // Deadlock! }) }
  66. func TestSynctestUnderstanding(t *testing.T) { synctest.Test(t, func(t *testing.T) { var mu

    sync.Mutex go func() { mu.Lock() defer mu.Unlock() time.Sleep(10 * time.Millisecond) }() synctest.Wait() mu.Lock() // Deadlock! }) }
  67. func TestSynctestUnderstanding(t *testing.T) { synctest.Test(t, func(t *testing.T) { var mu

    sync.Mutex go func() { mu.Lock() defer mu.Unlock() time.Sleep(10 * time.Millisecond) }() synctest.Wait() mu.Lock() // Deadlock! }) }
  68. func TestSynctestUnderstanding(t *testing.T) { synctest.Test(t, func(t *testing.T) { var mu

    sync.Mutex go func() { mu.Lock() defer mu.Unlock() time.Sleep(10 * time.Millisecond) }() synctest.Wait() mu.Lock() // Deadlock! }) }
  69. func TestSynctestUnderstanding(t *testing.T) { synctest.Test(t, func(t *testing.T) { var mu

    sync.Mutex go func() { mu.Lock() defer mu.Unlock() time.Sleep(10 * time.Millisecond) }() synctest.Wait() mu.Lock() // Deadlock! }) }
  70. func TestSynctestUnderstanding(t *testing.T) { synctest.Test(t, func(t *testing.T) { var mu

    sync.Mutex go func() { mu.Lock() defer mu.Unlock() time.Sleep(10 * time.Millisecond) }() synctest.Wait() mu.Lock() // Deadlock! }) }
  71. func TestSynctestUnderstanding(t *testing.T) { synctest.Test(t, func(t *testing.T) { var mu

    sync.Mutex go func() { mu.Lock() defer mu.Unlock() time.Sleep(10 * time.Millisecond) }() synctest.Wait() mu.Lock() // Timelock 😱 }) }
  72. Do not wait for a future event in a way

    that prevents it from ever happening! func TestSynctestUnderstanding(t *testing.T) { synctest.Test(t, func(t *testing.T) { var mu sync.Mutex go func() { mu.Lock() defer mu.Unlock() time.Sleep(10 * time.Millisecond) }() synctest.Wait() mu.Lock() // Timelock 😱 }) }
  73. My life before synctest 1. I still needed fast feedback...

    2. So, I did some refactoring! • How can I maximize coverage... • ...while minimizing time.Sleep?
  74. T T B = Transport layer E = Execution policy

    B = Business logic E E E B B T B B B E B E T
  75. E T E E T = Transport layer E =

    Execution policy B = Business logic T E T E B B B B B B B
  76. E time.Sleep(...) T = Transport layer E = Execution policy

    B = Business logic T + E E T E T E B B B = B B B B still meh
  77. T T E T = Transport layer E = Execution

    policy B = Business logic E E E B B B T B B E B B
  78. T T E T = Transport layer E = Execution

    policy B = Business logic E E E B B B T B B E B B
  79. T T E T = Transport layer E = Execution

    policy B = Business logic time.Sleep(...) + E E E B B B T B B = E B B acceptable
  80. T T E T = Transport layer E = Execution

    policy B = Business logic synctest.Wait() + E E E B B B T B B = E B B 0.010s
  81. T T E T = Transport layer E = Execution

    policy B = Business logic E E E B B B T B B E B B
  82. T T E T = Transport layer E = Execution

    policy B = Business logic high cohesion E E E B B B T B B low coupling E B B
  83. Lessons! • Testing with synctest is awesome: not complicated, not

    fragile, not slow. • A clean design tends to also be synctest-friendly! • If testing concurrent code feels painful, perhaps it's not you. It's the tooling.
  84. Lessons! • Testing with synctest is awesome: not complicated, not

    fragile, not slow. • A clean design tends to also be synctest-friendly! • If testing concurrent code feels painful, perhaps it's not you. It's the tooling. I started building it for Ruby! github.com/baygeldin/synctest-rb