This guide explains how to migrate tests that used the removed ClientMonitor.get_metric() and ClientMonitor.get_metrics() methods to ClientMonitor.metrics(), ClientMonitor.single_metric(), ClientMonitor.single_metric_optional(), MetricsSnapshot, and ClientMonitor.metrics_diff().
The API docstrings are the source of truth. See pytest_userver.client.ClientMonitor and pytest_userver.metrics.MetricsSnapshot.
The removed methods requested the removed format=internal. That format was a nested JSON object without metric types. Labels were encoded indirectly through $meta: {solomon_children_labels: <label-name>}: children of that node became values of the specified label and disappeared from the metric path. A node marked with SolomonSkip also disappeared from the path, but its children did not become label values.
The current API requests format=json and returns a flat collection of metric series. Every series has a path, labels, a value, and a type. Tests receive it as a MetricsSnapshot.
A literal dot inside one key of the legacy nested format may become an underscore in the JSON path. For example, the legacy key "requests.success" may become the path segment requests_success; this differs from dots that separate nested object levels. Inspect the actual JSON snapshot when the resulting path is unclear.
Classify each legacy call before rewriting it:
Preserve the width of the original assertion. A whole legacy subtree normally maps to metrics(prefix=...); one leaf maps to path=... or single_metric(); several selected values map to one broad fetch followed by client-side checks. A narrower replacement can miss unexpected series, while a wider one can include unrelated metrics.
When possible, inspect the metric writer or registration:
Plain nesting is not evidence of labels. For example, a writer equivalent to writer["jobs"][kind]["attempt"][number] without label metadata produces paths such as jobs.email.attempt.1, not jobs with kind and attempt labels.
MinMaxAvg and Percentile also have different JSON representations:
If static inspection is inconclusive, temporarily print the actual snapshot:
Run the focused test normally:
The assertion output contains each path, label set, type, and value. Remove the temporary failing assertion after updating the test.
MetricsSnapshot behaves like a mapping from metric paths to sets of Metric objects. Prefer plain equality because pytest provides a useful snapshot diff:
For from_layered_dict():
A path mapped to an empty set and an absent path compare equal. For a completely empty result, prefer assert not snapshot over comparison with MetricsSnapshot.from_layered_dict({}).
from_layered_dict() supports any number of nested labels. For isolated series with many labels, direct construction or exact value_at() checks may be clearer:
Do not keep legacy $meta data and convert it at runtime. Rewrite expected data directly in the current snapshot format so that the test documents the real path-and-label model.
value_at(path, labels) uses exact label matching. The series must have exactly the supplied labels. Without default, it asserts that exactly one series was found; with default, it returns that value when no series was found. The default may be any value, including None.
metrics_at(path, require_labels=...) and has_metrics_at(path, require_labels=...) use subset matching. A series may have additional labels:
Do not replace a subset existence check with value_at(..., default=None) unless the complete label set is known. Conversely, use value_at() when the test needs one exact value.
An empty snapshot and a value of zero are different assertions. assert not snapshot proves that no matching series exist. value_at(path, labels, default=0) == 0 accepts either an absent series or a present series whose value is zero. Choose according to the original test semantics.
Use path='worker.processed' for one exact metric path. Use prefix='worker.' for a subtree.
prefix is a literal string prefix at the monitor endpoint. A trailing dot establishes a path-segment boundary: prefix='worker.job' can also match worker.jobs, while prefix='worker.job.' selects descendants of worker.job.
A legacy assertion that a complete subtree is absent must remain a subtree assertion:
Replacing it with single_metric_optional('worker.errors.timeout') is None would miss another unexpected series such as worker.errors.protocol.
A broad prefix can include generated or neighboring metrics that the legacy test discarded manually. Either narrow the prefix to the intended namespace or fetch once and assert only the selected values. Do not claim complete snapshot coverage when the test checks only a few leaves.
For one measured action, use the context manager:
The context manager fetches the baseline on entry and the current snapshot on exit. Put unrelated setup before the context so that fixture activity, mock configuration, or test-data preparation does not affect the measured interval.
A single scope may contain multiple metrics_diff() context managers. Use this when the measured metrics have no useful common path prefix or labels and fetching every service metric would be wasteful. Each differ then requests only its own path or prefix while all baselines are captured before the action and all current values after it:
By default, numeric subtraction is applied to RATE metrics. A GAUGE normally retains its current absolute value. If the test intentionally needs a gauge delta, pass diff_gauge=True:
Use this only when subtraction is meaningful. Plain atomics and RecentPeriod values commonly appear as GAUGE metrics even when application code increments them like counters.
For an intermediate checkpoint, call fetch_current(). It stores the new current snapshot and updates the diff:
For a lifecycle that does not map cleanly onto one context-manager scope, use fetch_baseline() before the measured action and fetch_current() after it. For the usual single-action lifecycle, prefer async with.
Server-side labels= filtering in metrics_diff() can be unsuitable for metrics whose labels are produced by Solomon metadata. If it excludes the series unexpectedly, fetch by path or prefix and filter in value_at():
A diff snapshot may omit a series that did not change. If an unchanged or non-incremented series semantically means zero, pass default=0:
Do not add default=0 when the test must prove that the series exists. Likewise, do not use it when absence and an explicit zero have different meanings.
When migrating a shared helper, update every caller and every stored value consistently. Comparing a legacy mapping with a MetricsSnapshot fails even if the contained numbers look equivalent. Shared fixtures that manually fetch and subtract metrics should normally be replaced by metrics_diff() at the call site.
If several checks repeat a long common path or common labels, request a sliced snapshot:
metrics(..., sliced=True) is equivalent to calling .sliced(path or prefix, labels) on the returned snapshot. MetricsSnapshot.sliced(prefix, labels) keeps matching series, removes the common path prefix, and remembers common labels for later exact lookups. Calls may be chained to narrow a snapshot in stages. unsliced() restores full paths of the remaining series; it does not restore series filtered out by slicing.
Slicing uses whole dot-separated path segments, unlike the server-side literal prefix filter. Do not add slicing for a single lookup when it does not reduce repetition.
With sliced=True and an exact path=..., a nonexistent path produces an empty snapshot. A later value_at('') error includes the sliced prefix, which is usually sufficient to diagnose the mismatch.
metrics_diff() uses sliced=True by default, unlike metrics(). Therefore paths passed to differ.value_at() are normally relative to the requested path or prefix. Pass sliced=False when retaining full paths is clearer.
Some tests call a custom HTTP endpoint that exposes userver metrics instead of using monitor_client. First determine which format that endpoint intentionally supports:
Preserve the endpoint's path, prefix, and label filtering semantics. Prefer monitor_client.metrics() when the standard monitor endpoint is available and supports format=json.
Use import pytest_userver.metrics and the qualified names pytest_userver.metrics.MetricsSnapshot and pytest_userver.metrics.Metric. This leaves metrics available as a local snapshot variable. If a file already imports from pytest_userver import metrics, avoid shadowing that module; name the local value snapshot instead.
For metrics(), metrics_diff(), single_metric(), single_metric_optional(), value_at(), metrics_at(), sliced(), and from_layered_dict():
Construct expected snapshots next to their data. Do not pass a raw legacy mapping through a distant helper and convert it later. Avoid comments that merely restate the assertion.
A service created from the userver service template registers one CTest test named testsuite-service_template via userver_testsuite_add_simple(). Select that CTest test with -R, and pass a pytest filter inside it through PYTEST_ADDOPTS. For example, with tests/test_postgres.py:
The CTest -R expression selects the single testsuite test. The first pytest -k expression selects the test module by its filename stem; the second selects one test function inside that testsuite test. Because -k is a keyword expression rather than an exact node-id selector, use a distinctive module or function name and check the collection summary. Run the whole modified module after the focused function.
Read snapshot diffs carefully: they show the actual path, labels, type, and value. Verify that the migrated test performs the same number of monitor requests, covers the same set of series, and contains no temporary diagnostic assertions.