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  }