Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.57% covered (warning)
82.57%
90 / 109
70.59% covered (warning)
70.59%
12 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
UnitTestingHelper
82.57% covered (warning)
82.57%
90 / 109
70.59% covered (warning)
70.59%
12 / 17
54.25
0.00% covered (danger)
0.00%
0 / 1
 __construct
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
1.01
 withComponent
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getStatsFactory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllFormatted
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 consumeAllFormatted
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 last
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 sum
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 max
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 median
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 min
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getMetricFromSelector
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
4.94
 getFilteredSamples
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
5.27
 getName
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getFilters
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getFilterComponents
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
8
 matches
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7declare( strict_types=1 );
8
9namespace Wikimedia\Stats;
10
11use InvalidArgumentException;
12use MediaWiki\Logger\LoggerFactory;
13use OutOfBoundsException;
14use OutOfRangeException;
15use Psr\Log\LoggerInterface;
16use Wikimedia\Stats\Emitters\NullEmitter;
17use Wikimedia\Stats\Formatters\DogStatsdFormatter;
18use Wikimedia\Stats\Metrics\MetricInterface;
19
20/**
21 * A helper class for testing metrics in Unit Tests.
22 *
23 * @author Cole White
24 * @since 1.44
25 */
26class UnitTestingHelper {
27    public const EQUALS = '=';
28    public const NOT_EQUALS = '!=';
29    public const EQUALS_REGEX = '=~';
30    public const NOT_EQUALS_REGEX = '!~';
31
32    private StatsCache $cache;
33    private StatsFactory $factory;
34    private string $component = '';
35    private LoggerInterface $logger;
36
37    /**
38     * @internal Use StatsFactory::newUnitTestingHelper() instead.
39     */
40    public function __construct() {
41        $this->cache = new StatsCache();
42        $this->logger = LoggerFactory::getInstance( 'Stats' );
43        // Disable StatsFactory::flush() and its StatsCache::clear() calls, because automatic
44        // flushes would otherwise delete metrics before we can assert them, e.g. after whenever
45        // a subject under test commits a database transaction or when a tested Maintenance script
46        // prints output.
47        $this->factory = new class( $this->cache, new NullEmitter(), $this->logger ) extends StatsFactory {
48            public function flush(): void {
49            }
50        };
51    }
52
53    /**
54     * Set a component on the underlying StatsFactory
55     */
56    public function withComponent( string $component ): self {
57        $this->factory = $this->factory->withComponent( $component );
58        $this->component = $component;
59        return $this;
60    }
61
62    /**
63     * Get the underlying StatsFactory, to pass to your subject under test.
64     */
65    public function getStatsFactory(): StatsFactory {
66        return $this->factory;
67    }
68
69    /**
70     * Get all samples in dogstatsd format
71     *
72     * @return string[]
73     */
74    public function getAllFormatted(): array {
75        $output = [];
76        $dogFmt = new DogStatsdFormatter();
77        foreach ( $this->cache->getAllMetrics() as $metric ) {
78            $output = array_merge( $output, $dogFmt->getFormattedSamples( 'mediawiki', $metric ) );
79        }
80        return $output;
81    }
82
83    /**
84     * Get all samples in dogstatsd format and clear the buffer
85     *
86     * @return string[]
87     */
88    public function consumeAllFormatted(): array {
89        $output = $this->getAllFormatted();
90        $this->cache->clear();
91        return $output;
92    }
93
94    /**
95     * How many samples were observed for a given metric.
96     *
97     * Example:
98     * ```php
99     * $unitTestingHelper->count( 'the_metric_name{fooLabel="bar"}' );
100     * ```
101     */
102    public function count( string $selector ): int {
103        return count( $this->getFilteredSamples( $selector ) );
104    }
105
106    /**
107     * The last recorded sample value for a given metric.
108     *
109     * Example:
110     * ```php
111     * $unitTestingHelper->last( 'the_metric_name{fooLabel="bar"}' );
112     * ```
113     */
114    public function last( string $selector ): float {
115        $samples = $this->getFilteredSamples( $selector );
116        return $samples[array_key_last( $samples )]->getValue();
117    }
118
119    /**
120     * The sum of all sample values for a given metric.
121     *
122     * Example:
123     * ```php
124     * $unitTestingHelper->sum( 'the_metric_name{fooLabel="bar"}' );
125     * ```
126     */
127    public function sum( string $selector ): float {
128        $output = 0;
129        foreach ( $this->getFilteredSamples( $selector ) as $sample ) {
130            $output += $sample->getValue();
131        }
132        return $output;
133    }
134
135    /**
136     * The max of all sample values for a given metric.
137     *
138     * Example:
139     * ```php
140     * $unitTestingHelper->max( 'the_metric_name{fooLabel="bar"}' );
141     * ```
142     */
143    public function max( string $selector ): float {
144        $output = 0;
145        foreach ( $this->getFilteredSamples( $selector ) as $sample ) {
146            if ( $sample->getValue() > $output ) {
147                $output = $sample->getValue();
148            }
149        }
150        return $output;
151    }
152
153    /**
154     * The median of all sample values for a given metric.
155     *
156     * Example:
157     * ```php
158     * $unitTestingHelper->median( 'the_metric_name{fooLabel="bar"}' );
159     * ```
160     */
161    public function median( string $selector ): float {
162        return $this->sum( $selector ) / $this->count( $selector );
163    }
164
165    /**
166     * The min of all sample values for a given metric.
167     *
168     * Example:
169     * ```php
170     * $unitTestingHelper->min( 'the_metric_name{fooLabel="bar"}' );
171     * ```
172     */
173    public function min( string $selector ): float {
174        $output = INF;
175        foreach ( $this->getFilteredSamples( $selector ) as $sample ) {
176            if ( $sample->getValue() < $output ) {
177                $output = $sample->getValue();
178            }
179        }
180        return $output;
181    }
182
183    private function getMetricFromSelector( string $selector ): MetricInterface {
184        $key = StatsCache::cacheKey( $this->component, $this->getName( $selector ) );
185        $metric = $this->cache->getAllMetrics()[$key] ?? null;
186        if ( $metric === null ) {
187            $actual = 'Actual metrics:';
188            foreach ( $this->cache->getAllMetrics() as $metric ) {
189                $name = $metric->getName();
190                $sampleCount = $metric->getSampleCount();
191                $actual .= "\n  $name ($sampleCount samples)";
192            }
193            throw new OutOfBoundsException( "Could not find metric with key '$key'\n\n$actual" );
194        }
195        return $metric;
196    }
197
198    private function getFilteredSamples( string $selector ): array {
199        $metric = $this->getMetricFromSelector( $selector );
200        $filters = $this->getFilters( $selector );
201        $labelKeys = $metric->getLabelKeys();
202        $left = $metric->getSamples();
203        foreach ( $filters as $filter ) {
204            $right = [];
205            [ $key, $value, $operator ] = $filter;
206            $labelPosition = array_search( $key, $labelKeys );
207            foreach ( $left as $sample ) {
208                if ( $this->matches( $sample->getLabelValues()[$labelPosition], $value, $operator ) ) {
209                    $right[] = $sample;
210                }
211            }
212            $left = $right;
213        }
214        if ( !$left ) {
215            $dogFmt = new DogStatsdFormatter();
216            $actual = 'Actual samples:'
217                . "\n" . implode( "\n", $dogFmt->getFormattedSamples( 'mediawiki', $metric ) );
218            throw new OutOfRangeException( "Metric selector '$selector' matched zero samples.\n\n$actual" );
219        }
220        return $left;
221    }
222
223    private function getName( string $selector ): string {
224        $selector = preg_replace( '/\'/', '"', $selector );
225        if ( str_contains( $selector, '{' ) ) {
226            $selector = substr( $selector, 0, strpos( $selector, '{' ) );
227        }
228        if ( !$selector ) {
229            throw new InvalidArgumentException( 'Selector cannot be empty.' );
230        }
231        return $selector;
232    }
233
234    private function getFilters( string $selector ): array {
235        $selector = preg_replace( '/\'/', '"', $selector );
236        if ( !str_contains( $selector, '{' ) && !str_contains( $selector, ',' ) ) {
237            return [];
238        }
239        $output = [];
240        $filters = substr( $selector, strpos( $selector, '{' ) + 1, -1 );
241        $filters = explode( ',', $filters );
242        foreach ( $filters as $filter ) {
243            $output[] = $this->getFilterComponents( $filter );
244        }
245        return $output;
246    }
247
248    private function getFilterComponents( string $filter ): array {
249        $key = null;
250        $value = null;
251        $operator = null;
252        if ( str_contains( $filter, self::EQUALS ) ) {
253            [ $key, $value ] = explode( self::EQUALS, $filter );
254            $operator = self::EQUALS;
255        }
256        if ( str_contains( $filter, self::EQUALS_REGEX ) ) {
257            [ $key, $value ] = explode( self::EQUALS_REGEX, $filter );
258            $operator = self::EQUALS_REGEX;
259        }
260        if ( str_contains( $filter, self::NOT_EQUALS_REGEX ) ) {
261            [ $key, $value ] = explode( self::NOT_EQUALS_REGEX, $filter );
262            $operator = self::NOT_EQUALS_REGEX;
263        }
264        if ( str_contains( $filter, self::NOT_EQUALS ) ) {
265            [ $key, $value ] = explode( self::NOT_EQUALS, $filter );
266            $operator = self::NOT_EQUALS;
267        }
268        if ( !$key || !$value || !$operator ) {
269            $this->logger->debug(
270                'Got filter expression: {' . $filter . '}',
271                [ 'key' => $key, 'value' => $value, 'operator' => $operator ]
272            );
273            throw new InvalidArgumentException( "Filter components cannot be empty." );
274        }
275        $key = preg_replace( '/[^a-zA-Z0-9_]+/', '', $key );
276        $value = preg_replace( '/[^a-zA-Z0-9_]+/', '', $value );
277        return [ $key, $value, $operator ];
278    }
279
280    /**
281     * Return the boolean result of stored and expected values according to the operator.
282     */
283    private function matches( string $stored, string $expected, string $operator ): bool {
284        if ( $operator === self::NOT_EQUALS ) {
285            return $stored != $expected;
286        }
287        if ( $operator === self::EQUALS_REGEX ) {
288            return (bool)preg_match( "/$expected/", $stored );
289        }
290        if ( $operator === self::NOT_EQUALS_REGEX ) {
291            return !preg_match( "/$expected/", $stored );
292        }
293        return $stored === $expected;
294    }
295}