Tango test harnesses
The ska_tango_testing.harness
subpackage provides high-level
tooling for implementing test harnesses for Tango devices,
including situations where testing of Tango devices requires other,
non-Tango, test harness elements.
Motivation
To provide a motivation for this subpackage, consider this example:
We want to test a Tango device that uses TCP to monitor and control a laboratory instrument. A real instrument won’t always be available to test against, so we will test against an instrument simulator. Since we access the real instrument over TCP, the simulator will also be accessed over TCP, which means our test harness needs to manage a simulator TCP server.
In order to test against the instrument simulator, the simulator TCP server must be launched prior to testing, and must be running during testing, and must be torn down after testing is complete. In the case of unit tests, where test isolation is desirable, we will want to launch a fresh TCP server for each unit test. To avoid port congestion, we do not fix the TCP server port. Rather, we configure the server to run on any available port, and we dynamically configure our Tango device properties to use whatever port the server ends up on.
The problem with pytest fixtures
Requirements like these can be met through the use of pytest fixtures. In this case, we simply need
a fixture that launches the simulator TCP server, yields the server port, and shuts the server down afterwards.
a fixture that configures and launches the Tango device under test. This feature obtains the server port from the aforementioned simulator TCP server fixture.
The problem with this approach is it doesn’t scale well. Pytest fixtures are excellent for setting up small, rigid test harnesses, but if you try to build a large, flexible test harness out of Pytest fixtures, you tend to end up with a big bowl of fixture spaghetti. For example, just adding support for testing against a real instrument when available would significantly complicate this Pytest fixture-based harness. Moreover, this complexity cannot be encapsulated in a class and hidden away.
Enter TangoTestHarness
The ska_tango_testing.harness.TangoTestHarness
class
makes it easy to implement a pure python test harness.
Several methods are provided to configure the harness:
The
add_device()
method is used to specify a Tango device to be included in the harness. The specification includes the device name, the device class, and the device’s properties.The
add_mock_device()
method is used to associate a mock with a device name. Once a mock has been associated with a device name, any attempt to create a proxy to that device name results in the specified mock rather than a real proxy.The
add_context_manager()
method specifies any additional contexts that are to be entered when we enter the test harness context. This is how we provide for non-Tango test harness elements. In our example above, we would provide for a simulator TCP server by implementing it as a context manager, and then usingadd_context_manager
to add it to the test harness:@contextmanager def server_factory(backend): server = TcpServer("localhost", 0, backend) with server: server_thread = threading.Thread(target=server.serve_forever) server_thread.start() (hostname, port) = server.server_address yield port server.shutdown() tango_test_harness.add_context_manager( "instrument_simulator", server_factory(instrument_simulator), ) ... with tango_test_harness as test_context: # when we enter the test harness context, # we also enter any context managers that we added to the harness.
As just noted, TangoTestHarness
is a context manager.
Having configured the harness, we enter its context
(i.e. the context in which the Tango subsystem
and any other required test harness elements
are all running and available)
using the usual with
syntax.
Deferred property resolution
In our example, we need to know what port
the simulator TCP server is running on,
in order to configure the device properties of our Tango device.
TangoTestHarness
supports this by allowing the user
to provide unresolved properties to the add_device
method.
These properties are resolved upon entry into the test harness context,
against the contexts that were registered with add_context_manager
.
To provide an unresolved property to TangoTestHarness
,
provide a property value that is callable.
Whenever a property value is callable,
TangoTestHarness
treats it as unresolved,
and resolves it by calling that callable
with a dictionary of its contexts.
For example, recall that our simulator server context manager
yields the server port.
Since that context manager was registered with add_context_manager
under the name “instrument_simulator”,
that means that when we add our tango device using add_device
,
we can specify the port as a callable
that extracts the required port from the collected contexts:
tango_test_harness.add_context_manager(
"instrument_simulator",
simulator_server(instrument_simulator),
)
tango_test_harness.add_device(
"test/instrument/1",
InstrumentDevice,
Host="localhost",
Port=lambda contexts: contexts["instrument_simulator"],
)
Encapsulation
One advantage of this approach is that the test harness for a test suite can be encapsulated in its own test harness class:
class InstrumentTestHarness:
def __init__(self):
self._tango_test_harness = TangoTestHarness()
def add_instrument(self, instrument_id, simulator):
simulator_context_name = f"simulator_{instrument_id}"
self._tango_test_harness.add_context_manager(
simulator_context_name,
server_context_manager_factory(simulator),
)
self._tango_test_harness.add_device(
f"test/instrument/{instrument_id}",
InstrumentDevice",
Host="localhost",
Port=lambda context: context[simulator_context_name],
)
def __enter__(self):
return self._tango_test_harness.__enter__()
def __exit__(self, exc_type, exception, trace):
return self._tango_test_harness.__exit__(exc_type, exception, trace)
And then using the class in a test or pytest fixture might be as simple as:
test_harness = InstrumentTestHarness()
test_harness.add_instrument(instrument_id, InstrumentSimulator())
with test_harness as test_context:
...