Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ Unreleased
### Improvements

- Added tests and documentation for the assertion explainer.
- Added color output for outcomes.
- Grouped output by module and class.
- Included timing in the summary.
- Captured each test's stdout and stderr, replaying them in the
failure detail block.


[0.2.2] (2026-06-27)
Expand Down
2 changes: 2 additions & 0 deletions src/testsweet/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Failed,
Outcome,
Passed,
Result,
Skipped,
XFailed,
XPassed,
Expand All @@ -27,6 +28,7 @@
'Outcome',
'Passed',
'Plugin',
'Result',
'Skipped',
'XFailed',
'XPassed',
Expand Down
77 changes: 65 additions & 12 deletions src/testsweet/__main__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import argparse
import os
import pathlib
import sys
import time

from testsweet._config import load_config
from testsweet._loaders import scoped_sys_path
from testsweet._plugins import load_plugins, session_for, unit_wrapper
from testsweet._outcomes import Errored, Failed, Outcome, XPassed
from testsweet._outcomes import Errored, Failed, Result, XPassed
from testsweet._report import (
format_result_line,
print_failure_detail,
Expand Down Expand Up @@ -45,6 +47,31 @@
"""


def _split_group(name: str) -> tuple[str | None, str]:
"""Split 'ClassName.method[n]' into ('ClassName', 'method[n]').

Returns (None, name) for standalone functions with no class prefix.
"""
dot = name.find('.')
return (name[:dot], name[dot + 1:]) if dot != -1 else (None, name)


def _supports_color() -> bool:
if not sys.stdout.isatty():
return False
if os.environ.get('NO_COLOR'):
return False
if sys.platform == 'win32':
# VT processing is auto-enabled from Python 3.12+. On older
# versions, accept Windows Terminal (WT_SESSION) and ANSICON.
return (
sys.version_info >= (3, 12)
or bool(os.environ.get('WT_SESSION'))
or bool(os.environ.get('ANSICON'))
)
return True


def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument(
Expand Down Expand Up @@ -94,25 +121,51 @@ def main(argv: list[str]) -> int:
config = load_config(pathlib.Path.cwd())
plugins = load_plugins()
wrap_unit = unit_wrapper(plugins)
results: list[tuple[str, Outcome]] = []
real_failures: list[tuple[str, Outcome]] = []
use_color = _supports_color()
results: list[Result] = []
real_failures: list[tuple[str, Result]] = []
start = time.monotonic()
with session_for(plugins):
groups = discover_targets(args.targets, config)
last_module: str | None = None
last_class: str | None = None
for module, names in groups:
for name, outcome in run(
for result in run(
module,
names=names,
wrap_unit=wrap_unit,
keep=keep,
):
full_name = f'{module.__name__}.{name}'
print(format_result_line(full_name, outcome))
results.append((full_name, outcome))
if isinstance(outcome, (Failed, Errored, XPassed)):
real_failures.append((full_name, outcome))
for full_name, outcome in real_failures:
print_failure_detail(full_name, outcome)
print(summarize(results))
full_name = f'{module.__name__}.{result.name}'
class_name, short_name = _split_group(result.name)
if module.__name__ != last_module:
print(module.__name__)
last_module = module.__name__
last_class = None
if class_name != last_class:
if class_name is not None:
print(f' {class_name}')
last_class = class_name
indent = ' ' if class_name else ' '
result_line = format_result_line(
short_name,
result.outcome,
use_color=use_color,
)
print(f'{indent}{result_line}')
results.append(result)
if isinstance(result.outcome, (Failed, Errored, XPassed)):
real_failures.append((full_name, result))
elapsed = time.monotonic() - start
for full_name, result in real_failures:
print_failure_detail(
full_name,
result.outcome,
result.stdout,
result.stderr,
)
print()
print(summarize(results, use_color=use_color, elapsed=elapsed))
return 1 if real_failures else 0


Expand Down
16 changes: 16 additions & 0 deletions src/testsweet/_outcomes.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,19 @@ class XPassed:


Outcome = Passed | Failed | Errored | Skipped | XFailed | XPassed


@dataclass(frozen=True)
class Result:
"""A test unit's outcome plus any output it produced.

``run()`` returns ``list[Result]``. ``stdout`` and ``stderr`` hold
the test's captured streams; they are empty unless the unit wrote
to those streams. Captured output is replayed only when the test
fails — see ``_report.print_failure_detail``.
"""

name: str
outcome: Outcome
stdout: str = ''
stderr: str = ''
90 changes: 68 additions & 22 deletions src/testsweet/_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,22 @@
Failed,
Outcome,
Passed,
Result,
Skipped,
XFailed,
XPassed,
)

_GREEN = '32'
_YELLOW = '33'
_RED = '31'
_BOLD_RED = '1;31'
_MAGENTA = '35'


def _c(text: str, code: str, enabled: bool) -> str:
return f'\033[{code}m{text}\033[0m' if enabled else text


_XPASS_DETAIL = (
'Test was marked @xfail but passed. '
Expand All @@ -30,36 +41,63 @@ def _suffix(reason: str | None) -> str:
return f': {reason}' if reason else ''


def format_result_line(full_name: str, outcome: Outcome) -> str:
def format_result_line(
full_name: str,
outcome: Outcome,
use_color: bool = False,
) -> str:
"""One-line summary suitable for streaming output."""
match outcome:
case Passed():
return f'{full_name} ... ok'
status = _c('ok', _GREEN, use_color)
return f'{full_name} ... {status}'
case Skipped(reason=reason):
return f'{full_name} ... skipped{_suffix(reason)}'
status = _c(f'skipped{_suffix(reason)}', _YELLOW, use_color)
return f'{full_name} ... {status}'
case XFailed(reason=reason):
return f'{full_name} ... xfailed{_suffix(reason)}'
status = _c(f'xfailed{_suffix(reason)}', _YELLOW, use_color)
return f'{full_name} ... {status}'
case XPassed(reason=reason):
return f'{full_name} ... XPASSED{_suffix(reason)}'
status = _c(f'XPASSED{_suffix(reason)}', _MAGENTA, use_color)
return f'{full_name} ... {status}'
case Failed(exc=exc):
detail = str(exc) or assertion_source(exc) or ''
return f'{full_name} ... FAIL: AssertionError: {detail}'
status = _c(
f'FAIL: AssertionError: {detail}',
_BOLD_RED,
use_color,
)
return f'{full_name} ... {status}'
case Errored(exc=exc):
return (
f'{full_name} ... ERROR: {type(exc).__name__}: {exc}'
status = _c(
f'ERROR: {type(exc).__name__}: {exc}', _RED, use_color,
)
return f'{full_name} ... {status}'


def _print_captured(stdout: str, stderr: str, file: TextIO) -> None:
if stdout:
print('-' * 26 + ' Captured stdout ' + '-' * 27, file=file)
print(stdout, end='' if stdout.endswith('\n') else '\n', file=file)
if stderr:
print('-' * 26 + ' Captured stderr ' + '-' * 27, file=file)
print(stderr, end='' if stderr.endswith('\n') else '\n', file=file)


def print_failure_detail(
full_name: str,
outcome: Outcome,
file: TextIO = sys.stdout,
stdout: str = '',
stderr: str = '',
file: TextIO | None = None,
) -> None:
"""Multi-line failure block.

Fires only for ``Failed``, ``Errored``, and ``XPassed``. Other
outcomes do not get a detail block.
"""
if file is None:
file = sys.stdout
match outcome:
case Passed() | Skipped() | XFailed():
return
Expand All @@ -69,14 +107,17 @@ def print_failure_detail(
print(f'XPASSED: {full_name}', file=file)
print('-' * 70, file=file)
print(_XPASS_DETAIL, file=file)
_print_captured(stdout, stderr, file)
return
case Failed(exc=exc):
_print_traceback_block('FAIL', full_name, exc, file)
explanation = explain_assertion(exc)
if explanation is not None:
print(explanation, file=file)
_print_captured(stdout, stderr, file)
case Errored(exc=exc):
_print_traceback_block('ERROR', full_name, exc, file)
_print_captured(stdout, stderr, file)


def _print_traceback_block(
Expand Down Expand Up @@ -110,27 +151,32 @@ def _outcome_key(outcome: Outcome) -> str:


_SUMMARY_ORDER = (
('passed', 'passed'),
('failed', 'failed'),
('errored', 'error'),
('skipped', 'skipped'),
('xfailed', 'xfailed'),
('xpassed', 'xpassed'),
('passed', 'passed', _GREEN),
('failed', 'failed', _BOLD_RED),
('errored', 'error', _RED),
('skipped', 'skipped', _YELLOW),
('xfailed', 'xfailed', _YELLOW),
('xpassed', 'xpassed', _MAGENTA),
)


def summarize(results: Iterable[tuple[str, Outcome]]) -> str:
def summarize(
results: Iterable[Result],
use_color: bool = False,
elapsed: float | None = None,
) -> str:
"""One-line summary of result counts."""
counts: collections.Counter = collections.Counter()
total = 0
for _name, outcome in results:
counts[_outcome_key(outcome)] += 1
for r in results:
counts[_outcome_key(r.outcome)] += 1
total += 1
timing = f' in {elapsed:.2f}s' if elapsed is not None else ''
if total == 0:
return '0 tests'
return f'0 tests{timing}'
parts = [
f'{counts[key]} {label}'
for key, label in _SUMMARY_ORDER
_c(f'{counts[key]} {label}', color, use_color)
for key, label, color in _SUMMARY_ORDER
if counts.get(key)
]
return ', '.join(parts)
return f'{", ".join(parts)}{timing}'
Loading
Loading