Skip to content

Commit c0efed5

Browse files
committed
feat(assertions): add expect.soft() for collecting multiple failures
Adds JS-style soft assertions: `expect.soft(locator).to_have_text(...)` records the failure into a per-scope collector instead of raising. A collector scope is established by `pytest-playwright>=0.7.3` / `pytest-playwright-asyncio>=0.7.3` via an autouse fixture that re-raises collected failures (or a `BaseExceptionGroup`) at end of test. Outside of an active scope, soft assertions raise `RuntimeError` to make the missing plugin requirement obvious instead of silently behaving as hard assertions. References #1272
1 parent 1f847dd commit c0efed5

5 files changed

Lines changed: 223 additions & 15 deletions

File tree

playwright/_impl/_assertions.py

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
# limitations under the License.
1414

1515
import collections.abc
16-
from typing import Any, List, Optional, Pattern, Sequence, Union
16+
from contextlib import contextmanager
17+
from typing import Any, Iterator, List, Optional, Pattern, Sequence, Union
1718
from urllib.parse import urljoin
1819

1920
from playwright._impl._api_structures import (
@@ -30,6 +31,33 @@
3031
from playwright._impl._page import Page
3132
from playwright._impl._str_utils import escape_regex_flags
3233

34+
_soft_errors: Optional[List[AssertionError]] = None
35+
36+
37+
@contextmanager
38+
def _soft_scope() -> Iterator[List[AssertionError]]:
39+
global _soft_errors
40+
assert _soft_errors is None, "nested soft assertion scopes are not supported"
41+
_soft_errors = []
42+
try:
43+
yield _soft_errors
44+
finally:
45+
_soft_errors = None
46+
47+
48+
def _record_soft_or_raise(error: AssertionError, is_soft: bool) -> None:
49+
__tracebackhide__ = True
50+
if is_soft:
51+
if _soft_errors is None:
52+
raise RuntimeError(
53+
"expect.soft(...) requires pytest-playwright>=0.7.3 "
54+
"(or pytest-playwright-asyncio>=0.7.3). Upgrade the plugin, "
55+
"or use a regular expect(...) assertion."
56+
)
57+
_soft_errors.append(error)
58+
return
59+
raise error
60+
3361

3462
class AssertionsBase:
3563
def __init__(
@@ -38,13 +66,15 @@ def __init__(
3866
timeout: float = None,
3967
is_not: bool = False,
4068
message: Optional[str] = None,
69+
is_soft: bool = False,
4170
) -> None:
4271
self._actual_locator = locator
4372
self._loop = locator._loop
4473
self._dispatcher_fiber = locator._dispatcher_fiber
4574
self._timeout = timeout
4675
self._is_not = is_not
4776
self._custom_message = message
77+
self._is_soft = is_soft
4878

4979
async def _call_expect(
5080
self, expression: str, expect_options: FrameExpectOptions, title: Optional[str]
@@ -82,8 +112,11 @@ async def _expect_impl(
82112
)
83113
error_message = result.get("errorMessage")
84114
error_message = f"\n{error_message}" if error_message else ""
85-
raise AssertionError(
86-
f"{out_message}\nActual value: {actual}{error_message} {format_call_log(result.get('log'))}"
115+
_record_soft_or_raise(
116+
AssertionError(
117+
f"{out_message}\nActual value: {actual}{error_message} {format_call_log(result.get('log'))}"
118+
),
119+
self._is_soft,
87120
)
88121

89122

@@ -94,8 +127,9 @@ def __init__(
94127
timeout: float = None,
95128
is_not: bool = False,
96129
message: Optional[str] = None,
130+
is_soft: bool = False,
97131
) -> None:
98-
super().__init__(page.locator(":root"), timeout, is_not, message)
132+
super().__init__(page.locator(":root"), timeout, is_not, message, is_soft)
99133
self._actual_page = page
100134

101135
async def _call_expect(
@@ -109,7 +143,11 @@ async def _call_expect(
109143
@property
110144
def _not(self) -> "PageAssertions":
111145
return PageAssertions(
112-
self._actual_page, self._timeout, not self._is_not, self._custom_message
146+
self._actual_page,
147+
self._timeout,
148+
not self._is_not,
149+
self._custom_message,
150+
self._is_soft,
113151
)
114152

115153
async def to_have_title(
@@ -169,8 +207,9 @@ def __init__(
169207
timeout: float = None,
170208
is_not: bool = False,
171209
message: Optional[str] = None,
210+
is_soft: bool = False,
172211
) -> None:
173-
super().__init__(locator, timeout, is_not, message)
212+
super().__init__(locator, timeout, is_not, message, is_soft)
174213
self._actual_locator = locator
175214

176215
async def _call_expect(
@@ -182,7 +221,11 @@ async def _call_expect(
182221
@property
183222
def _not(self) -> "LocatorAssertions":
184223
return LocatorAssertions(
185-
self._actual_locator, self._timeout, not self._is_not, self._custom_message
224+
self._actual_locator,
225+
self._timeout,
226+
not self._is_not,
227+
self._custom_message,
228+
self._is_soft,
186229
)
187230

188231
async def to_contain_text(
@@ -944,18 +987,24 @@ def __init__(
944987
timeout: float = None,
945988
is_not: bool = False,
946989
message: Optional[str] = None,
990+
is_soft: bool = False,
947991
) -> None:
948992
self._loop = response._loop
949993
self._dispatcher_fiber = response._dispatcher_fiber
950994
self._timeout = timeout
951995
self._is_not = is_not
952996
self._actual = response
953997
self._custom_message = message
998+
self._is_soft = is_soft
954999

9551000
@property
9561001
def _not(self) -> "APIResponseAssertions":
9571002
return APIResponseAssertions(
958-
self._actual, self._timeout, not self._is_not, self._custom_message
1003+
self._actual,
1004+
self._timeout,
1005+
not self._is_not,
1006+
self._custom_message,
1007+
self._is_soft,
9591008
)
9601009

9611010
async def to_be_ok(
@@ -976,7 +1025,7 @@ async def to_be_ok(
9761025
if text is not None:
9771026
out_message += f"\n Response Text:\n{text[:1000]}"
9781027

979-
raise AssertionError(out_message)
1028+
_record_soft_or_raise(AssertionError(out_message), self._is_soft)
9801029

9811030
async def not_to_be_ok(self) -> None:
9821031
__tracebackhide__ = True

playwright/async_api/__init__.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,19 +129,64 @@ def __call__(
129129

130130
def __call__(
131131
self, actual: Union[Page, Locator, APIResponse], message: Optional[str] = None
132+
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
133+
return self._dispatch(actual, message, is_soft=False)
134+
135+
@overload
136+
def soft(self, actual: Page, message: Optional[str] = None) -> PageAssertions: ...
137+
138+
@overload
139+
def soft(
140+
self, actual: Locator, message: Optional[str] = None
141+
) -> LocatorAssertions: ...
142+
143+
@overload
144+
def soft(
145+
self, actual: APIResponse, message: Optional[str] = None
146+
) -> APIResponseAssertions: ...
147+
148+
def soft(
149+
self, actual: Union[Page, Locator, APIResponse], message: Optional[str] = None
150+
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
151+
"""
152+
Creates a [soft assertion](https://playwright.dev/python/docs/test-assertions#soft-assertions).
153+
Failing soft assertions do not abort test execution, but mark the test
154+
as failed. Multiple failures from the same test are surfaced together
155+
at the end of the test.
156+
"""
157+
return self._dispatch(actual, message, is_soft=True)
158+
159+
def _dispatch(
160+
self,
161+
actual: Union[Page, Locator, APIResponse],
162+
message: Optional[str],
163+
is_soft: bool,
132164
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
133165
if isinstance(actual, Page):
134166
return PageAssertions(
135-
PageAssertionsImpl(actual._impl_obj, self._timeout, message=message)
167+
PageAssertionsImpl(
168+
actual._impl_obj,
169+
self._timeout,
170+
message=message,
171+
is_soft=is_soft,
172+
)
136173
)
137174
elif isinstance(actual, Locator):
138175
return LocatorAssertions(
139-
LocatorAssertionsImpl(actual._impl_obj, self._timeout, message=message)
176+
LocatorAssertionsImpl(
177+
actual._impl_obj,
178+
self._timeout,
179+
message=message,
180+
is_soft=is_soft,
181+
)
140182
)
141183
elif isinstance(actual, APIResponse):
142184
return APIResponseAssertions(
143185
APIResponseAssertionsImpl(
144-
actual._impl_obj, self._timeout, message=message
186+
actual._impl_obj,
187+
self._timeout,
188+
message=message,
189+
is_soft=is_soft,
145190
)
146191
)
147192
raise ValueError(f"Unsupported type: {type(actual)}")

playwright/sync_api/__init__.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,19 +129,64 @@ def __call__(
129129

130130
def __call__(
131131
self, actual: Union[Page, Locator, APIResponse], message: Optional[str] = None
132+
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
133+
return self._dispatch(actual, message, is_soft=False)
134+
135+
@overload
136+
def soft(self, actual: Page, message: Optional[str] = None) -> PageAssertions: ...
137+
138+
@overload
139+
def soft(
140+
self, actual: Locator, message: Optional[str] = None
141+
) -> LocatorAssertions: ...
142+
143+
@overload
144+
def soft(
145+
self, actual: APIResponse, message: Optional[str] = None
146+
) -> APIResponseAssertions: ...
147+
148+
def soft(
149+
self, actual: Union[Page, Locator, APIResponse], message: Optional[str] = None
150+
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
151+
"""
152+
Creates a [soft assertion](https://playwright.dev/python/docs/test-assertions#soft-assertions).
153+
Failing soft assertions do not abort test execution, but mark the test
154+
as failed. Multiple failures from the same test are surfaced together
155+
at the end of the test.
156+
"""
157+
return self._dispatch(actual, message, is_soft=True)
158+
159+
def _dispatch(
160+
self,
161+
actual: Union[Page, Locator, APIResponse],
162+
message: Optional[str],
163+
is_soft: bool,
132164
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
133165
if isinstance(actual, Page):
134166
return PageAssertions(
135-
PageAssertionsImpl(actual._impl_obj, self._timeout, message=message)
167+
PageAssertionsImpl(
168+
actual._impl_obj,
169+
self._timeout,
170+
message=message,
171+
is_soft=is_soft,
172+
)
136173
)
137174
elif isinstance(actual, Locator):
138175
return LocatorAssertions(
139-
LocatorAssertionsImpl(actual._impl_obj, self._timeout, message=message)
176+
LocatorAssertionsImpl(
177+
actual._impl_obj,
178+
self._timeout,
179+
message=message,
180+
is_soft=is_soft,
181+
)
140182
)
141183
elif isinstance(actual, APIResponse):
142184
return APIResponseAssertions(
143185
APIResponseAssertionsImpl(
144-
actual._impl_obj, self._timeout, message=message
186+
actual._impl_obj,
187+
self._timeout,
188+
message=message,
189+
is_soft=is_soft,
145190
)
146191
)
147192
raise ValueError(f"Unsupported type: {type(actual)}")

tests/async/test_assertions.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,3 +1132,50 @@ async def test_to_have_role(page: Page) -> None:
11321132
with pytest.raises(Error) as excinfo:
11331133
await expect(page.locator("div")).to_have_role(re.compile(r"button|checkbox")) # type: ignore
11341134
assert '"role" argument in to_have_role must be a string' in str(excinfo.value)
1135+
1136+
1137+
async def test_soft_outside_scope_raises_runtime_error(
1138+
page: Page, server: Server
1139+
) -> None:
1140+
await page.set_content("<div>hello</div>")
1141+
with pytest.raises(RuntimeError, match="pytest-playwright"):
1142+
await expect.soft(page.locator("div")).to_have_text("nope", timeout=500)
1143+
1144+
1145+
async def test_soft_inside_scope_collects_failures(page: Page, server: Server) -> None:
1146+
from playwright._impl._assertions import _soft_scope
1147+
1148+
await page.goto(server.EMPTY_PAGE)
1149+
await page.set_content("<title>actual</title><div>hello</div>")
1150+
1151+
with _soft_scope() as errors:
1152+
# should collect, not raise
1153+
await expect.soft(page).to_have_title("expected", timeout=500)
1154+
await expect.soft(page.locator("div")).to_have_text("goodbye", timeout=500)
1155+
# passing soft should not affect the collector
1156+
await expect.soft(page.locator("div")).to_have_text("hello")
1157+
# nested .not_ should still be soft
1158+
await expect.soft(page.locator("div")).not_to_have_text("hello", timeout=500)
1159+
1160+
assert len(errors) == 3
1161+
assert all(isinstance(e, AssertionError) for e in errors)
1162+
1163+
1164+
async def test_soft_does_not_leak_between_scopes(page: Page, server: Server) -> None:
1165+
from playwright._impl._assertions import _soft_scope
1166+
1167+
await page.goto(server.EMPTY_PAGE)
1168+
await page.set_content("<title>actual</title>")
1169+
1170+
with _soft_scope() as errors_a:
1171+
await expect.soft(page).to_have_title("nope", timeout=500)
1172+
assert len(errors_a) == 1
1173+
1174+
with _soft_scope() as errors_b:
1175+
pass
1176+
assert errors_b == []
1177+
1178+
# After scope ends, soft assertions raise RuntimeError again.
1179+
await page.set_content("<div>hello</div>")
1180+
with pytest.raises(RuntimeError, match="pytest-playwright"):
1181+
await expect.soft(page.locator("div")).to_have_text("nope", timeout=500)

tests/sync/test_assertions.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,3 +1067,25 @@ def test_to_have_role(page: Page) -> None:
10671067
with pytest.raises(Error) as excinfo:
10681068
expect(page.locator("div")).to_have_role(re.compile(r"button|checkbox")) # type: ignore
10691069
assert '"role" argument in to_have_role must be a string' in str(excinfo.value)
1070+
1071+
1072+
def test_soft_outside_scope_raises_runtime_error(page: Page, server: Server) -> None:
1073+
page.set_content("<div>hello</div>")
1074+
with pytest.raises(RuntimeError, match="pytest-playwright"):
1075+
expect.soft(page.locator("div")).to_have_text("nope", timeout=500)
1076+
1077+
1078+
def test_soft_inside_scope_collects_failures(page: Page, server: Server) -> None:
1079+
from playwright._impl._assertions import _soft_scope
1080+
1081+
page.goto(server.EMPTY_PAGE)
1082+
page.set_content("<title>actual</title><div>hello</div>")
1083+
1084+
with _soft_scope() as errors:
1085+
expect.soft(page).to_have_title("expected", timeout=500)
1086+
expect.soft(page.locator("div")).to_have_text("goodbye", timeout=500)
1087+
expect.soft(page.locator("div")).to_have_text("hello")
1088+
expect.soft(page.locator("div")).not_to_have_text("hello", timeout=500)
1089+
1090+
assert len(errors) == 3
1091+
assert all(isinstance(e, AssertionError) for e in errors)

0 commit comments

Comments
 (0)