Repository: microsoft/playwright-python

Issue:
[Feature] Add custom expect messages
I was looking for a way to specify custom error messages for assertions and it looks like they are available in [Node.js](https://playwright.dev/docs/test-assertions#custom-expect-message) Playwright. How much effort would it be to also implement them in Python? With a little hint on where to start, I could give it a try.


Discussion:
I would also like this feature. At the very least let's update the documentation so that it explicitly states this is a feature that is not available (and perhaps even with a link to this Github issue so those who want it can voice their support).
+1 for this feature!
+1 would be really nice to have it
+1 really need this one! 

Resolving patch:
diff --git a/playwright/_impl/_assertions.py b/playwright/_impl/_assertions.py
--- a/playwright/_impl/_assertions.py
+++ b/playwright/_impl/_assertions.py
@@ -24,11 +24,14 @@
 
 
 class AssertionsBase:
-    def __init__(self, locator: Locator, is_not: bool = False) -> None:
+    def __init__(
+        self, locator: Locator, is_not: bool = False, message: Optional[str] = None
+    ) -> None:
         self._actual_locator = locator
         self._loop = locator._loop
         self._dispatcher_fiber = locator._dispatcher_fiber
         self._is_not = is_not
+        self._custom_message = message
 
     async def _expect_impl(
         self,
@@ -51,21 +54,27 @@ async def _expect_impl(
             log = "\n".join(result.get("log", "")).strip()
             if log:
                 log = "\nCall log:\n" + log
-            if expected is not None:
-                raise AssertionError(
-                    f"{message} '{expected}'\nActual value: {actual} {log}"
+            if self._custom_message:
+                out_message = (
+                    f"{self._custom_message}\nExpected value: {expected or '<None>'}"
+                )
+            else:
+                out_message = (
+                    f"{message} '{expected}'" if expected is not None else f"{message}"
                 )
-            raise AssertionError(f"{message}\nActual value: {actual} {log}")
+            raise AssertionError(f"{out_message}\nActual value: {actual} {log}")
 
 
 class PageAssertions(AssertionsBase):
-    def __init__(self, page: Page, is_not: bool = False) -> None:
-        super().__init__(page.locator(":root"), is_not)
+    def __init__(
+        self, page: Page, is_not: bool = False, message: Optional[str] = None
+    ) -> None:
+        super().__init__(page.locator(":root"), is_not, message)
         self._actual_page = page
 
     @property
     def _not(self) -> "PageAssertions":
-        return PageAssertions(self._actual_page, not self._is_not)
+        return PageAssertions(self._actual_page, not self._is_not, self._custom_message)
 
     async def to_have_title(
         self, title_or_reg_exp: Union[Pattern[str], str], timeout: float = None
@@ -110,13 +119,17 @@ async def not_to_have_url(
 
 
 class LocatorAssertions(AssertionsBase):
-    def __init__(self, locator: Locator, is_not: bool = False) -> None:
-        super().__init__(locator, is_not)
+    def __init__(
+        self, locator: Locator, is_not: bool = False, message: Optional[str] = None
+    ) -> None:
+        super().__init__(locator, is_not, message)
         self._actual_locator = locator
 
     @property
     def _not(self) -> "LocatorAssertions":
-        return LocatorAssertions(self._actual_locator, not self._is_not)
+        return LocatorAssertions(
+            self._actual_locator, not self._is_not, self._custom_message
+        )
 
     async def to_contain_text(
         self,
@@ -639,15 +652,20 @@ async def not_to_be_in_viewport(
 
 
 class APIResponseAssertions:
-    def __init__(self, response: APIResponse, is_not: bool = False) -> None:
+    def __init__(
+        self, response: APIResponse, is_not: bool = False, message: Optional[str] = None
+    ) -> None:
         self._loop = response._loop
         self._dispatcher_fiber = response._dispatcher_fiber
         self._is_not = is_not
         self._actual = response
+        self._custom_message = message
 
     @property
     def _not(self) -> "APIResponseAssertions":
-        return APIResponseAssertions(self._actual, not self._is_not)
+        return APIResponseAssertions(
+            self._actual, not self._is_not, self._custom_message
+        )
 
     async def to_be_ok(
         self,
@@ -658,18 +676,19 @@ async def to_be_ok(
         message = f"Response status expected to be within [200..299] range, was '{self._actual.status}'"
         if self._is_not:
             message = message.replace("expected to", "expected not to")
+        out_message = self._custom_message or message
         log_list = await self._actual._fetch_log()
         log = "\n".join(log_list).strip()
         if log:
-            message += f"\n Call log:\n{log}"
+            out_message += f"\n Call log:\n{log}"
 
         content_type = self._actual.headers.get("content-type")
         is_text_encoding = content_type and is_textual_mime_type(content_type)
         text = await self._actual.text() if is_text_encoding else None
         if text is not None:
-            message += f"\n Response Text:\n{text[:1000]}"
+            out_message += f"\n Response Text:\n{text[:1000]}"
 
-        raise AssertionError(message)
+        raise AssertionError(out_message)
 
     async def not_to_be_ok(self) -> None:
         __tracebackhide__ = True
diff --git a/playwright/async_api/__init__.py b/playwright/async_api/__init__.py
--- a/playwright/async_api/__init__.py
+++ b/playwright/async_api/__init__.py
@@ -18,7 +18,7 @@
 web automation that is ever-green, capable, reliable and fast.
 """
 
-from typing import Union, overload
+from typing import Optional, Union, overload
 
 import playwright._impl._api_structures
 import playwright._impl._api_types
@@ -88,29 +88,33 @@ def async_playwright() -> PlaywrightContextManager:
 
 
 @overload
-def expect(actual: Page) -> PageAssertions:
+def expect(actual: Page, message: Optional[str] = None) -> PageAssertions:
     ...
 
 
 @overload
-def expect(actual: Locator) -> LocatorAssertions:
+def expect(actual: Locator, message: Optional[str] = None) -> LocatorAssertions:
     ...
 
 
 @overload
-def expect(actual: APIResponse) -> APIResponseAssertions:
+def expect(actual: APIResponse, message: Optional[str] = None) -> APIResponseAssertions:
     ...
 
 
 def expect(
-    actual: Union[Page, Locator, APIResponse]
+    actual: Union[Page, Locator, APIResponse], message: Optional[str] = None
 ) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
     if isinstance(actual, Page):
-        return PageAssertions(PageAssertionsImpl(actual._impl_obj))
+        return PageAssertions(PageAssertionsImpl(actual._impl_obj, message=message))
     elif isinstance(actual, Locator):
-        return LocatorAssertions(LocatorAssertionsImpl(actual._impl_obj))
+        return LocatorAssertions(
+            LocatorAssertionsImpl(actual._impl_obj, message=message)
+        )
     elif isinstance(actual, APIResponse):
-        return APIResponseAssertions(APIResponseAssertionsImpl(actual._impl_obj))
+        return APIResponseAssertions(
+            APIResponseAssertionsImpl(actual._impl_obj, message=message)
+        )
     raise ValueError(f"Unsupported type: {type(actual)}")
 
 
diff --git a/playwright/sync_api/__init__.py b/playwright/sync_api/__init__.py
--- a/playwright/sync_api/__init__.py
+++ b/playwright/sync_api/__init__.py
@@ -18,7 +18,7 @@
 web automation that is ever-green, capable, reliable and fast.
 """
 
-from typing import Union, overload
+from typing import Optional, Union, overload
 
 import playwright._impl._api_structures
 import playwright._impl._api_types
@@ -88,29 +88,33 @@ def sync_playwright() -> PlaywrightContextManager:
 
 
 @overload
-def expect(actual: Page) -> PageAssertions:
+def expect(actual: Page, message: Optional[str] = None) -> PageAssertions:
     ...
 
 
 @overload
-def expect(actual: Locator) -> LocatorAssertions:
+def expect(actual: Locator, message: Optional[str] = None) -> LocatorAssertions:
     ...
 
 
 @overload
-def expect(actual: APIResponse) -> APIResponseAssertions:
+def expect(actual: APIResponse, message: Optional[str] = None) -> APIResponseAssertions:
     ...
 
 
 def expect(
-    actual: Union[Page, Locator, APIResponse]
+    actual: Union[Page, Locator, APIResponse], message: Optional[str] = None
 ) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
     if isinstance(actual, Page):
-        return PageAssertions(PageAssertionsImpl(actual._impl_obj))
+        return PageAssertions(PageAssertionsImpl(actual._impl_obj, message=message))
     elif isinstance(actual, Locator):
-        return LocatorAssertions(LocatorAssertionsImpl(actual._impl_obj))
+        return LocatorAssertions(
+            LocatorAssertionsImpl(actual._impl_obj, message=message)
+        )
     elif isinstance(actual, APIResponse):
-        return APIResponseAssertions(APIResponseAssertionsImpl(actual._impl_obj))
+        return APIResponseAssertions(
+            APIResponseAssertionsImpl(actual._impl_obj, message=message)
+        )
     raise ValueError(f"Unsupported type: {type(actual)}")
 
 


Test patch:
diff --git a/tests/async/test_assertions.py b/tests/async/test_assertions.py
--- a/tests/async/test_assertions.py
+++ b/tests/async/test_assertions.py
@@ -658,3 +658,16 @@ async def test_should_print_response_with_text_content_type_if_to_be_ok_fails(
     assert "← 404 Not Found" in error_message
     assert "Response Text:" not in error_message
     assert "Image content type error" not in error_message
+
+
+async def test_should_print_users_message_for_page_based_assertion(
+    page: Page, server: Server
+) -> None:
+    await page.goto(server.EMPTY_PAGE)
+    await page.set_content("<title>new title</title>")
+    with pytest.raises(AssertionError) as excinfo:
+        await expect(page, "Title is not new").to_have_title("old title", timeout=100)
+    assert "Title is not new" in str(excinfo.value)
+    with pytest.raises(AssertionError) as excinfo:
+        await expect(page).to_have_title("old title", timeout=100)
+    assert "Page title expected to be" in str(excinfo.value)
diff --git a/tests/sync/test_assertions.py b/tests/sync/test_assertions.py
--- a/tests/sync/test_assertions.py
+++ b/tests/sync/test_assertions.py
@@ -746,3 +746,16 @@ def test_should_print_response_with_text_content_type_if_to_be_ok_fails(
     assert "← 404 Not Found" in error_message
     assert "Response Text:" not in error_message
     assert "Image content type error" not in error_message
+
+
+def test_should_print_users_message_for_page_based_assertion(
+    page: Page, server: Server
+) -> None:
+    page.goto(server.EMPTY_PAGE)
+    page.set_content("<title>new title</title>")
+    with pytest.raises(AssertionError) as excinfo:
+        expect(page, "Title is not new").to_have_title("old title", timeout=100)
+    assert "Title is not new" in str(excinfo.value)
+    with pytest.raises(AssertionError) as excinfo:
+        expect(page).to_have_title("old title", timeout=100)
+    assert "Page title expected to be" in str(excinfo.value)