Mocking Network Calls

Mocking data in unit tests will allow for validating code logic, and to quickly replicate and diagnose issues involving unexpected data values. In the SDK, there are a couple of tools available that allow for mocking network requests and for building expected return objects.

Mocking q2_request calls

In order to mock q2_request calls in a unit test, the SDK provides a mock_q2_request decorator that can be added to a unit test function.

In the example code, there are three q2_request calls. This will require two different mock_q2_request declarations on the unit test function. The first decorator is for the post calls, and a list of two payloads is given as the first parameter value. The list index values used as the mock response correspond to the call number for that particular request call type (request verb: ie. post, put, get). The first list item will be used for the first post call, and the second list item will be used for the second post call. To better illustrate this, the example extension code does a post, then a put, followed by a second post call. If the test were to fail, the log lines are captured and shown in the CLI. The below snippets shows the order of the calls and their output.

"""
testing_example Extension
"""

from q2_sdk.core.http_handlers.tecton_server_handler import Q2TectonServerRequestHandler
from q2_sdk.core import q2_requests
from .install.db_plan import DbPlan


class TestingExampleHandler(Q2TectonServerRequestHandler):
    DB_PLAN = DbPlan()

    FRIENDLY_NAME = 'testing_example'  # this will be used for end user facing references to this extension like Central and Menu items.
    DEFAULT_MENU_ICON = 'landing-page'  # this will be the default icon used if extension placed at top level (not used if a child element)

    CONFIG_FILE_NAME = 'testing_example'  # configuration/testing_example.py file must exist if REQUIRED_CONFIGURATIONS exist

    TECTON_URL = 'https://cdn1.onlineaccess1.com/cdn/base/tecton/v1.38.0/q2-tecton-sdk.js'

    def __init__(self, application, request, **kwargs):
        super().__init__(application, request, **kwargs)

    @property
    def router(self):
        router = super().router
        router.update({
            'default': self.default,
            'submit': self.submit,
            # Add new routes here
        })
        return router

    async def default(self):
        template = self.get_template('index.html.jinja2', {})

        payload1 = await q2_requests.post(self.logger, "http://test.com")
        self.logger.info(payload1.json())
        payload2 = await q2_requests.put(self.logger, "http://test.com")
        if not payload2.ok:
            self.logger.error("Request failed")
            self.logger.info(payload2.status_code)
            self.logger.info(payload2.json())
        payload3 = await q2_requests.post(self.logger, "http://test.com")
        self.logger.info(payload3.json())

        html = self.get_tecton_form(
            "testing_example",
            custom_template=template,
            routing_key="submit",
            hide_submit_button=True
        )
        return html

    async def submit(self):
        template = self.get_template(
            'submit.html.jinja2',
            {
                'header': "testing_example",
                'message': 'Hello World POST: From "testing_example".<br>',
                'data': self.form_fields
            }
        )

        html = self.get_tecton_form(
            "testing_example",
            custom_template=template,
            hide_submit_button=True
        )

        return html

If the test were to fail, the log lines are captured and shown in the CLI. The below snippets shows the order of the calls and their output.

-> % q2 test
q2_sdk.general  INFO     testing_example: Frontend dependency hash out of date. Rebuilding all JS packages
$ pytest --asyncio-mode=auto  -W ignore::DeprecationWarning --ignore-glob=env --ignore-glob=venv --ignore-glob=**/node_modules --ignore-glob=**/.cache --ignore-glob=**/dist
======================================================================================================================================================================================================== test session starts =========================================================================================================================================================================================================
platform darwin -- Python 3.10.5, pytest-7.1.1, pluggy-1.0.0
plugins: asyncio-0.18.3
asyncio: mode=auto
collected 1 item

testing_example/tests/test_extension.py F                                                                                                                                                                                                                                                                                                                                                                                      [100%]

============================================================================================================================================================================================================== FAILURES ==============================================================================================================================================================================================================
_________________________________________________________________________________________________________________________________________________________________________________________________________ test_default_route _________________________________________________________________________________________________________________________________________________________________________________________________________

    @mock_q2_request([{"Key": "payload 1 value"}, {"Key": "payload 3 value"}], verb="post")
    @mock_q2_request([{"Key": "payload 2 value"}], verb="put", status_code=404, return_success=False)
    async def test_default_route():
        handler = MockTestingExampleHandler()
        actual = await handler.default()

        assert isinstance(actual, Q2Form)
        assert actual.routing_key == 'submit'
>       assert actual.hide_submit_button is False
E       assert True is False
E        +  where True = <q2_sdk.ui.forms.Q2TectonForm object at 0x1107cae00>.hide_submit_button

testing_example/tests/test_extension.py:26: AssertionError
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Captured log call ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
INFO     extension.foo:extension.py:39 {"Key": "payload 1 value"}
WARNING  extension.foo:q2_requests.py:174 Response URL: http://test.com
WARNING  extension.foo:q2_requests.py:175 Response Headers: {}
WARNING  extension.foo:q2_requests.py:176 Response Body: {"Key": "payload 2 value"}
ERROR    extension.foo:extension.py:42 Request failed
INFO     extension.foo:extension.py:43 404
INFO     extension.foo:extension.py:44 {"Key": "payload 2 value"}
INFO     extension.foo:extension.py:46 {"Key": "payload 3 value"}
====================================================================================================================================================================================================== short test summary info =======================================================================================================================================================================================================
FAILED testing_example/tests/test_extension.py::test_default_route - assert True is False
==================================================================================================================================================================================================== 1 failed, 1 warning in 0.65s ====================================================================================================================================================================================================
Tests not passed

Mocking DB Object requests

Mocking a call to the platform can be done with a pytest tool called monkeypatch. This tool will swap out the target function call with a different function call. In this example, the setattr function is used to swap the get method on the UserData class. The new method that is being called instead of the class method will use the `lxml.objectify XML builder E to create an XML shape that the model is expecting. The XML is then handed to the model class TableRow along with the model representation class, which describes the expected return data shape.

"""
testing_example Extension
"""

from q2_sdk.core.http_handlers.tecton_server_handler import Q2TectonServerRequestHandler
from .install.db_plan import DbPlan



class TestingExampleHandler(Q2TectonServerRequestHandler):
    DB_PLAN = DbPlan()

    FRIENDLY_NAME = 'testing_example'  # this will be used for end user facing references to this extension like Central and Menu items.
    DEFAULT_MENU_ICON = 'landing-page'  # this will be the default icon used if extension placed at top level (not used if a child element)

    CONFIG_FILE_NAME = 'testing_example'  # configuration/testing_example.py file must exist if REQUIRED_CONFIGURATIONS exist

    TECTON_URL = 'https://cdn1.onlineaccess1.com/cdn/base/tecton/v1.38.0/q2-tecton-sdk.js'

    def __init__(self, application, request, **kwargs):
        super().__init__(application, request, **kwargs)

    @property
    def router(self):
        router = super().router
        router.update({
            'default': self.default,
            'submit': self.submit,
            # Add new routes here
        })
        return router

    async def default(self):
        template = self.get_template('index.html.jinja2', {})

        html = self.get_tecton_form(
            "testing_example",
            custom_template=template,
            routing_key="submit",
            hide_submit_button=False
        )
        return html

    async def submit(self):
        user_data = await self.db.user_data.get(self.online_user.user_id, "DemographicUpdate")
        self.logger.info(f"Got the user data: {user_data[0].GTDataValue}")
        self.logger.info(user_data[0].ShortName)

        template = self.get_template(
            'submit.html.jinja2',
            {
                'header': "testing_example",
                'message': 'Hello World POST: From "testing_example".<br>',
                'data': self.form_fields
            }
        )

        html = self.get_tecton_form(
            "testing_example",
            custom_template=template,
            hide_submit_button=True
        )

        return html

If the test were to fail, the log lines are captured and shown in the CLI. The below snippets shows the order of the calls and their output.

-> % q2 test
q2_sdk.general  INFO     testing_example: Frontend dependency hash up to date. Skipping rebuild
$ pytest --asyncio-mode=auto  -W ignore::DeprecationWarning --ignore-glob=env --ignore-glob=venv --ignore-glob=**/node_modules --ignore-glob=**/.cache --ignore-glob=**/dist
======================================================================================================================================================================================================== test session starts =========================================================================================================================================================================================================
platform darwin -- Python 3.10.5, pytest-7.1.1, pluggy-1.0.0
plugins: asyncio-0.18.3
asyncio: mode=auto
collected 1 item

testing_example/tests/test_extension.py F                                                                                                                                                                                                                                                                                                                                                                                      [100%]

============================================================================================================================================================================================================== FAILURES ==============================================================================================================================================================================================================
_________________________________________________________________________________________________________________________________________________________________________________________________________ test_submit_route __________________________________________________________________________________________________________________________________________________________________________________________________________

monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x1092b2b90>

    async def test_submit_route(monkeypatch):
        handler = MockTestingExampleHandler()
        handler.form_fields = {
            'foo': 'bar'
        }
        handler.online_user.user_id = 2
        monkeypatch.setattr(UserData, "get", get_mock_user_data)
        actual = await handler.submit()

        assert isinstance(actual, Q2Form)
        assert actual.routing_key == ''
>       assert actual.hide_submit_button is False
E       assert True is False
E        +  where True = <q2_sdk.ui.forms.Q2TectonForm object at 0x10addff70>.hide_submit_button

testing_example/tests/test_extension.py:59: AssertionError
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Captured log call ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
INFO     extension.foo:extension.py:55 Got the user data: 2022-01-01 00:00:00.000
INFO     extension.foo:extension.py:56 DemographicUpdate
FAILED testing_example/tests/test_extension.py::test_submit_route - assert True is False
==================================================================================================================================================================================================== 1 failed, 1 warning in 0.72s ====================================================================================================================================================================================================
Tests not passed

Mocking HQ requests

In order to mock HQ calls in a unit test, the pytest library provides a mocking function known as monkeypatch. In order to properly mock an HQ request, a function needs to be provided to monkeypatch that returns an instance of the HqResponse object.

Note

Newer versions of the SDK have a modified version of MockHqResponse that provide the mock_execute_function function. This function can be provided to monkeypatch to return the response object. If this is not available in your current version, either upgrade to the latest version, or build your own function that does this.

In the example code, there is a WedgeOnlineBanking account list call. The response object is checked for success before parsing for account ids. The response shape was grabbed from a log when running the extension in the sandbox. Example responses can also be grabbed from the documentation if they are provided.

"""
testing_example Extension
"""

from q2_sdk.core.http_handlers.tecton_server_handler import Q2TectonServerRequestHandler
from q2_sdk.hq.hq_api.wedge_online_banking import GetUserAccountListAndDetails
from .install.db_plan import DbPlan



class TestingExampleHandler(Q2TectonServerRequestHandler):
    DB_PLAN = DbPlan()

    FRIENDLY_NAME = 'testing_example'  # this will be used for end user facing references to this extension like Central and Menu items.
    DEFAULT_MENU_ICON = 'landing-page'  # this will be the default icon used if extension placed at top level (not used if a child element)

    CONFIG_FILE_NAME = 'testing_example'  # configuration/testing_example.py file must exist if REQUIRED_CONFIGURATIONS exist

    TECTON_URL = 'https://cdn1.onlineaccess1.com/cdn/base/tecton/v1.38.0/q2-tecton-sdk.js'

    def __init__(self, application, request, **kwargs):
        super().__init__(application, request, **kwargs)

    @property
    def router(self):
        router = super().router
        router.update({
            'default': self.default,
            'submit': self.submit,
            # Add new routes here
        })
        return router

    async def default(self):
        template = self.get_template('index.html.jinja2', {})

        params = GetUserAccountListAndDetails.ParamsObj(
            self.logger,
            self.hq_credentials,
            GetUserAccountListAndDetails.detailType.UseCurrentDetails
        )

        results = await GetUserAccountListAndDetails.execute(params)

        if not results.success:
            self.logger.error(f"HQ failure: {results.error_message}")
            html = self.get_tecton_form(
                "error",
                custom_template=self.get_template("error.html.jinja2", {}),
                hide_submit_button=True
            )
            return html

        gathered_id_list = []
        for account in results.result_node.Data.AccountListResponse.AccountListResponseRecord:
            gathered_id_list.append(account.HostAccountID.text)

        self.logger.info(f"Gathered account ids: {gathered_id_list}")

        html = self.get_tecton_form(
            "testing_example",
            custom_template=template,
            routing_key="submit",
            hide_submit_button=False
        )
        return html

If the test were to fail, the log lines are captured and shown in the CLI. The below snippets shows the order of the calls and their output.

-> % q2 test
q2_sdk.general  INFO     testing_example: Frontend dependency hash up to date. Skipping rebuild
$ pytest --asyncio-mode=auto  -W ignore::DeprecationWarning --ignore-glob=env --ignore-glob=venv --ignore-glob=**/node_modules --ignore-glob=**/.cache --ignore-glob=**/dist
======================================================================================================================================================================================================== test session starts =========================================================================================================================================================================================================
platform darwin -- Python 3.10.5, pytest-7.1.1, pluggy-1.0.0
plugins: asyncio-0.18.3
asyncio: mode=auto
collected 1 item

testing_example/tests/test_extension.py F                                                                                                                                                                                                                                                                                                                                                                                      [100%]

============================================================================================================================================================================================================== FAILURES ==============================================================================================================================================================================================================
________________________________________________________________________________________________________________________________________________________________________________________________________ test_default_hq_call ________________________________________________________________________________________________________________________________________________________________________________________________________

monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x113afd0f0>

    async def test_default_hq_call(monkeypatch):
        handler = MockTestingExampleHandler()
        mock_hq = HqResponseMock(success=True, error_message=None, result_node=get_mock_account_list())
        monkeypatch.setattr(GetUserAccountListAndDetails, "execute", mock_hq.mock_execute_function)
        actual = await handler.default()

        assert isinstance(actual, Q2Form)
        assert actual.routing_key == 'submit'
>       assert actual.hide_submit_button is True
E       assert False is True
E        +  where False = <q2_sdk.ui.forms.Q2TectonForm object at 0x115684d90>.hide_submit_button

testing_example/tests/test_extension.py:80: AssertionError
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Captured log call ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
INFO     extension.foo:extension.py:58 Gathered account ids: ['5007']
====================================================================================================================================================================================================== short test summary info =======================================================================================================================================================================================================
FAILED testing_example/tests/test_extension.py::test_default_hq_call - assert False is True
==================================================================================================================================================================================================== 1 failed, 1 warning in 0.58s ====================================================================================================================================================================================================
Tests not passed