File: mocks.md

package info (click to toggle)
pytango 10.0.2-3
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 10,216 kB
  • sloc: python: 28,206; cpp: 16,380; sql: 255; sh: 82; makefile: 43
file content (135 lines) | stat: -rw-r--r-- 8,541 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
```{eval-rst}
.. currentmodule:: tango
```

```{highlight} python
:linenothreshold: 3
```

(testing-mocks)=

# Mocking clients for Testing

## Test Doubles: The idea behind mocking Tango entities

Suppose we want to test a Tango Device, **Device A**. In particular, we want to assert that when a certain *action* is invoked on **Device A**, it should produce an expected result. This will prove to us that **Device A** 's implementation sufficiently manifests the behaviour we would like it to have.

Now suppose **Device A** depends on **Device B** to complete its action. In other words, the *result* will depend, in part, on the state of **Device B**. This means that to test this scenario, both devices need to be in a base state that we control.

This might be difficult to achieve when using real devices since it might require a lot of orchestration and manipulation of details irrelevant to the test scenario, i.e. to get **Device B** into the required state.

A **Test Double** is a component that can act as a real device but is easier to manipulate and configure into the states that we want during testing. This means that we can replace **Device B** with its **Test Double** as long as it conforms to the interface that **Device A** expects.

What's more, we can manipulate the **Test Double** to respond in the way we expect **Device B** to respond under the various conditions we want to test. A **Mock** is simply a type of **Test Double** that might have some conditional logic or behaviour to help in testing.

```{image} ../../_static/mocking-tango-test-doubles.png
```

## Tango's DeviceProxys

In Tango, the **DeviceProxy** is an object that allows communication between devices. It can be regarded as the *client* part of a *client-server* interaction.

Thus, any Tango device (say, **Device A**) that depends on a secondary Tango device (**Device B**) will need to use a **DeviceProxy** to connect and communicate with the secondary device (**Device B**).

This invariably means that in the implementation of **Device A**, it will be instantiating and using a **DeviceProxy** object.

However, the mechanism by which this happens is a multi-step process which requires communication with a TangoDB DS and an appropriately configured Tango system that contains a deployed **Device B**.

```{image} ../../_static/mocking-tango-db-access.png
```

If we can replace the **DeviceProxy** object that **Device A** will use to communicate to **Device B** with our own Mock object (**DeviceProxyMock**), we can test the behaviour of **Device A** while faking the responses it expects to receive from querying **Device B**.
All this without the need for deploying a real **Device B**, since for all intents and purposes, the **DeviceProxyMock** would represent the real device.

```{image} ../../_static/mocking-tango-db-access-crossed-out.png
```

In other words, mocking the **DeviceProxy** is sufficient to mock the underlying device it connects to, with reference to how **DeviceProxy** is used by **Device A**.

## Mocking the DeviceProxy

In some **PyTango** devices, such as those in the [SKA TMC Prototype](https://gitlab.com/ska-telescope/tmc-prototype), the **DeviceProxy** object is instantiated during the operation of the **Device Under Test** (**DUT**). Also, there isn't usually an explicit interface to inject a **DeviceProxyMock** as a replacement for the real class.

As an example, the [CentralNode](https://gitlab.com/ska-telescope/tmc-prototype/-/blob/0.1.8/tmcprototype/CentralNode/CentralNode/CentralNode.py) (at v0.1.8) device from the TMC Prototype instantiates all the **DeviceProxy** objects it needs to connect to its child elements/components in its [init_device](https://gitlab.com/ska-telescope/tmc-prototype/-/blob/0.1.8/tmcprototype/CentralNode/CentralNode/CentralNode.py#L246) method:

```
class CentralNode(SKABaseDevice):
...
  def init_device(self):
    ...
    self._leaf_device_proxy.append(DeviceProxy(self._dish_leaf_node_devices[name]))
    ...
    self._csp_master_leaf_proxy = DeviceProxy(self.CspMasterLeafNodeFQDN)
    ...
    self._sdp_master_leaf_proxy = DeviceProxy(self.SdpMasterLeafNodeFQDN)
    ...
    subarray_proxy = DeviceProxy(self.TMMidSubarrayNodes[subarray])
```

Unfortunately, the *init_device* method does not allow us to provide the class we want the device to use when it needs to instantiate its **DeviceProxys**.

So we will have to mock the **DeviceProxy** class that the **DUT** imports before it instantiates that class.

The diagram below illustrates the relationship between the TestCase, **DUT** and its transitive import of the **DeviceProxy** class from the **PyTango** module:

```{image} ../../_static/mocking-tango-test-case-not-mocked.png
```

So, we want to replace the imported **DeviceProxy** class with our own Fake Constructor that will provide a Mocked Device Proxy for the **DUT** during tests.

In other words, we want to replace the thing that instantiates the **DeviceProxy** (i.e. the constructor) with our own [callable](https://docs.python.org/3.8/reference/expressions.html#calls) object that constructs a mocked **DeviceProxy** object instead of the real one.
We want to move from the original implementation to the mocked implementation shown in the diagram below:

```{image} ../../_static/mocking-tango-test-case-real-vs-mocked.png
```

## Solution

This can be achieved by using the [unittest.mock](https://docs.python.org/3.7/library/unittest.mock.html) library that comes with Python 3.

The *mock.patch()* method allows us to temporarily change the object that a name points to with another one.

We use this mechanism to replace the **DeviceProxy** class (constructor) with our own fake constructor (a mock) that returns a Mock object:

```
with mock.patch(device_proxy_import_path) as patched_constructor:
    patched_constructor.side_effect = lambda device_fqdn: proxies_to_mock.get(device_fqdn, Mock())
    patched_module = importlib.reload(sys.modules[device_under_test.__module__])
```

An alternative to using *mock.patch* is pytest's *monkeypatch*. Its *.setattr* method provides the same functionality, i.e. allowing you to intercept what an object call would normally do and substituting its full  execution with your own specification. There are more examples of its use in the OET implementation which is discussed below.

*proxies_to_mock* is a dictionary that maps **DeviceProxyMock** objects to their associated Tango device addresses that we expect the **DUT** to use when instantiating **DeviceProxy** objects. A brand new generic *Mock()* is returned if a specific mapping isn't provided.

Since the **DeviceProxy** class is defined at import time, we will need to reload the module that holds the **DUT**. This is why we explicitly call *importlib.reload(...)* in the context of *mock.patch()*.

For full details and code that implement this solution, see the following merge requests:

> - <https://gitlab.com/ska-telescope/tmc-prototype/-/merge_requests/23>
> - <https://gitlab.com/ska-telescope/tmc-prototype/-/merge_requests/35>

## Moving on

Once we mocked **DeviceProxy**, then we can use the constructor of this object to return a device that is fake. This can be:

> - a stub device, programmed to behave in a way that suits the tests that we are writing; in this case we are using the stub to inject other inputs to the **DUT**, under control of the test case;
> - a mock device, a stub device where we can inspect also how the **DUT** interacted with it, and we can write assertions.

The benefits that we can achieve with the technique described here are:

> 1. ability to test the **DUT** in isolation
> 2. ability to create tests that are very fast (no network, no databases)
> 3. ability to inject into the **DUT** indirect inputs
> 4. ability to observe the indirect outputs of the **DUT**
> 5. ability to observe the interactions that the **DUT** has with the mock.

## Using pytest and fixtures

The above mocking techniques can be achieved in a very succint way using pytest fixtures.
Examples of this can be found in the [pytango/examples](https://gitlab.com/tango-controls/pytango/-/tree/develop/examples/multidevicetestcontext).  And more examples are
available in the last section of the *Unit testing Tango devices in Python* presentation
from the [Tango 2020 November status update meeting](https://indico.esrf.fr/indico/event/49/other-view?view=standard).

## Acknowledgement

Initial content for this page contributed by the [Square Kilometre Array](https://www.skatelescope.org).