From c6c532b50fa81068775582b55cb44cb35158d6db Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 5 Jan 2021 22:25:57 -0500 Subject: [PATCH] add tests using https://github.com/custom-components/integration_blueprint/pull/50 as base --- requirements_dev.txt | 1 + requirements_test.txt | 3 ++ tests/__init__.py | 1 + tests/conftest.py | 35 +++++++++++++ tests/const.py | 4 ++ tests/test_api.py | 69 ++++++++++++++++++++++++ tests/test_config_flow.py | 70 +++++++++++++++++++++++++ tests/test_init.py | 56 ++++++++++++++++++++ tests/test_switch.py | 43 +++++++++++++++ {{cookiecutter.project_name}}/README.md | 2 +- 10 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 requirements_dev.txt create mode 100644 requirements_test.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/const.py create mode 100644 tests/test_api.py create mode 100644 tests/test_config_flow.py create mode 100644 tests/test_init.py create mode 100644 tests/test_switch.py diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..7d78f01 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1 @@ +homeassistant diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..38e1349 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,3 @@ +-r requirements_dev.txt +pytest +pytest-homeassistant-custom-component diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b9873cc --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for {{cookiecutter.project_name}} integration.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..66e4441 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,35 @@ +"""Global fixtures for {{cookiecutter.project_name}} integration.""" +from unittest import mock + +import pytest +from pytest_homeassistant_custom_component.async_mock import patch + +pytest_plugins = "pytest_homeassistant_custom_component" + + +@pytest.fixture(name="skip_notifications", autouse=True) +def skip_notifications_fixture(): + """Skip notification calls.""" + with patch("homeassistant.components.persistent_notification.async_create"), patch( + "homeassistant.components.persistent_notification.async_dismiss" + ): + yield + + +@pytest.fixture(name="bypass_get_data") +def bypass_get_data_fixture(): + """Skip calls to get data from API.""" + with patch( + "custom_components.{{cookiecutter.project_name}}.{{cookiecutter.class_name_prefix}}ApiClient.async_get_data" + ): + yield + + +@pytest.fixture(name="error_on_get_data") +def error_get_data_fixture(): + """Simulate error when retrieving data from API.""" + with patch( + "custom_components.{{cookiecutter.project_name}}.{{cookiecutter.class_name_prefix}}ApiClient.async_get_data", + side_effect=Exception, + ): + yield diff --git a/tests/const.py b/tests/const.py new file mode 100644 index 0000000..bcef2e9 --- /dev/null +++ b/tests/const.py @@ -0,0 +1,4 @@ +"""Constants for {{cookiecutter.project_name}} tests.""" +from custom_components.{{cookiecutter.project_name}}.const import CONF_PASSWORD, CONF_USERNAME + +MOCK_CONFIG = {CONF_USERNAME: "test_username", CONF_PASSWORD: "test_password"} diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..94881dc --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,69 @@ +"""Tests for {{cookiecutter.project_name}} api.""" +import asyncio + +import aiohttp +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from custom_components.{{cookiecutter.project_name}}.api import {{cookiecutter.class_name_prefix}}ApiClient + + +async def test_ap(hass, aioclient_mock, caplog): + """Test API calls.""" + + api = {{cookiecutter.class_name_prefix}}ApiClient("test", "test", async_get_clientsession(hass)) + + aioclient_mock.get( + "https://jsonplaceholder.typicode.com/posts/1", json={"test": "test"} + ) + assert await api.async_get_data() == {"test": "test"} + + aioclient_mock.patch("https://jsonplaceholder.typicode.com/posts/1") + assert await api.async_set_title("test") is None + + caplog.clear() + aioclient_mock.put( + "https://jsonplaceholder.typicode.com/posts/1", exc=asyncio.TimeoutError + ) + assert ( + await api.api_wrapper("put", "https://jsonplaceholder.typicode.com/posts/1") + is None + ) + assert ( + len(caplog.record_tuples) == 1 + and "Timeout error fetching information from" in caplog.record_tuples[0][2] + ) + + caplog.clear() + aioclient_mock.post( + "https://jsonplaceholder.typicode.com/posts/1", exc=aiohttp.ClientError + ) + assert ( + await api.api_wrapper("post", "https://jsonplaceholder.typicode.com/posts/1") + is None + ) + assert ( + len(caplog.record_tuples) == 1 + and "Error fetching information from" in caplog.record_tuples[0][2] + ) + + caplog.clear() + aioclient_mock.post("https://jsonplaceholder.typicode.com/posts/2", exc=Exception) + assert ( + await api.api_wrapper("post", "https://jsonplaceholder.typicode.com/posts/2") + is None + ) + assert ( + len(caplog.record_tuples) == 1 + and "Something really wrong happened!" in caplog.record_tuples[0][2] + ) + + caplog.clear() + aioclient_mock.post("https://jsonplaceholder.typicode.com/posts/3", exc=TypeError) + assert ( + await api.api_wrapper("post", "https://jsonplaceholder.typicode.com/posts/3") + is None + ) + assert ( + len(caplog.record_tuples) == 1 + and "Error parsing information from" in caplog.record_tuples[0][2] + ) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..0bdae13 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,70 @@ +"""Test {{cookiecutter.project_name}} config flow.""" +from homeassistant import config_entries, data_entry_flow +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.{{cookiecutter.project_name}}.const import ( + BINARY_SENSOR, + DOMAIN, + PLATFORMS, + SENSOR, + SWITCH, +) + +from .const import MOCK_CONFIG + + +async def test_successful_config_flow(hass, bypass_get_data): + """Test a successful config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test_username" + assert result["data"] == MOCK_CONFIG + assert result["result"] + + +async def test_failed_config_flow(hass, error_on_get_data): + """Test a failed config flow due to credential validation failure.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "auth"} + + +async def test_options_flow(hass): + """Test an options flow.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + entry.add_to_hass(hass) + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={platform: platform != SENSOR for platform in PLATFORMS}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test_username" + + assert entry.options == {BINARY_SENSOR: True, SENSOR: False, SWITCH: True} diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..59300f6 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,56 @@ +"""Test {{cookiecutter.project_name}} setup process.""" +from homeassistant.exceptions import ConfigEntryNotReady +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.{{cookiecutter.project_name}} import ( + {{cookiecutter.class_name_prefix}}DataUpdateCoordinator, + async_reload_entry, + async_setup_entry, + async_unload_entry, +) +from custom_components.{{cookiecutter.project_name}}.const import DOMAIN + +from .const import MOCK_CONFIG + + +# We can pass fixtures as defined in conftest.py to tell pytest to use the fixture +# for a given test. We can also leverage fixtures and mocks that are available in +# Home Assistant using the pytest_homeassistant_custom_component plugin. +# Assertions allow you to verify that the return value of whatever is on the left +# side of the assertion matches with the right side. +async def test_setup_unload_and_reload_entry(hass, bypass_get_data): + """Test entry setup and unload.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + + # Set up the entry and assert that the values set during setup are where we expect + # them to be. Because we have patched the {{cookiecutter.class_name_prefix}}DataUpdateCoordinator.async_get_data + # call, no code from custom_components/{{cookiecutter.project_name}}/api.py actually runs. + assert await async_setup_entry(hass, config_entry) + assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] + assert ( + type(hass.data[DOMAIN][config_entry.entry_id]) == {{cookiecutter.class_name_prefix}}DataUpdateCoordinator + ) + + # Reload the entry and assert that the data from above is still there + assert await async_reload_entry(hass, config_entry) is None + assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] + assert ( + type(hass.data[DOMAIN][config_entry.entry_id]) == {{cookiecutter.class_name_prefix}}DataUpdateCoordinator + ) + + # Unload the entry and verify that the data has been removed + assert await async_unload_entry(hass, config_entry) + assert config_entry.entry_id not in hass.data[DOMAIN] + + +async def test_setup_entry_exception(hass, error_on_get_data): + """Test ConfigEntryNotReady when API raises an exception during entry setup.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + + # In this case we are testing the condition where async_setup_entry raises + # ConfigEntryNotReady using the `error_on_get_data` fixture which simulates + # an error. + with pytest.raises(ConfigEntryNotReady): + assert await async_setup_entry(hass, config_entry) diff --git a/tests/test_switch.py b/tests/test_switch.py new file mode 100644 index 0000000..c217d74 --- /dev/null +++ b/tests/test_switch.py @@ -0,0 +1,43 @@ +"""Test {{cookiecutter.project_name}} switch.""" +from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID +from pytest_homeassistant_custom_component.async_mock import call, patch +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.{{cookiecutter.project_name}} import async_setup_entry +from custom_components.{{cookiecutter.project_name}}.const import DEFAULT_NAME, DOMAIN, SWITCH + +from .const import MOCK_CONFIG + + +async def test_switch_services(hass): + """Test switch services.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + assert await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + + # Functions/objects can be patched directly in test code as well and can be used to test + # additional things, like whether a function was called or what arguments it was called with + with patch( + "custom_components.{{cookiecutter.project_name}}.{{cookiecutter.class_name_prefix}}ApiClient.async_set_title" + ) as title_func: + await hass.services.async_call( + SWITCH, + SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: f"{SWITCH}.{DEFAULT_NAME}_{SWITCH}"}, + blocking=True, + ) + assert title_func.called + assert title_func.call_args == call("foo") + + title_func.reset_mock() + + await hass.services.async_call( + SWITCH, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: f"{SWITCH}.{DEFAULT_NAME}_{SWITCH}"}, + blocking=True, + ) + assert title_func.called + assert title_func.call_args == call("bar") diff --git a/{{cookiecutter.project_name}}/README.md b/{{cookiecutter.project_name}}/README.md index 81517e6..e52c2e4 100644 --- a/{{cookiecutter.project_name}}/README.md +++ b/{{cookiecutter.project_name}}/README.md @@ -14,7 +14,7 @@ [![Discord][discord-shield]][discord] [![Community Forum][forum-shield]][forum] -**TO BE REMOVED: If you need help, as a developper, to use this custom component tempalte, +**TO BE REMOVED: If you need help, as a developer, to use this custom component tempalte, please look at the [User Guide in the Cookiecutter documentation](https://cookiecutter-homeassistant-custom-component.readthedocs.io/en/stable/quickstart.html)** **This component will set up the following platforms.**