Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tests: change how pytest adds markers #246

Merged
merged 11 commits into from
Sep 4, 2024
4 changes: 2 additions & 2 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ jobs:

- name: BDD Integration tests
if: ${{ false }} # disable for now
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml run api behave
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run api behave

- name: Pytest Integration tests
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml run --rm api pytest --integration
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm api pytest --integration

test-web:
runs-on: ubuntu-latest
Expand Down
15 changes: 8 additions & 7 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ repos:
pass_filenames: false

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v4.6.0
hooks:
- id: check-ast
language_version: python3.10
Expand All @@ -20,9 +20,9 @@ repos:
- id: check-toml
- id: check-yaml
- id: trailing-whitespace
exclude: ^.*\.(lock)$
exclude: ^web/src/api/generated/|^.*\.(lock)$
- id: end-of-file-fixer
exclude: ^.*\.(lock)$
exclude: ^web/src/api/generated/|^.*\.(lock)$
- id: mixed-line-ending
exclude: ^.*\.(lock)$
- id: detect-private-key
Expand All @@ -32,13 +32,13 @@ repos:
stages: [commit-msg]

- repo: https://github.com/compilerla/conventional-pre-commit
rev: v2.4.0
rev: v3.4.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.4.7"
rev: "v0.6.3"
hooks:
- id: ruff
name: Python lint
Expand All @@ -50,10 +50,10 @@ repos:
files: ^api/.*\.py$

- repo: https://github.com/biomejs/pre-commit
rev: v0.2.0
rev: v0.4.0
hooks:
- id: biome-check
additional_dependencies: [ "@biomejs/[email protected].0" ]
additional_dependencies: [ "@biomejs/[email protected].3" ]
args: ["--config-path", "web"]


Expand Down Expand Up @@ -92,6 +92,7 @@ repos:
name: codespell
description: Checks for common misspellings in text files.
entry: codespell --toml=api/pyproject.toml
exclude: documentation/docs/changelog/changelog.md
language: python
types: [text]
additional_dependencies:
Expand Down
10 changes: 8 additions & 2 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,13 @@ ignore = [
skip = "*.lock,*.cjs"
ignore-words-list = "ignored-word"

[tool.pytest]
markers =[
[tool.pytest.ini_options]
# Makes pytest CLI discover markers and conftest settings:
markers = [
"unit: mark a test as unit test.",
"integration: mark a test as integration test."
]
testpaths = [
"src/tests/unit",
"src/tests/integration"
]
2 changes: 1 addition & 1 deletion api/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def run() -> None:
port=5000,
factory=True,
reload=config.ENVIRONMENT == "local",
log_level=config.LOGGER_LEVEL.lower(),
log_level=config.log_level,
)


Expand Down
13 changes: 0 additions & 13 deletions api/src/authentication/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,6 @@ def check_privilege(self, required_level: "AccessLevel") -> bool:
return True
return False

@classmethod
def __get_validators__(cls): # type:ignore
yield cls.validate

@classmethod
def validate(cls, v: str) -> "AccessLevel":
if isinstance(v, cls):
return v
try:
return cls[v]
except KeyError:
raise ValueError("invalid AccessLevel enum value ")

@classmethod
def __get_pydantic_json_schema__(
cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
Expand Down
2 changes: 1 addition & 1 deletion api/src/common/exception_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def add_exception_handlers(app: FastAPI) -> None:

# Override built-in default handler
app.add_exception_handler(RequestValidationError, validation_exception_handler) # type: ignore
app.add_exception_handler(HTTPStatusError, http_exception_handler) # type: ignore
app.add_exception_handler(HTTPStatusError, http_exception_handler)

# Fallback exception handler for all unexpected exceptions
app.add_exception_handler(Exception, fall_back_exception_handler)
Expand Down
2 changes: 1 addition & 1 deletion api/src/common/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def __init__(
self.extra = extra
self.severity = severity

def dict(self) -> dict[str, int | str | dict[str, Any] | None]:
def to_dict(self) -> dict[str, int | str | dict[str, Any] | None]:
return {
"status": self.status,
"type": self.type,
Expand Down
4 changes: 2 additions & 2 deletions api/src/common/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
uvicorn_logger = logging.getLogger("uvicorn")

logger = logging.getLogger("API")
logger.setLevel(config.LOGGER_LEVEL.upper())
logger.setLevel(config.log_level.upper())
formatter = logging.Formatter("%(levelname)s:%(asctime)s %(message)s")
channel = logging.StreamHandler()
channel.setFormatter(formatter)
channel.setLevel(config.LOGGER_LEVEL.upper())
channel.setLevel(config.log_level.upper())
logger.addHandler(channel)
12 changes: 12 additions & 0 deletions api/src/common/logger_level.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from enum import Enum


class LoggerLevel(Enum):
"""Enum containing the different levels for logging."""

CRITICAL = "critical"
ERROR = "error"
WARNING = "warning"
INFO = "info"
DEBUG = "debug"
TRACE = "trace"
15 changes: 9 additions & 6 deletions api/src/common/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,22 @@
)

responses: dict[int | str, dict[str, Any]] = {
400: {"model": ErrorResponse, "content": {"application/json": {"example": BadRequestException().dict()}}},
400: {"model": ErrorResponse, "content": {"application/json": {"example": BadRequestException().to_dict()}}},
401: {
"model": ErrorResponse,
"content": {
"application/json": {
"example": ErrorResponse(
status=401, type="UnauthorizedException", message="Token validation failed"
).dict()
).model_dump()
}
},
},
403: {"model": ErrorResponse, "content": {"application/json": {"example": MissingPrivilegeException().dict()}}},
404: {"model": ErrorResponse, "content": {"application/json": {"example": NotFoundException().dict()}}},
422: {"model": ErrorResponse, "content": {"application/json": {"example": ValidationException().dict()}}},
500: {"model": ErrorResponse, "content": {"application/json": {"example": ApplicationException().dict()}}},
403: {
"model": ErrorResponse,
"content": {"application/json": {"example": MissingPrivilegeException().to_dict()}},
},
404: {"model": ErrorResponse, "content": {"application/json": {"example": NotFoundException().to_dict()}}},
422: {"model": ErrorResponse, "content": {"application/json": {"example": ValidationException().to_dict()}}},
500: {"model": ErrorResponse, "content": {"application/json": {"example": ApplicationException().to_dict()}}},
}
8 changes: 7 additions & 1 deletion api/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
from pydantic_settings import BaseSettings

from authentication.models import User
from common.logger_level import LoggerLevel


class Config(BaseSettings):
# Pydantic-settings in pydantic v2 automatically fetch config settings from env-variables
ENVIRONMENT: str = "local"

# Logging
LOGGER_LEVEL: str = Field("INFO", validation_alias="LOGGING_LEVEL", to_lower=True)
LOGGER_LEVEL: LoggerLevel = Field(default=LoggerLevel.INFO)
APPINSIGHTS_CONSTRING: str | None = None

# Database
Expand All @@ -36,6 +37,11 @@ class Config(BaseSettings):
OAUTH_AUDIENCE: str = ""
MICROSOFT_AUTH_PROVIDER: str = "login.microsoftonline.com"

@property
def log_level(self) -> str:
"""Returns LOGGER_LEVEL as a (lower case) string."""
return str(self.LOGGER_LEVEL.value).lower()


config = Config()

Expand Down
16 changes: 13 additions & 3 deletions api/src/features/todo/use_cases/add_todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,23 @@ class AddTodoRequest(BaseModel):
title="The title of the item",
max_length=300,
min_length=1,
example="Read about clean architecture",
json_schema_extra={
"examples": ["Read about clean architecture"],
},
)


class AddTodoResponse(BaseModel):
id: str = Field(example="vytxeTZskVKR7C7WgdSP3d")
title: str = Field(example="Read about clean architecture")
id: str = Field(
json_schema_extra={
"examples": ["vytxeTZskVKR7C7WgdSP3d"],
}
)
title: str = Field(
json_schema_extra={
"examples": ["Read about clean architecture"],
}
)
is_completed: bool = False

@staticmethod
Expand Down
53 changes: 44 additions & 9 deletions api/src/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from app import create_app
from authentication.authentication import auth_with_jwt
from config import config
from config import config as project_config
from data_providers.clients.mongodb.mongo_database_client import MongoDatabaseClient
from features.todo.repository.todo_repository import TodoRepository, get_todo_repository
from tests.integration.mock_authentication import mock_auth_with_jwt
Expand All @@ -28,7 +28,7 @@ def test_client():

@pytest.fixture(autouse=True)
def disable_auth():
config.AUTH_ENABLED = False
project_config.AUTH_ENABLED = False
os.environ["AUTH_ENABLED"] = "False"


Expand All @@ -50,8 +50,8 @@ def mock_request(
method: str = "GET",
server: str = "www.example.com",
path: str = "/",
headers: dict = None,
body: str = None,
headers: dict | None = None,
body: bytes | None = None,
) -> Request:
if headers is None:
headers = {}
Expand All @@ -76,10 +76,45 @@ async def request_body():
return request


def pytest_addoption(parser):
parser.addoption("--integration", action="store_true", help="run integration tests")
def pytest_configure(config: pytest.Config):
"""Add markers to be recognised by pytest."""
has_unit_option = config.getoption("unit", default=False)
has_integration_option = config.getoption("integration", default=False)
marker_expr = config.getoption("markexpr", default="")
if marker_expr != "" and (has_integration_option or has_unit_option):
pytest.exit("Invalid options: Cannot use --markexpr with --unit or --integration options", 4)


def pytest_runtest_setup(item):
if "integration" in item.keywords and not item.config.getoption("integration"):
pytest.skip("need --integration option to run")
def pytest_addoption(parser):
"""Add option to pytest parser for running unit/integration tests."""
parser.addoption("--unit", action="store_true", default=False, help="run unit tests")
parser.addoption("--integration", action="store_true", default=False, help="run integration tests")


def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]):
"""Add markers to tests based on folder structure."""
unit_tests_directory = config.rootpath / "src/tests/unit"
integration_tests_directory = config.rootpath / "src/tests/integration"
for item in items:
if item.path.is_relative_to(unit_tests_directory):
item.add_marker("unit")
if item.path.is_relative_to(integration_tests_directory):
item.add_marker("integration")


def pytest_runtest_setup(item: pytest.Item):
"""Skip tests based on options provided."""
has_unit_option = item.config.getoption("unit", default=False)
has_integration_option = item.config.getoption("integration", default=False)
match (has_unit_option, has_integration_option):
case (False, True):
# skip unit tests
if "unit" in item.keywords:
pytest.skip("unit tests are skipped when explicitly running integration tests")
case (True, False):
# skip integration tests
if "integration" in item.keywords:
pytest.skip("integration tests are skipped when explicitly running unit tests")
case _:
# run all tests
return
3 changes: 0 additions & 3 deletions api/src/tests/integration/common/test_exception_handler.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import pytest
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY
from starlette.testclient import TestClient

pytestmark = pytest.mark.integration


def test_exception_handler_validation_error(test_app: TestClient):
response = test_app.post("/todos", json={"title": 1})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import pytest
from starlette.status import HTTP_200_OK
from starlette.testclient import TestClient

pytestmark = pytest.mark.integration


class TestTodo:
def test_get(self, test_app: TestClient):
Expand Down
2 changes: 0 additions & 2 deletions api/src/tests/integration/features/todo/test_todo_feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@

from data_providers.clients.client_interface import ClientInterface

pytestmark = pytest.mark.integration


class TestTodo:
@pytest.fixture(autouse=True)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import pytest
from starlette.status import HTTP_200_OK
from starlette.testclient import TestClient

from authentication.models import User
from config import config
from tests.integration.mock_authentication import get_mock_jwt_token

pytestmark = pytest.mark.integration


class TestWhoami:
def test_whoami(self, test_app: TestClient):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import pytest
from pydantic.error_wrappers import ValidationError
from pydantic import ValidationError

from features.todo.repository.todo_repository_interface import TodoRepositoryInterface
from features.todo.use_cases.add_todo import AddTodoRequest, add_todo_use_case
Expand Down
2 changes: 1 addition & 1 deletion documentation/docs/about/running/03-starting-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
You can start running:

```shell
docker-compose up
docker compose up
```

The web app will be served at [http://localhost](http://localhost)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ git checkout -b feature/my-new-feature
5. Make the changes in the created branch.
6. Add and run tests for your changes (we only take pull requests with passing tests).
```shell
docker-compose run --rm api pytest
docker-compose run --rm web yarn test
docker compose run --rm api pytest
docker compose run --rm web yarn test
```
7. Add the changed files
```shell
Expand Down
Loading
Loading