diff --git a/AUTHORS b/AUTHORS index d6d2737a4bf..f4a2769ca3c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -195,6 +195,7 @@ Grig Gheorghiu Grigorii Eremeev (budulianin) Guido Wesdorp Guoqiang Zhang +Hamza Mobeen Harald Armin Massa Harshna Henk-Jaap Wagenaar diff --git a/changelog/6757.feature.rst b/changelog/6757.feature.rst new file mode 100644 index 00000000000..667ef543908 --- /dev/null +++ b/changelog/6757.feature.rst @@ -0,0 +1,3 @@ +Added the :confval:`assertion_text_diff_style` configuration option, allowing +multiline string equality failures to be rendered as separate ``Left:`` and +``Right:`` blocks instead of ``ndiff`` output. diff --git a/changelog/8395.feature.rst b/changelog/8395.feature.rst new file mode 100644 index 00000000000..b7b8b959b03 --- /dev/null +++ b/changelog/8395.feature.rst @@ -0,0 +1 @@ +Added support for :class:`~datetime.datetime` and :class:`~datetime.timedelta` comparisons with :func:`pytest.approx`. An explicit ``abs`` tolerance as a :class:`~datetime.timedelta` is required; relative tolerance is not supported for time-based comparisons -- by :user:`hamza-mobeen`. diff --git a/doc/en/how-to/output.rst b/doc/en/how-to/output.rst index a594fcb3aab..127d776dfad 100644 --- a/doc/en/how-to/output.rst +++ b/doc/en/how-to/output.rst @@ -363,6 +363,10 @@ This is done by setting a verbosity level in the configuration file for the spec ``pytest --no-header`` with a value of ``2`` would have the same output as the previous example, but each test inside the file is shown by a single character in the output. +:confval:`assertion_text_diff_style`: Controls how pytest renders ``str == str`` failures. The default ``ndiff`` output +keeps the existing inline diff markers. Setting it to ``block`` prints multiline string comparisons as separate +``Left:`` and ``Right:`` blocks, which can be easier to read when whitespace or indentation differences dominate. + :confval:`verbosity_test_cases`: Controls how verbose the test execution output should be when pytest is executed. Running ``pytest --no-header`` with a value of ``2`` would have the same output as the first verbosity example, but each test inside the file gets its own line in the output. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index a69aa2c7887..9fd55c58e47 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -2699,6 +2699,32 @@ passed multiple times. The expected format is ``name=value``. For example:: A special value of ``"auto"`` can be used to explicitly use the global verbosity level. +.. confval:: assertion_text_diff_style + :type: ``str`` + :default: ``"ndiff"`` + + Set how pytest renders diffs for string equality assertions. + + Supported values are: + + * ``ndiff``: use the default inline diff rendering. + * ``block``: render multiline string comparisons as separate ``Left:`` and ``Right:`` blocks. + + .. tab:: toml + + .. code-block:: toml + + [pytest] + assertion_text_diff_style = "block" + + .. tab:: ini + + .. code-block:: ini + + [pytest] + assertion_text_diff_style = block + + .. confval:: verbosity_subtests :type: ``str`` :default: ``"auto"`` diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index 22f3ca8e258..3b26d83cb2f 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -57,6 +57,15 @@ def pytest_addoption(parser: Parser) -> None: default=None, help=("Set threshold of CHARS after which truncation will take effect"), ) + parser.addini( + "assertion_text_diff_style", + default=util.ASSERTION_TEXT_DIFF_STYLE_NDIFF, + help=( + "Choose how pytest renders diffs for string equality assertions: " + f"{util.ASSERTION_TEXT_DIFF_STYLE_NDIFF} or " + f"{util.ASSERTION_TEXT_DIFF_STYLE_BLOCK} for multiline strings" + ), + ) Config._add_verbosity_ini( parser, @@ -68,6 +77,10 @@ def pytest_addoption(parser: Parser) -> None: ) +def pytest_configure(config: Config) -> None: + util.validate_assertion_text_diff_style(config) + + def register_assert_rewrite(*names: str) -> None: """Register one or more module names to be rewritten on import. diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index f35d83a6fe4..c9078f5f6d3 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -22,6 +22,7 @@ from _pytest._io.saferepr import saferepr_unlimited from _pytest.compat import running_on_ci from _pytest.config import Config +from _pytest.config import UsageError # The _reprcompare attribute on the util module is used by the new assertion @@ -37,6 +38,14 @@ # Config object which is assigned during pytest_runtest_protocol. _config: Config | None = None +ASSERTION_TEXT_DIFF_STYLE_INI = "assertion_text_diff_style" +ASSERTION_TEXT_DIFF_STYLE_NDIFF = "ndiff" +ASSERTION_TEXT_DIFF_STYLE_BLOCK = "block" +ASSERTION_TEXT_DIFF_STYLE_CHOICES = ( + ASSERTION_TEXT_DIFF_STYLE_NDIFF, + ASSERTION_TEXT_DIFF_STYLE_BLOCK, +) + class _HighlightFunc(Protocol): def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str: @@ -51,6 +60,22 @@ def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python") return source +def get_assertion_text_diff_style(config: Config) -> str: + style = str(config.getini(ASSERTION_TEXT_DIFF_STYLE_INI)) + if style not in ASSERTION_TEXT_DIFF_STYLE_CHOICES: + choices = ", ".join( + repr(choice) for choice in ASSERTION_TEXT_DIFF_STYLE_CHOICES + ) + raise UsageError( + f"{ASSERTION_TEXT_DIFF_STYLE_INI} must be one of {choices}; got {style!r}" + ) + return style + + +def validate_assertion_text_diff_style(config: Config) -> None: + get_assertion_text_diff_style(config) + + def format_explanation(explanation: str) -> str: r"""Format an explanation. @@ -180,6 +205,7 @@ def assertrepr_compare( ) -> list[str] | None: """Return specialised explanations for some operators/operands.""" verbose = config.get_verbosity(Config.VERBOSITY_ASSERTIONS) + assertion_text_diff_style = get_assertion_text_diff_style(config) # Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier. # See issue #3246. @@ -208,7 +234,13 @@ def assertrepr_compare( explanation = None try: if op == "==": - explanation = _compare_eq_any(left, right, highlighter, verbose) + explanation = _compare_eq_any( + left, + right, + highlighter, + verbose, + assertion_text_diff_style, + ) elif op == "not in": if istext(left) and istext(right): explanation = _notin_text(left, right, verbose) @@ -246,11 +278,21 @@ def assertrepr_compare( def _compare_eq_any( - left: Any, right: Any, highlighter: _HighlightFunc, verbose: int = 0 + left: Any, + right: Any, + highlighter: _HighlightFunc, + verbose: int = 0, + assertion_text_diff_style: str = ASSERTION_TEXT_DIFF_STYLE_NDIFF, ) -> list[str]: explanation = [] if istext(left) and istext(right): - explanation = _diff_text(left, right, highlighter, verbose) + explanation = _compare_eq_text( + left, + right, + highlighter, + verbose, + assertion_text_diff_style, + ) else: from _pytest.python_api import ApproxBase @@ -282,6 +324,40 @@ def _compare_eq_any( return explanation +def _compare_eq_text( + left: str, + right: str, + highlighter: _HighlightFunc, + verbose: int, + assertion_text_diff_style: str, +) -> list[str]: + if ( + assertion_text_diff_style == ASSERTION_TEXT_DIFF_STYLE_BLOCK + and _is_multiline_text(left, right) + and not (left.isspace() or right.isspace()) + ): + return _diff_text_block(left, right) + return _diff_text(left, right, highlighter, verbose) + + +def _is_multiline_text(*texts: str) -> bool: + return any("\n" in text or "\r" in text for text in texts) + + +def _diff_text_block(left: str, right: str) -> list[str]: + return [ + "Left:", + *_format_text_block_lines(left), + "", + "Right:", + *_format_text_block_lines(right), + ] + + +def _format_text_block_lines(text: str) -> list[str]: + return [f" {line}" for line in text.split("\n")] + + def _diff_text( left: str, right: str, highlighter: _HighlightFunc, verbose: int = 0 ) -> list[str]: diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 9e2e1826a4f..90404140971 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -1,10 +1,13 @@ # mypy: allow-untyped-defs from __future__ import annotations +import builtins from collections.abc import Collection from collections.abc import Mapping from collections.abc import Sequence from collections.abc import Sized +from datetime import datetime +from datetime import timedelta from decimal import Decimal import math from numbers import Complex @@ -558,10 +561,87 @@ def __repr__(self) -> str: return f"{self.expected} ± {tol_str}" +class ApproxTimedelta(ApproxBase): + """Perform approximate comparisons where the expected value is a + datetime or timedelta. + + Requires an explicit absolute tolerance as a timedelta. + Relative tolerance is not supported for time-based comparisons. + """ + + def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: + __tracebackhide__ = True + if rel is not None: + raise TypeError( + "pytest.approx() does not support relative tolerance for " + "datetime/timedelta comparisons. Use abs=timedelta(...) instead." + ) + if nan_ok: + raise TypeError( + "pytest.approx() does not support nan_ok for " + "datetime/timedelta comparisons." + ) + if abs is None: + raise TypeError( + "pytest.approx() requires an absolute tolerance for " + "datetime/timedelta comparisons: " + "e.g. approx(expected, abs=timedelta(seconds=1))" + ) + if not isinstance(abs, timedelta): + raise TypeError( + f"absolute tolerance for datetime/timedelta must be a " + f"timedelta, got {type(abs).__name__}" + ) + # Store the timedelta tolerance directly. + self.expected = expected + self._tolerance = abs + # Call grandparent init to set up basic state without _check_type. + self.abs = abs + self.rel = None + self.nan_ok = False + + def __repr__(self) -> str: + return f"{self.expected} ± {self._tolerance}" + + def __eq__(self, actual) -> bool: + try: + return bool(builtins.abs(self.expected - actual) <= self._tolerance) + except (TypeError, OverflowError): + return False + + __hash__ = None + + def __ne__(self, actual) -> bool: + return not (actual == self) + + def __bool__(self): + __tracebackhide__ = True + raise AssertionError( + "approx() is not supported in a boolean context.\n" + "Did you mean: `assert a == approx(b)`?" + ) + + def _yield_comparisons(self, actual): + yield actual, self.expected + + def _repr_compare(self, other_side: Any) -> list[str]: + try: + abs_diff = builtins.abs(self.expected - other_side) + except (TypeError, OverflowError): + abs_diff = "N/A" + return [ + "comparison failed", + f"Obtained: {other_side}", + f"Expected: {self.expected} ± {self._tolerance}", + f"Absolute difference: {abs_diff}", + f"Tolerance: {self._tolerance}", + ] + + def approx( expected: Any, rel: float | Decimal | None = None, - abs: float | Decimal | None = None, + abs: float | Decimal | timedelta | None = None, nan_ok: bool = False, ) -> ApproxBase: """Assert that two numbers (or two ordered sequences of numbers) are equal to each other @@ -677,6 +757,23 @@ def approx( >>> ["foo", 1.0000005] == approx([None,1]) False + **datetime and timedelta** + + You can also use ``approx`` to compare :class:`~datetime.datetime` and + :class:`~datetime.timedelta` objects by specifying an absolute tolerance + as a :class:`~datetime.timedelta`:: + + >>> from datetime import datetime, timedelta + >>> dt1 = datetime(2024, 1, 1, 12, 0, 0) + >>> dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) + >>> dt1 == approx(dt2, abs=timedelta(seconds=1)) + True + + Note that ``rel`` is not supported for datetime/timedelta comparisons, + and ``abs`` must be explicitly provided as a ``timedelta`` object. + + .. versionadded:: 8.4 + If you're thinking about using ``approx``, then you might want to know how it compares to other good ways of comparing floating-point numbers. All of these algorithms are based on relative and absolute tolerances and should @@ -785,6 +882,8 @@ def approx( elif isinstance(expected, Collection) and not isinstance(expected, str | bytes): msg = f"pytest.approx() only supports ordered sequences, but got: {expected!r}" raise TypeError(msg) + elif isinstance(expected, (datetime, timedelta)): + cls = ApproxTimedelta else: cls = ApproxScalar diff --git a/testing/python/approx.py b/testing/python/approx.py index bfbb59fb61d..0aa0b6182c2 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1105,6 +1105,200 @@ def test_approx_on_unordered_mapping_matching(): result.assert_outcomes(passed=1) +class TestApproxDatetime: + """Tests for datetime/timedelta support in approx (issue #8395).""" + + def test_datetime_exactly_equal(self): + from datetime import datetime + from datetime import timedelta + + dt = datetime(2024, 1, 1, 12, 0, 0) + assert dt == approx(dt, abs=timedelta(seconds=1)) + + def test_datetime_within_tolerance(self): + from datetime import datetime + from datetime import timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) # +0.5s + assert dt1 == approx(dt2, abs=timedelta(seconds=1)) + + def test_datetime_outside_tolerance(self): + from datetime import datetime + from datetime import timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 2) # +2s + assert dt1 != approx(dt2, abs=timedelta(seconds=1)) + + def test_datetime_negative_difference(self): + from datetime import datetime + from datetime import timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 1) + dt2 = datetime(2024, 1, 1, 12, 0, 0) # dt2 < dt1 + assert dt1 == approx(dt2, abs=timedelta(seconds=2)) + assert dt1 != approx(dt2, abs=timedelta(milliseconds=500)) + + def test_timedelta_within_tolerance(self): + from datetime import timedelta + + td1 = timedelta(seconds=100) + td2 = timedelta(seconds=100.5) + assert td1 == approx(td2, abs=timedelta(seconds=1)) + + def test_timedelta_outside_tolerance(self): + from datetime import timedelta + + td1 = timedelta(seconds=100) + td2 = timedelta(seconds=102) + assert td1 != approx(td2, abs=timedelta(seconds=1)) + + def test_requires_abs(self): + from datetime import datetime + + with pytest.raises(TypeError, match="requires an absolute tolerance"): + approx(datetime(2024, 1, 1)) + + def test_rejects_rel(self): + from datetime import datetime + from datetime import timedelta + + with pytest.raises(TypeError, match="does not support relative tolerance"): + approx(datetime(2024, 1, 1), rel=0.1, abs=timedelta(seconds=1)) + + def test_abs_must_be_timedelta(self): + from datetime import datetime + + with pytest.raises(TypeError, match="must be a timedelta"): + approx(datetime(2024, 1, 1), abs=1.0) + + def test_rejects_nan_ok(self): + from datetime import datetime + from datetime import timedelta + + with pytest.raises(TypeError, match="does not support nan_ok"): + approx(datetime(2024, 1, 1), abs=timedelta(seconds=1), nan_ok=True) + + def test_datetime_repr(self): + from datetime import datetime + from datetime import timedelta + + dt = datetime(2024, 1, 1, 12, 0, 0) + result = repr(approx(dt, abs=timedelta(seconds=1))) + assert "2024-01-01 12:00:00" in result + assert "0:00:01" in result + + def test_timedelta_repr(self): + from datetime import timedelta + + td = timedelta(seconds=100) + result = repr(approx(td, abs=timedelta(seconds=1))) + assert "0:01:40" in result # 100 seconds + assert "0:00:01" in result # 1 second tolerance + + def test_datetime_symmetry(self): + """Approx comparison should work on both sides of ==.""" + from datetime import datetime + from datetime import timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) + tol = timedelta(seconds=1) + assert dt1 == approx(dt2, abs=tol) + assert approx(dt2, abs=tol) == dt1 + + def test_datetime_ne_operator(self): + from datetime import datetime + from datetime import timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 5) + tol = timedelta(seconds=1) + assert dt1 != approx(dt2, abs=tol) + assert not (dt1 == approx(dt2, abs=tol)) + + def test_datetime_with_timezone(self): + from datetime import datetime + from datetime import timedelta + from datetime import timezone + + tz = timezone.utc + dt1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=tz) + dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000, tzinfo=tz) + assert dt1 == approx(dt2, abs=timedelta(seconds=1)) + + def test_datetime_error_message(self): + from datetime import datetime + from datetime import timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 5) # 5 seconds off + with pytest.raises(AssertionError, match="comparison failed"): + assert dt1 == approx(dt2, abs=timedelta(seconds=1)) + + def test_timedelta_zero(self): + from datetime import timedelta + + td1 = timedelta(seconds=0) + td2 = timedelta(seconds=0) + assert td1 == approx(td2, abs=timedelta(seconds=1)) + + def test_datetime_boundary_exact(self): + """Test that values exactly at the tolerance boundary are equal.""" + from datetime import datetime + from datetime import timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 1) # exactly 1 second + assert dt1 == approx(dt2, abs=timedelta(seconds=1)) + + def test_datetime_microsecond_tolerance(self): + from datetime import datetime + from datetime import timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 0, 100) # +100 microseconds + assert dt1 == approx(dt2, abs=timedelta(microseconds=200)) + assert dt1 != approx(dt2, abs=timedelta(microseconds=50)) + + def test_bool_context_raises(self): + from datetime import datetime + from datetime import timedelta + + with pytest.raises(AssertionError, match="boolean context"): + bool(approx(datetime(2024, 1, 1), abs=timedelta(seconds=1))) + + def test_wrong_type_comparison(self): + """Comparing a datetime approx with a non-datetime should return False.""" + from datetime import datetime + from datetime import timedelta + + assert 42 != approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) + assert "string" != approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) + + def test_yield_comparisons(self): + """Test that _yield_comparisons yields (actual, expected) pairs.""" + from datetime import datetime + from datetime import timedelta + + dt = datetime(2024, 1, 1, 12, 0, 0) + a = approx(dt, abs=timedelta(seconds=1)) + actual = datetime(2024, 1, 1, 12, 0, 0, 500000) + pairs = list(a._yield_comparisons(actual)) + assert pairs == [(actual, dt)] + + def test_repr_compare_with_incompatible_type(self): + """_repr_compare handles TypeError when actual is not a datetime.""" + from datetime import datetime + from datetime import timedelta + + a = approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) + result = a._repr_compare("not a datetime") + assert "comparison failed" in result[0] + assert "N/A" in result[3] + + class MyVec3: # incomplete """sequence like""" diff --git a/testing/test_assertion.py b/testing/test_assertion.py index d68fd0b1fba..b392c119a48 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -19,7 +19,11 @@ import pytest -def mock_config(verbose: int = 0, assertion_override: int | None = None): +def mock_config( + verbose: int = 0, + assertion_override: int | None = None, + assertion_text_diff_style: str = util.ASSERTION_TEXT_DIFF_STYLE_NDIFF, +): class TerminalWriter: def _highlight(self, source, lexer="python"): return source @@ -38,6 +42,11 @@ def get_verbosity(self, verbosity_type: str | None = None) -> int: raise KeyError(f"Not mocked out: {verbosity_type}") + def getini(self, name: str) -> str: + if name == util.ASSERTION_TEXT_DIFF_STYLE_INI: + return assertion_text_diff_style + raise KeyError(f"Not mocked out: {name}") + return Config() @@ -75,6 +84,12 @@ def test_get_unsupported_type_error(self): with pytest.raises(KeyError): config.get_verbosity("--- NOT A VERBOSITY LEVEL ---") + def test_getini_unsupported_error(self): + config = mock_config() + + with pytest.raises(KeyError, match="Not mocked out: --- NOT AN INI ---"): + config.getini("--- NOT AN INI ---") + class TestImportHookInstallation: @pytest.mark.parametrize("initial_conftest", [True, False]) @@ -404,13 +419,33 @@ def test_check(list): result.stdout.fnmatch_lines(["*test_hello*FAIL*", "*test_check*PASS*"]) -def callop(op: str, left: Any, right: Any, verbose: int = 0) -> list[str] | None: - config = mock_config(verbose=verbose) +def callop( + op: str, + left: Any, + right: Any, + verbose: int = 0, + assertion_text_diff_style: str = util.ASSERTION_TEXT_DIFF_STYLE_NDIFF, +) -> list[str] | None: + config = mock_config( + verbose=verbose, + assertion_text_diff_style=assertion_text_diff_style, + ) return plugin.pytest_assertrepr_compare(config, op, left, right) -def callequal(left: Any, right: Any, verbose: int = 0) -> list[str] | None: - return callop("==", left, right, verbose) +def callequal( + left: Any, + right: Any, + verbose: int = 0, + assertion_text_diff_style: str = util.ASSERTION_TEXT_DIFF_STYLE_NDIFF, +) -> list[str] | None: + return callop( + "==", + left, + right, + verbose, + assertion_text_diff_style=assertion_text_diff_style, + ) class TestAssert_reprcompare: @@ -452,6 +487,55 @@ def test_multiline_text_diff(self) -> None: assert "- eggs" in diff assert "+ spam" in diff + def test_multiline_text_diff_block(self) -> None: + assert callequal( + "foo\nspam\nbar", + "foo\neggs\nbar", + assertion_text_diff_style=util.ASSERTION_TEXT_DIFF_STYLE_BLOCK, + ) == [ + r"'foo\nspam\nbar' == 'foo\neggs\nbar'", + "", + "Left:", + " foo", + " spam", + " bar", + "", + "Right:", + " foo", + " eggs", + " bar", + ] + + def test_multiline_text_diff_block_preserves_blank_lines(self) -> None: + assert callequal( + "\nfoo\n", + "\nbar", + assertion_text_diff_style=util.ASSERTION_TEXT_DIFF_STYLE_BLOCK, + ) == [ + r"'\nfoo\n' == '\nbar'", + "", + "Left:", + " ", + " foo", + " ", + "", + "Right:", + " ", + " bar", + ] + + def test_single_line_text_diff_block_falls_back_to_ndiff(self) -> None: + assert callequal( + "spam", + "eggs", + assertion_text_diff_style=util.ASSERTION_TEXT_DIFF_STYLE_BLOCK, + ) == [ + "'spam' == 'eggs'", + "", + "- eggs", + "+ spam", + ] + def test_bytes_diff_normal(self) -> None: """Check special handling for bytes diff (#5260)""" diff = callequal(b"spam", b"eggs") @@ -2178,6 +2262,65 @@ def test_long_text_fail(): ) +def test_assertion_text_diff_style_block_for_multiline_strings( + pytester: Pytester, +) -> None: + pytester.makepyfile( + r""" + actual = "alpha\n beta\n" + expected = "alpha\n beta" + + def test_text_diff(): + assert actual == expected + """ + ) + pytester.makeini( + f""" + [pytest] + assertion_text_diff_style = {util.ASSERTION_TEXT_DIFF_STYLE_BLOCK} + """ + ) + + result = pytester.runpytest("-vv") + + result.stdout.fnmatch_lines( + [ + "E Left:", + "E alpha", + "E beta", + "E ", + "E Right:", + "E alpha", + "E beta", + ] + ) + result.stdout.no_fnmatch_line("*? -*") + + +def test_assertion_text_diff_style_invalid(pytester: Pytester) -> None: + pytester.makepyfile( + """ + def test_ok(): + pass + """ + ) + pytester.makeini( + """ + [pytest] + assertion_text_diff_style = side-by-side + """ + ) + + result = pytester.runpytest() + + assert result.ret == pytest.ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines( + [ + "*ERROR: assertion_text_diff_style must be one of 'ndiff', 'block'; got 'side-by-side'" + ] + ) + + def test_full_output_vvv(pytester: Pytester) -> None: pytester.makepyfile( r"""