Repository: plotly/dash

Issue:
[Feature Request] dash.testing.browser should allow custom timeout on wait_for_page()
While wait_for_page() the function itself is possible to take a timeout parameter, looks like when it's called in dash.testing.browser it is now always 10 sec for selenium to wait for dash app to serve. 
https://github.com/plotly/dash/blob/597dc22c86de97530fb9b0a9bd9f7876bd0d7601/dash/testing/browser.py#L602
https://github.com/plotly/dash/blob/597dc22c86de97530fb9b0a9bd9f7876bd0d7601/dash/testing/browser.py#L335

At our workplace we have some "heavy" setups on our dash apps. And I get error when trying out dash.testing like `wait_for_page(347): dash server is not loaded within 10 seconds`...

[Feature Request] Better failure messages from `_wait_for` test methods
Currently hitting a [CI-only test failure](https://circleci.com/gh/plotly/dash/8887#tests/containers/2) from `wait_for_text_to_equal`, works fine locally:
```
TimeoutException: Message: text -> a not found within 10s
```
I'd like to know what *was* found at the end of the timeout period instead of "a" but it's unclear to me how to massage the existing code to find that out.




Discussion:
Ah, and I presume you're using `dash_duo.start_server(app)` which then throws an error on timeout, so you can't simply add another wait after that.

Looks like we should have `wait_for_page` work like the other `wait_for_*` methods, that set default `timeout=None` and then use it like `timeout if timeout else self._wait_timeout`

Then you'd be able to set `dash_duo._wait_timeout` to whatever you want, prior to calling `start_server` - though I notice we bake the timeout set on `__init__` into [`_wd_wait`](https://github.com/plotly/dash/blob/597dc22c86de97530fb9b0a9bd9f7876bd0d7601/dash/testing/browser.py#L64) and use it [in `_wait_for`](https://github.com/plotly/dash/blob/597dc22c86de97530fb9b0a9bd9f7876bd0d7601/dash/testing/browser.py#L235) - let's just get rid of `_wd_wait`, I can't imagine a command that's specifically designed to wait caring about the cost of creating a new `WebDriverWait` object...
@alexcjohnson Thanks for the comment. I didn't go to `_wd_wait` that level of details, but your understanding of my issue is exact. I was thinking you can change to use `self._wait_timeout` at this place https://github.com/plotly/dash/blob/597dc22c86de97530fb9b0a9bd9f7876bd0d7601/dash/testing/browser.py#L602 But your idea of changing `wait_for_page` looks better.
is there any update on this ? I am hitting the same error
```
dash.testing.browser:browser.py:395 dash server is not loaded within 10 seconds
```


Resolving patch:
diff --git a/dash/dash.py b/dash/dash.py
--- a/dash/dash.py
+++ b/dash/dash.py
@@ -125,7 +125,8 @@
             html.Div(id=_ID_DUMMY, disable_n_clicks=True),
         ]
     )
-except AttributeError:
+# pylint: disable-next=bare-except
+except:  # noqa: E722
     page_container = None
 
 


Test patch:
diff --git a/dash/testing/browser.py b/dash/testing/browser.py
--- a/dash/testing/browser.py
+++ b/dash/testing/browser.py
@@ -262,19 +262,28 @@ def _get_element(self, elem_or_selector):
             return self.find_element(elem_or_selector)
         return elem_or_selector
 
-    def _wait_for(self, method, args, timeout, msg):
+    def _wait_for(self, method, timeout, msg):
         """Abstract generic pattern for explicit WebDriverWait."""
-        _wait = (
-            self._wd_wait if timeout is None else WebDriverWait(self.driver, timeout)
-        )
-        logger.debug(
-            "method, timeout, poll => %s %s %s",
-            method,
-            _wait._timeout,  # pylint: disable=protected-access
-            _wait._poll,  # pylint: disable=protected-access
-        )
+        try:
+            _wait = (
+                self._wd_wait
+                if timeout is None
+                else WebDriverWait(self.driver, timeout)
+            )
+            logger.debug(
+                "method, timeout, poll => %s %s %s",
+                method,
+                _wait._timeout,  # pylint: disable=protected-access
+                _wait._poll,  # pylint: disable=protected-access
+            )
 
-        return _wait.until(method(*args), msg)
+            return _wait.until(method)
+        except Exception as err:
+            if callable(msg):
+                message = msg(self.driver)
+            else:
+                message = msg
+            raise TimeoutException(message) from err
 
     def wait_for_element(self, selector, timeout=None):
         """wait_for_element is shortcut to `wait_for_element_by_css_selector`
@@ -286,8 +295,9 @@ def wait_for_element_by_css_selector(self, selector, timeout=None):
         equals to the fixture's `wait_timeout` shortcut to `WebDriverWait` with
         `EC.presence_of_element_located`."""
         return self._wait_for(
-            EC.presence_of_element_located,
-            ((By.CSS_SELECTOR, selector),),
+            EC.presence_of_element_located(
+                (By.CSS_SELECTOR, selector),
+            ),
             timeout,
             f"timeout {timeout or self._wait_timeout}s => waiting for selector {selector}",
         )
@@ -310,8 +320,9 @@ def wait_for_element_by_id(self, element_id, timeout=None):
         equals to the fixture's `wait_timeout` shortcut to `WebDriverWait` with
         `EC.presence_of_element_located`."""
         return self._wait_for(
-            EC.presence_of_element_located,
-            ((By.ID, element_id),),
+            EC.presence_of_element_located(
+                (By.ID, element_id),
+            ),
             timeout,
             f"timeout {timeout or self._wait_timeout}s => waiting for element id {element_id}",
         )
@@ -321,8 +332,7 @@ def wait_for_class_to_equal(self, selector, classname, timeout=None):
         if not set, equals to the fixture's `wait_timeout` shortcut to
         `WebDriverWait` with customized `class_to_equal` condition."""
         return self._wait_for(
-            method=class_to_equal,
-            args=(selector, classname),
+            method=class_to_equal(selector, classname),
             timeout=timeout,
             msg=f"classname => {classname} not found within {timeout or self._wait_timeout}s",
         )
@@ -332,8 +342,7 @@ def wait_for_style_to_equal(self, selector, style, val, timeout=None):
         if not set, equals to the fixture's `wait_timeout` shortcut to
         `WebDriverWait` with customized `style_to_equal` condition."""
         return self._wait_for(
-            method=style_to_equal,
-            args=(selector, style, val),
+            method=style_to_equal(selector, style, val),
             timeout=timeout,
             msg=f"style val => {style} {val} not found within {timeout or self._wait_timeout}s",
         )
@@ -345,11 +354,12 @@ def wait_for_text_to_equal(self, selector, text, timeout=None):
         shortcut to `WebDriverWait` with customized `text_to_equal`
         condition.
         """
+        method = text_to_equal(selector, text, timeout or self.wait_timeout)
+
         return self._wait_for(
-            method=text_to_equal,
-            args=(selector, text),
+            method=method,
             timeout=timeout,
-            msg=f"text -> {text} not found within {timeout or self._wait_timeout}s",
+            msg=method.message,
         )
 
     def wait_for_contains_class(self, selector, classname, timeout=None):
@@ -360,8 +370,7 @@ def wait_for_contains_class(self, selector, classname, timeout=None):
         condition.
         """
         return self._wait_for(
-            method=contains_class,
-            args=(selector, classname),
+            method=contains_class(selector, classname),
             timeout=timeout,
             msg=f"classname -> {classname} not found inside element within {timeout or self._wait_timeout}s",
         )
@@ -373,11 +382,11 @@ def wait_for_contains_text(self, selector, text, timeout=None):
         shortcut to `WebDriverWait` with customized `contains_text`
         condition.
         """
+        method = contains_text(selector, text, timeout or self.wait_timeout)
         return self._wait_for(
-            method=contains_text,
-            args=(selector, text),
+            method=method,
             timeout=timeout,
-            msg=f"text -> {text} not found inside element within {timeout or self._wait_timeout}s",
+            msg=method.message,
         )
 
     def wait_for_page(self, url=None, timeout=10):
@@ -449,11 +458,7 @@ def open_new_tab(self, url=None):
         )
 
     def get_webdriver(self):
-        try:
-            return getattr(self, f"_get_{self._browser}")()
-        except WebDriverException:
-            logger.exception("<<<Webdriver not initialized correctly>>>")
-            return None
+        return getattr(self, f"_get_{self._browser}")()
 
     def _get_wd_options(self):
         options = (
@@ -640,3 +645,12 @@ def server_url(self, value):
     @property
     def download_path(self):
         return self._download_path
+
+    @property
+    def wait_timeout(self):
+        return self._wait_timeout
+
+    @wait_timeout.setter
+    def wait_timeout(self, value):
+        self._wait_timeout = value
+        self._wd_wait = WebDriverWait(self.driver, value)
diff --git a/dash/testing/plugin.py b/dash/testing/plugin.py
--- a/dash/testing/plugin.py
+++ b/dash/testing/plugin.py
@@ -22,6 +22,10 @@ def __init__(self, **kwargs):
     )
     from dash.testing.browser import Browser
     from dash.testing.composite import DashComposite, DashRComposite, DashJuliaComposite
+    # pylint: disable=unused-import
+    import dash_testing_stub  # noqa: F401
+
+    _installed = True
 except ImportError:
     # Running pytest without dash[testing] installed.
     ThreadedRunner = MissingDashTesting
@@ -33,9 +37,13 @@ def __init__(self, **kwargs):
     DashComposite = MissingDashTesting
     DashRComposite = MissingDashTesting
     DashJuliaComposite = MissingDashTesting
+    _installed = False
 
 
 def pytest_addoption(parser):
+    if not _installed:
+        return
+
     dash = parser.getgroup("Dash", "Dash Integration Tests")
 
     dash.addoption(
@@ -82,6 +90,8 @@ def pytest_addoption(parser):
 
 @pytest.mark.tryfirst
 def pytest_addhooks(pluginmanager):
+    if not _installed:
+        return
     # https://github.com/pytest-dev/pytest-xdist/blob/974bd566c599dc6a9ea291838c6f226197208b46/xdist/plugin.py#L67
     # avoid warnings with pytest-2.8
     from dash.testing import newhooks  # pylint: disable=import-outside-toplevel
@@ -94,6 +104,8 @@ def pytest_addhooks(pluginmanager):
 
 @pytest.hookimpl(tryfirst=True, hookwrapper=True)
 def pytest_runtest_makereport(item, call):  # pylint: disable=unused-argument
+    if not _installed:
+        return
     # execute all other hooks to obtain the report object
     outcome = yield
     rep = outcome.get_result()
diff --git a/dash/testing/wait.py b/dash/testing/wait.py
--- a/dash/testing/wait.py
+++ b/dash/testing/wait.py
@@ -53,9 +53,10 @@ def until_not(
 
 
 class contains_text:
-    def __init__(self, selector, text):
+    def __init__(self, selector, text, timeout):
         self.selector = selector
         self.text = text
+        self.timeout = timeout
 
     def __call__(self, driver):
         try:
@@ -67,6 +68,17 @@ def __call__(self, driver):
         except WebDriverException:
             return False
 
+    def message(self, driver):
+        try:
+            element = self._get_element(driver)
+            text = "found: " + str(element.text) or str(element.get_attribute("value"))
+        except WebDriverException:
+            text = f"{self.selector} not found"
+        return f"text -> {self.text} not found inside element within {self.timeout}s, {text}"
+
+    def _get_element(self, driver):
+        return driver.find_element(By.CSS_SELECTOR, self.selector)
+
 
 class contains_class:
     def __init__(self, selector, classname):
@@ -86,13 +98,14 @@ def __call__(self, driver):
 
 
 class text_to_equal:
-    def __init__(self, selector, text):
+    def __init__(self, selector, text, timeout):
         self.selector = selector
         self.text = text
+        self.timeout = timeout
 
     def __call__(self, driver):
         try:
-            elem = driver.find_element(By.CSS_SELECTOR, self.selector)
+            elem = self._get_element(driver)
             logger.debug("text to equal {%s} => expected %s", elem.text, self.text)
             return (
                 str(elem.text) == self.text
@@ -101,6 +114,17 @@ def __call__(self, driver):
         except WebDriverException:
             return False
 
+    def message(self, driver):
+        try:
+            element = self._get_element(driver)
+            text = "found: " + str(element.text) or str(element.get_attribute("value"))
+        except WebDriverException:
+            text = f"{self.selector} not found"
+        return f"text -> {self.text} not found within {self.timeout}s, {text}"
+
+    def _get_element(self, driver):
+        return driver.find_element(By.CSS_SELECTOR, self.selector)
+
 
 class style_to_equal:
     def __init__(self, selector, style, val):
diff --git a/tests/integration/test_duo.py b/tests/integration/test_duo.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/test_duo.py
@@ -0,0 +1,36 @@
+import pytest
+from selenium.common.exceptions import TimeoutException
+
+from dash import Dash, html
+
+
+def test_duo001_wait_for_text_error(dash_duo):
+    app = Dash(__name__)
+    app.layout = html.Div([html.Div("Content", id="content")])
+    dash_duo.start_server(app)
+
+    with pytest.raises(TimeoutException) as err:
+        dash_duo.wait_for_text_to_equal("#content", "Invalid", timeout=1.0)
+
+    assert err.value.args[0] == "text -> Invalid not found within 1.0s, found: Content"
+
+    with pytest.raises(TimeoutException) as err:
+        dash_duo.wait_for_text_to_equal("#none", "None", timeout=1.0)
+
+    assert err.value.args[0] == "text -> None not found within 1.0s, #none not found"
+
+    with pytest.raises(TimeoutException) as err:
+        dash_duo.wait_for_contains_text("#content", "invalid", timeout=1.0)
+
+    assert (
+        err.value.args[0]
+        == "text -> invalid not found inside element within 1.0s, found: Content"
+    )
+
+    with pytest.raises(TimeoutException) as err:
+        dash_duo.wait_for_contains_text("#none", "none", timeout=1.0)
+
+    assert (
+        err.value.args[0]
+        == "text -> none not found inside element within 1.0s, #none not found"
+    )