Improve testing harness to allow tests on real devices (#197)

* test_cli: provide return values to patched objects to avoid warning about non-awaited calls

* test_cli: restore alias after testing

* smartstrip: remove internal update() calls for turn_{on,off}, set_led

* Make sure power is always a float

* Fix discovery tests

* Make tests runnable on real devices

* Add a note about running tests on a real device

* test_strip: run update against the parent device
This commit is contained in:
Teemu R 2021-09-19 23:45:48 +02:00 committed by GitHub
parent b088596205
commit 1803a83ae6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 70 additions and 24 deletions

View File

@ -94,6 +94,15 @@ This will make sure that the checks are passing when you do a commit.
You can also execute the checks by running either `tox -e lint` to only do the linting checks, or `tox` to also execute the tests.
### Running tests
You can run tests on the library by executing `pytest` in the source directory.
This will run the tests against contributed example responses, but you can also execute the tests against a real device:
```
pytest --ip <address>
```
Note that this will perform state changes on the device.
### Analyzing network captures
The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device.

View File

@ -247,6 +247,7 @@ async def alias(dev, new_alias, index):
if new_alias is not None:
click.echo(f"Setting alias to {new_alias}")
click.echo(await dev.set_alias(new_alias))
await dev.update()
click.echo(f"Alias: {dev.alias}")
if dev.is_strip:

View File

@ -603,7 +603,7 @@ class SmartDevice:
raise SmartDeviceException("Device has no emeter")
response = EmeterStatus(await self.get_emeter_realtime())
return response["power"]
return float(response["power"])
async def reboot(self, delay: int = 1) -> None:
"""Reboot the device.
@ -658,7 +658,8 @@ class SmartDevice:
def device_id(self) -> str:
"""Return unique ID for the device.
This is the MAC address of the device.
If not overridden, this is the MAC address of the device.
Individual sockets on strips will override this.
"""
return self.mac

View File

@ -100,12 +100,10 @@ class SmartStrip(SmartDevice):
async def turn_on(self, **kwargs):
"""Turn the strip on."""
await self._query_helper("system", "set_relay_state", {"state": 1})
await self.update()
async def turn_off(self, **kwargs):
"""Turn the strip off."""
await self._query_helper("system", "set_relay_state", {"state": 0})
await self.update()
@property # type: ignore
@requires_update
@ -126,7 +124,6 @@ class SmartStrip(SmartDevice):
async def set_led(self, state: bool):
"""Set the state of the led (night mode)."""
await self._query_helper("system", "set_led_off", {"off": int(not state)})
await self.update()
@property # type: ignore
@requires_update

View File

@ -147,7 +147,7 @@ def get_device_for_file(file):
with open(p) as f:
sysinfo = json.load(f)
model = basename(file)
p = device_for_file(model)(host="123.123.123.123")
p = device_for_file(model)(host="127.0.0.123")
p.protocol = FakeTransportProtocol(sysinfo)
asyncio.run(p.update())
return p
@ -168,21 +168,29 @@ def dev(request):
asyncio.run(d.update())
if d.model in file:
return d
raise Exception("Unable to find type for %s" % ip)
else:
pytest.skip(f"skipping file {file}")
return get_device_for_file(file)
def pytest_addoption(parser):
parser.addoption("--ip", action="store", default=None, help="run against device")
parser.addoption(
"--ip", action="store", default=None, help="run against device on given ip"
)
def pytest_collection_modifyitems(config, items):
if not config.getoption("--ip"):
print("Testing against fixtures.")
return
else:
print("Running against ip %s" % config.getoption("--ip"))
requires_dummy = pytest.mark.skip(
reason="test requires to be run against dummy data"
)
for item in items:
if "requires_dummy" in item.keywords:
item.add_marker(requires_dummy)
# allow mocks to be awaited

View File

@ -124,7 +124,7 @@ LIGHT_STATE_SCHEMA = Schema(
"dft_on_state": Optional(
{
"brightness": All(int, Range(min=0, max=100)),
"color_temp": All(int, Range(min=2000, max=9000)),
"color_temp": All(int, Range(min=0, max=9000)),
"hue": All(int, Range(min=0, max=255)),
"mode": str,
"saturation": All(int, Range(min=0, max=255)),

View File

@ -66,6 +66,7 @@ async def test_hsv(dev, turn_on):
await dev.set_hsv(hue=1, saturation=1, value=1)
await dev.update()
hue, saturation, brightness = dev.hsv
assert hue == 1
assert saturation == 1
@ -134,6 +135,7 @@ async def test_variable_temp_state_information(dev):
async def test_try_set_colortemp(dev, turn_on):
await handle_turn_on(dev, turn_on)
await dev.set_color_temp(2700)
await dev.update()
assert dev.color_temp == 2700
@ -179,9 +181,11 @@ async def test_dimmable_brightness(dev, turn_on):
assert dev.is_dimmable
await dev.set_brightness(50)
await dev.update()
assert dev.brightness == 50
await dev.set_brightness(10)
await dev.update()
assert dev.brightness == 10
with pytest.raises(ValueError):

View File

@ -18,7 +18,7 @@ async def test_state(dev, turn_on):
await handle_turn_on(dev, turn_on)
runner = CliRunner()
res = await runner.invoke(state, obj=dev)
print(res.output)
await dev.update()
if dev.is_on:
assert "Device state: ON" in res.output
@ -32,6 +32,8 @@ async def test_alias(dev):
res = await runner.invoke(alias, obj=dev)
assert f"Alias: {dev.alias}" in res.output
old_alias = dev.alias
new_alias = "new alias"
res = await runner.invoke(alias, [new_alias], obj=dev)
assert f"Setting alias to {new_alias}" in res.output
@ -39,6 +41,8 @@ async def test_alias(dev):
res = await runner.invoke(alias, obj=dev)
assert f"Alias: {new_alias}" in res.output
await dev.set_alias(old_alias)
async def test_raw_command(dev):
runner = CliRunner()
@ -63,11 +67,13 @@ async def test_emeter(dev: SmartDevice, mocker):
assert "== Emeter ==" in res.output
monthly = mocker.patch.object(dev, "get_emeter_monthly")
monthly.return_value = []
res = await runner.invoke(emeter, ["--year", "1900"], obj=dev)
assert "For year" in res.output
monthly.assert_called()
daily = mocker.patch.object(dev, "get_emeter_daily")
daily.return_value = []
res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev)
assert "For month" in res.output
daily.assert_called()

View File

@ -8,14 +8,14 @@ from .conftest import bulb, dimmer, lightstrip, plug, pytestmark, strip
@plug
async def test_type_detection_plug(dev: SmartDevice):
d = Discover._get_device_class(dev.protocol.discovery_data)("localhost")
d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_plug
assert d.device_type == DeviceType.Plug
@bulb
async def test_type_detection_bulb(dev: SmartDevice):
d = Discover._get_device_class(dev.protocol.discovery_data)("localhost")
d = Discover._get_device_class(dev._last_update)("localhost")
# TODO: light_strip is a special case for now to force bulb tests on it
if not d.is_light_strip:
assert d.is_bulb
@ -24,21 +24,21 @@ async def test_type_detection_bulb(dev: SmartDevice):
@strip
async def test_type_detection_strip(dev: SmartDevice):
d = Discover._get_device_class(dev.protocol.discovery_data)("localhost")
d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_strip
assert d.device_type == DeviceType.Strip
@dimmer
async def test_type_detection_dimmer(dev: SmartDevice):
d = Discover._get_device_class(dev.protocol.discovery_data)("localhost")
d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_dimmer
assert d.device_type == DeviceType.Dimmer
@lightstrip
async def test_type_detection_lightstrip(dev: SmartDevice):
d = Discover._get_device_class(dev.protocol.discovery_data)("localhost")
d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_light_strip
assert d.device_type == DeviceType.LightStrip

View File

@ -32,6 +32,7 @@ async def test_get_emeter_realtime(dev):
@has_emeter
@pytest.mark.requires_dummy
async def test_get_emeter_daily(dev):
if dev.is_strip:
pytest.skip("Disabled for strips temporarily")
@ -54,6 +55,7 @@ async def test_get_emeter_daily(dev):
@has_emeter
@pytest.mark.requires_dummy
async def test_get_emeter_monthly(dev):
if dev.is_strip:
pytest.skip("Disabled for strips temporarily")

View File

@ -20,9 +20,11 @@ async def test_led(dev):
original = dev.led
await dev.set_led(False)
await dev.update()
assert not dev.led
await dev.set_led(True)
await dev.update()
assert dev.led
await dev.set_led(original)

View File

@ -13,6 +13,7 @@ async def test_state_info(dev):
assert isinstance(dev.state_information, dict)
@pytest.mark.requires_dummy
async def test_invalid_connection(dev):
with patch.object(FakeTransportProtocol, "query", side_effect=SmartDeviceException):
with pytest.raises(SmartDeviceException):
@ -49,18 +50,22 @@ async def test_state(dev, turn_on):
orig_state = dev.is_on
if orig_state:
await dev.turn_off()
await dev.update()
assert not dev.is_on
assert dev.is_off
await dev.turn_on()
await dev.update()
assert dev.is_on
assert not dev.is_off
else:
await dev.turn_on()
await dev.update()
assert dev.is_on
assert not dev.is_off
await dev.turn_off()
await dev.update()
assert not dev.is_on
assert dev.is_off
@ -71,9 +76,11 @@ async def test_alias(dev):
assert isinstance(original, str)
await dev.set_alias(test_alias)
await dev.update()
assert dev.alias == test_alias
await dev.set_alias(original)
await dev.update()
assert dev.alias == original

View File

@ -15,20 +15,24 @@ async def test_children_change_state(dev, turn_on):
orig_state = plug.is_on
if orig_state:
await plug.turn_off()
assert not plug.is_on
assert plug.is_off
await dev.update()
assert plug.is_on is False
assert plug.is_off is True
await plug.turn_on()
assert plug.is_on
assert not plug.is_off
await dev.update()
assert plug.is_on is True
assert plug.is_off is False
else:
await plug.turn_on()
assert plug.is_on
assert not plug.is_off
await dev.update()
assert plug.is_on is True
assert plug.is_off is False
await plug.turn_off()
assert not plug.is_on
assert plug.is_off
await dev.update()
assert plug.is_on is False
assert plug.is_off is True
@strip

View File

@ -72,6 +72,11 @@ fail-under = 100
exclude = ['kasa/tests/*']
verbose = 2
[tool.pytest.ini_options]
markers = [
"requires_dummy: test requires dummy data to pass, skipped on real devices",
]
[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"