Skip to content

Commit 4b5244f

Browse files
committed
Implement soft assertions
1 parent d3aacbb commit 4b5244f

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 (
@@ -29,6 +41,10 @@
2941
from playwright._impl._page import Page
3042
from playwright._impl._str_utils import escape_regex_flags
3143

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

3349
class AssertionsBase:
3450
def __init__(
@@ -37,13 +53,15 @@ def __init__(
3753
timeout: float = None,
3854
is_not: bool = False,
3955
message: Optional[str] = None,
56+
soft_context: Optional["SoftAssertionContext"] = None,
4057
) -> None:
4158
self._actual_locator = locator
4259
self._loop = locator._loop
4360
self._dispatcher_fiber = locator._dispatcher_fiber
4461
self._timeout = timeout
4562
self._is_not = is_not
4663
self._custom_message = message
64+
self._soft_context = soft_context
4765

4866
async def _expect_impl(
4967
self,
@@ -71,9 +89,13 @@ async def _expect_impl(
7189
out_message = (
7290
f"{message} '{expected}'" if expected is not None else f"{message}"
7391
)
74-
raise AssertionError(
92+
error = AssertionError(
7593
f"{out_message}\nActual value: {actual} {format_call_log(result.get('log'))}"
7694
)
95+
if self._soft_context is not None:
96+
self._soft_context.add_failure(error)
97+
else:
98+
raise error
7799

78100

79101
class PageAssertions(AssertionsBase):
@@ -83,14 +105,19 @@ def __init__(
83105
timeout: float = None,
84106
is_not: bool = False,
85107
message: Optional[str] = None,
108+
soft_context: Optional["SoftAssertionContext"] = None,
86109
) -> None:
87-
super().__init__(page.locator(":root"), timeout, is_not, message)
110+
super().__init__(page.locator(":root"), timeout, is_not, message, soft_context)
88111
self._actual_page = page
89112

90113
@property
91114
def _not(self) -> "PageAssertions":
92115
return PageAssertions(
93-
self._actual_page, self._timeout, not self._is_not, self._custom_message
116+
self._actual_page,
117+
self._timeout,
118+
not self._is_not,
119+
self._custom_message,
120+
self._soft_context,
94121
)
95122

96123
async def to_have_title(
@@ -148,14 +175,19 @@ def __init__(
148175
timeout: float = None,
149176
is_not: bool = False,
150177
message: Optional[str] = None,
178+
soft_context: Optional["SoftAssertionContext"] = None,
151179
) -> None:
152-
super().__init__(locator, timeout, is_not, message)
180+
super().__init__(locator, timeout, is_not, message, soft_context)
153181
self._actual_locator = locator
154182

155183
@property
156184
def _not(self) -> "LocatorAssertions":
157185
return LocatorAssertions(
158-
self._actual_locator, self._timeout, not self._is_not, self._custom_message
186+
self._actual_locator,
187+
self._timeout,
188+
not self._is_not,
189+
self._custom_message,
190+
self._soft_context,
159191
)
160192

161193
async def to_contain_text(
@@ -848,18 +880,24 @@ def __init__(
848880
timeout: float = None,
849881
is_not: bool = False,
850882
message: Optional[str] = None,
883+
soft_context: Optional["SoftAssertionContext"] = None,
851884
) -> None:
852885
self._loop = response._loop
853886
self._dispatcher_fiber = response._dispatcher_fiber
854887
self._timeout = timeout
855888
self._is_not = is_not
856889
self._actual = response
857890
self._custom_message = message
891+
self._soft_context = soft_context
858892

859893
@property
860894
def _not(self) -> "APIResponseAssertions":
861895
return APIResponseAssertions(
862-
self._actual, self._timeout, not self._is_not, self._custom_message
896+
self._actual,
897+
self._timeout,
898+
not self._is_not,
899+
self._custom_message,
900+
self._soft_context,
863901
)
864902

865903
async def to_be_ok(
@@ -880,7 +918,11 @@ async def to_be_ok(
880918
if text is not None:
881919
out_message += f"\n Response Text:\n{text[:1000]}"
882920

883-
raise AssertionError(out_message)
921+
error = AssertionError(out_message)
922+
if self._soft_context is not None:
923+
self._soft_context.add_failure(error)
924+
else:
925+
raise error
884926

885927
async def not_to_be_ok(self) -> None:
886928
__tracebackhide__ = True
@@ -933,3 +975,58 @@ def to_expected_text_values(
933975
else:
934976
raise Error("value must be a string or regular expression")
935977
return out
978+
979+
980+
class SoftAssertionContext:
981+
def __init__(self) -> None:
982+
self._failures: List[Exception] = []
983+
984+
def __repr__(self) -> str:
985+
return f"<SoftAssertionContext failures={self._failures!r}>"
986+
987+
def add_failure(self, error: Exception) -> None:
988+
self._failures.append(error)
989+
990+
def has_failures(self) -> bool:
991+
return bool(self._failures)
992+
993+
def get_failure_messages(self) -> str:
994+
return "\n".join(
995+
f"{i}. {str(error)}" for i, error in enumerate(self._failures, 1)
996+
)
997+
998+
999+
E = TypeVar("E", "SyncExpect", "AsyncExpect")
1000+
1001+
1002+
class SoftAssertionContextManager(Generic[E]):
1003+
def __init__(self, expect: E, context: SoftAssertionContext) -> None:
1004+
self._expect: E = expect
1005+
self._context = context
1006+
1007+
def __enter__(self) -> E:
1008+
self._expect._soft_context = self._context
1009+
return self._expect
1010+
1011+
def __exit__(
1012+
self,
1013+
exc_type: Optional[Type[BaseException]],
1014+
exc_val: Optional[BaseException],
1015+
exc_tb: Optional[TracebackType],
1016+
) -> None:
1017+
__tracebackhide__ = True
1018+
1019+
if self._context.has_failures():
1020+
if exc_type is not None:
1021+
failure_message = (
1022+
f"{str(exc_val)}"
1023+
f"\n\nThe above exception occurred within soft assertion block."
1024+
f"\n\nSoft assertion failures:\n{self._context.get_failure_messages()}"
1025+
)
1026+
if exc_val is not None:
1027+
exc_val.args = (failure_message,) + exc_val.args[1:]
1028+
return
1029+
1030+
raise AssertionError(
1031+
f"Soft assertion failures\n{self._context.get_failure_messages()}"
1032+
)

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,
@@ -94,6 +98,7 @@ class Expect:
9498

9599
def __init__(self) -> None:
96100
self._timeout: Optional[float] = None
101+
self._soft_context: Optional[SoftAssertionContext] = None
97102

98103
def set_options(self, timeout: Optional[float] = _unset) -> None:
99104
"""
@@ -108,6 +113,11 @@ def set_options(self, timeout: Optional[float] = _unset) -> None:
108113
if timeout is not self._unset:
109114
self._timeout = timeout
110115

116+
def soft(self) -> SoftAssertionContextManager:
117+
expect = Expect()
118+
expect._timeout = self._timeout
119+
return SoftAssertionContextManager(expect, SoftAssertionContext())
120+
111121
@overload
112122
def __call__(
113123
self, actual: Page, message: Optional[str] = None
@@ -128,16 +138,29 @@ def __call__(
128138
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
129139
if isinstance(actual, Page):
130140
return PageAssertions(
131-
PageAssertionsImpl(actual._impl_obj, self._timeout, message=message)
141+
PageAssertionsImpl(
142+
actual._impl_obj,
143+
self._timeout,
144+
message=message,
145+
soft_context=self._soft_context,
146+
)
132147
)
133148
elif isinstance(actual, Locator):
134149
return LocatorAssertions(
135-
LocatorAssertionsImpl(actual._impl_obj, self._timeout, message=message)
150+
LocatorAssertionsImpl(
151+
actual._impl_obj,
152+
self._timeout,
153+
message=message,
154+
soft_context=self._soft_context,
155+
)
136156
)
137157
elif isinstance(actual, APIResponse):
138158
return APIResponseAssertions(
139159
APIResponseAssertionsImpl(
140-
actual._impl_obj, self._timeout, message=message
160+
actual._impl_obj,
161+
self._timeout,
162+
message=message,
163+
soft_context=self._soft_context,
141164
)
142165
)
143166
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,
@@ -94,6 +98,7 @@ class Expect:
9498

9599
def __init__(self) -> None:
96100
self._timeout: Optional[float] = None
101+
self._soft_context: Optional[SoftAssertionContext] = None
97102

98103
def set_options(self, timeout: Optional[float] = _unset) -> None:
99104
"""
@@ -108,6 +113,11 @@ def set_options(self, timeout: Optional[float] = _unset) -> None:
108113
if timeout is not self._unset:
109114
self._timeout = timeout
110115

116+
def soft(self) -> SoftAssertionContextManager:
117+
expect = Expect()
118+
expect._timeout = self._timeout
119+
return SoftAssertionContextManager(expect, SoftAssertionContext())
120+
111121
@overload
112122
def __call__(
113123
self, actual: Page, message: Optional[str] = None
@@ -128,16 +138,29 @@ def __call__(
128138
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
129139
if isinstance(actual, Page):
130140
return PageAssertions(
131-
PageAssertionsImpl(actual._impl_obj, self._timeout, message=message)
141+
PageAssertionsImpl(
142+
actual._impl_obj,
143+
self._timeout,
144+
message=message,
145+
soft_context=self._soft_context,
146+
)
132147
)
133148
elif isinstance(actual, Locator):
134149
return LocatorAssertions(
135-
LocatorAssertionsImpl(actual._impl_obj, self._timeout, message=message)
150+
LocatorAssertionsImpl(
151+
actual._impl_obj,
152+
self._timeout,
153+
message=message,
154+
soft_context=self._soft_context,
155+
)
136156
)
137157
elif isinstance(actual, APIResponse):
138158
return APIResponseAssertions(
139159
APIResponseAssertionsImpl(
140-
actual._impl_obj, self._timeout, message=message
160+
actual._impl_obj,
161+
self._timeout,
162+
message=message,
163+
soft_context=self._soft_context,
141164
)
142165
)
143166
raise ValueError(f"Unsupported type: {type(actual)}")

0 commit comments

Comments
 (0)