Skip to content

Commit

Permalink
Add pytest helpers
Browse files Browse the repository at this point in the history
Closes #5
  • Loading branch information
tiagocoutinho committed Feb 9, 2021
1 parent 54f3dd7 commit 47f051d
Show file tree
Hide file tree
Showing 2 changed files with 208 additions and 1 deletion.
83 changes: 82 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,8 +411,89 @@ class Oscilloscope(BaseDevice):

## Pytest fixture

`TODO`
If you are developing a python library that provides access to an instrument
accessible through socket or serial line and you wrote a simulator for it, you
might be interested in testing your library against the simulator.

sinstruments provides a pair of pytest helpers that spawn a simulator in a
separate thread.

### `server_context`
The first usage is simply using the `server_context` helper.
There is actually nothing pytest speficic about this helper so you could
imagine using it in other scenarios as well.

Here is an example:

```python
import pytest
from sinstruments.pytest import server_context
cfg = {
"devices": [{
"name": "oscillo-1",
"class": "Oscilloscope",
"transports": [
{"type": "tcp", "url": "localhost:0"}
]
}]
}
def test_oscilloscope_id():
with server_context(cfg) as server:
# put here code to perform your tests that need to communicate with
# the simulator. In this example an oscilloscope client
addr = server.devices["oscillo-1"].transports[0].address
oscillo = Oscilloscope(addr)
assert oscillo.idn().startswith("ACME Inc,O-3000")
```

You might notice that in the configuration we use port `0`. This is telling
the simulator to listen on any free port provided by the OS.

The actual test retrieves the current address assigned by the OS and uses it in
the test.

As you can see, the tests are not dependent of the availability of one specific
port which makes them portable.

Here is a suggestion on how you could write your own fixture using the
`server_context` helper. The aim was to reduce the amount of boilerplate
code you need to write your test:

```python
@pytest.fixture
def oscillo_server():
with server_context(config) as server:
server.oscillo1 = server.devices["oscillo-1"]
server.oscillo1.addr = server.oscillo1.transports[0].address
yield server
def test_oscilloscope_current(oscillo_server):
oscillo = Oscilloscope(oscillo_server.oscillo1.addr)
assert .05 < oscillo.current() < 0.01
```

### `server`

A second helper is the `server` fixture. This fixture depends on an existing
`config` feature that must be present in your module. Here is an example
following the previous code:

```python
from sinstruments.pytest import server
@pytest.fixture
def config()
yield cfg
def test_oscilloscope_voltage(server):
addr = server.devices["oscillo-1"].transports[0].address
oscillo = Oscilloscope(addr)
assert 5 < oscillo.voltage() < 10
```

[pypi-python-versions]: https://img.shields.io/pypi/pyversions/sinstruments.svg
[pypi-version]: https://img.shields.io/pypi/v/sinstruments.svg
Expand Down
126 changes: 126 additions & 0 deletions sinstruments/pytest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
#
# This file is part of the sinstruments project
#
# Copyright (c) 2018-present Tiago Coutinho
# Distributed under the GPLv3 license. See LICENSE for more info.

"""
pytest server context helper
Example usage:
```python
import pytest
from sinstruments.pytest import server_context
cfg = {
"devices": [{
"name": "oscillo-1",
"class": "Oscilloscope",
"transports": [
{"type": "tcp", "url": "localhost:0"}
]
}]
}
# example 1: use as a context manager
def test_oscilloscope_id():
with server_context(cfg) as server:
# put here code to perform your tests that need to communicate with
# the simulator. In this example an oscilloscope client
addr = server.devices["oscillo-1"].transports[0].address
oscillo = Oscilloscope(addr)
assert oscillo.idn().startswith("ACME Inc,O-3000")
# example 2: use a predefined fixture (depends on an existing config fixture)
from sinstruments.pytest import server
@pytest.fixture
def config()
yield cfg
def test_oscilloscope_voltage(server):
addr = server.devices["oscillo-1"].transports[0].address
oscillo = Oscilloscope(addr)
assert 5 < oscillo.voltage() < 10
# example 3: define your own fixture
@pytest.fixture
def oscillo_server():
with server_context(config) as server:
server.oscillo1 = server.devices["oscillo-1"]
server.oscillo1.addr = server.oscillo1.transports[0].address
yield server
def test_oscilloscope_current(oscillo_server):
oscillo = Oscilloscope(oscillo_server.oscillo1.addr)
assert .05 < oscillo.current() < 0.01
```
"""

import socket
import threading

import pytest
import gevent.event

from .simulator import create_server_from_config


class server_context:

def __init__(self, config):
self.config = config
self.thread = None

def start(self):
started_event = threading.Event()
self.thread = threading.Thread(target=self._run, args=(started_event,))
self.thread.start()
started_event.wait()

def stop(self):
if self.thread is None:
return
self.watcher.start(self.stop_event.set)
self.watcher.send()
self.thread.join()
self.thread = None

def _run(self, started_event):
self.server = create_server_from_config(self.config)
self.server.stop_thread_safe = self.stop
self.watcher = gevent.get_hub().loop.async_()
self.stop_event = gevent.event.Event()
# we start each transport manually to make sure all sockets are open
# before we notify that we have started. Otherwise tests might get
# random ConnectionRefused errors
for device in self.server.devices.values():
for transport in device.transports:
transport.start()
server_task = gevent.spawn(self.server.serve_forever)
started_event.set()
self.stop_event.wait()
server_task.kill()
self.watcher.close()

def __enter__(self):
self.start()
return self.server

def __exit__(self, exc_type, exc_value, tb):
self.stop()


@pytest.fixture
def server(config):
with server_context(config) as server:
yield server

0 comments on commit 47f051d

Please sign in to comment.