Testing

Testing SOCS Agent code is critical to ensuring code functions properly, especially when making changes after initial creation of the Agent. Testing within SOCS comes in two forms, unit tests and integration tests.

Unit tests test functionality of the Agent code directly, without running the Agent itself (or any supporting parts, such as the crossbar server, or a piece of hardware to connect to.)

Integration tests run a small OCS network, starting up the crossbar server, and likely require some code emulating the piece of hardware the OCS Agent is built to communicate with. Integration tests are more involved than unit tests, requiring more setup and thus taking longer to execute than unit tests. Both are important for fully testing the functionality of your Agent.

Running Tests

We use pytest as the test runner for SOCS. To run all of the tests, from with in the socs/tests/ directory, run pytest:

$ python3 -m pytest --cov

This will run every test, both unit and integration tests. Integration tests depend on mocked up versions of the hardware the agents in question interface with. Testing against actual hardware has previously been done in the hardware/ directory. These hardware tests will not automatically be run.

Test Selection

You typically will not want to run all tests all the time. There are many ways to limit which tests run. Here are some examples.

Run only one test file:

$ python3 -m pytest --cov socs agents/test_ls372_agent.py

Run tests based on test name(s):

$ python3 -m pytest --cov -k 'test_ls372_init_lakeshore_task'

Note that this will match to the beginning of the test names, so the above will match ‘test_ls372_init_lakeshore_task’ as well as ‘test_ls372_init_lakeshore_task_already_initialzed’.

Custom Markers

There are some custom markers for tests in SOCS, ‘integtest’ for integration tests, and ‘spt3g’ for tests dependent on spt3g_software. These markers can be used to select or deselect tests.

To run only the unit tests run:

$ python3 -m pytest --cov -m 'not integtest'

To run only the integration tests:

$ python3 -m pytest --cov -m 'integtest'

Note

The integration tests depend on ‘–cov’ being used, so all examples here throw that flag. You could omit it when running unit tests, but it’s often useful to view coverage results anyway.

You can view the available markers with:

$ python3 -m pytest --markers
@pytest.mark.integtest: marks tests as integration test (deselect with '-m "not integtest"')
@pytest.mark.spt3g: marks tests that depend on spt3g (deselect with '-m "not spt3g"')

Code Coverage

Code coverage measures how much of the code the tests cover. This is typically reported as a percentage of lines executed during testing. Coverage is measured with Coverage.py.

To obtain code coverage:

$ python3 -m pytest --cov --cov-report=html

You can then view the coverage report in the htmlcov/ directory. Coverage for SOCS is also automatically reported to coveralls.io.

Testing Against Hardware

Warning

Testing against hardware is not well supported at this time. Tests early on in development were initially built against actual hardware, but have since been neglected.

If you are designing and running tests against actual hardware you should store and run them directly from the hardware directory, which is configured to be ignored by the automatic test discovery in pytest.

When running against hardware, call only the test you’d like to run. For instance, to test just the Lakeshore 372:

$ cd hardware/
$ python3 -m pytest test_ls372.py

Writing Tests

When writing integration tests for an Agent we need to mock the communication with the hardware the Agent interfaces with. We can do that with the DeviceEmulator. socs provides a function for creating pytest fixtures that yield a Device Emulator. Here’s an example of how to use it:

# define the fixture, including any responses needed for Agent startup
emulator = create_device_emulator({'*IDN?': 'LSCI,MODEL425,4250022,1.0'}, relay_type='serial')

@pytest.mark.integtest
def test_ls425_operational_status(wait_for_crossbar, emulator, run_agent, client):
    # define the responses needed for this test
    responses = {'OPST?': '001'}
    emulator.define_responses(responses)

    # run task that sends the 'OPST?' command
    resp = client.operational_status()
    assert resp.status == ocs.OK
    assert resp.session['op_code'] == OpCode.SUCCEEDED.value

In the example, we initialize the emulator with any commands and responses needed for Agent startup, and specify that the Agent communicates via serial. Then, within the test, we define commands and their responses needed during testing. In this case that is the OPST? command and the expected response of 001. Once these are saved with socs.testing.device_emulator.DeviceEmulator.define_responses() we can run our Agent Tasks/Processes for testing.

API

socs.testing.device_emulator.create_device_emulator(responses, relay_type, port=9001, encoding='utf-8', reconnect=False)[source]

Create a device emulator fixture.

This provides a device emulator that can be used to mock a device during testing.

Parameters:
  • responses (dict) – Dictionary with commands as keys, and responses as values. See DeviceEmulator for details.

  • relay_type (str) – Communication relay type. Either ‘serial’ or ‘tcp’.

  • port (int) – Port for the TCP relay to listen for connections on. Defaults to 9001. Only used if relay_type is ‘tcp’.

  • encoding (str) – Encoding for the messages and responses. See socs.testing.device_emulator.DeviceEmulator() for more details.

  • reconnect (bool) – If True, on TCP client disconnect, the emulator will listen for new incoming connections instead of quitting

Returns:

A pytest fixture that creates a Device emulator of the specified type.

Return type:

function

class socs.testing.device_emulator.DeviceEmulator(responses, encoding='utf-8', reconnect=False)[source]

A mocked device which knows how to respond on various communication channels.

Parameters:
  • responses (dict) – Initial responses, any response required by Agent startup, if any.

  • encoding (str) – Encoding for the messages and responses. DeviceEmulator will try to encode and decode messages with the given encoding. No encoding is used if set to None. That can be useful if you need to use raw data from your hardware. Defaults to ‘utf-8’.

  • reconnect (bool) – If True, on TCP client disconnect, the emulator will listen for new incoming connections instead of quitting

responses

Current set of responses the DeviceEmulator would give. Should all be strings, not bytes-like.

Type:

dict

default_response

Default response to send if a command is unrecognized. No response is sent and an error message is logged if a command is unrecognized and the default response is set to None. Defaults to None.

Type:

str

encoding

Encoding for the messages and responses, set by the encoding argument.

Type:

str

_type

Relay type, either ‘serial’ or ‘tcp’.

Type:

str

_read

Used to stop the background reading of data recieved on the relay.

Type:

bool

_conn

TCP connection for use in ‘tcp’ relay.

Type:

socket.socket

create_serial_relay()[source]

Create the serial relay, emulating a hardware device connected over serial.

This first uses socat to setup a relay. It then connects to the “internal” end of the relay, ready to receive communications sent to the “responder” end of the relay. This end of the relay is located at ./responder. You will need to configure your Agent to use that path for communication.

Next it creates a thread to read commands sent to the serial relay in the background. This allows responses to be defined within a test using DeviceEmulator.define_responses() after instantiation of the DeviceEmulator object within a given test.

get_response(msg)[source]

Determine the response to a given message.

Parameters:

msg (str) – Command string to get the response for.

Returns:

Response string. Will return None if a valid response is not

found.

Return type:

str

shutdown()[source]

Shutdown communication on the configured relay. This will stop any attempt to read communication on the relay, as well as shutdown the relay itself.

create_tcp_relay(port)[source]

Create the TCP relay, emulating a hardware device connected over TCP.

Creates a thread to read commands sent to the TCP relay in the background. This allows responses to be defined within a test using DeviceEmulator.define_responses() after instantiation of the DeviceEmulator object within a given test.

Parameters:

port (int) – Port for the TCP relay to listen for connections on.

Notes

This will not return until the socket is properly bound to the given port. If this setup is not working it is likely another device emulator instance is not yet finished or has not been properly shutdown.

update_responses(responses: Dict)[source]

Updates the current responses. See define_responses for more detail.

Parameters:

responses (dict) – Dict of commands to use to update the current responses.

define_responses(responses, default_response=None)[source]

Define what responses are available to reply with on the configured communication relay.

Parameters:
  • responses (dict) – Dictionary of commands: response. Values can be a list, in which case the responses in the list are popped and given in order until depleted.

  • default_response (str) – Default response to send if a command is unrecognized. No response is sent and an error message is logged if a command is unrecognized and the default response is set to None. Defaults to None.

Examples

The given responses might look like:

>>> responses = {'KRDG? 1': '+1.7E+03'}
>>> responses = {'*IDN?': 'LSCI,MODEL425,4250022,1.0',
                 'RDGFIELD?': ['+1.0E-01', '+1.2E-01', '+1.4E-01']}

Notes

The DeviceEmulator will handle encoding/decoding. The responses defined should all be strings, not bytes-like, unless you set encoding=None.