go.fuchsia.dev/infra@v0.0.0-20240507153436-9b593402251b/cmd/update_test_durations/update_test.go (about) 1 // Copyright 2022 The Fuchsia Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package main 6 7 import ( 8 "encoding/json" 9 "errors" 10 "fmt" 11 "os" 12 "testing" 13 14 "github.com/google/go-cmp/cmp" 15 "github.com/google/go-cmp/cmp/cmpopts" 16 ) 17 18 func TestSplitTestsByBuilder(t *testing.T) { 19 t.Parallel() 20 21 testCases := []struct { 22 name string 23 options durationFileOptions 24 inputAndExpected func() (input []test, expected testDurationMap) 25 expectedErr error 26 }{ 27 { 28 name: "groups tests by builder", 29 inputAndExpected: func() ([]test, testDurationMap) { 30 fooTests := testsWithDurations("foo", 1, 1) 31 barTests := testsWithDurations("bar", 1) 32 33 allTests := appendAll(fooTests, barTests) 34 35 expectedDurations := testDurationMap{ 36 "foo": fooTests, 37 "bar": barTests, 38 } 39 return allTests, expectedDurations 40 }, 41 }, 42 { 43 name: "adds a default entry for each builder", 44 options: durationFileOptions{ 45 includeDefaultTests: true, 46 }, 47 inputAndExpected: func() ([]test, testDurationMap) { 48 fooTests := testsWithDurations("foo", 1, 3) 49 barTests := testsWithDurations("bar", 8, 10) 50 51 allTests := appendAll(fooTests, barTests) 52 53 expectedDurations := testDurationMap{ 54 "foo": append(fooTests, defaultEntryWithDuration(2)), 55 "bar": append(barTests, defaultEntryWithDuration(9)), 56 } 57 return allTests, expectedDurations 58 }, 59 }, 60 { 61 name: "default entry duration is average of other entry durations", 62 options: durationFileOptions{ 63 includeDefaultTests: true, 64 }, 65 inputAndExpected: func() ([]test, testDurationMap) { 66 // We want to use the average of medians rather than the median of 67 // medians so that total expected shard durations are still accurate if 68 // many new tests are added (assuming the new tests have a similar 69 // distribution of durations to the existing tests). 70 barTests := testsWithDurations("bar", 3, 30, 300) 71 72 expectedDurations := testDurationMap{ 73 "bar": append(barTests, defaultEntryWithDuration(111)), 74 } 75 return barTests, expectedDurations 76 }, 77 }, 78 { 79 name: "weights default entry duration by run count", 80 options: durationFileOptions{ 81 includeDefaultTests: true, 82 }, 83 inputAndExpected: func() ([]test, testDurationMap) { 84 allTests := []test{ 85 { 86 Name: "foo1", 87 Builder: "foo", 88 MedianDurationMS: 150, 89 Runs: 2, 90 }, 91 { 92 Name: "foo1", 93 Builder: "foo", 94 MedianDurationMS: 3, 95 Runs: 1, 96 }, 97 } 98 99 expectedDurations := testDurationMap{ 100 // The default entry's duration should be the average of the other 101 // entries' durations, weighted by run count. 102 "foo": append(allTests, defaultEntryWithDuration(101)), 103 } 104 return allTests, expectedDurations 105 }, 106 }, 107 { 108 name: "adds a default builder", 109 options: durationFileOptions{ 110 includeDefaultBuilder: true, 111 }, 112 inputAndExpected: func() ([]test, testDurationMap) { 113 sharedTest := test{Name: "shared", Runs: 2, MedianDurationMS: 5} 114 fooSharedTest := test{Name: "shared", Runs: 1, MedianDurationMS: 5, Builder: "foo"} 115 barSharedTest := test{Name: "shared", Runs: 1, MedianDurationMS: 5, Builder: "bar"} 116 117 fooOnlyTests := testsWithDurations("foo", 1) 118 fooTests := append(fooOnlyTests, fooSharedTest) 119 barOnlyTests := testsWithDurations("bar", 3) 120 barTests := append(barOnlyTests, barSharedTest) 121 122 allTests := appendAll(fooTests, barTests) 123 124 expectedDefaultTests := appendAll([]test{sharedTest}, fooOnlyTests, barOnlyTests) 125 expectedDurations := testDurationMap{ 126 "foo": fooTests, 127 "bar": barTests, 128 defaultBuilderName: expectedDefaultTests, 129 } 130 return allTests, expectedDurations 131 }, 132 }, 133 { 134 name: "adds a default entry for the default builder", 135 options: durationFileOptions{ 136 includeDefaultBuilder: true, 137 includeDefaultTests: true, 138 }, 139 inputAndExpected: func() ([]test, testDurationMap) { 140 fooTests := testsWithDurations("foo", 1) 141 barTests := testsWithDurations("bar", 5) 142 143 allTests := appendAll(fooTests, barTests) 144 145 expectedDurations := testDurationMap{ 146 "foo": append(fooTests, defaultEntryWithDuration(1)), 147 "bar": append(barTests, defaultEntryWithDuration(5)), 148 defaultBuilderName: append(allTests, defaultEntryWithDuration(3)), 149 } 150 return allTests, expectedDurations 151 }, 152 }, 153 { 154 name: "returns no builders if input contains no tests", 155 options: durationFileOptions{ 156 includeDefaultBuilder: true, 157 includeDefaultTests: true, 158 }, 159 inputAndExpected: func() ([]test, testDurationMap) { 160 var emptyTests []test 161 var emptyDurations testDurationMap 162 return emptyTests, emptyDurations 163 }, 164 }, 165 { 166 name: "returns error if all tests for a builder have zero runs", 167 options: durationFileOptions{ 168 includeDefaultBuilder: true, 169 includeDefaultTests: true, 170 }, 171 expectedErr: errZeroTotalRuns, 172 inputAndExpected: func() ([]test, testDurationMap) { 173 fooTests := testsWithDurations("foo", 1, 2, 3) 174 for i := range fooTests { 175 fooTests[i].Runs = 0 176 } 177 return fooTests, nil 178 }, 179 }, 180 } 181 182 for _, tc := range testCases { 183 t.Run(tc.name, func(t *testing.T) { 184 input, expected := tc.inputAndExpected() 185 186 for builder, origTests := range expected { 187 // Make a copy to avoid modifying the slices in the original, which may 188 // share the same underlying array with the slice of input tests. 189 tests := make([]test, len(origTests)) 190 copy(tests, origTests) 191 expected[builder] = tests 192 // It's very repetitive to specify the builder in each expected 193 // test, so set it here based on the map key. 194 for i := range tests { 195 tests[i].Builder = builder 196 } 197 } 198 199 actual, err := splitTestsByBuilder(input, tc.options) 200 if !errors.Is(err, tc.expectedErr) { 201 t.Fatalf("splitTestsByBuilder() returned wrong error, got: %v, wanted %v", err, tc.expectedErr) 202 } 203 204 opts := []cmp.Option{ 205 cmpopts.EquateEmpty(), 206 // In production, duration files should always be sorted by name because 207 // the Dremel query orders results by name. So there's no need for 208 // splitTestsByBuilder to do sorting (except for the default duration 209 // file), so we shouldn't care about ordering in its return value. 210 cmpopts.SortSlices(func(a, b test) bool { 211 return a.Name < b.Name 212 }), 213 } 214 if diff := cmp.Diff(expected, actual, opts...); diff != "" { 215 t.Errorf("splitTestsByBuilder() diff (-want +got):\n%s", diff) 216 } 217 }) 218 } 219 } 220 221 // testsWithDurations constructs a slice of tests with the given durations, in 222 // order, all have the same builder. 223 func testsWithDurations(builder string, durations ...int64) []test { 224 var res []test 225 for i, duration := range durations { 226 res = append(res, test{ 227 Name: fmt.Sprintf("%s-%d", builder, i), 228 Builder: builder, 229 MedianDurationMS: duration, 230 Runs: 1, 231 }) 232 } 233 return res 234 } 235 236 func defaultEntryWithDuration(medianDurationMS int64) test { 237 return test{ 238 Name: defaultTestName, 239 MedianDurationMS: medianDurationMS, 240 } 241 } 242 243 func appendAll(slices ...[]test) []test { 244 var res []test 245 for _, s := range slices { 246 res = append(res, s...) 247 } 248 return res 249 } 250 251 func TestMarshalDurations(t *testing.T) { 252 dir, err := os.MkdirTemp("/tmp", "test-durations-tests") 253 if err != nil { 254 t.Fatalf("Failed to create temporary directory: %v", err) 255 } 256 defer os.RemoveAll(dir) 257 258 durations := testDurationMap{ 259 "foo": append(testsWithDurations("foo", 0, 1, 2), defaultEntryWithDuration(1)), 260 "bar": append(testsWithDurations("bar", -3, 5), defaultEntryWithDuration(1)), 261 } 262 263 fileContents, err := marshalDurations(durations) 264 if err != nil { 265 t.Fatalf("marshalDurations() returned unexpected error: %v", err) 266 } 267 268 for builder, tests := range durations { 269 // Use t.Run() so we can Fatalf() while still checking all duration files. 270 t.Run(fmt.Sprintf("marshals durations for %s", builder), func(t *testing.T) { 271 basename := fmt.Sprintf("%s.json", builder) 272 b, ok := fileContents[basename] 273 if !ok { 274 t.Fatalf("File %s was not marshaled", basename) 275 } 276 var unmarshaledTests []test 277 if err := json.Unmarshal(b, &unmarshaledTests); err != nil { 278 t.Fatalf("Failed to unmarshal file %s: %v", basename, err) 279 } 280 281 var expectedTests []test 282 for _, test := range tests { 283 // The builder should not be included in the files, since it's in the 284 // file name. 285 test.Builder = "" 286 expectedTests = append(expectedTests, test) 287 } 288 289 if diff := cmp.Diff(expectedTests, unmarshaledTests); diff != "" { 290 t.Errorf("marshalDurations() diff in file %s (-want +got):\n%s", basename, diff) 291 } 292 }) 293 } 294 } 295 296 func TestOverlayFileContents(t *testing.T) { 297 oldFiles := map[string][]byte{ 298 "foo": []byte("foo-old"), 299 "bar": []byte("bar-old"), 300 } 301 302 newFiles := map[string][]byte{ 303 "bar": []byte("bar-new"), 304 "baz": []byte("baz-new"), 305 } 306 307 expected := map[string][]byte{ 308 "foo": []byte("foo-old"), 309 "bar": []byte("bar-new"), 310 "baz": []byte("baz-new"), 311 } 312 313 result := overlayFileContents(oldFiles, newFiles) 314 315 if diff := cmp.Diff(expected, result); diff != "" { 316 t.Errorf("overlayFileContents() diff (-want +got):\n%s", diff) 317 } 318 }