From 4be5c07241e0a146101ac7c50d510902b9517fff Mon Sep 17 00:00:00 2001 From: congysu Date: Wed, 9 Oct 2019 13:33:23 -0700 Subject: [PATCH 1/2] DialogTestClient for Py * dialog test client * dialog test logger * unit tests. --- .../botbuilder/core/adapters/test_adapter.py | 6 +- libraries/botbuilder-testing/README.rst | 84 +++++++++ .../botbuilder/testing/__init__.py | 8 + .../botbuilder/testing/about.py | 15 ++ .../botbuilder/testing/dialog_test_client.py | 122 +++++++++++++ .../botbuilder/testing/dialog_test_logger.py | 103 +++++++++++ libraries/botbuilder-testing/requirements.txt | 4 + libraries/botbuilder-testing/setup.py | 47 +++++ .../tests/test_dialog_test_client.py | 160 ++++++++++++++++++ 9 files changed, 545 insertions(+), 4 deletions(-) create mode 100644 libraries/botbuilder-testing/README.rst create mode 100644 libraries/botbuilder-testing/botbuilder/testing/__init__.py create mode 100644 libraries/botbuilder-testing/botbuilder/testing/about.py create mode 100644 libraries/botbuilder-testing/botbuilder/testing/dialog_test_client.py create mode 100644 libraries/botbuilder-testing/botbuilder/testing/dialog_test_logger.py create mode 100644 libraries/botbuilder-testing/requirements.txt create mode 100644 libraries/botbuilder-testing/setup.py create mode 100644 libraries/botbuilder-testing/tests/test_dialog_test_client.py diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 656050bab..11941f851 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -80,10 +80,8 @@ def __init__( recipient=ChannelAccount(id="bot", name="Bot"), conversation=ConversationAccount(id="Convo1"), ) - if self.template is not None: - self.template.service_url = self.template.service_url - self.template.conversation = self.template.conversation - self.template.channel_id = self.template.channel_id + if conversation is not None and conversation.channel_id is not None: + self.template.channel_id = conversation.channel_id async def send_activities(self, context, activities: List[Activity]): """ diff --git a/libraries/botbuilder-testing/README.rst b/libraries/botbuilder-testing/README.rst new file mode 100644 index 000000000..76d0531a2 --- /dev/null +++ b/libraries/botbuilder-testing/README.rst @@ -0,0 +1,84 @@ + +============================ +BotBuilder-Testing SDK for Python +============================ + +.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master + :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI + :align: right + :alt: Azure DevOps status for master branch +.. image:: https://badge.fury.io/py/botbuilder-testing.svg + :target: https://badge.fury.io/py/botbuilder-testing + :alt: Latest PyPI package version + +Some helper classes useful for testing bots built with Microsoft BotBuilder. + + +How to Install +============== + +.. code-block:: python + + pip install botbuilder-testing + + +Documentation/Wiki +================== + +You can find more information on the botbuilder-python project by visiting our `Wiki`_. + +Requirements +============ + +* `Python >= 3.7.0`_ + + +Source Code +=========== +The latest developer version is available in a github repository: +https://github.com/Microsoft/botbuilder-python/ + + +Contributing +============ + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the `Microsoft Open Source Code of Conduct`_. +For more information see the `Code of Conduct FAQ`_ or +contact `opencode@microsoft.com`_ with any additional questions or comments. + +Reporting Security Issues +========================= + +Security issues and bugs should be reported privately, via email, to the Microsoft Security +Response Center (MSRC) at `secure@microsoft.com`_. You should +receive a response within 24 hours. If for some reason you do not, please follow up via +email to ensure we received your original message. Further information, including the +`MSRC PGP`_ key, can be found in +the `Security TechCenter`_. + +License +======= + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the MIT_ License. + +.. _Wiki: https://github.com/Microsoft/botbuilder-python/wiki +.. _Python >= 3.7.0: https://www.python.org/downloads/ +.. _MIT: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt +.. _Microsoft Open Source Code of Conduct: https://opensource.microsoft.com/codeofconduct/ +.. _Code of Conduct FAQ: https://opensource.microsoft.com/codeofconduct/faq/ +.. _opencode@microsoft.com: mailto:opencode@microsoft.com +.. _secure@microsoft.com: mailto:secure@microsoft.com +.. _MSRC PGP: https://technet.microsoft.com/en-us/security/dn606155 +.. _Security TechCenter: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt + +.. `_ \ No newline at end of file diff --git a/libraries/botbuilder-testing/botbuilder/testing/__init__.py b/libraries/botbuilder-testing/botbuilder/testing/__init__.py new file mode 100644 index 000000000..681a168e4 --- /dev/null +++ b/libraries/botbuilder-testing/botbuilder/testing/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .dialog_test_client import DialogTestClient +from .dialog_test_logger import DialogTestLogger + + +__all__ = ["DialogTestClient", "DialogTestLogger"] diff --git a/libraries/botbuilder-testing/botbuilder/testing/about.py b/libraries/botbuilder-testing/botbuilder/testing/about.py new file mode 100644 index 000000000..d44f889b1 --- /dev/null +++ b/libraries/botbuilder-testing/botbuilder/testing/about.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + + +__title__ = "botbuilder-testing" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/libraries/botbuilder-testing/botbuilder/testing/dialog_test_client.py b/libraries/botbuilder-testing/botbuilder/testing/dialog_test_client.py new file mode 100644 index 000000000..4ead12a3e --- /dev/null +++ b/libraries/botbuilder-testing/botbuilder/testing/dialog_test_client.py @@ -0,0 +1,122 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List, Union + +from botbuilder.core import ( + AutoSaveStateMiddleware, + ConversationState, + MemoryStorage, + Middleware, + StatePropertyAccessor, + TurnContext, +) +from botbuilder.core.adapters import TestAdapter +from botbuilder.dialogs import Dialog, DialogSet, DialogTurnResult, DialogTurnStatus +from botbuilder.schema import Activity, ConversationReference + + +class DialogTestClient: + """A client for testing dialogs in isolation.""" + + def __init__( + self, + channel_or_adapter: Union[str, TestAdapter], + target_dialog: Dialog, + initial_dialog_options: object = None, + middlewares: List[Middleware] = None, + conversation_state: ConversationState = None, + ): + """ + Create a DialogTestClient to test a dialog without having to create a full-fledged adapter. + + ```python + client = DialogTestClient("test", MY_DIALOG, MY_OPTIONS) + reply = await client.send_activity("first message") + self.assertEqual(reply.text, "first reply", "reply failed") + ``` + + :param channel_or_adapter: The channel Id or test adapter to be used for the test. + For channel Id, use 'emulator' or 'test' if you are uncertain of the channel you are targeting. + Otherwise, it is recommended that you use the id for the channel(s) your bot will be using and + write a test case for each channel. + Or, a test adapter instance can be used. + :type channel_or_adapter: Union[str, TestAdapter] + :param target_dialog: The dialog to be tested. This will be the root dialog for the test client. + :type target_dialog: Dialog + :param initial_dialog_options: (Optional) additional argument(s) to pass to the dialog being started. + :type initial_dialog_options: object + :param middlewares: (Optional) The test adapter to use. If this parameter is not provided, the test client will + use a default TestAdapter. + :type middlewares: List[Middleware] + :param conversation_state: (Optional) A ConversationState instance to use in the test client. + :type conversation_state: ConversationState + """ + self.dialog_turn_result: DialogTurnResult = None + self.conversation_state: ConversationState = ( + ConversationState(MemoryStorage()) + if conversation_state is None + else conversation_state + ) + dialog_state = self.conversation_state.create_property("DialogState") + self._callback = self._get_default_callback( + target_dialog, initial_dialog_options, dialog_state + ) + + if isinstance(channel_or_adapter, str): + conversation_reference = ConversationReference( + channel_id=channel_or_adapter + ) + self.test_adapter = TestAdapter(self._callback, conversation_reference) + self.test_adapter.use( + AutoSaveStateMiddleware().add(self.conversation_state) + ) + else: + self.test_adapter = channel_or_adapter + + self._add_user_middlewares(middlewares) + + async def send_activity(self, activity) -> Activity: + """ + Send an activity into the dialog. + + :param activity: an activity potentially with text. + :type activity: + :return: a TestFlow that can be used to assert replies etc. + :rtype: Activity + """ + await self.test_adapter.receive_activity(activity) + return self.test_adapter.get_next_activity() + + def get_next_reply(self) -> Activity: + """ + Get the next reply waiting to be delivered (if one exists) + + :return: a TestFlow that can be used to assert replies etc. + :rtype: Activity + """ + return self.test_adapter.get_next_activity() + + def _get_default_callback( + self, + target_dialog: Dialog, + initial_dialog_options: object, + dialog_state: StatePropertyAccessor, + ): + async def default_callback(turn_context: TurnContext) -> None: + dialog_set = DialogSet(dialog_state) + dialog_set.add(target_dialog) + + dialog_context = await dialog_set.create_context(turn_context) + self.dialog_turn_result = await dialog_context.continue_dialog() + if self.dialog_turn_result.status == DialogTurnStatus.Empty: + self.dialog_turn_result = await dialog_context.begin_dialog( + target_dialog.id, initial_dialog_options + ) + + return default_callback + + def _add_user_middlewares(self, middlewares: List[Middleware]) -> None: + if middlewares is not None: + for middleware in middlewares: + self.test_adapter.use(middleware) diff --git a/libraries/botbuilder-testing/botbuilder/testing/dialog_test_logger.py b/libraries/botbuilder-testing/botbuilder/testing/dialog_test_logger.py new file mode 100644 index 000000000..190764c09 --- /dev/null +++ b/libraries/botbuilder-testing/botbuilder/testing/dialog_test_logger.py @@ -0,0 +1,103 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import logging +import time +import uuid +from datetime import datetime +from typing import Awaitable, Callable, List + +from botbuilder.core import Middleware, TurnContext +from botbuilder.schema import Activity, ActivityTypes, ResourceResponse + + +class DialogTestLogger(Middleware): + """ + A middleware to output incoming and outgoing activities as json strings to the console during + unit tests. + """ + + def __init__( + self, + log_func: Callable[..., None] = None, + json_indent: int = 4, + time_func: Callable[[], float] = None, + ): + """ + Initialize a new instance of the dialog test logger. + + :param log_func: A callable method or object that can log a message, + default to `logging.getLogger(__name__).info`. + :type log_func: Callable[..., None] + :param json_indent: An indent for json output, default indent is 4. + :type json_indent: int + :param time_func: A time function to record time spans, default to `time.monotonic`. + :type time_func: Callable[[], float] + """ + self._log = logging.getLogger(__name__).info if log_func is None else log_func + self._stopwatch_state_key = f"stopwatch.{uuid.uuid4()}" + self._json_indent = json_indent + self._time_func = time.monotonic if time_func is None else time_func + + async def on_turn( + self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] + ): + context.turn_state[self._stopwatch_state_key] = self._time_func() + await self._log_incoming_activity(context, context.activity) + context.on_send_activities(self._send_activities_handler) + await logic() + + async def _log_incoming_activity( + self, context: TurnContext, activity: Activity + ) -> None: + self._log("") + if context.activity.type == ActivityTypes.message: + self._log("User: Text = %s", context.activity.text) + else: + self._log_activity_as_json(actor="User", activity=activity) + + timestamp = self._get_timestamp() + self._log("-> ts: %s", timestamp) + + async def _send_activities_handler( + self, + context: TurnContext, + activities: List[Activity], + next_send: Callable[[], Awaitable[None]], + ) -> List[ResourceResponse]: + for activity in activities: + await self._log_outgoing_activity(context, activity) + responses = await next_send() + return responses + + async def _log_outgoing_activity( + self, context: TurnContext, activity: Activity + ) -> None: + self._log("") + start_time = context.turn_state[self._stopwatch_state_key] + if activity.type == ActivityTypes.message: + message = ( + f"Bot: Text = {activity.text}\r\n" + f" Speak = {activity.speak}\r\n" + f" InputHint = {activity.input_hint}" + ) + self._log(message) + else: + self._log_activity_as_json(actor="Bot", activity=activity) + + now = self._time_func() + mms = int(round((now - start_time) * 1000)) + timestamp = self._get_timestamp() + self._log("-> ts: %s elapsed %d ms", timestamp, mms) + + def _log_activity_as_json(self, actor: str, activity: Activity) -> None: + activity_dict = activity.serialize() + activity_json = json.dumps(activity_dict, indent=self._json_indent) + message = f"{actor}: Activity = {activity.type}\r\n" f"{activity_json}" + self._log(message) + + @staticmethod + def _get_timestamp() -> str: + timestamp = datetime.now().strftime("%H:%M:%S") + return timestamp diff --git a/libraries/botbuilder-testing/requirements.txt b/libraries/botbuilder-testing/requirements.txt new file mode 100644 index 000000000..9eb053052 --- /dev/null +++ b/libraries/botbuilder-testing/requirements.txt @@ -0,0 +1,4 @@ +botbuilder-schema>=4.4.0b1 +botbuilder-core>=4.4.0b1 +botbuilder-dialogs>=4.4.0b1 +aiounittest>=1.1.0 diff --git a/libraries/botbuilder-testing/setup.py b/libraries/botbuilder-testing/setup.py new file mode 100644 index 000000000..2bdff64d8 --- /dev/null +++ b/libraries/botbuilder-testing/setup.py @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from setuptools import setup + +REQUIRES = [ + "botbuilder-schema>=4.4.0b1", + "botbuilder-core>=4.4.0b1", + "botbuilder-dialogs>=4.4.0b1", +] + +TESTS_REQUIRES = ["aiounittest>=1.1.0"] + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, "botbuilder", "testing", "about.py")) as f: + package_info = {} + info = f.read() + exec(info, package_info) + +with open(os.path.join(root, "README.rst"), encoding="utf-8") as f: + long_description = f.read() + +setup( + name=package_info["__title__"], + version=package_info["__version__"], + url=package_info["__uri__"], + author=package_info["__author__"], + description=package_info["__description__"], + keywords="botbuilder-testing bots ai testing botframework botbuilder", + long_description=long_description, + long_description_content_type="text/x-rst", + license=package_info["__license__"], + packages=["botbuilder.testing"], + install_requires=REQUIRES + TESTS_REQUIRES, + tests_require=TESTS_REQUIRES, + include_package_data=True, + classifiers=[ + "Programming Language :: Python :: 3.7", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 3 - Alpha", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + ], +) diff --git a/libraries/botbuilder-testing/tests/test_dialog_test_client.py b/libraries/botbuilder-testing/tests/test_dialog_test_client.py new file mode 100644 index 000000000..cf44ba32f --- /dev/null +++ b/libraries/botbuilder-testing/tests/test_dialog_test_client.py @@ -0,0 +1,160 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging + +from aiounittest import AsyncTestCase +from botbuilder.core import MessageFactory +from botbuilder.dialogs import ( + ComponentDialog, + DialogContext, + DialogTurnResult, + DialogTurnStatus, + PromptOptions, + TextPrompt, + WaterfallDialog, + WaterfallStepContext, +) +from botbuilder.schema import Activity +from botbuilder.testing import DialogTestClient, DialogTestLogger + + +class DialogTestClientTest(AsyncTestCase): + """Tests for dialog test client.""" + + def __init__(self, *args, **kwargs): + super(DialogTestClientTest, self).__init__(*args, **kwargs) + logging.basicConfig(format="", level=logging.INFO) + + def test_init(self): + client = DialogTestClient(channel_or_adapter="test", target_dialog=None) + self.assertIsInstance(client, DialogTestClient) + + def test_init_with_custom_channel_id(self): + client = DialogTestClient(channel_or_adapter="custom", target_dialog=None) + self.assertEqual("custom", client.test_adapter.template.channel_id) + + async def test_single_turn_waterfall_dialog(self): + async def step1(step: DialogContext) -> DialogTurnResult: + await step.context.send_activity("hello") + return await step.end_dialog() + + dialog = WaterfallDialog("waterfall", [step1]) + client = DialogTestClient("test", dialog) + + reply = await client.send_activity("hello") + + self.assertEqual("hello", reply.text) + self.assertEqual("test", reply.channel_id) + self.assertEqual(DialogTurnStatus.Complete, client.dialog_turn_result.status) + + async def test_single_turn_waterfall_dialog_with_logger(self): + """ + Test for single turn waterfall dialog with logger with test client. + To view the console output: + * unittest + ```bash + python -m unittest -v -k logger + ``` + * pytest + ```bash + pytest --log-cli-level=INFO --log-format="%(message)s" -k logger + ``` + The results are similar to: + ``` + User: Text = hello + -> ts: 13:39:59 + + Bot: Text = hello + Speak = None + InputHint = acceptingInput + -> ts: 13:39:59 elapsed 8 ms + ``` + + :return: None + :rtype: None + """ + + async def step1(step: DialogContext) -> DialogTurnResult: + await step.context.send_activity("hello") + return await step.end_dialog() + + dialog = WaterfallDialog("waterfall", [step1]) + client = DialogTestClient( + "test", + dialog, + initial_dialog_options=None, + middlewares=[DialogTestLogger()], + ) + + reply = await client.send_activity("hello") + + self.assertEqual("hello", reply.text) + self.assertEqual("test", reply.channel_id) + self.assertEqual(DialogTurnStatus.Complete, client.dialog_turn_result.status) + + async def test_two_turn_waterfall_dialog(self): + async def step1(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("hello") + await step.context.send_activity(Activity(type="typing")) + return await step.next(result=None) + + async def step2(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("hello 2") + return await step.end_dialog() + + dialog = WaterfallDialog("waterfall", [step1, step2]) + client = DialogTestClient( + "test", + dialog, + initial_dialog_options=None, + middlewares=[DialogTestLogger()], + ) + + reply = await client.send_activity("hello") + self.assertEqual("hello", reply.text) + + reply = client.get_next_reply() + self.assertEqual("typing", reply.type) + + reply = client.get_next_reply() + self.assertEqual("hello 2", reply.text) + self.assertEqual(DialogTurnStatus.Complete, client.dialog_turn_result.status) + + async def test_component_dialog(self): + component = MainDialog("component") + client = DialogTestClient( + "test", + component, + initial_dialog_options=None, + middlewares=[DialogTestLogger()], + ) + + reply = await client.send_activity("hello") + + self.assertEqual("Tell me something", reply.text) + reply = await client.send_activity("foo") + self.assertEqual("you said: foo", reply.text) + self.assertEqual(DialogTurnStatus.Complete, client.dialog_turn_result.status) + + +class MainDialog(ComponentDialog): + def __init__(self, dialog_id: str): + super().__init__(dialog_id) + + dialog = WaterfallDialog("waterfall", [self.step1, self.step2]) + self.add_dialog(TextPrompt(TextPrompt.__name__)) + self.add_dialog(dialog) + self.initial_dialog_id = dialog.id + + @staticmethod + async def step1(step: WaterfallStepContext) -> DialogTurnResult: + options = PromptOptions(prompt=MessageFactory.text("Tell me something")) + return await step.prompt(TextPrompt.__name__, options) + + @staticmethod + async def step2(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity( + MessageFactory.text(f"you said: {step.result}") + ) + return await step.end_dialog() From 58bdbafbdb4f1b6eb015eb2dc78145045bc3b4ab Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 16 Oct 2019 11:21:40 -0700 Subject: [PATCH 2/2] Test adapter initializer with template or conversation ref --- .../botbuilder/core/adapters/test_adapter.py | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 93d6751df..efbd63c91 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -57,7 +57,7 @@ class TestAdapter(BotAdapter, UserTokenProvider): def __init__( self, logic: Coroutine = None, - template: Activity = None, + template_or_conversation: Union[Activity, ConversationReference] = None, send_trace_activities: bool = False, ): # pylint: disable=unused-argument """ @@ -76,14 +76,21 @@ def __init__( self.deleted_activities: List[ConversationReference] = [] self.send_trace_activities = send_trace_activities - self.template = template or Activity( - channel_id="test", - service_url="https://test.com", - from_property=ChannelAccount(id="User1", name="user"), - recipient=ChannelAccount(id="bot", name="Bot"), - conversation=ConversationAccount(id="Convo1"), + self.template = ( + template_or_conversation + if isinstance(template_or_conversation, Activity) + else Activity( + channel_id="test", + service_url="https://test.com", + from_property=ChannelAccount(id="User1", name="user"), + recipient=ChannelAccount(id="bot", name="Bot"), + conversation=ConversationAccount(id="Convo1"), + ) ) + if isinstance(template_or_conversation, ConversationReference): + self.template.channel_id = template_or_conversation.channel_id + async def process_activity( self, activity: Activity, logic: Callable[[TurnContext], Awaitable] ):