Skip to content

Commit 0ebdf2d

Browse files
authored
feat(assertions): add expect.soft() for collecting multiple failures (#3065)
1 parent 18810d8 commit 0ebdf2d

5 files changed

Lines changed: 231 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, Literal, Optional, Pattern, Sequence, Union
16+
from contextlib import contextmanager
17+
from typing import Any, Iterator, List, Literal, Optional, Pattern, Sequence, Union
1718
from urllib.parse import urljoin
1819

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

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

3563
class AssertionsBase:
3664
def __init__(
@@ -39,13 +67,15 @@ def __init__(
3967
timeout: float = None,
4068
is_not: bool = False,
4169
message: Optional[str] = None,
70+
is_soft: bool = False,
4271
) -> None:
4372
self._actual_locator = locator
4473
self._loop = locator._loop
4574
self._dispatcher_fiber = locator._dispatcher_fiber
4675
self._timeout = timeout
4776
self._is_not = is_not
4877
self._custom_message = message
78+
self._is_soft = is_soft
4979

5080
async def _call_expect(
5181
self, expression: str, expect_options: FrameExpectOptions, title: Optional[str]
@@ -90,8 +120,11 @@ async def _expect_impl(
90120
)
91121
error_message = result.get("errorMessage")
92122
error_message = f"\n{error_message}" if error_message else ""
93-
raise AssertionError(
94-
f"{out_message}\nActual value: {actual}{error_message} {format_call_log(result.get('log'))}"
123+
_record_soft_or_raise(
124+
AssertionError(
125+
f"{out_message}\nActual value: {actual}{error_message} {format_call_log(result.get('log'))}"
126+
),
127+
self._is_soft,
95128
)
96129

97130

@@ -102,8 +135,9 @@ def __init__(
102135
timeout: float = None,
103136
is_not: bool = False,
104137
message: Optional[str] = None,
138+
is_soft: bool = False,
105139
) -> None:
106-
super().__init__(page.locator(":root"), timeout, is_not, message)
140+
super().__init__(page.locator(":root"), timeout, is_not, message, is_soft)
107141
self._actual_page = page
108142

109143
async def _call_expect(
@@ -117,7 +151,11 @@ async def _call_expect(
117151
@property
118152
def _not(self) -> "PageAssertions":
119153
return PageAssertions(
120-
self._actual_page, self._timeout, not self._is_not, self._custom_message
154+
self._actual_page,
155+
self._timeout,
156+
not self._is_not,
157+
self._custom_message,
158+
self._is_soft,
121159
)
122160

123161
async def to_have_title(
@@ -195,8 +233,9 @@ def __init__(
195233
timeout: float = None,
196234
is_not: bool = False,
197235
message: Optional[str] = None,
236+
is_soft: bool = False,
198237
) -> None:
199-
super().__init__(locator, timeout, is_not, message)
238+
super().__init__(locator, timeout, is_not, message, is_soft)
200239
self._actual_locator = locator
201240

202241
async def _call_expect(
@@ -208,7 +247,11 @@ async def _call_expect(
208247
@property
209248
def _not(self) -> "LocatorAssertions":
210249
return LocatorAssertions(
211-
self._actual_locator, self._timeout, not self._is_not, self._custom_message
250+
self._actual_locator,
251+
self._timeout,
252+
not self._is_not,
253+
self._custom_message,
254+
self._is_soft,
212255
)
213256

214257
async def to_contain_text(
@@ -974,18 +1017,24 @@ def __init__(
9741017
timeout: float = None,
9751018
is_not: bool = False,
9761019
message: Optional[str] = None,
1020+
is_soft: bool = False,
9771021
) -> None:
9781022
self._loop = response._loop
9791023
self._dispatcher_fiber = response._dispatcher_fiber
9801024
self._timeout = timeout
9811025
self._is_not = is_not
9821026
self._actual = response
9831027
self._custom_message = message
1028+
self._is_soft = is_soft
9841029

9851030
@property
9861031
def _not(self) -> "APIResponseAssertions":
9871032
return APIResponseAssertions(
988-
self._actual, self._timeout, not self._is_not, self._custom_message
1033+
self._actual,
1034+
self._timeout,
1035+
not self._is_not,
1036+
self._custom_message,
1037+
self._is_soft,
9891038
)
9901039

9911040
async def to_be_ok(
@@ -1006,7 +1055,7 @@ async def to_be_ok(
10061055
if text is not None:
10071056
out_message += f"\n Response Text:\n{text[:1000]}"
10081057

1009-
raise AssertionError(out_message)
1058+
_record_soft_or_raise(AssertionError(out_message), self._is_soft)
10101059

10111060
async def not_to_be_ok(self) -> None:
10121061
__tracebackhide__ = True

playwright/async_api/__init__.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,19 +131,68 @@ def __call__(
131131

132132
def __call__(
133133
self, actual: Union[Page, Locator, APIResponse], message: Optional[str] = None
134+
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
135+
return self._dispatch(actual, message, is_soft=False)
136+
137+
@overload
138+
def soft(self, actual: Page, message: Optional[str] = None) -> PageAssertions: ...
139+
140+
@overload
141+
def soft(
142+
self, actual: Locator, message: Optional[str] = None
143+
) -> LocatorAssertions: ...
144+
145+
@overload
146+
def soft(
147+
self, actual: APIResponse, message: Optional[str] = None
148+
) -> APIResponseAssertions: ...
149+
150+
def soft(
151+
self, actual: Union[Page, Locator, APIResponse], message: Optional[str] = None
152+
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
153+
"""
154+
Creates a [soft assertion](https://playwright.dev/python/docs/test-assertions#soft-assertions).
155+
Failing soft assertions do not abort test execution, but mark the test
156+
as failed. Multiple failures from the same test are surfaced together
157+
at the end of the test.
158+
159+
Requires the [pytest-playwright](https://pypi.org/project/pytest-playwright/)
160+
plugin to establish the per-test scope that collects soft assertion
161+
failures.
162+
"""
163+
return self._dispatch(actual, message, is_soft=True)
164+
165+
def _dispatch(
166+
self,
167+
actual: Union[Page, Locator, APIResponse],
168+
message: Optional[str],
169+
is_soft: bool,
134170
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
135171
if isinstance(actual, Page):
136172
return PageAssertions(
137-
PageAssertionsImpl(actual._impl_obj, self._timeout, message=message)
173+
PageAssertionsImpl(
174+
actual._impl_obj,
175+
self._timeout,
176+
message=message,
177+
is_soft=is_soft,
178+
)
138179
)
139180
elif isinstance(actual, Locator):
140181
return LocatorAssertions(
141-
LocatorAssertionsImpl(actual._impl_obj, self._timeout, message=message)
182+
LocatorAssertionsImpl(
183+
actual._impl_obj,
184+
self._timeout,
185+
message=message,
186+
is_soft=is_soft,
187+
)
142188
)
143189
elif isinstance(actual, APIResponse):
144190
return APIResponseAssertions(
145191
APIResponseAssertionsImpl(
146-
actual._impl_obj, self._timeout, message=message
192+
actual._impl_obj,
193+
self._timeout,
194+
message=message,
195+
is_soft=is_soft,
147196
)
148197
)
149198
raise ValueError(f"Unsupported type: {type(actual)}")

playwright/sync_api/__init__.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,19 +131,68 @@ def __call__(
131131

132132
def __call__(
133133
self, actual: Union[Page, Locator, APIResponse], message: Optional[str] = None
134+
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
135+
return self._dispatch(actual, message, is_soft=False)
136+
137+
@overload
138+
def soft(self, actual: Page, message: Optional[str] = None) -> PageAssertions: ...
139+
140+
@overload
141+
def soft(
142+
self, actual: Locator, message: Optional[str] = None
143+
) -> LocatorAssertions: ...
144+
145+
@overload
146+
def soft(
147+
self, actual: APIResponse, message: Optional[str] = None
148+
) -> APIResponseAssertions: ...
149+
150+
def soft(
151+
self, actual: Union[Page, Locator, APIResponse], message: Optional[str] = None
152+
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
153+
"""
154+
Creates a [soft assertion](https://playwright.dev/python/docs/test-assertions#soft-assertions).
155+
Failing soft assertions do not abort test execution, but mark the test
156+
as failed. Multiple failures from the same test are surfaced together
157+
at the end of the test.
158+
159+
Requires the [pytest-playwright](https://pypi.org/project/pytest-playwright/)
160+
plugin to establish the per-test scope that collects soft assertion
161+
failures.
162+
"""
163+
return self._dispatch(actual, message, is_soft=True)
164+
165+
def _dispatch(
166+
self,
167+
actual: Union[Page, Locator, APIResponse],
168+
message: Optional[str],
169+
is_soft: bool,
134170
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
135171
if isinstance(actual, Page):
136172
return PageAssertions(
137-
PageAssertionsImpl(actual._impl_obj, self._timeout, message=message)
173+
PageAssertionsImpl(
174+
actual._impl_obj,
175+
self._timeout,
176+
message=message,
177+
is_soft=is_soft,
178+
)
138179
)
139180
elif isinstance(actual, Locator):
140181
return LocatorAssertions(
141-
LocatorAssertionsImpl(actual._impl_obj, self._timeout, message=message)
182+
LocatorAssertionsImpl(
183+
actual._impl_obj,
184+
self._timeout,
185+
message=message,
186+
is_soft=is_soft,
187+
)
142188
)
143189
elif isinstance(actual, APIResponse):
144190
return APIResponseAssertions(
145191
APIResponseAssertionsImpl(
146-
actual._impl_obj, self._timeout, message=message
192+
actual._impl_obj,
193+
self._timeout,
194+
message=message,
195+
is_soft=is_soft,
147196
)
148197
)
149198
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)