Write unit tests that mock interactions with Tango
This MR is the investigation and implementation to test Tango devices independent of framework infrastructure and collaborating processes (MySQL/MariaDB, CORBA, boost, etc.)
The idea is to be able to assert behaviour of Tango device under test while mocking its dependencies using standard Python mocking tools and Pytest.
Approach
A scenario was selected that would sufficiently demonstrate the implementation of a required behaviour of a part of TMC Prototype. The behaviour corresponds to a requirement at a low-level, which would make a good candidate for unit testing.
Test Scenario
Reviewing the production code, we extracted the following requirement of the CentralNode class, and reified it in the following test:
test_telescope_health_state_is_degraded_when_csp_master_leaf_node_is_degraded_after_start
Test semantics
In this scenario the device under test, CentralNode, will be started up and will query the health state of the CSP Master Leaf Node that it's been configured with. Then the Central Node's own health state will be determined by the health state of its underlying "child" nodes/subdevices/components. In this particular case, we're interested in the behaviour that ensures that if the CSP Master Node is Degraded, so too should the Central Mode.
Test structure
It's useful to structure a test into 3 phases:
- Setup: arrangement of all the required components, state, preconditions and inputs.
- Invoke the object, interface or method under test.
- Check that the expected behaviour has occured by asserting on the result.
This is the well-known Arrange-Act-Assert pattern.
Test Implementation
Arrange
It's useful to define the configuration details using variables with meaningful names:
# arrange:
device_under_test = CentralNode # the behaviour we're testing belongs to the device under test
csp_master_fqdn = 'mid/csp_elt/master' # an FQDN that we can expect to be set
csp_master_health_attribute = 'cspHealthState' # Tango Attribute on the CSP master device we expect Central Node to query
initial_dut_properties = {
'CspMasterLeafNodeFQDN': csp_master_fqdn
}
While some of the above configuration data provides meaning to the test, it's also the set of minimum details required for the implementation to work in the test scenario. You'll notice csp_master_fqdn is used in the CentralNode's init_device() function as well as the healthStateCallback(). The value must be defined to start the device in a reasonable state and have the expected result after the test.
Mocking Tango DeviceProxy and Events
Since we expect the Tango event system to be used by Central Node, we must utilise mocking to "fake" the Tango event system. This is achieved by understanding what Tango-specific methods will be invoked by the device under test and implementing the expected behaviour by some other mechanism.
The unittest.mock package provides some useful tools to assist in this (https://docs.python.org/3.7/library/unittest.mock.html).
We know that CentralNode will instantiate DeviceProxy objects during execution and invoke its subscribe_events() method, so we'll have to mock two things:
- The instantiation of a
DeviceProxyso that Central Node can query CSP Master Leaf Node, i.e. replace its constructor to return a Mock - The subcription to
CspHealthStateTango Attribute of the CSP Master Leaf Node, i.e. replacesubscribe_event()method with our own implementation that approximates Tango's event system for this test scenario
event_subscription_map = {} # 2) a data structure to fake the Tango events system
csp_master_device_proxy_mock = Mock() # 1) create a mock object
# 2) wire up the subscription method to our fake events implementation
csp_master_device_proxy_mock.subscribe_event.side_effect = (
lambda attr_name, event_type, callback, *args, **kwargs: event_subscription_map.update({attr_name: callback}))
The fake event system is simply a map(python dict) of callables (the callbacks) are referenced by a given Tango Attribute name. This is sufficient to replace Tango's event system for unit testing.
The fake_tango_system(...) is a context manager that provides a DeviceTestContext and replaces the constructor of DeviceProxy, returning the appropriate object defined by the mapping passed in to proxies_to_mock.
Because the path that the CentralNode module uses to import DeviceProxy is important for patching, we need to make sure it is the same. See https://docs.python.org/3/library/unittest.mock.html#where-to-patch
@contextlib.contextmanager
def fake_tango_system(device_under_test, initial_dut_properties={}, proxies_to_mock={},
device_proxy_import_path='tango.DeviceProxy'):
Some extra effort is required to make the mocking of the DeviceProxy constructor work since it is defined at import time. In order to make sure it's replaced by our own patched constructor, we need to reload the module that DeviceProxy belongs to by using importlib.reload
importlib.reload(sys.modules[device_under_test.__module__])
Act
After arranging the test scenario, we want to trigger the action that will make the device under test respond and hopefully trigger the requisite behaviour.
Since we're mocking out Tango, some low-level interactions are unavoidable and the action we want to trigger is that the appropriate Tango Event is emitted, namely an update to the CspHealthState Attribute.
Once again, we only set the absolute minimum bits of details that are relevant to the scenario (and will make it work correctly):
# act:
fake_event = Mock()
fake_event.err = False
fake_event.attr_name = f"{csp_master_fqdn}/healthState"
fake_event.attr_value.value = ENUM_DEGRADED
event_subscription_map[csp_master_health_attribute](fake_event)
Assert
Finally, we want to assert that the end result is what we expect. It's a good idea to minimise the number of assertions to ensure that tests are kept simple by the principle of separation of concerns.
# assert:
assert tango_context.device.telescopeHealthState == ENUM_DEGRADED
NOTE: one can only assert on interfaces that are related to the behaviour, but not the behaviour itself. In other words we need to ensure the implementation guarantees that tango_context.device.telescopeHealthState is a sufficient "marker" of the behaviour under test.