Skip to content

Commit 86c6f1a

Browse files
committed
test: deflake TestBaseMetricObject with testing/synctest
TestBaseMetricObject fails intermittently in the Test Retina Image workflow. It compares two process-wide runtime.NumGoroutine() snapshots, and unrelated goroutine churn breaks the comparison. It also sleeps real time before each assertion, and the expire callback writes expireCalled from the expiration goroutine while the test reads it, a data race under the race detector. Move the test body into a testing/synctest bubble. The fake clock makes the expiration ticker fire deterministically, synctest.Wait orders the callback write before the test reads it, and the bubble fails the test if the expiration goroutine leaks. t.Cleanup(b.clean) stops the goroutine on every exit path. The fake clock also closes a latent gap: the ticker starts before b.updated, so a late first assertion could observe the label already expired. Signed-off-by: Quang Nguyen <28567936+nddq@users.noreply.github.com>
1 parent b4f7289 commit 86c6f1a

1 file changed

Lines changed: 46 additions & 53 deletions

File tree

pkg/module/metrics/basemetricsobject_test.go

Lines changed: 46 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
package metrics
44

55
import (
6-
"runtime"
76
"slices"
87
"testing"
8+
"testing/synctest"
99
"time"
1010

1111
api "github.com/microsoft/retina/crd/api/v1alpha1"
@@ -42,63 +42,56 @@ func TestBaseMetricObject(t *testing.T) {
4242

4343
for _, tt := range tests {
4444
t.Run(tt.name, func(t *testing.T) {
45-
before := runtime.NumGoroutine()
46-
expireCalled := new([]string)
47-
b := newBaseMetricsObject(
48-
&api.MetricsContextOptions{
49-
MetricName: "test_metric",
50-
},
51-
l,
52-
localContext,
53-
func(lbs []string) bool {
54-
*expireCalled = lbs
55-
return true
56-
},
57-
tt.ttl,
58-
)
45+
// The bubble gives the test a fake clock, so the expiration
46+
// ticker fires deterministically. It also fails the test if the
47+
// expiration goroutine is still alive when the bubble ends.
48+
synctest.Test(t, func(t *testing.T) {
49+
var expireCalled []string
50+
b := newBaseMetricsObject(
51+
&api.MetricsContextOptions{
52+
MetricName: "test_metric",
53+
},
54+
l,
55+
localContext,
56+
func(lbs []string) bool {
57+
expireCalled = lbs
58+
return true
59+
},
60+
tt.ttl,
61+
)
62+
t.Cleanup(b.clean)
5963

60-
testLabels := []string{"test"}
61-
b.updated(testLabels)
64+
testLabels := []string{"test"}
65+
b.updated(testLabels)
6266

63-
metrics := len(b.trackedMetricLabels())
64-
if tt.trackMetrics {
65-
if metrics != 1 {
66-
t.Errorf("expected 1 tracked metric label, got %d", metrics)
67+
metrics := len(b.trackedMetricLabels())
68+
if tt.trackMetrics {
69+
if metrics != 1 {
70+
t.Errorf("expected 1 tracked metric label, got %d", metrics)
71+
}
72+
} else {
73+
if metrics != 0 {
74+
t.Errorf("expected 0 tracked metric labels, got %d", metrics)
75+
}
6776
}
68-
} else {
69-
if metrics != 0 {
70-
t.Errorf("expected 0 tracked metric labels, got %d", metrics)
71-
}
72-
}
7377

74-
// If we have a positive TTL, we should see the expire function get called after the TTL has passed
75-
if tt.ttl > 0 {
76-
time.Sleep(tt.ttl + time.Millisecond*100)
77-
if !slices.Equal(*expireCalled, testLabels) {
78-
t.Errorf("expected expire to be called with %v, got %v", testLabels, *expireCalled)
79-
}
80-
metrics = len(b.trackedMetricLabels())
81-
if metrics != 0 {
82-
t.Errorf("expected 0 tracked metric labels after expiration, got %d", metrics)
78+
// If we have a positive TTL, we should see the expire function get called after the TTL has passed
79+
if tt.ttl > 0 {
80+
// Advance the fake clock to the first tick, then wait for
81+
// the expiration goroutine to block again.
82+
time.Sleep(tt.ttl)
83+
synctest.Wait()
84+
if !slices.Equal(expireCalled, testLabels) {
85+
t.Errorf("expected expire to be called with %v, got %v", testLabels, expireCalled)
86+
}
87+
metrics = len(b.trackedMetricLabels())
88+
if metrics != 0 {
89+
t.Errorf("expected 0 tracked metric labels after expiration, got %d", metrics)
90+
}
91+
} else if len(expireCalled) != 0 {
92+
t.Errorf("expected expire to not be called, but got %v", expireCalled)
8393
}
84-
} else if len(*expireCalled) != 0 {
85-
t.Errorf("expected expire to not be called, but got %v", *expireCalled)
86-
}
87-
88-
b.clean()
89-
if b.expireFn != nil {
90-
<-b.ctx.Done()
91-
}
92-
93-
// Wait for any goroutines to exit after clean is called
94-
if tt.trackMetrics {
95-
time.Sleep(tt.ttl + time.Millisecond*100)
96-
}
97-
98-
after := runtime.NumGoroutine()
99-
if after != before {
100-
t.Errorf("expected number of goroutines to be the same as before, expected %d, got %d", before, after)
101-
}
94+
})
10295
})
10396
}
10497
}

0 commit comments

Comments
 (0)