diff --git a/dash/CHANGELOG.md b/dash/CHANGELOG.md index cc5be65cdf..69669d4489 100644 --- a/dash/CHANGELOG.md +++ b/dash/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### Added + +- [#827](https://github.com/plotly/dash/pull/827) Adds support for dashR testing using pytest framework + ## [1.0.2] - 2019-07-15 ### Fixed - [#821](https://github.com/plotly/dash/pull/821) Fix a bug with callback error reporting, [#791](https://github.com/plotly/dash/issues/791). diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py index 5dc2d3fa8f..7456bff34f 100644 --- a/dash/testing/application_runners.py +++ b/dash/testing/application_runners.py @@ -1,6 +1,7 @@ from __future__ import print_function import sys +import os import uuid import shlex import threading @@ -68,6 +69,14 @@ def start(self, *args, **kwargs): def stop(self): raise NotImplementedError # pragma: no cover + @staticmethod + def accessible(url): + try: + requests.get(url) + except requests.exceptions.RequestException: + return False + return True + def __call__(self, *args, **kwargs): return self.start(*args, **kwargs) @@ -91,6 +100,10 @@ def url(self): """the default server url""" return "http://localhost:{}".format(self.port) + @property + def is_windows(self): + return sys.platform == "win32" + class ThreadedRunner(BaseDashRunner): """Runs a dash application in a thread @@ -145,15 +158,8 @@ def run(): self.started = self.thread.is_alive() - def accessible(): - try: - requests.get(self.url) - except requests.exceptions.RequestException: - return False - return True - # wait until server is able to answer http request - wait.until(accessible, timeout=1) + wait.until(lambda: self.accessible(self.url), timeout=1) def stop(self): requests.get("{}{}".format(self.url, self.stop_route)) @@ -180,7 +186,7 @@ def start(self, app_module, application_name="app", port=8050): args = shlex.split( "waitress-serve --listen=0.0.0.0:{} {}".format(port, entrypoint), - posix=sys.platform != "win32", + posix=not self.is_windows, ) logger.debug("start dash process with %s", args) @@ -188,6 +194,9 @@ def start(self, app_module, application_name="app", port=8050): self.proc = subprocess.Popen( args, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) + # wait until server is able to answer http request + wait.until(lambda: self.accessible(self.url), timeout=3) + except (OSError, ValueError): logger.exception("process server has encountered an error") self.started = False @@ -214,3 +223,55 @@ def stop(self): ) self.proc.kill() self.proc.communicate() + + +class RRunner(ProcessRunner): + def __init__(self, keep_open=False, stop_timeout=3): + super(RRunner, self).__init__( + keep_open=keep_open, stop_timeout=stop_timeout + ) + self.proc = None + + # pylint: disable=arguments-differ + def start(self, app): + """Start the server with waitress-serve in process flavor """ + + # app is a R string chunk + if not (os.path.isfile(app) and os.path.exists(app)): + path = ( + "/tmp/app_{}.R".format(uuid.uuid4().hex) + if not self.is_windows + else os.path.join( + (os.getenv("TEMP"), "app_{}.R".format(uuid.uuid4().hex)) + ) + ) + logger.info("RRuner start => app is R code chunk") + logger.info("make a temporay R file for execution=> %s", path) + logger.debug("the content of dashR app") + logger.debug("%s", app) + + with open(path, "w") as fp: + fp.write(app) + + app = path + + logger.info("Run dashR app with Rscript => %s", app) + args = shlex.split( + "Rscript {}".format(os.path.realpath(app)), + posix=not self.is_windows, + ) + logger.debug("start dash process with %s", args) + + try: + self.proc = subprocess.Popen( + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + # wait until server is able to answer http request + wait.until(lambda: self.accessible(self.url), timeout=2) + + except (OSError, ValueError): + logger.exception("process server has encountered an error") + self.started = False + return + + self.started = True diff --git a/dash/testing/composite.py b/dash/testing/composite.py index 626be6a8b5..d88b41e097 100644 --- a/dash/testing/composite.py +++ b/dash/testing/composite.py @@ -14,3 +14,17 @@ def start_server(self, app, **kwargs): # set the default server_url, it implicitly call wait_for_page self.server_url = self.server.url + + +class DashRComposite(Browser): + def __init__(self, server, **kwargs): + super(DashRComposite, self).__init__(**kwargs) + self.server = server + + def start_server(self, app): + + # start server with dashR app, the dash arguments are hardcoded + self.server(app) + + # set the default server_url, it implicitly call wait_for_page + self.server_url = self.server.url diff --git a/dash/testing/plugin.py b/dash/testing/plugin.py index 1fba6b8da1..a42a644f3f 100644 --- a/dash/testing/plugin.py +++ b/dash/testing/plugin.py @@ -4,9 +4,13 @@ try: import pytest - from dash.testing.application_runners import ThreadedRunner, ProcessRunner + from dash.testing.application_runners import ( + ThreadedRunner, + ProcessRunner, + RRunner, + ) from dash.testing.browser import Browser - from dash.testing.composite import DashComposite + from dash.testing.composite import DashComposite, DashRComposite except ImportError: warnings.warn("run `pip install dash[testing]` if you need dash.testing") @@ -26,9 +30,7 @@ def pytest_addoption(parser): ) dash.addoption( - "--headless", - action="store_true", - help="Run tests in headless mode", + "--headless", action="store_true", help="Run tests in headless mode" ) @@ -79,13 +81,19 @@ def dash_process_server(): yield starter +@pytest.fixture +def dashr_server(): + with RRunner() as starter: + yield starter + + @pytest.fixture def dash_br(request, tmpdir): with Browser( browser=request.config.getoption("webdriver"), headless=request.config.getoption("headless"), options=request.config.hook.pytest_setup_options(), - download_path=tmpdir.mkdir('download').strpath + download_path=tmpdir.mkdir("download").strpath, ) as browser: yield browser @@ -97,6 +105,18 @@ def dash_duo(request, dash_thread_server, tmpdir): browser=request.config.getoption("webdriver"), headless=request.config.getoption("headless"), options=request.config.hook.pytest_setup_options(), - download_path=tmpdir.mkdir('download').strpath + download_path=tmpdir.mkdir("download").strpath, + ) as dc: + yield dc + + +@pytest.fixture +def dashr(request, dashr_server, tmpdir): + with DashRComposite( + dashr_server, + browser=request.config.getoption("webdriver"), + headless=request.config.getoption("headless"), + options=request.config.hook.pytest_setup_options(), + download_path=tmpdir.mkdir("download").strpath, ) as dc: yield dc diff --git a/tests/unit/test_app_runners.py b/tests/unit/test_app_runners.py index 4bd4ed43d1..22bca7407a 100644 --- a/tests/unit/test_app_runners.py +++ b/tests/unit/test_app_runners.py @@ -1,4 +1,3 @@ -import time import sys import requests import pytest @@ -27,7 +26,6 @@ def test_threaded_server_smoke(dash_thread_server): ) def test_process_server_smoke(dash_process_server): dash_process_server("simple_app") - time.sleep(2.5) r = requests.get(dash_process_server.url) assert r.status_code == 200, "the server is reachable" assert 'id="react-entry-point"' in r.text, "the entrypoint is present"