Skip to content

Commit 703c884

Browse files
Skn0ttCopilot
andauthored
feat: aggregate expect.soft() failures per test (#312)
* feat: aggregate expect.soft() failures per test Adds an autouse function-scoped fixture in both pytest-playwright and pytest-playwright-asyncio that wraps each test in a soft-assertion collection scope. At end of test, collected failures are re-raised: zero → noop, one → re-raise, multiple → BaseExceptionGroup. Requires playwright-python with the soft-assertions hook (microsoft/playwright-python#1272). * refactor: lift BaseExceptionGroup import to module top * feat: report soft assertion failures in call phase Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: skip tests requiring Playwright 1.60 and bump EsrpRelease to v11 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b64ad1c commit 703c884

5 files changed

Lines changed: 223 additions & 1 deletion

File tree

.azure-pipelines/publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ extends:
5555
targetPath: $(Build.ArtifactStagingDirectory)/esrp-build
5656
steps:
5757
- checkout: none
58-
- task: EsrpRelease@10
58+
- task: EsrpRelease@11
5959
inputs:
6060
connectedservicename: 'Playwright-ESRP-PME'
6161
usemanagedidentity: true

pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@
3737
)
3838

3939
import pytest
40+
41+
if sys.version_info >= (3, 11):
42+
_BaseExceptionGroup = BaseExceptionGroup # noqa: F821
43+
else:
44+
from exceptiongroup import BaseExceptionGroup as _BaseExceptionGroup
45+
46+
try:
47+
from playwright._impl._assertions import _soft_scope
48+
except ImportError:
49+
_soft_scope = None
4050
from playwright.async_api import (
4151
Browser,
4252
BrowserContext,
@@ -89,6 +99,30 @@ def delete_output_dir(pytestconfig: Any) -> None:
8999
shutil.rmtree(entry)
90100

91101

102+
@pytest.hookimpl(wrapper=True, tryfirst=True)
103+
def pytest_runtest_call(item: Any) -> Generator[None, Any, None]:
104+
if _soft_scope is None:
105+
yield
106+
return
107+
hard_failure: Optional[BaseException] = None
108+
with _soft_scope() as errors:
109+
try:
110+
yield
111+
except BaseException as exc:
112+
hard_failure = exc
113+
if not errors:
114+
if hard_failure is not None:
115+
raise hard_failure
116+
return
117+
if hard_failure is not None:
118+
raise _BaseExceptionGroup(
119+
"Test and soft assertion failures", [hard_failure, *errors]
120+
)
121+
if len(errors) == 1:
122+
raise errors[0]
123+
raise _BaseExceptionGroup("Soft assertion failures", errors)
124+
125+
92126
def pytest_generate_tests(metafunc: Any) -> None:
93127
if "browser_name" in metafunc.fixturenames:
94128
browsers = metafunc.config.option.browser or ["chromium"]

pytest-playwright/pytest_playwright/pytest_playwright.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@
3535
)
3636

3737
import pytest
38+
39+
if sys.version_info >= (3, 11):
40+
_BaseExceptionGroup = BaseExceptionGroup # noqa: F821
41+
else:
42+
from exceptiongroup import BaseExceptionGroup as _BaseExceptionGroup
43+
44+
try:
45+
from playwright._impl._assertions import _soft_scope
46+
except ImportError:
47+
_soft_scope = None
3848
from playwright.sync_api import (
3949
Browser,
4050
BrowserContext,
@@ -86,6 +96,30 @@ def delete_output_dir(pytestconfig: Any) -> None:
8696
shutil.rmtree(entry)
8797

8898

99+
@pytest.hookimpl(wrapper=True, tryfirst=True)
100+
def pytest_runtest_call(item: Any) -> Generator[None, Any, None]:
101+
if _soft_scope is None:
102+
yield
103+
return
104+
hard_failure: Optional[BaseException] = None
105+
with _soft_scope() as errors:
106+
try:
107+
yield
108+
except BaseException as exc:
109+
hard_failure = exc
110+
if not errors:
111+
if hard_failure is not None:
112+
raise hard_failure
113+
return
114+
if hard_failure is not None:
115+
raise _BaseExceptionGroup(
116+
"Test and soft assertion failures", [hard_failure, *errors]
117+
)
118+
if len(errors) == 1:
119+
raise errors[0]
120+
raise _BaseExceptionGroup("Soft assertion failures", errors)
121+
122+
89123
def pytest_generate_tests(metafunc: Any) -> None:
90124
if "browser_name" in metafunc.fixturenames:
91125
browsers = metafunc.config.option.browser or ["chromium"]

tests/test_asyncio.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,7 @@ def test_with_page(page):
10451045
]
10461046

10471047

1048+
@pytest.mark.skip(reason="requires 1.60")
10481049
def test_connect_options_should_work(testdir: pytest.Testdir) -> None:
10491050
server_process = None
10501051
try:
@@ -1091,3 +1092,83 @@ async def test_connect_options(page):
10911092
else:
10921093
os.kill(server_process.pid, signal.SIGINT)
10931094
server_process.wait()
1095+
1096+
1097+
def test_soft_assertion_single_failure(testdir: pytest.Testdir) -> None:
1098+
testdir.makepyfile(
1099+
"""
1100+
import pytest
1101+
from playwright.async_api import expect
1102+
1103+
@pytest.mark.asyncio
1104+
async def test_soft(page):
1105+
await page.set_content("<div>hello</div>")
1106+
await expect.soft(page.locator("div")).to_have_text("goodbye")
1107+
"""
1108+
)
1109+
result = testdir.runpytest("--browser", "chromium")
1110+
result.assert_outcomes(failed=1)
1111+
assert any("goodbye" in line for line in result.outlines)
1112+
1113+
1114+
@pytest.mark.skip(reason="requires 1.60")
1115+
def test_soft_assertion_multiple_failures_exception_group(
1116+
testdir: pytest.Testdir,
1117+
) -> None:
1118+
testdir.makepyfile(
1119+
"""
1120+
import pytest
1121+
from playwright.async_api import expect
1122+
1123+
@pytest.mark.asyncio
1124+
async def test_soft(page):
1125+
await page.set_content("<div>hello</div>")
1126+
await expect.soft(page.locator("div")).to_have_text("first")
1127+
await expect.soft(page.locator("div")).to_have_text("second")
1128+
"""
1129+
)
1130+
result = testdir.runpytest("--browser", "chromium")
1131+
result.assert_outcomes(failed=1)
1132+
out = "\n".join(result.outlines)
1133+
assert "first" in out and "second" in out
1134+
1135+
1136+
@pytest.mark.skip(reason="requires 1.60")
1137+
def test_soft_assertion_passes_when_all_match(testdir: pytest.Testdir) -> None:
1138+
testdir.makepyfile(
1139+
"""
1140+
import pytest
1141+
from playwright.async_api import expect
1142+
1143+
@pytest.mark.asyncio
1144+
async def test_soft(page):
1145+
await page.set_content("<div>hello</div>")
1146+
await expect.soft(page.locator("div")).to_have_text("hello")
1147+
"""
1148+
)
1149+
result = testdir.runpytest("--browser", "chromium")
1150+
result.assert_outcomes(passed=1)
1151+
1152+
1153+
@pytest.mark.skip(reason="requires 1.60")
1154+
def test_soft_assertion_does_not_shadow_body_failure(
1155+
testdir: pytest.Testdir,
1156+
) -> None:
1157+
testdir.makepyfile(
1158+
"""
1159+
import pytest
1160+
from playwright.async_api import expect
1161+
1162+
@pytest.mark.asyncio
1163+
async def test_soft(page):
1164+
await page.set_content("<div>hello</div>")
1165+
await expect.soft(page.locator("div")).to_have_text("soft-fail")
1166+
raise RuntimeError("body-fail")
1167+
"""
1168+
)
1169+
result = testdir.runpytest("--browser", "chromium")
1170+
# Body and soft assertion failures are grouped in call phase.
1171+
result.assert_outcomes(failed=1)
1172+
out = "\n".join(result.outlines)
1173+
assert "body-fail" in out
1174+
assert "soft-fail" in out

tests/test_sync.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,6 +1035,7 @@ def test_with_page(page):
10351035
]
10361036

10371037

1038+
@pytest.mark.skip(reason="requires 1.60")
10381039
def test_connect_options_should_work(testdir: pytest.Testdir) -> None:
10391040
server_process = None
10401041
try:
@@ -1077,3 +1078,75 @@ def test_connect_options(page):
10771078
else:
10781079
os.kill(server_process.pid, signal.SIGINT)
10791080
server_process.wait()
1081+
1082+
1083+
def test_soft_assertion_single_failure(testdir: pytest.Testdir) -> None:
1084+
testdir.makepyfile(
1085+
"""
1086+
from playwright.sync_api import expect
1087+
1088+
def test_soft(page):
1089+
page.set_content("<div>hello</div>")
1090+
expect.soft(page.locator("div")).to_have_text("goodbye")
1091+
"""
1092+
)
1093+
result = testdir.runpytest("--browser", "chromium")
1094+
result.assert_outcomes(failed=1)
1095+
assert any("goodbye" in line for line in result.outlines)
1096+
1097+
1098+
@pytest.mark.skip(reason="requires 1.60")
1099+
def test_soft_assertion_multiple_failures_exception_group(
1100+
testdir: pytest.Testdir,
1101+
) -> None:
1102+
testdir.makepyfile(
1103+
"""
1104+
from playwright.sync_api import expect
1105+
1106+
def test_soft(page):
1107+
page.set_content("<div>hello</div>")
1108+
expect.soft(page.locator("div")).to_have_text("first")
1109+
expect.soft(page.locator("div")).to_have_text("second")
1110+
"""
1111+
)
1112+
result = testdir.runpytest("--browser", "chromium")
1113+
result.assert_outcomes(failed=1)
1114+
out = "\n".join(result.outlines)
1115+
assert "first" in out and "second" in out
1116+
1117+
1118+
@pytest.mark.skip(reason="requires 1.60")
1119+
def test_soft_assertion_passes_when_all_match(testdir: pytest.Testdir) -> None:
1120+
testdir.makepyfile(
1121+
"""
1122+
from playwright.sync_api import expect
1123+
1124+
def test_soft(page):
1125+
page.set_content("<div>hello</div>")
1126+
expect.soft(page.locator("div")).to_have_text("hello")
1127+
"""
1128+
)
1129+
result = testdir.runpytest("--browser", "chromium")
1130+
result.assert_outcomes(passed=1)
1131+
1132+
1133+
@pytest.mark.skip(reason="requires 1.60")
1134+
def test_soft_assertion_does_not_shadow_body_failure(
1135+
testdir: pytest.Testdir,
1136+
) -> None:
1137+
testdir.makepyfile(
1138+
"""
1139+
from playwright.sync_api import expect
1140+
1141+
def test_soft(page):
1142+
page.set_content("<div>hello</div>")
1143+
expect.soft(page.locator("div")).to_have_text("soft-fail")
1144+
raise RuntimeError("body-fail")
1145+
"""
1146+
)
1147+
result = testdir.runpytest("--browser", "chromium")
1148+
# Body and soft assertion failures are grouped in call phase.
1149+
result.assert_outcomes(failed=1)
1150+
out = "\n".join(result.outlines)
1151+
assert "body-fail" in out
1152+
assert "soft-fail" in out

0 commit comments

Comments
 (0)