Skip to content

Commit 1636a40

Browse files
committed
Implement soft assertions
1 parent bd5b033 commit 1636a40

File tree

5 files changed

+481
-14
lines changed

5 files changed

+481
-14
lines changed

playwright/_impl/_assertions.py

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

1515
import collections.abc
16-
from typing import Any, List, Optional, Pattern, Sequence, Union
16+
from types import TracebackType
17+
from typing import (
18+
TYPE_CHECKING,
19+
Any,
20+
Generic,
21+
List,
22+
Optional,
23+
Pattern,
24+
Sequence,
25+
Type,
26+
TypeVar,
27+
Union,
28+
)
1729
from urllib.parse import urljoin
1830

1931
from playwright._impl._api_structures import (
@@ -30,6 +42,10 @@
3042
from playwright._impl._page import Page
3143
from playwright._impl._str_utils import escape_regex_flags
3244

45+
if TYPE_CHECKING:
46+
from ..async_api import Expect as AsyncExpect
47+
from ..sync_api import Expect as SyncExpect
48+
3349

3450
class AssertionsBase:
3551
def __init__(
@@ -38,13 +54,15 @@ def __init__(
3854
timeout: float = None,
3955
is_not: bool = False,
4056
message: Optional[str] = None,
57+
soft_context: Optional["SoftAssertionContext"] = None,
4158
) -> None:
4259
self._actual_locator = locator
4360
self._loop = locator._loop
4461
self._dispatcher_fiber = locator._dispatcher_fiber
4562
self._timeout = timeout
4663
self._is_not = is_not
4764
self._custom_message = message
65+
self._soft_context = soft_context
4866

4967
async def _call_expect(
5068
self, expression: str, expect_options: FrameExpectOptions, title: Optional[str]
@@ -80,9 +98,13 @@ async def _expect_impl(
8098
out_message = (
8199
f"{message} '{expected}'" if expected is not None else f"{message}"
82100
)
83-
raise AssertionError(
101+
error = AssertionError(
84102
f"{out_message}\nActual value: {actual} {format_call_log(result.get('log'))}"
85103
)
104+
if self._soft_context is not None:
105+
self._soft_context.add_failure(error)
106+
else:
107+
raise error
86108

87109

88110
class PageAssertions(AssertionsBase):
@@ -92,8 +114,9 @@ def __init__(
92114
timeout: float = None,
93115
is_not: bool = False,
94116
message: Optional[str] = None,
117+
soft_context: Optional["SoftAssertionContext"] = None,
95118
) -> None:
96-
super().__init__(page.locator(":root"), timeout, is_not, message)
119+
super().__init__(page.locator(":root"), timeout, is_not, message, soft_context)
97120
self._actual_page = page
98121

99122
async def _call_expect(
@@ -107,7 +130,11 @@ async def _call_expect(
107130
@property
108131
def _not(self) -> "PageAssertions":
109132
return PageAssertions(
110-
self._actual_page, self._timeout, not self._is_not, self._custom_message
133+
self._actual_page,
134+
self._timeout,
135+
not self._is_not,
136+
self._custom_message,
137+
self._soft_context,
111138
)
112139

113140
async def to_have_title(
@@ -167,8 +194,9 @@ def __init__(
167194
timeout: float = None,
168195
is_not: bool = False,
169196
message: Optional[str] = None,
197+
soft_context: Optional["SoftAssertionContext"] = None,
170198
) -> None:
171-
super().__init__(locator, timeout, is_not, message)
199+
super().__init__(locator, timeout, is_not, message, soft_context)
172200
self._actual_locator = locator
173201

174202
async def _call_expect(
@@ -180,7 +208,11 @@ async def _call_expect(
180208
@property
181209
def _not(self) -> "LocatorAssertions":
182210
return LocatorAssertions(
183-
self._actual_locator, self._timeout, not self._is_not, self._custom_message
211+
self._actual_locator,
212+
self._timeout,
213+
not self._is_not,
214+
self._custom_message,
215+
self._soft_context,
184216
)
185217

186218
async def to_contain_text(
@@ -942,18 +974,24 @@ def __init__(
942974
timeout: float = None,
943975
is_not: bool = False,
944976
message: Optional[str] = None,
977+
soft_context: Optional["SoftAssertionContext"] = None,
945978
) -> None:
946979
self._loop = response._loop
947980
self._dispatcher_fiber = response._dispatcher_fiber
948981
self._timeout = timeout
949982
self._is_not = is_not
950983
self._actual = response
951984
self._custom_message = message
985+
self._soft_context = soft_context
952986

953987
@property
954988
def _not(self) -> "APIResponseAssertions":
955989
return APIResponseAssertions(
956-
self._actual, self._timeout, not self._is_not, self._custom_message
990+
self._actual,
991+
self._timeout,
992+
not self._is_not,
993+
self._custom_message,
994+
self._soft_context,
957995
)
958996

959997
async def to_be_ok(
@@ -974,7 +1012,11 @@ async def to_be_ok(
9741012
if text is not None:
9751013
out_message += f"\n Response Text:\n{text[:1000]}"
9761014

977-
raise AssertionError(out_message)
1015+
error = AssertionError(out_message)
1016+
if self._soft_context is not None:
1017+
self._soft_context.add_failure(error)
1018+
else:
1019+
raise error
9781020

9791021
async def not_to_be_ok(self) -> None:
9801022
__tracebackhide__ = True
@@ -1027,3 +1069,58 @@ def to_expected_text_values(
10271069
else:
10281070
raise Error("value must be a string or regular expression")
10291071
return out
1072+
1073+
1074+
class SoftAssertionContext:
1075+
def __init__(self) -> None:
1076+
self._failures: List[Exception] = []
1077+
1078+
def __repr__(self) -> str:
1079+
return f"<SoftAssertionContext failures={self._failures!r}>"
1080+
1081+
def add_failure(self, error: Exception) -> None:
1082+
self._failures.append(error)
1083+
1084+
def has_failures(self) -> bool:
1085+
return bool(self._failures)
1086+
1087+
def get_failure_messages(self) -> str:
1088+
return "\n".join(
1089+
f"{i}. {str(error)}" for i, error in enumerate(self._failures, 1)
1090+
)
1091+
1092+
1093+
E = TypeVar("E", "SyncExpect", "AsyncExpect")
1094+
1095+
1096+
class SoftAssertionContextManager(Generic[E]):
1097+
def __init__(self, expect: E, context: SoftAssertionContext) -> None:
1098+
self._expect: E = expect
1099+
self._context = context
1100+
1101+
def __enter__(self) -> E:
1102+
self._expect._soft_context = self._context
1103+
return self._expect
1104+
1105+
def __exit__(
1106+
self,
1107+
exc_type: Optional[Type[BaseException]],
1108+
exc_val: Optional[BaseException],
1109+
exc_tb: Optional[TracebackType],
1110+
) -> None:
1111+
__tracebackhide__ = True
1112+
1113+
if self._context.has_failures():
1114+
if exc_type is not None:
1115+
failure_message = (
1116+
f"{str(exc_val)}"
1117+
f"\n\nThe above exception occurred within soft assertion block."
1118+
f"\n\nSoft assertion failures:\n{self._context.get_failure_messages()}"
1119+
)
1120+
if exc_val is not None:
1121+
exc_val.args = (failure_message,) + exc_val.args[1:]
1122+
return
1123+
1124+
raise AssertionError(
1125+
f"Soft assertion failures\n{self._context.get_failure_messages()}"
1126+
)

playwright/async_api/__init__.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
)
2929
from playwright._impl._assertions import LocatorAssertions as LocatorAssertionsImpl
3030
from playwright._impl._assertions import PageAssertions as PageAssertionsImpl
31+
from playwright._impl._assertions import (
32+
SoftAssertionContext,
33+
SoftAssertionContextManager,
34+
)
3135
from playwright.async_api._context_manager import PlaywrightContextManager
3236
from playwright.async_api._generated import (
3337
Accessibility,
@@ -95,6 +99,7 @@ class Expect:
9599

96100
def __init__(self) -> None:
97101
self._timeout: Optional[float] = None
102+
self._soft_context: Optional[SoftAssertionContext] = None
98103

99104
def set_options(self, timeout: Optional[float] = _unset) -> None:
100105
"""
@@ -109,6 +114,11 @@ def set_options(self, timeout: Optional[float] = _unset) -> None:
109114
if timeout is not self._unset:
110115
self._timeout = timeout
111116

117+
def soft(self) -> SoftAssertionContextManager:
118+
expect = Expect()
119+
expect._timeout = self._timeout
120+
return SoftAssertionContextManager(expect, SoftAssertionContext())
121+
112122
@overload
113123
def __call__(
114124
self, actual: Page, message: Optional[str] = None
@@ -129,16 +139,29 @@ def __call__(
129139
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
130140
if isinstance(actual, Page):
131141
return PageAssertions(
132-
PageAssertionsImpl(actual._impl_obj, self._timeout, message=message)
142+
PageAssertionsImpl(
143+
actual._impl_obj,
144+
self._timeout,
145+
message=message,
146+
soft_context=self._soft_context,
147+
)
133148
)
134149
elif isinstance(actual, Locator):
135150
return LocatorAssertions(
136-
LocatorAssertionsImpl(actual._impl_obj, self._timeout, message=message)
151+
LocatorAssertionsImpl(
152+
actual._impl_obj,
153+
self._timeout,
154+
message=message,
155+
soft_context=self._soft_context,
156+
)
137157
)
138158
elif isinstance(actual, APIResponse):
139159
return APIResponseAssertions(
140160
APIResponseAssertionsImpl(
141-
actual._impl_obj, self._timeout, message=message
161+
actual._impl_obj,
162+
self._timeout,
163+
message=message,
164+
soft_context=self._soft_context,
142165
)
143166
)
144167
raise ValueError(f"Unsupported type: {type(actual)}")

playwright/sync_api/__init__.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
)
2929
from playwright._impl._assertions import LocatorAssertions as LocatorAssertionsImpl
3030
from playwright._impl._assertions import PageAssertions as PageAssertionsImpl
31+
from playwright._impl._assertions import (
32+
SoftAssertionContext,
33+
SoftAssertionContextManager,
34+
)
3135
from playwright.sync_api._context_manager import PlaywrightContextManager
3236
from playwright.sync_api._generated import (
3337
Accessibility,
@@ -95,6 +99,7 @@ class Expect:
9599

96100
def __init__(self) -> None:
97101
self._timeout: Optional[float] = None
102+
self._soft_context: Optional[SoftAssertionContext] = None
98103

99104
def set_options(self, timeout: Optional[float] = _unset) -> None:
100105
"""
@@ -109,6 +114,11 @@ def set_options(self, timeout: Optional[float] = _unset) -> None:
109114
if timeout is not self._unset:
110115
self._timeout = timeout
111116

117+
def soft(self) -> SoftAssertionContextManager:
118+
expect = Expect()
119+
expect._timeout = self._timeout
120+
return SoftAssertionContextManager(expect, SoftAssertionContext())
121+
112122
@overload
113123
def __call__(
114124
self, actual: Page, message: Optional[str] = None
@@ -129,16 +139,29 @@ def __call__(
129139
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
130140
if isinstance(actual, Page):
131141
return PageAssertions(
132-
PageAssertionsImpl(actual._impl_obj, self._timeout, message=message)
142+
PageAssertionsImpl(
143+
actual._impl_obj,
144+
self._timeout,
145+
message=message,
146+
soft_context=self._soft_context,
147+
)
133148
)
134149
elif isinstance(actual, Locator):
135150
return LocatorAssertions(
136-
LocatorAssertionsImpl(actual._impl_obj, self._timeout, message=message)
151+
LocatorAssertionsImpl(
152+
actual._impl_obj,
153+
self._timeout,
154+
message=message,
155+
soft_context=self._soft_context,
156+
)
137157
)
138158
elif isinstance(actual, APIResponse):
139159
return APIResponseAssertions(
140160
APIResponseAssertionsImpl(
141-
actual._impl_obj, self._timeout, message=message
161+
actual._impl_obj,
162+
self._timeout,
163+
message=message,
164+
soft_context=self._soft_context,
142165
)
143166
)
144167
raise ValueError(f"Unsupported type: {type(actual)}")

0 commit comments

Comments
 (0)