Skip to content

Core

modeltest

ModelSuite(name='suite', tests=None)

A collection of tests run together against a model + data.

Collect tests to run together against one model + dataset.

Args: name: Label used in reports and JUnit XML. tests: Initial tests; more can be added with add_test / add_tests.

Source code in modeltest/core/base.py
231
232
233
234
235
236
237
238
239
240
def __init__(self, name: str = "suite", tests: Optional[List[ModelTest]] = None):
    """Collect tests to run together against one model + dataset.

    Args:
        name: Label used in reports and JUnit XML.
        tests: Initial tests; more can be added with
            ``add_test`` / ``add_tests``.
    """
    self.name = name
    self.tests: List[ModelTest] = list(tests) if tests else []

add_test(test)

Append a test to the suite. Returns self, for chaining.

Source code in modeltest/core/base.py
242
243
244
245
def add_test(self, test: ModelTest) -> "ModelSuite":
    """Append a test to the suite. Returns self, for chaining."""
    self.tests.append(test)
    return self

add_tests(*tests)

Append several tests at once. Returns self, for chaining.

Source code in modeltest/core/base.py
247
248
249
250
def add_tests(self, *tests: ModelTest) -> "ModelSuite":
    """Append several tests at once. Returns self, for chaining."""
    self.tests.extend(tests)
    return self

run(model, X_val, y_val, X_train=None, y_train=None, **metadata)

Run every test against a model and validation data.

Builds a single TestContext (with the shared prediction cache) and passes it to all tests, so predictions are computed once.

Args: model: The model under test (wrapped automatically). X_val: Validation features (DataFrame or array). y_val: Ground-truth labels for the validation set. X_train: Optional training features — required by the drift tests. y_train: Optional training labels. **metadata: Extra key/value pairs stored in the context, e.g. model_name="fraud_rf".

Returns: Aggregate outcome with one TestResult per test.

Source code in modeltest/core/base.py
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
def run(
    self,
    model: Any,
    X_val: Any,
    y_val: Any,
    X_train: Any = None,
    y_train: Any = None,
    **metadata: Any,
) -> "SuiteResult":
    """Run every test against a model and validation data.

    Builds a single ``TestContext`` (with the shared prediction cache)
    and passes it to all tests, so predictions are computed once.

    Args:
        model: The model under test (wrapped automatically).
        X_val: Validation features (DataFrame or array).
        y_val: Ground-truth labels for the validation set.
        X_train: Optional training features — required by the
            [drift tests](../scenarios/drift.md).
        y_train: Optional training labels.
        **metadata: Extra key/value pairs stored in the context, e.g.
            ``model_name="fraud_rf"``.

    Returns:
        Aggregate outcome with one ``TestResult`` per test.
    """
    ctx = TestContext(
        model=model,
        X_val=X_val,
        y_val=y_val,
        X_train=X_train,
        y_train=y_train,
        metadata=metadata or {},
    )
    from modeltest.core.runner import run_suite

    return run_suite(self, ctx)

ModelTest

Base class for all model tests.

Subclasses override test(self, ctx). Raising AssertionError (or any assert failure) marks the test as FAILED; raising anything else marks it as ERROR. Returning a :class:TestResult lets a test fully control the outcome (useful for warning-only / non-blocking checks).

test(ctx)

Run the check. Subclasses must override this.

Args: ctx: Context with the model, data and shared prediction cache.

Returns: Normally nothing: pass/fail is expressed via exceptions. Return a TestResult to fully control the outcome (warning-only or non-blocking checks).

Raises: AssertionError: Marks the test as FAILED. Exception: Anything else marks it as ERROR.

Source code in modeltest/core/base.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def test(self, ctx: TestContext) -> Any:
    """Run the check. Subclasses must override this.

    Args:
        ctx: Context with the model, data and shared prediction cache.

    Returns:
        Normally nothing: pass/fail is expressed via exceptions. Return
        a ``TestResult`` to fully control the outcome (warning-only or
        non-blocking checks).

    Raises:
        AssertionError: Marks the test as FAILED.
        Exception: Anything else marks it as ERROR.
    """
    raise NotImplementedError

run(ctx)

Execute test and translate the outcome into a TestResult.

Times the call and maps exceptions to statuses: an AssertionError becomes FAILED, any other exception becomes ERROR.

Args: ctx: Context to run against.

Returns: The outcome for this test, named after self.name or the class name when no explicit name was set.

Source code in modeltest/core/base.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def run(self, ctx: TestContext) -> TestResult:
    """Execute ``test`` and translate the outcome into a ``TestResult``.

    Times the call and maps exceptions to statuses: an
    ``AssertionError`` becomes FAILED, any other exception becomes
    ERROR.

    Args:
        ctx: Context to run against.

    Returns:
        The outcome for this test, named after ``self.name`` or the
        class name when no explicit name was set.
    """
    import time

    start = time.perf_counter()
    status, detail, metrics = TestStatus.PASSED, "", {}
    try:
        outcome = self.test(ctx)
        if isinstance(outcome, TestResult):
            return outcome
    except AssertionError as exc:
        status = TestStatus.FAILED
        detail = str(exc)
    except Exception as exc:  # noqa: BLE001 - unknown failures are ERRORs
        status = TestStatus.ERROR
        detail = f"{type(exc).__name__}: {exc}"
    duration = (time.perf_counter() - start) * 1000
    return TestResult(
        name=self.name or type(self).__name__,
        status=status,
        detail=detail,
        duration_ms=duration,
        metrics=metrics,
    )

SuiteResult(suite_name, results=list()) dataclass

Aggregate outcome of running every test in a suite.

passed property

True when every test in the suite passed.

num_passed property

Count of PASSED tests.

num_failed property

Count of tests that did not pass (FAILED, ERROR or SKIPPED).

report(style='table')

Render the suite outcome.

Args: style: "table" (console), "json" (machine-readable) or "junit" (XML for CI test reporters).

Returns: The rendered report as a string.

Source code in modeltest/core/base.py
314
315
316
317
318
319
320
321
322
323
324
325
326
def report(self, style: str = "table") -> str:
    """Render the suite outcome.

    Args:
        style: ``"table"`` (console), ``"json"`` (machine-readable) or
            ``"junit"`` (XML for CI test reporters).

    Returns:
        The rendered report as a string.
    """
    from modeltest.core.report import render_report

    return render_report(self, style=style)

TestContext(model, X_val, y_val, X_train=None, y_train=None, metadata=dict(), cache_predictions=True, _cache=None, _wrapper=None) dataclass

Everything a test may need at runtime.

A unified context (rather than a loose model, X, y signature) lets tests grow (drift needs train vs val; explainability needs metadata) without breaking the base API.

model_name property

Name of the model under test.

Uses the model_name metadata key when present, falling back to the wrapped model's class name.

predict(X=None)

Predict with caching.

Delegates to the model via the framework adapter while transparently dropping non-feature columns (via model_features). When cache_predictions is on, the result is keyed by a content hash of the input so that multiple tests predicting on the same data run the model only once per suite.

Passing X=None predicts on self.X_val.

Source code in modeltest/core/base.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def predict(self, X: Any = None) -> Any:
    """Predict with caching.

    Delegates to the model via the framework adapter while transparently
    dropping non-feature columns (via ``model_features``). When
    ``cache_predictions`` is on, the result is keyed by a content hash of
    the input so that multiple tests predicting on the *same data* run the
    model only once per suite.

    Passing ``X=None`` predicts on ``self.X_val``.
    """
    from modeltest.scenarios._utils import model_features

    X = self.X_val if X is None else X
    wrapped = self._wrapped()
    X_feat = model_features(wrapped, X)

    if not self.cache_predictions:
        return wrapped.predict(X_feat)

    if self._cache is None:
        self._cache = {}

    key = self._fingerprint(X_feat)
    cached = self._cache.get(key)
    if cached is not None:
        return cached
    pred = wrapped.predict(X_feat)
    self._cache[key] = pred
    return pred

predict_proba(X=None)

Probability estimates via the wrapper (or None if unsupported).

Source code in modeltest/core/base.py
107
108
109
110
111
112
def predict_proba(self, X: Any = None) -> Any:
    """Probability estimates via the wrapper (or ``None`` if unsupported)."""
    from modeltest.scenarios._utils import model_features

    X = self.X_val if X is None else X
    return self._wrapped().predict_proba(model_features(self._wrapped(), X))

TestResult(name, status, detail='', duration_ms=0.0, metrics=dict()) dataclass

Outcome of running a single test.

passed property

Whether the test passed (status is PASSED).

register(type_name, cls)

Map a YAML type string to a custom test class.

Custom tests must subclass :class:modeltest.ModelTest. Registering before :func:load_suite_yaml lets a suite reference your test by name::

from modeltest.config import register
register("my_error_check", MyErrorCheck)

# suite.yaml
# suite:
#   tests:
#     - type: my_error_check
#       params: {max_errors: 5}
Source code in modeltest/config.py
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def register(type_name: str, cls: type) -> None:
    """Map a YAML ``type`` string to a custom test class.

    Custom tests must subclass :class:`modeltest.ModelTest`. Registering
    before :func:`load_suite_yaml` lets a suite reference your test by name::

        from modeltest.config import register
        register("my_error_check", MyErrorCheck)

        # suite.yaml
        # suite:
        #   tests:
        #     - type: my_error_check
        #       params: {max_errors: 5}
    """
    if not (isinstance(cls, type) and issubclass(cls, ModelTest)):
        raise TypeError(f"register() expects a ModelTest subclass, got {cls!r}")
    _REGISTRY[type_name] = cls

unregister(type_name)

Remove a previously registered type (built-ins included).

Source code in modeltest/config.py
96
97
98
def unregister(type_name: str) -> None:
    """Remove a previously registered type (built-ins included)."""
    _REGISTRY.pop(type_name, None)

run_suite(suite, ctx)

Run every test in a suite against one shared context.

Sharing the context is what makes prediction caching work across tests: the first prediction is computed and every later test predicting on the same data reuses it.

Args: suite: The suite to execute. ctx: The (single) context handed to every test.

Returns: Aggregate outcome with one TestResult per test, in order.

Source code in modeltest/core/runner.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def run_suite(suite: ModelSuite, ctx: TestContext) -> SuiteResult:
    """Run every test in a suite against one shared context.

    Sharing the context is what makes prediction caching work across
    tests: the first prediction is computed and every later test predicting
    on the same data reuses it.

    Args:
        suite: The suite to execute.
        ctx: The (single) context handed to every test.

    Returns:
        Aggregate outcome with one ``TestResult`` per test, in order.
    """
    results = [test.run(ctx) for test in suite.tests]
    return SuiteResult(suite_name=suite.name, results=results)

run_test(test, ctx)

Run a single test against a context.

Args: test: The test to execute. ctx: Context with model, data and the shared prediction cache.

Returns: The test's TestResult.

Source code in modeltest/core/runner.py
 8
 9
10
11
12
13
14
15
16
17
18
def run_test(test: ModelTest, ctx: TestContext):
    """Run a single test against a context.

    Args:
        test: The test to execute.
        ctx: Context with model, data and the shared prediction cache.

    Returns:
        The test's ``TestResult``.
    """
    return test.run(ctx)

modeltest.core.base

Core primitives: ModelTest, TestContext, TestResult, ModelSuite.

The design mirrors how pytest structures tests but is oriented to ML: each test receives a rich context (model + data + metadata) instead of a bare function signature. A test passes by returning normally and fails by raising an assertion / returning a TestResult with passed=False.

TestStatus

Bases: str, Enum

Outcome status of a single test run.

Attributes: PASSED: The test returned normally — every assertion held. FAILED: An AssertionError was raised — the contract is violated. ERROR: Something else went wrong (bad config, missing column...). SKIPPED: The test decided not to run.

TestContext(model, X_val, y_val, X_train=None, y_train=None, metadata=dict(), cache_predictions=True, _cache=None, _wrapper=None) dataclass

Everything a test may need at runtime.

A unified context (rather than a loose model, X, y signature) lets tests grow (drift needs train vs val; explainability needs metadata) without breaking the base API.

model_name property

Name of the model under test.

Uses the model_name metadata key when present, falling back to the wrapped model's class name.

predict(X=None)

Predict with caching.

Delegates to the model via the framework adapter while transparently dropping non-feature columns (via model_features). When cache_predictions is on, the result is keyed by a content hash of the input so that multiple tests predicting on the same data run the model only once per suite.

Passing X=None predicts on self.X_val.

Source code in modeltest/core/base.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def predict(self, X: Any = None) -> Any:
    """Predict with caching.

    Delegates to the model via the framework adapter while transparently
    dropping non-feature columns (via ``model_features``). When
    ``cache_predictions`` is on, the result is keyed by a content hash of
    the input so that multiple tests predicting on the *same data* run the
    model only once per suite.

    Passing ``X=None`` predicts on ``self.X_val``.
    """
    from modeltest.scenarios._utils import model_features

    X = self.X_val if X is None else X
    wrapped = self._wrapped()
    X_feat = model_features(wrapped, X)

    if not self.cache_predictions:
        return wrapped.predict(X_feat)

    if self._cache is None:
        self._cache = {}

    key = self._fingerprint(X_feat)
    cached = self._cache.get(key)
    if cached is not None:
        return cached
    pred = wrapped.predict(X_feat)
    self._cache[key] = pred
    return pred

predict_proba(X=None)

Probability estimates via the wrapper (or None if unsupported).

Source code in modeltest/core/base.py
107
108
109
110
111
112
def predict_proba(self, X: Any = None) -> Any:
    """Probability estimates via the wrapper (or ``None`` if unsupported)."""
    from modeltest.scenarios._utils import model_features

    X = self.X_val if X is None else X
    return self._wrapped().predict_proba(model_features(self._wrapped(), X))

TestResult(name, status, detail='', duration_ms=0.0, metrics=dict()) dataclass

Outcome of running a single test.

passed property

Whether the test passed (status is PASSED).

ModelTest

Base class for all model tests.

Subclasses override test(self, ctx). Raising AssertionError (or any assert failure) marks the test as FAILED; raising anything else marks it as ERROR. Returning a :class:TestResult lets a test fully control the outcome (useful for warning-only / non-blocking checks).

test(ctx)

Run the check. Subclasses must override this.

Args: ctx: Context with the model, data and shared prediction cache.

Returns: Normally nothing: pass/fail is expressed via exceptions. Return a TestResult to fully control the outcome (warning-only or non-blocking checks).

Raises: AssertionError: Marks the test as FAILED. Exception: Anything else marks it as ERROR.

Source code in modeltest/core/base.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def test(self, ctx: TestContext) -> Any:
    """Run the check. Subclasses must override this.

    Args:
        ctx: Context with the model, data and shared prediction cache.

    Returns:
        Normally nothing: pass/fail is expressed via exceptions. Return
        a ``TestResult`` to fully control the outcome (warning-only or
        non-blocking checks).

    Raises:
        AssertionError: Marks the test as FAILED.
        Exception: Anything else marks it as ERROR.
    """
    raise NotImplementedError

run(ctx)

Execute test and translate the outcome into a TestResult.

Times the call and maps exceptions to statuses: an AssertionError becomes FAILED, any other exception becomes ERROR.

Args: ctx: Context to run against.

Returns: The outcome for this test, named after self.name or the class name when no explicit name was set.

Source code in modeltest/core/base.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def run(self, ctx: TestContext) -> TestResult:
    """Execute ``test`` and translate the outcome into a ``TestResult``.

    Times the call and maps exceptions to statuses: an
    ``AssertionError`` becomes FAILED, any other exception becomes
    ERROR.

    Args:
        ctx: Context to run against.

    Returns:
        The outcome for this test, named after ``self.name`` or the
        class name when no explicit name was set.
    """
    import time

    start = time.perf_counter()
    status, detail, metrics = TestStatus.PASSED, "", {}
    try:
        outcome = self.test(ctx)
        if isinstance(outcome, TestResult):
            return outcome
    except AssertionError as exc:
        status = TestStatus.FAILED
        detail = str(exc)
    except Exception as exc:  # noqa: BLE001 - unknown failures are ERRORs
        status = TestStatus.ERROR
        detail = f"{type(exc).__name__}: {exc}"
    duration = (time.perf_counter() - start) * 1000
    return TestResult(
        name=self.name or type(self).__name__,
        status=status,
        detail=detail,
        duration_ms=duration,
        metrics=metrics,
    )

ModelSuite(name='suite', tests=None)

A collection of tests run together against a model + data.

Collect tests to run together against one model + dataset.

Args: name: Label used in reports and JUnit XML. tests: Initial tests; more can be added with add_test / add_tests.

Source code in modeltest/core/base.py
231
232
233
234
235
236
237
238
239
240
def __init__(self, name: str = "suite", tests: Optional[List[ModelTest]] = None):
    """Collect tests to run together against one model + dataset.

    Args:
        name: Label used in reports and JUnit XML.
        tests: Initial tests; more can be added with
            ``add_test`` / ``add_tests``.
    """
    self.name = name
    self.tests: List[ModelTest] = list(tests) if tests else []

add_test(test)

Append a test to the suite. Returns self, for chaining.

Source code in modeltest/core/base.py
242
243
244
245
def add_test(self, test: ModelTest) -> "ModelSuite":
    """Append a test to the suite. Returns self, for chaining."""
    self.tests.append(test)
    return self

add_tests(*tests)

Append several tests at once. Returns self, for chaining.

Source code in modeltest/core/base.py
247
248
249
250
def add_tests(self, *tests: ModelTest) -> "ModelSuite":
    """Append several tests at once. Returns self, for chaining."""
    self.tests.extend(tests)
    return self

run(model, X_val, y_val, X_train=None, y_train=None, **metadata)

Run every test against a model and validation data.

Builds a single TestContext (with the shared prediction cache) and passes it to all tests, so predictions are computed once.

Args: model: The model under test (wrapped automatically). X_val: Validation features (DataFrame or array). y_val: Ground-truth labels for the validation set. X_train: Optional training features — required by the drift tests. y_train: Optional training labels. **metadata: Extra key/value pairs stored in the context, e.g. model_name="fraud_rf".

Returns: Aggregate outcome with one TestResult per test.

Source code in modeltest/core/base.py
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
def run(
    self,
    model: Any,
    X_val: Any,
    y_val: Any,
    X_train: Any = None,
    y_train: Any = None,
    **metadata: Any,
) -> "SuiteResult":
    """Run every test against a model and validation data.

    Builds a single ``TestContext`` (with the shared prediction cache)
    and passes it to all tests, so predictions are computed once.

    Args:
        model: The model under test (wrapped automatically).
        X_val: Validation features (DataFrame or array).
        y_val: Ground-truth labels for the validation set.
        X_train: Optional training features — required by the
            [drift tests](../scenarios/drift.md).
        y_train: Optional training labels.
        **metadata: Extra key/value pairs stored in the context, e.g.
            ``model_name="fraud_rf"``.

    Returns:
        Aggregate outcome with one ``TestResult`` per test.
    """
    ctx = TestContext(
        model=model,
        X_val=X_val,
        y_val=y_val,
        X_train=X_train,
        y_train=y_train,
        metadata=metadata or {},
    )
    from modeltest.core.runner import run_suite

    return run_suite(self, ctx)

SuiteResult(suite_name, results=list()) dataclass

Aggregate outcome of running every test in a suite.

passed property

True when every test in the suite passed.

num_passed property

Count of PASSED tests.

num_failed property

Count of tests that did not pass (FAILED, ERROR or SKIPPED).

report(style='table')

Render the suite outcome.

Args: style: "table" (console), "json" (machine-readable) or "junit" (XML for CI test reporters).

Returns: The rendered report as a string.

Source code in modeltest/core/base.py
314
315
316
317
318
319
320
321
322
323
324
325
326
def report(self, style: str = "table") -> str:
    """Render the suite outcome.

    Args:
        style: ``"table"`` (console), ``"json"`` (machine-readable) or
            ``"junit"`` (XML for CI test reporters).

    Returns:
        The rendered report as a string.
    """
    from modeltest.core.report import render_report

    return render_report(self, style=style)

assert_metric(actual, expected, op, msg)

Raise AssertionError when op(actual, expected) is not truthy.

Small helper for custom tests that express a check as a metric, a comparison and a message.

Args: actual: Computed metric value. expected: Reference value handed to op. op: Comparison callable, e.g. operator.ge. msg: Failure message used verbatim in the AssertionError.

Raises: AssertionError: If the comparison fails.

Source code in modeltest/core/base.py
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
def assert_metric(
    actual: float, expected: Any, op: Callable[[float, Any], bool], msg: str
) -> None:
    """Raise ``AssertionError`` when ``op(actual, expected)`` is not truthy.

    Small helper for custom tests that express a check as a metric, a
    comparison and a message.

    Args:
        actual: Computed metric value.
        expected: Reference value handed to ``op``.
        op: Comparison callable, e.g. ``operator.ge``.
        msg: Failure message used verbatim in the ``AssertionError``.

    Raises:
        AssertionError: If the comparison fails.
    """
    if not op(actual, expected):
        raise AssertionError(msg)

modeltest.core.runner

Test execution entry points.

run_test(test, ctx)

Run a single test against a context.

Args: test: The test to execute. ctx: Context with model, data and the shared prediction cache.

Returns: The test's TestResult.

Source code in modeltest/core/runner.py
 8
 9
10
11
12
13
14
15
16
17
18
def run_test(test: ModelTest, ctx: TestContext):
    """Run a single test against a context.

    Args:
        test: The test to execute.
        ctx: Context with model, data and the shared prediction cache.

    Returns:
        The test's ``TestResult``.
    """
    return test.run(ctx)

run_suite(suite, ctx)

Run every test in a suite against one shared context.

Sharing the context is what makes prediction caching work across tests: the first prediction is computed and every later test predicting on the same data reuses it.

Args: suite: The suite to execute. ctx: The (single) context handed to every test.

Returns: Aggregate outcome with one TestResult per test, in order.

Source code in modeltest/core/runner.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def run_suite(suite: ModelSuite, ctx: TestContext) -> SuiteResult:
    """Run every test in a suite against one shared context.

    Sharing the context is what makes prediction caching work across
    tests: the first prediction is computed and every later test predicting
    on the same data reuses it.

    Args:
        suite: The suite to execute.
        ctx: The (single) context handed to every test.

    Returns:
        Aggregate outcome with one ``TestResult`` per test, in order.
    """
    results = [test.run(ctx) for test in suite.tests]
    return SuiteResult(suite_name=suite.name, results=results)

modeltest.core.report

Report rendering: console table, JSON, and JUnit XML (for CI/CD).

render_report(result, style='table')

Render a SuiteResult in the requested style.

Args: result: The suite outcome to render. style: "table" (console, default), "json" or "junit".

Returns: The rendered report as a string.

Raises: ValueError: Via the JSON encoder path if the payload is not serializable (should not happen for standard results).

Source code in modeltest/core/report.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def render_report(result: SuiteResult, style: str = "table") -> str:
    """Render a ``SuiteResult`` in the requested style.

    Args:
        result: The suite outcome to render.
        style: ``"table"`` (console, default), ``"json"`` or ``"junit"``.

    Returns:
        The rendered report as a string.

    Raises:
        ValueError: Via the JSON encoder path if the payload is not
            serializable (should not happen for standard results).
    """
    if style == "json":
        return to_json(result)
    if style == "junit":
        return to_junit_xml(result)
    return _render_table(result)

to_json(result)

Serialize a SuiteResult to a JSON string.

Includes the suite name, aggregate counts and one object per test with name, status, detail, duration and recorded metrics.

Args: result: The suite outcome to serialize.

Returns: Pretty-printed JSON (2-space indent).

Source code in modeltest/core/report.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def to_json(result: SuiteResult) -> str:
    """Serialize a ``SuiteResult`` to a JSON string.

    Includes the suite name, aggregate counts and one object per test with
    name, status, detail, duration and recorded metrics.

    Args:
        result: The suite outcome to serialize.

    Returns:
        Pretty-printed JSON (2-space indent).
    """
    payload = {
        "suite": result.suite_name,
        "passed": result.passed,
        "num_passed": result.num_passed,
        "num_failed": result.num_failed,
        "tests": [
            {
                "name": r.name,
                "status": r.status.value,
                "detail": r.detail,
                "duration_ms": r.duration_ms,
                "metrics": r.metrics,
            }
            for r in result.results
        ],
    }
    return json.dumps(payload, indent=2)

to_junit_xml(result)

Serialize a SuiteResult to JUnit XML.

The output wraps everything in the standard <testsuites> element expected by CI test reporters (GitHub Actions, Jenkins, GitLab...): FAILED tests become <failure> nodes, ERROR tests <error>.

Args: result: The suite outcome to serialize.

Returns: The XML document as a string.

Source code in modeltest/core/report.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def to_junit_xml(result: SuiteResult) -> str:
    """Serialize a ``SuiteResult`` to JUnit XML.

    The output wraps everything in the standard ``<testsuites>`` element
    expected by CI test reporters (GitHub Actions, Jenkins, GitLab...):
    FAILED tests become ``<failure>`` nodes, ERROR tests ``<error>``.

    Args:
        result: The suite outcome to serialize.

    Returns:
        The XML document as a string.
    """
    failures = sum(1 for r in result.results if r.status == TestStatus.FAILED)
    errors = sum(1 for r in result.results if r.status == TestStatus.ERROR)
    total = len(result.results)
    timestamp = datetime.now(timezone.utc).isoformat(timespec="seconds")

    xml = ['<?xml version="1.0" encoding="UTF-8"?>']
    xml.append("<testsuites>")
    xml.append(
        f'<testsuite name="{_esc(result.suite_name)}" tests="{total}" '
        f'failures="{failures}" errors="{errors}" skipped="0" '
        f'time="{sum(r.duration_ms for r in result.results) / 1000:.3f}" '
        f'timestamp="{timestamp}">'
    )
    for r in result.results:
        xml.append(
            f'  <testcase classname="modeltest" name="{_esc(r.name)}" '
            f'time="{r.duration_ms / 1000:.3f}">'
        )
        if r.status == TestStatus.FAILED:
            xml.append(f'    <failure message="{_esc(r.detail)}" />')
        elif r.status == TestStatus.ERROR:
            xml.append(f'    <error message="{_esc(r.detail)}" />')
        xml.append("  </testcase>")
    xml.append("</testsuite>")
    xml.append("</testsuites>")
    return "\n".join(xml)