Skip to content

Commit

Permalink
Add unit tests (#305)
Browse files Browse the repository at this point in the history
* Add devcontainer, test requirements file and basic pytest fixtures
* Add pytest options file to set asyncio mode
* Add some minimal config flow tests
* Add new test that fails due to refresh and apply at the same time
* Add unittests to GH actions
  • Loading branch information
mill1000 authored Feb 12, 2025
1 parent 86df795 commit c3f4364
Show file tree
Hide file tree
Showing 8 changed files with 290 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"image": "mcr.microsoft.com/devcontainers/python:3.13",
"features": {
"ghcr.io/devcontainers/features/git:1": {}
},
"postCreateCommand": "pip install autopep8 isort; pip install -r tests/requirements.txt"

}
18 changes: 18 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,21 @@ jobs:
pip install autopep8 isort
- run: autopep8 --diff --exit-code $(git ls-files '*.py')
- run: isort --diff --check $(git ls-files '*.py')

run-unittest:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12", "3.13"]

steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
- run: python -c "import sys; print(sys.version)"
- run: |
python -m pip install --upgrade pip
pip install -r tests/requirements.txt
- run: pytest
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the Midea Smart AC integration."""
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Pytest fixtures for testing Midea Smart AC."""
import pytest


@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations):
yield
4 changes: 4 additions & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pytest
pytest-homeassistant-custom-component

msmart-ng
168 changes: 168 additions & 0 deletions tests/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""Tests for the config flow."""

import logging
from unittest.mock import patch

import pytest
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType, InvalidData
from msmart.lan import AuthenticationError
from pytest_homeassistant_custom_component.common import MockConfigEntry

from custom_components.midea_ac.const import CONF_BEEP, CONF_KEY, DOMAIN

logging.basicConfig(level=logging.DEBUG)
_LOGGER = logging.getLogger(__name__)


async def test_config_flow_options(hass: HomeAssistant) -> None:
"""Test the config flow starts with a menu with manual and discover options."""
# Check initial flow is a menu with two options
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)

assert result["step_id"] == "user"
assert result["type"] is FlowResultType.MENU
assert result["menu_options"] == ["discover", "manual"]

# Check discover flow can be started
discover_form_result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "discover"}
)
assert discover_form_result["type"] is FlowResultType.FORM
assert discover_form_result["step_id"] == "discover"
assert not discover_form_result["errors"]

# Check manual flow can be started
manual_form_result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "manual"}
)
assert manual_form_result["type"] is FlowResultType.FORM
assert manual_form_result["step_id"] == "manual"
assert not manual_form_result["errors"]


async def test_manual_flow(hass: HomeAssistant) -> None:
"""Test the manual flow validates input and failed connections return errors."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "manual"}
)
assert result

invalid_input = [
{
CONF_HOST: None
},
{
CONF_HOST: "localhost",
CONF_PORT: None
},
{
CONF_HOST: "localhost",
CONF_PORT: 6444,
CONF_ID: None
}
]
for input in invalid_input:
with pytest.raises(InvalidData):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=input
)

with (patch("custom_components.midea_ac.config_flow.AC.refresh",
return_value=False) as refresh_mock,
patch("custom_components.midea_ac.config_flow.AC.authenticate",
side_effect=AuthenticationError) as authenticate_mock):
# Check manually configuring a V2 device
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "localhost",
CONF_PORT: 6444,
CONF_ID: "1234"
}
)
assert result
# Refresh should be called
refresh_mock.assert_awaited_once()
# Authenticate shouldn't be called
authenticate_mock.assert_not_awaited()
# Connection should fail
assert result["errors"] == {"base": "cannot_connect"}

refresh_mock.reset_mock()
authenticate_mock.reset_mock()

# Check manually configuring a V3 device
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "localhost",
CONF_PORT: 6444,
CONF_ID: "1234",
CONF_TOKEN: "1234",
CONF_KEY: "1234"

}
)
assert result
# Authenticate should be called
authenticate_mock.assert_awaited_once()
# Refresh should be not called
refresh_mock.assert_not_awaited()
# Connection should fail
assert result["errors"] == {"base": "cannot_connect"}


async def test_options_flow_init(hass: HomeAssistant) -> None:
"""Test the integration options flow works and default options are set."""

# Create a mock config entry
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_ID: "1234",
CONF_HOST: "localhost",
CONF_PORT: 6444,
CONF_TOKEN: None,
CONF_KEY: None,
}
)

# Patch refresh and get_capabilities calls to allow integration to setup
with (patch("custom_components.midea_ac.config_flow.AC.get_capabilities"),
patch("custom_components.midea_ac.config_flow.AC.refresh")):
# Add mock config entry to HASS and setup integration
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

assert mock_config_entry.entry_id in hass.data[DOMAIN]
assert mock_config_entry.state is ConfigEntryState.LOADED

# Show options form
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
assert result["step_id"] == "init"
assert result["type"] is FlowResultType.FORM
assert not result["errors"]

with patch("custom_components.midea_ac.async_setup_entry",
return_value=True) as mock_setup_entry:
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_BEEP: False
},
)
await hass.async_block_till_done()

assert result["type"] is FlowResultType.CREATE_ENTRY
assert mock_config_entry.options == {
CONF_BEEP: False,
}
assert len(mock_setup_entry.mock_calls) == 1
81 changes: 81 additions & 0 deletions tests/test_coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Tests for the data update coordinator flow."""

import asyncio
import logging
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT, CONF_TOKEN
from homeassistant.core import HomeAssistant
from msmart.lan import _LanProtocol
from pytest_homeassistant_custom_component.common import MockConfigEntry

from custom_components.midea_ac.const import CONF_KEY, DOMAIN


async def _setup_integration(hass: HomeAssistant) -> MockConfigEntry:
"""Set up the integration with a mock config entry."""

# Create a mock config entry
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_ID: "1234",
CONF_HOST: "localhost",
CONF_PORT: 6444,
CONF_TOKEN: None,
CONF_KEY: None,
}
)

# Patch refresh and get_capabilities calls to allow integration to setup
with (patch("custom_components.midea_ac.config_flow.AC.get_capabilities"),
patch("custom_components.midea_ac.config_flow.AC.refresh")):
# Add mock config entry to HASS and setup integration
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

assert mock_config_entry.entry_id in hass.data[DOMAIN]
assert mock_config_entry.state is ConfigEntryState.LOADED

return mock_config_entry


async def test_concurrent_refresh_exception(
hass: HomeAssistant,
) -> None:
"""Test concurrent refreshes can cause an exception."""

# Setup the integration
mock_config_entry = await _setup_integration(hass)
assert mock_config_entry

# Fetch the coordinator
coordinator = hass.data[DOMAIN][mock_config_entry.entry_id]
device = coordinator.device
lan = device._lan

# Mock the read_available method so send() will be reached
lan._read_available = MagicMock()
lan._read_available.__aiter__.return_value = None

# Mock connect and protocol objects so network won't be used
lan._connect = AsyncMock()
lan._protocol = _LanProtocol()
lan._protocol._peer = "127.0.0.1:6444"

# Mock the transport so connection wil be seen as alive
lan._protocol._transport = MagicMock()
lan._protocol._transport.is_closing = MagicMock(return_value=False)

# logging.getLogger("msmart").setLevel(logging.DEBUG)
# logging.getLogger("custom_components.midea_ac").setLevel(logging.DEBUG)

# Check that concurrent calls to network actions can throw
with pytest.raises(AttributeError):
task1 = asyncio.create_task(coordinator.async_request_refresh())
await asyncio.sleep(3)
task2 = asyncio.create_task(coordinator.apply())
await asyncio.gather(task1, task2)

0 comments on commit c3f4364

Please sign in to comment.