From c0d249facb5c5c64961b8184ef5117ea3b75157d Mon Sep 17 00:00:00 2001 From: Kennedy Kori Date: Fri, 14 Apr 2023 14:43:11 +0300 Subject: [PATCH] chore(tests): add project TestCase (#69) Add a project `TestCase` that extends `unittest.TestCase` to provide the following: - Configure the execution environment at the class level, once for the whole `TestCase`. This is similar to calling `app.setup`. - Add a class method, `config_factory` that returns an `app.lib.Config` instance used to configure the execution environment of this `TestCase` instances. All existing unit tests have also been refactored to extend from this TestCase. Included also are minor bug fixes and improvements. --- .config.template.yaml | 32 ++++++----- app/__init__.py | 13 ++++- app/lib/transports/http/__init__.py | 1 + tests/__init__.py | 56 +++++++++++++++++++ tests/core/test_domain.py | 3 +- tests/core/test_exceptions.py | 3 +- tests/imp/sql_data/test_domain.py | 2 +- tests/lib/config/test_config.py | 2 +- tests/lib/retry/test_retry.py | 7 +-- tests/lib/retry/test_setting_initializers.py | 5 +- tests/lib/tasks/test_common.py | 2 +- tests/lib/tasks/test_concurrent.py | 2 +- tests/lib/tasks/test_sql.py | 2 +- tests/lib/test_app_registry.py | 2 +- .../transports/http/test_api_v1_dialect.py | 2 +- tests/lib/transports/http/test_http.py | 9 ++- .../transports/http/test_http_transport.py | 6 +- tests/test_app.py | 2 +- tests/use_cases/test_fetch_metadata.py | 8 +-- tests/use_cases/test_run_extraction.py | 2 +- tests/use_cases/test_upload_extracts.py | 2 +- 21 files changed, 109 insertions(+), 54 deletions(-) diff --git a/.config.template.yaml b/.config.template.yaml index b8f41ee..cc00642 100644 --- a/.config.template.yaml +++ b/.config.template.yaml @@ -6,7 +6,7 @@ # allows the app to communicate with the remote IDR Server and for data # exchange to happen. # This is a required setting. -DEFAULT_TRANSPORT_FACTORY: "app.lib.transports.http.http_transport_factory" +DEFAULT_TRANSPORT_FACTORY: app.lib.transports.http.http_transport_factory # The logging configuration for the app. This app use Python's built it logging. # https://docs.python.org/3/library/logging.html @@ -25,12 +25,14 @@ LOGGING: format: "%(levelname)s: %(asctime)s %(module)s %(process)d %(thread)d %(message)s" handlers: console: - level: "DEBUG" - class: "logging.StreamHandler" - formatter: "verbose" - root: - level: "INFO" - handlers": [ "console" ] + level: DEBUG + class: logging.StreamHandler + formatter: verbose + loggers: + app: + level: INFO + handlers: + - console # The retry module global configuration. The values here will be used for each # retry instance when not specified. @@ -47,7 +49,7 @@ RETRY: # on that data including what is uploaded to the server. # This setting is not required and defaults to an empty list. #SUPPORTED_DATA_SOURCE_TYPES: -# - "app.imp.sql_data.SQLDataSourceType" +# - app.imp.sql_data.SQLDataSourceType @@ -58,13 +60,13 @@ RETRY: # Configuration specific to the HTTPTransport. # This is only required if HTTPTransport is set as the default Transport. HTTP_TRANSPORT: - default_http_api_dialect_factory: "app.lib.transports.http.idr_server_api_v1_dialect_factory" + default_http_api_dialect_factory: app.lib.transports.http.idr_server_api_v1_dialect_factory connect_timeout: 60 # 1 minutes read_timeout: 60 # 1 minute # A function that when called should return a HTTPAPIDialect. # This is only required if HTTPTransport is set as the default Transport. -DEFAULT_HTTP_API_DIALECT_FACTORY: "app.lib.http.idr_server_api_v1_dialect_factory" +DEFAULT_HTTP_API_DIALECT_FACTORY: app.lib.http.idr_server_api_v1_dialect_factory # The configuration of the backend to this app. This setting is used by HTTP # API dialects. @@ -84,10 +86,10 @@ REMOTE_SERVER: # MySQL databases. # This is only required if the SQLDataSourceType is in use. MYSQL_DB_INSTANCE: - host: "localhost" + host: localhost port: 3306 - username: "idr_client" - password: "securePa$$word" + username: idr_client + password: securePa$$word @@ -100,6 +102,6 @@ MYSQL_DB_INSTANCE: # location data is composed of a unique code for ease of indexing and a # human-readable name. # Both of these settings are required. -ORG_UNIT_CODE: "12345" +ORG_UNIT_CODE: 12345 -ORG_UNIT_NAME: "Test Facility" +ORG_UNIT_NAME: Test Facility diff --git a/app/__init__.py b/app/__init__.py index 79fd0ca..0c1dbf7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -37,7 +37,7 @@ "verbose": { "format": ( "%(levelname)s: %(asctime)s %(module)s " - "%(process)d %(thread)d %(message)s" + "%(process)d %(message)s" ), }, }, @@ -48,7 +48,9 @@ "formatter": "verbose", }, }, - "root": {"level": "INFO", "handlers": ["console"]}, + "loggers": { + "app": {"level": "INFO", "handlers": ["console"]}, + }, }, _SETTINGS_INITIALIZERS_CONFIG_KEY: [], _SUPPORTED_DATA_SOURCE_TYPES_CONFIG_KEY: [], @@ -231,6 +233,11 @@ def setup( """ Set up the application and ready it for use. + This involves configuring the settings and app registry, configuring + logging, loading supported + :class:`data source types ` and initializing the + default :class:`transport `. + :param initial_settings: Optional configuration parameters to override the defaults. :param settings_initializers: @@ -255,6 +262,8 @@ def setup( _settings_dict.get(_SETTINGS_INITIALIZERS_CONFIG_KEY, ()), ), ) + # FIXME: This is hardcoded default behaviour and it's problematic to test + # or mock properly. _initializers.insert(0, _LoggingInitializer()) _initializers.insert(1, _SupportedDataSourceTypesInitializer()) _initializers.insert(2, _DefaultTransportFactoryInitializer()) diff --git a/app/lib/transports/http/__init__.py b/app/lib/transports/http/__init__.py index f144269..4777bd5 100644 --- a/app/lib/transports/http/__init__.py +++ b/app/lib/transports/http/__init__.py @@ -80,5 +80,6 @@ def http_transport_factory() -> HTTPTransport: "HTTPAPIDialect", "HTTPTransport", "IDRServerAPIv1", + "http_transport_factory", "idr_server_api_v1_dialect_factory", ] diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..870674a 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,56 @@ +from unittest import TestCase as _TestCase +from unittest.mock import patch + +from app.lib import AppRegistry, Config + +from .factories import config_factory as _cf + + +class TestCase(_TestCase): + """A project-specific ``TestCase``. + + Extend the default :class:`unittest.TestCase` to provide the following: + + * Configure the execution environment at the class level, once for the + whole ``TestCase``. This is similar to calling :func:`app.setup`. + * Add a class method, :meth:`config_factory` that returns a + :class:`Config ` instance used to configure the + execution environment of this ``TestCase`` instances. + """ + + @classmethod + def config_factory(cls) -> Config: + """Return a ``Config`` instance. + + Return a :class:`Config ` instance used to configure + the execution environment of this ``TestCase`` instances. + + :return: a Config instance. + """ + return Config(settings=_cf()) + + @classmethod + def setUpClass(cls) -> None: + """Set up class fixture before running the tests in the class. + + Extend the default implementation by setting up a patch for the + application settings and registry using the value returned by the + :meth:`config_factory` class method. The patch is accessible through + the class attribute :attr:`config_patch` for interested subclasses. + + .. warning:: + It is highly encouraged that subclasses extending this method call + ``super()`` to avoid weird, unpredictable behaviour during test + runs caused by misconfigured execution environments. + + :return: None. + """ + super().setUpClass() + config_patcher = patch.multiple( + "app", + registry=AppRegistry(), + settings=cls.config_factory(), + ) + config_patcher.start() + cls.config_patcher = config_patcher + cls.addClassCleanup(config_patcher.stop) diff --git a/tests/core/test_domain.py b/tests/core/test_domain.py index 19aea99..e800f5d 100644 --- a/tests/core/test_domain.py +++ b/tests/core/test_domain.py @@ -1,8 +1,7 @@ -from unittest import TestCase - import pytest from app.core import AbstractDomainObject, IdentifiableDomainObject +from tests import TestCase from .factories import ( FakeDataSource, diff --git a/tests/core/test_exceptions.py b/tests/core/test_exceptions.py index 8620be0..0931027 100644 --- a/tests/core/test_exceptions.py +++ b/tests/core/test_exceptions.py @@ -1,6 +1,5 @@ -from unittest import TestCase - from app.core import IDRClientException +from tests import TestCase class TestIDRClientException(TestCase): diff --git a/tests/imp/sql_data/test_domain.py b/tests/imp/sql_data/test_domain.py index d89d58e..57c80cd 100644 --- a/tests/imp/sql_data/test_domain.py +++ b/tests/imp/sql_data/test_domain.py @@ -1,6 +1,5 @@ import os from typing import TYPE_CHECKING, Any -from unittest import TestCase from unittest.mock import patch import pytest @@ -18,6 +17,7 @@ SupportedDBVendors, ) from app.lib import Config, ImproperlyConfiguredError +from tests import TestCase from .factories import ( SQLDataSourceFactory, diff --git a/tests/lib/config/test_config.py b/tests/lib/config/test_config.py index f435981..a68cfd1 100644 --- a/tests/lib/config/test_config.py +++ b/tests/lib/config/test_config.py @@ -1,9 +1,9 @@ from typing import TYPE_CHECKING, Any -from unittest import TestCase import pytest from app.lib import Config, MissingSettingError, SettingInitializer +from tests import TestCase if TYPE_CHECKING: from collections.abc import Mapping, Sequence diff --git a/tests/lib/retry/test_retry.py b/tests/lib/retry/test_retry.py index 67e1106..38eb264 100644 --- a/tests/lib/retry/test_retry.py +++ b/tests/lib/retry/test_retry.py @@ -1,5 +1,4 @@ from datetime import datetime, timedelta -from unittest import TestCase from unittest.mock import MagicMock, patch import pytest @@ -12,6 +11,7 @@ if_exception_type_factory, if_idr_exception, ) +from tests import TestCase def test_if_idr_exception() -> None: @@ -54,10 +54,7 @@ def setUp(self) -> None: self._patch.start() self._instance: Retry = Retry() self._instance.load_config() - - def tearDown(self) -> None: - super().tearDown() - self._patch.stop() + self.addCleanup(self._patch.stop) def test_calculate_deadline_time_return_value_with_no_deadline( self, diff --git a/tests/lib/retry/test_setting_initializers.py b/tests/lib/retry/test_setting_initializers.py index 2442725..c4946d4 100644 --- a/tests/lib/retry/test_setting_initializers.py +++ b/tests/lib/retry/test_setting_initializers.py @@ -1,9 +1,8 @@ -from unittest import TestCase - import pytest from app.lib import ImproperlyConfiguredError, RetryInitializer from app.lib.retry.constants import DEFAULT_RETRY_CONFIG +from tests import TestCase class TestRetryInitializer(TestCase): @@ -41,6 +40,6 @@ def test_execute_when_invalid_config_is_provided(self) -> None: self._instance.execute({setting: "-0.1"}) # type: ignore def test_setting_property_return_value(self) -> None: - """Assert the the ``setting`` property returns the expected value.""" + """Assert the ``setting`` property returns the expected value.""" assert self._instance.setting == "RETRY" diff --git a/tests/lib/tasks/test_common.py b/tests/lib/tasks/test_common.py index c2457d9..c0c2d95 100644 --- a/tests/lib/tasks/test_common.py +++ b/tests/lib/tasks/test_common.py @@ -1,10 +1,10 @@ from typing import TYPE_CHECKING -from unittest import TestCase import pytest from app.core import Task from app.lib import Chainable, Consumer, Pipeline +from tests import TestCase if TYPE_CHECKING: from collections.abc import Callable diff --git a/tests/lib/tasks/test_concurrent.py b/tests/lib/tasks/test_concurrent.py index 3950d36..4125ad2 100644 --- a/tests/lib/tasks/test_concurrent.py +++ b/tests/lib/tasks/test_concurrent.py @@ -1,12 +1,12 @@ from concurrent.futures import Executor, ThreadPoolExecutor, wait from typing import TYPE_CHECKING -from unittest import TestCase import pytest from app.core import Task from app.lib import ConcurrentExecutor, completed_successfully from app.lib.tasks.concurrent import ConcurrentExecutorDisposedError +from tests import TestCase if TYPE_CHECKING: from collections.abc import Sequence diff --git a/tests/lib/tasks/test_sql.py b/tests/lib/tasks/test_sql.py index e4596c8..ef23d58 100644 --- a/tests/lib/tasks/test_sql.py +++ b/tests/lib/tasks/test_sql.py @@ -1,5 +1,4 @@ from typing import TYPE_CHECKING -from unittest import TestCase from unittest.mock import patch import pytest @@ -8,6 +7,7 @@ from app.core import ExtractionOperationError from app.lib import SimpleSQLSelect +from tests import TestCase if TYPE_CHECKING: from pandas import DataFrame diff --git a/tests/lib/test_app_registry.py b/tests/lib/test_app_registry.py index b1e85dd..d6bf5a4 100644 --- a/tests/lib/test_app_registry.py +++ b/tests/lib/test_app_registry.py @@ -1,11 +1,11 @@ from typing import TYPE_CHECKING -from unittest import TestCase import pytest from app.imp.sql_data import SQLDataSourceType from app.lib import AppRegistry, ImproperlyConfiguredError from app.lib.transports.http import http_transport_factory +from tests import TestCase if TYPE_CHECKING: from collections.abc import Mapping diff --git a/tests/lib/transports/http/test_api_v1_dialect.py b/tests/lib/transports/http/test_api_v1_dialect.py index 1ca65e0..98d44f9 100644 --- a/tests/lib/transports/http/test_api_v1_dialect.py +++ b/tests/lib/transports/http/test_api_v1_dialect.py @@ -1,6 +1,5 @@ import json from typing import TYPE_CHECKING, Any -from unittest import TestCase from unittest.mock import patch import pytest @@ -10,6 +9,7 @@ IDRServerAPIv1, idr_server_api_v1_dialect_factory, ) +from tests import TestCase from tests.core.factories import ( FakeDataSourceFactory, FakeDataSourceTypeFactory, diff --git a/tests/lib/transports/http/test_http.py b/tests/lib/transports/http/test_http.py index 1e03ce6..00ab551 100644 --- a/tests/lib/transports/http/test_http.py +++ b/tests/lib/transports/http/test_http.py @@ -1,12 +1,11 @@ from typing import Any -from unittest import TestCase from unittest.mock import patch import pytest -import app from app.lib import ImproperlyConfiguredError from app.lib.transports.http import HTTPTransport, http_transport_factory +from tests import TestCase from tests.factories import config_factory @@ -32,10 +31,10 @@ def test_http_transport_factory_with_valid_settings_works(self) -> None: Assert that a http transport factory returns the expected value with a valid config. """ - app.setup(initial_settings=self._app_config) - transport: HTTPTransport = http_transport_factory() + with patch("app.settings", self._app_config): + transport: HTTPTransport = http_transport_factory() - assert transport is not None + assert transport is not None def test_http_transport_factory_with_invalid_settings_fails(self) -> None: """ diff --git a/tests/lib/transports/http/test_http_transport.py b/tests/lib/transports/http/test_http_transport.py index ac1373b..251def2 100644 --- a/tests/lib/transports/http/test_http_transport.py +++ b/tests/lib/transports/http/test_http_transport.py @@ -1,4 +1,3 @@ -from unittest import TestCase from unittest.mock import patch import pytest @@ -6,6 +5,7 @@ from app.core import TransportClosedError, TransportError from app.lib.transports.http import HTTPAPIDialect, HTTPTransport +from tests import TestCase from tests.core.factories import ( FakeDataSourceFactory, FakeDataSourceTypeFactory, @@ -63,8 +63,8 @@ def test_dispose_returns_cleanly(self) -> None: def test_a_disposed_transport_raises_expected_errors(self) -> None: """ - Assert that a disposed transport raises ``TransportClosedError`` on - attempted usage. + Assert that a disposed Transport instance raises + ``TransportClosedError`` on attempted usage. """ data_source = FakeDataSourceFactory() data_source_type = FakeDataSourceTypeFactory() diff --git a/tests/test_app.py b/tests/test_app.py index 1c6eee5..0c3a86b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,10 +1,10 @@ from typing import Any -from unittest import TestCase import pytest import app from app.lib import ImproperlyConfiguredError, SettingInitializer +from tests import TestCase from tests.factories import config_factory # ============================================================================= diff --git a/tests/use_cases/test_fetch_metadata.py b/tests/use_cases/test_fetch_metadata.py index e443c3b..3cbd69f 100644 --- a/tests/use_cases/test_fetch_metadata.py +++ b/tests/use_cases/test_fetch_metadata.py @@ -1,19 +1,17 @@ from typing import TYPE_CHECKING -from unittest import TestCase -import app from app.use_cases.fetch_metadata import ( DoFetchDataSources, DoFetchExtractMetadata, FetchDataSources, FetchExtractMetadata, ) +from tests import TestCase from tests.core.factories import ( FakeDataSourceFactory, FakeDataSourceTypeFactory, FakeTransportFactory, ) -from tests.factories import config_factory if TYPE_CHECKING: from collections.abc import Sequence @@ -26,7 +24,6 @@ class TestDoFetchDataSources(TestCase): def setUp(self) -> None: super().setUp() - app.setup(initial_settings=config_factory()) self._data_source_type: DataSourceType = FakeDataSourceTypeFactory() self._instance: DoFetchDataSources = DoFetchDataSources( data_source_type=self._data_source_type, @@ -56,7 +53,6 @@ class TestDoFetchExtractMetadata(TestCase): def setUp(self) -> None: super().setUp() - app.setup(initial_settings=config_factory()) self._data_source: DataSource = FakeDataSourceFactory() self._instance: DoFetchExtractMetadata = DoFetchExtractMetadata( data_source=self._data_source, @@ -86,7 +82,6 @@ class TestFetchDataSources(TestCase): def setUp(self) -> None: super().setUp() - app.setup(initial_settings=config_factory()) self._max_data_source_types: int = 5 self._max_data_sources: int = 3 self._data_source_types: Sequence[DataSourceType] @@ -126,7 +121,6 @@ class TestFetchExtractMetadata(TestCase): def setUp(self) -> None: super().setUp() - app.setup(initial_settings=config_factory()) self._max_data_sources: int = 4 self._max_extracts: int = 7 self._data_sources: Sequence[DataSource] diff --git a/tests/use_cases/test_run_extraction.py b/tests/use_cases/test_run_extraction.py index 87bc31f..8d56e06 100644 --- a/tests/use_cases/test_run_extraction.py +++ b/tests/use_cases/test_run_extraction.py @@ -1,11 +1,11 @@ from typing import TYPE_CHECKING -from unittest import TestCase from app.use_cases.run_extraction import ( DoExtract, GroupSiblingExtracts, RunDataSourceExtracts, ) +from tests import TestCase from tests.core.factories import ( FakeDataSourceFactory, FakeExtractMetadataFactory, diff --git a/tests/use_cases/test_upload_extracts.py b/tests/use_cases/test_upload_extracts.py index d590ca0..57d5a83 100644 --- a/tests/use_cases/test_upload_extracts.py +++ b/tests/use_cases/test_upload_extracts.py @@ -1,5 +1,4 @@ from typing import TYPE_CHECKING, Any -from unittest import TestCase from unittest.mock import patch from app.lib import Config @@ -14,6 +13,7 @@ RunExtractionResult, UploadExtractResult, ) +from tests import TestCase from tests.core.factories import ( FakeExtractMetadataFactory, FakeTransportFactory,