Skip to content
This repository has been archived by the owner on Sep 12, 2023. It is now read-only.

Commit

Permalink
feat(testing): pytest plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
peterschutt committed Jan 15, 2023
1 parent a70287c commit 80e3da1
Show file tree
Hide file tree
Showing 18 changed files with 361 additions and 182 deletions.
1 change: 1 addition & 0 deletions .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type-checking-exempt-modules = from sqlalchemy.orm
per-file-ignores =
examples/dto/*:T201,TC
examples/tests/*:SCS108,PT013
src/pytest_starlite_saqlalchemy/__init__.py:F401
src/starlite_saqlalchemy/dependencies.py:TC
src/starlite_saqlalchemy/health.py:TC
src/starlite_saqlalchemy/repository/filters.py:TC
Expand Down
152 changes: 152 additions & 0 deletions docs/testing/pytest_plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Pytest Plugin

The nature of applications built with the `starlite-saqlalchemy` pattern is that they rely heavily
on connected services.

Abstraction of [PostgreSQL][2] and [Redis][3] connectivity boilerplate is a nice convenience,
however to successfully patch the application for testing requires deeper knowledge of the
implementation than would be otherwise necessary.

So, `starlite-saqlalchemy` ships with a selection of [pytest fixtures][1] that are often necessary
when building applications such as these.

## `app`

The `app` fixture provides an instance of a `Starlite` application.

```python
from __future__ import annotations

from starlite import Starlite


def test_app_fixture(app: Starlite) -> None:
assert isinstance(app, Starlite)
```

The value of Pytest ini option, `test_app` is used to determine the application to load.

```toml
# pyproject.toml

[tool.pytest.ini_options]
test_app = "app.main:create_app"
```

If no value is configured for the `test_app` ini option, the default location of
`"app.main:create_app"` is searched.

The value of the `test_app` ini option can either point to an application factory or `Starlite`
instance.

If the object found at the import path is not a `Starlite` instance, the fixture assumes it is
an application factory, and will call the object and return the response.

The value of `test_app` is resolved using the uvicorn `import_from_string()` function, so it
supports the same format as `uvicorn` supports for its `app` and `factory` parameters.

## `client`

A `starlite.testing.TestClient` instance, wired to the same application that is produced by the
`app` fixture.

## `cap_logger`

The `cap_logger` fixture provides an instance of [`structlog.testing.CapturingLogger`][4].

```python
from __future__ import annotations

from typing import TYPE_CHECKING

from structlog.testing import CapturedCall

if TYPE_CHECKING:
from structlog.testing import CapturingLogger


def test_app_fixture(cap_logger: CapturingLogger) -> None:
cap_logger.info("hello")
cap_logger.info("hello", when="again")
assert cap_logger.calls == [
CapturedCall(method_name="info", args=("hello",), kwargs={}),
CapturedCall(method_name="info", args=("hello",), kwargs={"when": "again"}),
]
```

The `cap_logger` fixture will capture any `structlog` calls made by the starlite application or the
SAQ worker, so that they can be inspected as part of tests.

```python
from __future__ import annotations

from typing import TYPE_CHECKING

from httpx import AsyncClient

if TYPE_CHECKING:
from starlite import Starlite
from structlog.testing import CapturingLogger


async def test_health_logging_skipped(
app: Starlite, cap_logger: CapturingLogger
) -> None:
"""Test that calls to the health check route are not logged."""

async with AsyncClient(app=app, base_url="http://testserver") as client:
response = await client.get("/health")
assert response.status_code == 200

assert [] == cap_logger.calls
```

## is_unit_test

The `is_unit_test` fixture returns a `bool` that indicates if the test suite believes it is running
a unit test, or an integration test.

To determine this, we compare the path of the running test to the value of the Pytest ini option
`unit_test_pattern`, which by default is `"^.*/tests/unit/.*$"`.

This fixture is used to make fixtures behave differently between unit and integration test contexts.

## _patch_http_close

This is an [`autouse` fixture][5], that prevents HTTP clients that are defined in the global scope
from being closed.

The application is configured to close all instantiated HTTP clients on app shutdown, however when
apps are defined in a global/class scope, a test that runs after the first application shutdown in
the test suite would fail.

## _patch_sqlalchemy_plugin

This is an [`autouse` fixture][5], that mocks out the `on_shutdown` method of the SQLAlchemy config
object for unit tests.

## _patch_worker

This is an [`autouse` fixture][5], that mocks out the `on_app_startup` and `stop` methods of
`worker.Worker` type for unit tests.

- Test data fixtures, with automated discovery
- NOTE: base this on the name of files in a `data` directory in the tests file.
- binary_<domain_type>, raw_<domain_type>, <domain_type>, <domain_type>_repository_type,
<domain_type>_repository named fixtures, where <domain_type> is
- mocking of necessary elements of the SQLAlchemy integration depending on unit/integration test
use case.
- patching the SAQ worker where a running instance of reddit is not available.
- removal of service readiness checks when unit testing
- event loop and docker service fixtures for integration testing
- database seeding for integration tests
- plugin to make calls to async worker callbacks blocking
- a client for integration tests (that doesn't create its own event loop)

inferred from the name of the file in `./tests/data`.

[1]: https://docs.pytest.org/en/latest/explanation/fixtures.html#about-fixtures
[2]: https://www.postgresql.org/
[3]: https://redis.io
[4]: https://www.structlog.org/en/stable/api.html#structlog.testing.CapturingLogger
[5]: https://docs.pytest.org/en/6.2.x/fixture.html#autouse-fixtures-fixtures-you-don-t-have-to-request
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ classifiers = [
"Topic :: Internet :: WWW/HTTP",
"Topic :: Software Development",
"Topic :: System :: Installation/Setup",
"Framework :: Pytest",
]
packages = [
{ include = "starlite_saqlalchemy", from = "src" }
{ include = "starlite_saqlalchemy", from = "src" },
{ include = "pytest_starlite_saqlalchemy", from = "src" }
]

[tool.poetry.dependencies]
Expand All @@ -71,6 +73,9 @@ tenacity = "*"
uvicorn = "*"
uvloop = "*"

[tool.poetry.plugins."pytest11"]
pytest_starlite_saqlalchemy = "pytest_starlite_saqlalchemy"

[tool.poetry.urls]
GitHub = "https://github.com/topsport-com-au"
Bugs = "https://github.com/topsport-com-au/starlite-saqlalchemy/issues"
Expand All @@ -88,6 +93,7 @@ addopts = ["-ra", "--strict-config"]
asyncio_mode = "auto"
env_files = ["tests.env"]
testpaths = ["tests/unit"]
test_app = "tests.utils.app:create_app"

[tool.pylint.main]
disable = [
Expand Down
13 changes: 13 additions & 0 deletions src/pytest_starlite_saqlalchemy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Pytest plugin to support testing starlite-saqlalchemy applications."""
from __future__ import annotations

from .plugin import (
_patch_http_close,
_patch_sqlalchemy_plugin,
_patch_worker,
cap_logger,
fx_app,
fx_client,
is_unit_test,
pytest_addoption,
)
141 changes: 141 additions & 0 deletions src/pytest_starlite_saqlalchemy/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Pytest plugin to support testing starlite-saqlalchemy applications."""
# pylint: disable=import-outside-toplevel
from __future__ import annotations

import re
from typing import TYPE_CHECKING
from unittest.mock import MagicMock

import pytest
from starlite import Starlite, TestClient
from structlog.contextvars import clear_contextvars
from structlog.testing import CapturingLogger
from uvicorn.importer import ImportFromStringError, import_from_string

if TYPE_CHECKING:
from collections.abc import Generator

from pytest import Config, FixtureRequest, MonkeyPatch, Parser


__all__ = (
"cap_logger",
"fx_app",
"fx_client",
"pytest_addoption",
"is_unit_test",
"_patch_http_close",
"_patch_sqlalchemy_plugin",
"_patch_worker",
)


def pytest_addoption(parser: Parser) -> None:
"""Adds Pytest ini config variables for the plugin."""
parser.addini(
"test_app",
"Path to application instance, or callable that returns an application instance.",
type="string",
default="app.main:create_app",
)
parser.addini(
"unit_test_pattern",
(
"Regex used to identify if a test is running as part of a unit or integration test "
"suite. The pattern is matched against the path of each test function and affects the "
"behavior of fixtures that are shared between unit and integration tests."
),
type="string",
default=r"^.*/tests/unit/.*$",
)


@pytest.fixture()
def is_unit_test(request: FixtureRequest) -> bool:
"""Uses the ini option `unit_test_pattern` to determine if the test is part
of unit or integration tests."""
unittest_pattern: str = request.config.getini("unit_test_pattern") # pyright:ignore
return bool(re.search(unittest_pattern, str(request.path)))


@pytest.fixture(autouse=True)
def _patch_http_close(monkeypatch: MonkeyPatch) -> None:
"""We don't want global http clients to get closed between tests."""
import starlite_saqlalchemy

monkeypatch.setattr(starlite_saqlalchemy.http, "clients", set())


@pytest.fixture(autouse=True)
def _patch_sqlalchemy_plugin(is_unit_test, monkeypatch: MonkeyPatch) -> None:
if is_unit_test:
from starlite_saqlalchemy import sqlalchemy_plugin

monkeypatch.setattr(
sqlalchemy_plugin.SQLAlchemyConfig, # type:ignore[attr-defined]
"on_shutdown",
MagicMock(),
)


@pytest.fixture(autouse=True)
def _patch_worker(is_unit_test, monkeypatch: MonkeyPatch) -> None:
"""We don't want the worker to start for unittests."""
if is_unit_test:
from starlite_saqlalchemy import worker

monkeypatch.setattr(worker.Worker, "on_app_startup", MagicMock())
monkeypatch.setattr(worker.Worker, "stop", MagicMock())


@pytest.fixture(name="app")
def fx_app(pytestconfig: Config, monkeypatch: MonkeyPatch) -> Starlite:
"""
Returns:
An application instance, configured via plugin.
"""
test_app_str = pytestconfig.getini("test_app")

try:
app_or_callable = import_from_string(test_app_str)
except (ImportFromStringError, ModuleNotFoundError):
from starlite_saqlalchemy.init_plugin import ConfigureApp

app = Starlite(route_handlers=[], on_app_init=[ConfigureApp()], openapi_config=None)
else:
if isinstance(app_or_callable, Starlite):
app = app_or_callable
else:
app = app_or_callable()

monkeypatch.setattr(app, "before_startup", [])

return app


@pytest.fixture(name="client")
def fx_client(app: Starlite) -> Generator[TestClient, None, None]:
"""Test client fixture for making calls on the global app instance."""
with TestClient(app=app) as client:
yield client


@pytest.fixture()
def cap_logger(monkeypatch: MonkeyPatch) -> CapturingLogger:
"""Used to monkeypatch the app logger, so we can inspect output."""
import starlite_saqlalchemy

starlite_saqlalchemy.log.configure(
starlite_saqlalchemy.log.default_processors # type:ignore[arg-type]
)
# clear context for every test
clear_contextvars()
# pylint: disable=protected-access
logger = starlite_saqlalchemy.log.controller.LOGGER.bind()
logger._logger = CapturingLogger()
# drop rendering processor to get a dict, not bytes
# noinspection PyProtectedMember
logger._processors = starlite_saqlalchemy.log.default_processors[:-1]
monkeypatch.setattr(starlite_saqlalchemy.log.controller, "LOGGER", logger)
monkeypatch.setattr(starlite_saqlalchemy.log.worker, "LOGGER", logger)
return logger._logger
2 changes: 2 additions & 0 deletions src/starlite_saqlalchemy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def example_handler() -> dict:
dto,
exceptions,
health,
http,
log,
openapi,
redis,
Expand All @@ -51,6 +52,7 @@ def example_handler() -> dict:
"dto",
"exceptions",
"health",
"http",
"log",
"openapi",
"redis",
Expand Down
1 change: 0 additions & 1 deletion src/starlite_saqlalchemy/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ class Config:
REQUEST_FIELDS: list[RequestExtractorField] = [
"path",
"method",
"content_type",
"headers",
"cookies",
"query",
Expand Down
3 changes: 2 additions & 1 deletion src/starlite_saqlalchemy/sqlalchemy_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from starlite.datastructures.state import State
from starlite.types import Message, Scope

__all__ = ["config", "plugin"]
__all__ = ["SQLAlchemyHealthCheck", "config", "plugin"]


async def before_send_handler(message: "Message", _: "State", scope: "Scope") -> None:
Expand Down Expand Up @@ -49,6 +49,7 @@ class SQLAlchemyHealthCheck(AbstractHealthCheck):
name: str = "db"

def __init__(self) -> None:
"""Health check with database check."""
self.engine = create_async_engine(
settings.db.URL, logging_name="starlite_saqlalchemy.health"
)
Expand Down
1 change: 0 additions & 1 deletion src/starlite_saqlalchemy/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,4 @@
"ControllerTest",
"GenericMockRepository",
"modify_settings",
"pytest_plugin",
)
Loading

0 comments on commit 80e3da1

Please sign in to comment.