• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 // Copyright 2023 The Chromium Authors
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 org.chromium.base.test.util;
6 
7 import static org.junit.Assert.fail;
8 
9 import androidx.annotation.Nullable;
10 
11 import com.google.common.collect.Iterators;
12 import com.google.common.collect.PeekingIterator;
13 
14 import org.chromium.base.metrics.HistogramBucket;
15 import org.chromium.base.metrics.RecordHistogram;
16 
17 import java.util.ArrayList;
18 import java.util.HashMap;
19 import java.util.HashSet;
20 import java.util.Iterator;
21 import java.util.List;
22 import java.util.Map;
23 import java.util.Map.Entry;
24 import java.util.Objects;
25 import java.util.Set;
26 import java.util.TreeMap;
27 
28 /**
29  * Watches a number of histograms in tests to assert later that the expected values were recorded.
30  *
31  * Uses the delta of records between build() and assertExpected(), so that records logged in
32  * previous tests (in batched tests) don't interfere with the counting as would happen with direct
33  * calls to {@link RecordHistogram}.
34  *
35  * Usage:
36  *
37  * // Arrange
38  * var histogramWatcher = HistogramWatcher.newBuilder()
39  *     .expectIntRecord("Histogram1", 555)
40  *     .expectIntRecord("Histogram1", 666)
41  *     .expectBooleanRecord("Histogram2", true)
42  *     .expectAnyRecord("Histogram3")
43  *     .build();
44  * or:
45  * var histogramWatcher = HistogramWatcher.newSingleRecordWatcher("Histogram1", 555);
46  *
47  * // Act
48  * [code under test that is expected to record the histograms above]
49  *
50  * // Assert
51  * histogramWatcher.assertExpected();
52  *
53  * Alternatively, Java's try-with-resources can be used to wrap the act block to make the assert
54  * implicit. This can be especially helpful when a test case needs to create multiple watchers,
55  * as the watcher variables are scoped separately and cannot be accidentally swapped.
56  *
57  * try (HistogramWatcher ignored = HistogramWatcher.newSingleRecordWatcher("Histogram1") {
58  *     [code under test that is expected to record the histogram above]
59  * }
60  */
61 public class HistogramWatcher implements AutoCloseable {
62     /** Create a new {@link HistogramWatcher.Builder} to instantiate {@link HistogramWatcher}. */
newBuilder()63     public static HistogramWatcher.Builder newBuilder() {
64         return new HistogramWatcher.Builder();
65     }
66 
67     /**
68      * Convenience method to create a new {@link HistogramWatcher} that expects a single boolean
69      * record with {@code value} for {@code histogram} and no more records to the same histogram.
70      */
newSingleRecordWatcher(String histogram, boolean value)71     public static HistogramWatcher newSingleRecordWatcher(String histogram, boolean value) {
72         return newBuilder().expectBooleanRecord(histogram, value).build();
73     }
74 
75     /**
76      * Convenience method to create a new {@link HistogramWatcher} that expects a single integer
77      * record with {@code value} for {@code histogram} and no more records to the same histogram.
78      */
newSingleRecordWatcher(String histogram, int value)79     public static HistogramWatcher newSingleRecordWatcher(String histogram, int value) {
80         return newBuilder().expectIntRecord(histogram, value).build();
81     }
82 
83     /**
84      * Convenience method to create a new {@link HistogramWatcher} that expects a single record with
85      * any value for {@code histogram} and no more records to the same histogram.
86      */
newSingleRecordWatcher(String histogram)87     public static HistogramWatcher newSingleRecordWatcher(String histogram) {
88         return newBuilder().expectAnyRecord(histogram).build();
89     }
90 
91     /** Builder for {@link HistogramWatcher}. Use to list the expectations of records. */
92     public static class Builder {
93         private final Map<HistogramAndValue, Integer> mRecordsExpected = new HashMap<>();
94         private final Map<String, Integer> mTotalRecordsExpected = new HashMap<>();
95         private final Set<String> mHistogramsAllowedExtraRecords = new HashSet<>();
96 
97         /** Use {@link HistogramWatcher#newBuilder()} to instantiate. */
Builder()98         private Builder() {}
99 
100         /**
101          * Build the {@link HistogramWatcher} and snapshot current number of records of the expected
102          * histograms to calculate the delta later.
103          */
build()104         public HistogramWatcher build() {
105             return new HistogramWatcher(
106                     mRecordsExpected,
107                     mTotalRecordsExpected.keySet(),
108                     mHistogramsAllowedExtraRecords);
109         }
110 
111         /**
112          * Add an expectation that {@code histogram} will be recorded once with a boolean {@code
113          * value}.
114          */
expectBooleanRecord(String histogram, boolean value)115         public Builder expectBooleanRecord(String histogram, boolean value) {
116             return expectBooleanRecordTimes(histogram, value, 1);
117         }
118 
119         /**
120          * Add an expectation that {@code histogram} will be recorded a number of {@code times} with
121          * a boolean {@code value}.
122          */
expectBooleanRecordTimes(String histogram, boolean value, int times)123         public Builder expectBooleanRecordTimes(String histogram, boolean value, int times) {
124             return expectIntRecordTimes(histogram, value ? 1 : 0, times);
125         }
126 
127         /**
128          * Add an expectation that {@code histogram} will be recorded once with an int {@code
129          * value}.
130          */
expectIntRecord(String histogram, int value)131         public Builder expectIntRecord(String histogram, int value) {
132             return expectIntRecordTimes(histogram, value, 1);
133         }
134 
135         /**
136          * Add expectations that {@code histogram} will be recorded with each of the int {@code
137          * values} provided.
138          */
expectIntRecords(String histogram, int... values)139         public Builder expectIntRecords(String histogram, int... values) {
140             for (int value : values) {
141                 expectIntRecord(histogram, value);
142             }
143             return this;
144         }
145 
146         /**
147          * Add an expectation that {@code histogram} will be recorded a number of {@code times} with
148          * an int {@code value}.
149          */
expectIntRecordTimes(String histogram, int value, int times)150         public Builder expectIntRecordTimes(String histogram, int value, int times) {
151             if (times < 0) {
152                 throw new IllegalArgumentException(
153                         "Cannot expect records a negative number of times");
154             } else if (times == 0) {
155                 throw new IllegalArgumentException(
156                         "Cannot expect records zero times. Use expectNoRecords() if no records are"
157                             + " expected for this histogram. If only certain values are expected"
158                             + " for this histogram, by default extra records will already raise an"
159                             + " assert.");
160             }
161             HistogramAndValue histogramAndValue = new HistogramAndValue(histogram, value);
162             incrementRecordsExpected(histogramAndValue, times);
163             incrementTotalRecordsExpected(histogram, times);
164             return this;
165         }
166 
167         /** Add an expectation that {@code histogram} will be recorded once with any value. */
expectAnyRecord(String histogram)168         public Builder expectAnyRecord(String histogram) {
169             return expectAnyRecordTimes(histogram, 1);
170         }
171 
172         /**
173          * Add an expectation that {@code histogram} will be recorded a number of {@code times} with
174          * any values.
175          */
expectAnyRecordTimes(String histogram, int times)176         public Builder expectAnyRecordTimes(String histogram, int times) {
177             HistogramAndValue histogramAndValue = new HistogramAndValue(histogram, ANY_VALUE);
178             incrementRecordsExpected(histogramAndValue, times);
179             incrementTotalRecordsExpected(histogram, times);
180             return this;
181         }
182 
183         /** Add an expectation that {@code histogram} will not be recorded with any values. */
expectNoRecords(String histogram)184         public Builder expectNoRecords(String histogram) {
185             Integer recordsAlreadyExpected = mTotalRecordsExpected.get(histogram);
186             if (recordsAlreadyExpected != null && recordsAlreadyExpected != 0) {
187                 throw new IllegalStateException(
188                         "Cannot expect no records but also expect records in previous calls.");
189             }
190 
191             mTotalRecordsExpected.put(histogram, 0);
192             return this;
193         }
194 
195         /**
196          * Make more lenient the assert that records matched the expectations for {@code histogram}
197          * by ignoring extra records.
198          */
allowExtraRecords(String histogram)199         public Builder allowExtraRecords(String histogram) {
200             mHistogramsAllowedExtraRecords.add(histogram);
201             return this;
202         }
203 
204         /**
205          * For all histograms with expectations added before, make more lenient the assert that
206          * records matched the expectations by ignoring extra records.
207          */
allowExtraRecordsForHistogramsAbove()208         public Builder allowExtraRecordsForHistogramsAbove() {
209             for (String histogram : mTotalRecordsExpected.keySet()) {
210                 allowExtraRecords(histogram);
211             }
212             return this;
213         }
214 
incrementRecordsExpected(HistogramAndValue histogramAndValue, int increase)215         private void incrementRecordsExpected(HistogramAndValue histogramAndValue, int increase) {
216             Integer previousCountExpected = mRecordsExpected.get(histogramAndValue);
217             if (previousCountExpected == null) {
218                 previousCountExpected = 0;
219             }
220             mRecordsExpected.put(histogramAndValue, previousCountExpected + increase);
221         }
222 
incrementTotalRecordsExpected(String histogram, int increase)223         private void incrementTotalRecordsExpected(String histogram, int increase) {
224             Integer previousCountExpected = mTotalRecordsExpected.get(histogram);
225             if (previousCountExpected == null) {
226                 previousCountExpected = 0;
227             }
228             mTotalRecordsExpected.put(histogram, previousCountExpected + increase);
229         }
230     }
231 
232     private static final int ANY_VALUE = -1;
233 
234     private final Map<HistogramAndValue, Integer> mRecordsExpected;
235     private final Set<String> mHistogramsWatched;
236     private final Set<String> mHistogramsAllowedExtraRecords;
237 
238     private final Map<String, List<HistogramBucket>> mStartingSamples = new HashMap<>();
239 
HistogramWatcher( Map<HistogramAndValue, Integer> recordsExpected, Set<String> histogramsWatched, Set<String> histogramsAllowedExtraRecords)240     private HistogramWatcher(
241             Map<HistogramAndValue, Integer> recordsExpected,
242             Set<String> histogramsWatched,
243             Set<String> histogramsAllowedExtraRecords) {
244         mRecordsExpected = recordsExpected;
245         mHistogramsWatched = histogramsWatched;
246         mHistogramsAllowedExtraRecords = histogramsAllowedExtraRecords;
247 
248         takeSnapshot();
249     }
250 
takeSnapshot()251     private void takeSnapshot() {
252         for (String histogram : mHistogramsWatched) {
253             mStartingSamples.put(
254                     histogram, RecordHistogram.getHistogramSamplesForTesting(histogram));
255         }
256     }
257 
258     /**
259      * Implements {@link AutoCloseable}. Note while this interface throws an {@link Exception}, we
260      * do not have to, and this allows call sites that know they're handling a
261      * {@link HistogramWatcher} to not catch or declare an exception either.
262      */
263     @Override
close()264     public void close() {
265         assertExpected();
266     }
267 
268     /** Assert that the watched histograms were recorded as expected. */
assertExpected()269     public void assertExpected() {
270         assertExpected(/* customMessage= */ null);
271     }
272 
273     /**
274      * Assert that the watched histograms were recorded as expected, with a custom message if the
275      * assertion is not satisfied.
276      */
assertExpected(@ullable String customMessage)277     public void assertExpected(@Nullable String customMessage) {
278         for (String histogram : mHistogramsWatched) {
279             assertExpected(histogram, customMessage);
280         }
281     }
282 
assertExpected(String histogram, @Nullable String customMessage)283     private void assertExpected(String histogram, @Nullable String customMessage) {
284         List<HistogramBucket> actualBuckets = computeActualBuckets(histogram);
285         TreeMap<Integer, Integer> expectedValuesAndCounts = new TreeMap<>();
286         for (Entry<HistogramAndValue, Integer> kv : mRecordsExpected.entrySet()) {
287             if (kv.getKey().mHistogram.equals(histogram)) {
288                 expectedValuesAndCounts.put(kv.getKey().mValue, kv.getValue());
289             }
290         }
291 
292         // Since |expectedValuesAndCounts| is a TreeMap, iterates expected records in ascending
293         // order by value.
294         Iterator<Entry<Integer, Integer>> expectedValuesAndCountsIt =
295                 expectedValuesAndCounts.entrySet().iterator();
296         Entry<Integer, Integer> expectedValueAndCount =
297                 expectedValuesAndCountsIt.hasNext() ? expectedValuesAndCountsIt.next() : null;
298         if (expectedValueAndCount != null && expectedValueAndCount.getKey() == ANY_VALUE) {
299             // Skip the ANY_VALUE records expected - conveniently always the first entry -1 when
300             // present to check them differently at the end.
301             expectedValueAndCount =
302                     expectedValuesAndCountsIt.hasNext() ? expectedValuesAndCountsIt.next() : null;
303         }
304 
305         // Will match the actual records with the expected and flag |unexpected| when the actual
306         // records cannot match the expected, and count how many |actualExtraRecords| are seen to
307         // match them with |ANY_VALUE|s at the end.
308         boolean unexpected = false;
309         int actualExtraRecords = 0;
310 
311         for (HistogramBucket actualBucket : actualBuckets) {
312             if (expectedValueAndCount == null) {
313                 // No expected values are left, so all records seen in this bucket are extra.
314                 actualExtraRecords += actualBucket.mCount;
315                 continue;
316             }
317 
318             // Count how many expected records fall inside the bucket.
319             int expectedRecordsMatchedToActualBucket = 0;
320             do {
321                 int expectedValue = expectedValueAndCount.getKey();
322                 int expectedCount = expectedValueAndCount.getValue();
323                 if (actualBucket.contains(expectedValue)) {
324                     expectedRecordsMatchedToActualBucket += expectedCount;
325                     expectedValueAndCount =
326                             expectedValuesAndCountsIt.hasNext()
327                                     ? expectedValuesAndCountsIt.next()
328                                     : null;
329                 } else {
330                     break;
331                 }
332             } while (expectedValueAndCount != null);
333 
334             if (actualBucket.mCount > expectedRecordsMatchedToActualBucket) {
335                 // Saw more records than expected for that bucket's range.
336                 // Consider the difference as extra records.
337                 actualExtraRecords += actualBucket.mCount - expectedRecordsMatchedToActualBucket;
338             } else if (actualBucket.mCount < expectedRecordsMatchedToActualBucket) {
339                 // Saw fewer records than expected for that bucket's range.
340                 // Assert since all expected records should be accounted for.
341                 unexpected = true;
342                 break;
343             }
344             // else, actual records match expected, so just move to check the next actual bucket.
345         }
346 
347         if (expectedValueAndCount != null) {
348             // Still had more expected values but not seen in any actual bucket.
349             unexpected = true;
350         }
351 
352         boolean allowAnyNumberOfExtraRecords = mHistogramsAllowedExtraRecords.contains(histogram);
353         Integer expectedExtraRecords =
354                 mRecordsExpected.get(new HistogramAndValue(histogram, ANY_VALUE));
355         if (expectedExtraRecords == null) {
356             expectedExtraRecords = 0;
357         }
358         if (!allowAnyNumberOfExtraRecords && actualExtraRecords > expectedExtraRecords
359                 || actualExtraRecords < expectedExtraRecords) {
360             // Expected |extraRecordsExpected| records with any value, found |extraActualRecords|.
361             unexpected = true;
362         }
363 
364         if (unexpected) {
365             String expectedRecordsString =
366                     getExpectedHistogramSamplesAsString(expectedValuesAndCounts);
367             String actualRecordsString = bucketsToString(actualBuckets);
368             String atLeastString = allowAnyNumberOfExtraRecords ? "At least " : "";
369             int expectedTotalDelta = 0;
370             for (Integer expectedCount : expectedValuesAndCounts.values()) {
371                 expectedTotalDelta += expectedCount;
372             }
373             int actualTotalDelta = 0;
374             for (HistogramBucket actualBucket : actualBuckets) {
375                 actualTotalDelta += actualBucket.mCount;
376             }
377             String defaultMessage =
378                     String.format(
379                             "Records for histogram \"%s\" did not match expected.\n"
380                                     + "%s%d record(s) expected: [%s]\n"
381                                     + "%d record(s) seen: [%s]",
382                             histogram,
383                             atLeastString,
384                             expectedTotalDelta,
385                             expectedRecordsString,
386                             actualTotalDelta,
387                             actualRecordsString);
388             failWithDefaultOrCustomMessage(defaultMessage, customMessage);
389         }
390     }
391 
getExpectedHistogramSamplesAsString( TreeMap<Integer, Integer> expectedValuesAndCounts)392     private static String getExpectedHistogramSamplesAsString(
393             TreeMap<Integer, Integer> expectedValuesAndCounts) {
394         List<String> expectedRecordsStrings = new ArrayList<>();
395         for (Entry<Integer, Integer> kv : expectedValuesAndCounts.entrySet()) {
396             int value = kv.getKey();
397             int count = kv.getValue();
398             if (value == ANY_VALUE) {
399                 // Write records matching "Any" at the end.
400                 continue;
401             }
402             expectedRecordsStrings.add(bucketToString(value, value + 1, count));
403         }
404 
405         if (expectedValuesAndCounts.containsKey(ANY_VALUE)) {
406             int anyExpectedCount = expectedValuesAndCounts.get(ANY_VALUE);
407             expectedRecordsStrings.add(bucketToString(ANY_VALUE, ANY_VALUE + 1, anyExpectedCount));
408         }
409 
410         return String.join(", ", expectedRecordsStrings);
411     }
412 
computeActualBuckets(String histogram)413     private List<HistogramBucket> computeActualBuckets(String histogram) {
414         List<HistogramBucket> startingBuckets = mStartingSamples.get(histogram);
415         List<HistogramBucket> finalBuckets =
416                 RecordHistogram.getHistogramSamplesForTesting(histogram);
417         List<HistogramBucket> deltaBuckets = new ArrayList<>();
418 
419         PeekingIterator<HistogramBucket> startingBucketsIt =
420                 Iterators.peekingIterator(startingBuckets.iterator());
421 
422         for (HistogramBucket finalBucket : finalBuckets) {
423             int totalInEquivalentStartingBuckets = 0;
424             while (startingBucketsIt.hasNext()
425                     && startingBucketsIt.peek().mMax <= finalBucket.mMax) {
426                 HistogramBucket startBucket = startingBucketsIt.next();
427                 if (startBucket.mMin >= finalBucket.mMax) {
428                     // This should not happen as the only transition in bucket schema is from the
429                     // CachingUmaRecord (which is as granular as possible, buckets of [n, n+1) )
430                     // to NativeUmaRecorder (which has varying granularity).
431                     fail(
432                             String.format(
433                                     "Histogram bucket bounds before and after the test don't match,"
434                                             + " cannot assert histogram counts.\n"
435                                             + "Before: [%s]\n"
436                                             + "After: [%s]",
437                                     bucketsToString(startingBuckets),
438                                     bucketsToString(finalBuckets)));
439                 }
440                 if (startBucket.mMin >= finalBucket.mMin) {
441                     // Since start.max <= final.max, this means the start bucket is contained in the
442                     // final bucket.
443                     totalInEquivalentStartingBuckets += startBucket.mCount;
444                 }
445             }
446 
447             int delta = finalBucket.mCount - totalInEquivalentStartingBuckets;
448 
449             if (delta == 0) {
450                 // Empty buckets don't need to be printed.
451                 continue;
452             } else {
453                 deltaBuckets.add(new HistogramBucket(finalBucket.mMin, finalBucket.mMax, delta));
454             }
455         }
456         return deltaBuckets;
457     }
458 
bucketsToString(List<HistogramBucket> buckets)459     private static String bucketsToString(List<HistogramBucket> buckets) {
460         List<String> bucketStrings = new ArrayList<>();
461         for (HistogramBucket bucket : buckets) {
462             bucketStrings.add(bucketToString(bucket));
463         }
464         return String.join(", ", bucketStrings);
465     }
466 
bucketToString(HistogramBucket bucket)467     private static String bucketToString(HistogramBucket bucket) {
468         return bucketToString(bucket.mMin, bucket.mMax, bucket.mCount);
469     }
470 
bucketToString(int bucketMin, long bucketMax, int count)471     private static String bucketToString(int bucketMin, long bucketMax, int count) {
472         String bucketString;
473         if (bucketMin == ANY_VALUE) {
474             bucketString = "Any";
475         } else if (bucketMax == bucketMin + 1) {
476             // bucketString is "100" for bucketMin == 100, bucketMax == 101
477             bucketString = String.valueOf(bucketMin);
478         } else {
479             // bucketString is "[100, 120)" for bucketMin == 100, bucketMax == 120
480             bucketString = String.format("[%d, %d)", bucketMin, bucketMax);
481         }
482 
483         if (count == 1) {
484             // result is "100" for count == 1
485             return bucketString;
486         } else {
487             // result is "100 (2 times)" for count == 2
488             return String.format("%s (%d times)", bucketString, count);
489         }
490     }
491 
failWithDefaultOrCustomMessage( String defaultMessage, @Nullable String customMessage)492     private static void failWithDefaultOrCustomMessage(
493             String defaultMessage, @Nullable String customMessage) {
494         if (customMessage != null) {
495             fail(String.format("%s\n%s", customMessage, defaultMessage));
496         } else {
497             fail(defaultMessage);
498         }
499     }
500 
501     /**
502      * Polls the instrumentation thread until the expected histograms are recorded.
503      *
504      * Throws {@link CriteriaNotSatisfiedException} if the polling times out, wrapping the
505      * assertion to printed out the state of the histograms at the last check.
506      */
pollInstrumentationThreadUntilSatisfied()507     public void pollInstrumentationThreadUntilSatisfied() {
508         CriteriaHelper.pollInstrumentationThread(
509                 () -> {
510                     try {
511                         assertExpected();
512                         return true;
513                     } catch (AssertionError e) {
514                         throw new CriteriaNotSatisfiedException(e);
515                     }
516                 });
517     }
518 
519     private static class HistogramAndValue {
520         private final String mHistogram;
521         private final int mValue;
522 
HistogramAndValue(String histogram, int value)523         private HistogramAndValue(String histogram, int value) {
524             mHistogram = histogram;
525             mValue = value;
526         }
527 
528         @Override
hashCode()529         public int hashCode() {
530             return Objects.hash(mHistogram, mValue);
531         }
532 
533         @Override
equals(@ullable Object obj)534         public boolean equals(@Nullable Object obj) {
535             if (obj instanceof HistogramAndValue) {
536                 HistogramAndValue that = (HistogramAndValue) obj;
537                 return this.mHistogram.equals(that.mHistogram) && this.mValue == that.mValue;
538             }
539             return false;
540         }
541     }
542 }
543