Skip to content

Commit

Permalink
Расширить использование dirty_equals (#149)
Browse files Browse the repository at this point in the history
Во время работы над реализацией эндпойнта регистрации (#88), было принято решение использовать библиотеку [freezegun](https://github.com/spulec/freezegun#freezegun-let-your-python-tests-travel-through-time), для поверок данных с динамически генерируемыми датами в тестах. Планировалось в тестах "замораживать" время используя определённую константу, и затем использовать эту константу в проверках.

Но в дальнейшем при попытке реализовать эндпойнты файла (#148), выяснилось, что если в тесте мы замораживаем время, а потом посылаем запрос в minio, то сервер minio отклоняет такой запрос с ошибкой, говорящей, что время запроса слишком сильно отличается от времени установленном на сервере.

Чтобы решить эту проблему, мы можем использовать библиотеку [dirty_equals](https://dirty-equals.helpmanual.io/latest/) вместо freezegun для проверки динамически генерируемых значений даты и времени в тестах.

Так же хотелось бы решить одну небольшую проблему возникающую при использовании dirty_equals - сокрытие примеров значений в ассертах. Например, в таком ассерте визуально не понятно, какое значение (хотя бы примерно) будет в колонке id:
```python
assert response.json() == {
    "id": IsPositiveInt(),
    "name": "John Doe",
}
```

Для этого было принято решение слегка модифицировать классы библиотеки dirty_equals и добавить им возможность указать пример значение через аргумент `like`, т.е.:
```python
assert response.json() == {
    "id": PositiveInt(like=42),
    "name": "John Doe",
}
```

При этом значение указанное в аргументе `like`, так же должно проверятся на соответствие используемому классу, т.е. следующий пример должен давать ошибку:
```python
assert response.json() == {
    "id": PositiveInt(like=-1)
    "name": "John Doe",
}
```

В рамках этой задачи необходимо настроить и использовать dirty_equals для проверок автогенерируемых значений.
  • Loading branch information
birthdaysgift committed Nov 27, 2023
1 parent fabec66 commit 3cec731
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 55 deletions.
17 changes: 1 addition & 16 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ optional = true
[tool.poetry.group.test.dependencies]

dirty-equals = "0.6.0"
freezegun = "1.2.2"
pytest = "7.3.0"
pytest-cov = "4.0.0"
pytest-env = "0.8.1" # needed to set up default $WLSS_ENV for tests execution
Expand Down
19 changes: 11 additions & 8 deletions src/shared/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,25 @@
from __future__ import annotations

from datetime import datetime, timezone
from typing import TYPE_CHECKING


if TYPE_CHECKING:
from typing import Final


DATETIME_FORMAT: Final = "%Y-%m-%dT%H:%M:%S.%fZ"


def utcnow() -> datetime:
"""Get timezone aware datetime with UTC timezone.
There are two reasons of having this function instead of just using `datetime.now(tz=timezone.utc)` everywhere:
1. We're using `freezgun` library which cannot mock datetime in sqlalchemy's column definitions, like:
```python
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
```
because class level attributes are evaluated at compile time. This happens before `freezegun`
has patched datetime.datetime.now, so the column default functions still point to the stdlib implementation.
See this SO answer for details: https://stackoverflow.com/a/58776798/8431075
1. We have to provide a callable to sqlalchemy column definition which should return timezone-aware datetime.
That's why we should wrap datetime.utcnow to another function.
It means that we can not provide just `default=datetime.now` because it is not using UTC timezone.
We also can not use `default=datetime.utcnow` because it returns timezone-naive datetime object.
2. Simply `utcnow()` is shorter than `datetime.now(tz=timezone.utc)`
Expand Down
4 changes: 3 additions & 1 deletion src/shared/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@

from pydantic import BaseModel

from src.shared.datetime import DATETIME_FORMAT


class Schema(BaseModel):
"""Customized 'BaseModel' class from pydantic."""

class Config:
"""Pydantic's special class to configure pydantic models."""

json_encoders = {datetime: lambda v: v.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
json_encoders = {datetime: lambda v: v.strftime(DATETIME_FORMAT)}


class HTTPError(Schema):
Expand Down
54 changes: 25 additions & 29 deletions tests/test_auth/test_create_account.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,47 @@
from __future__ import annotations

from datetime import datetime, timezone
from unittest.mock import patch

import bcrypt
import dirty_equals
import pytest
from freezegun import freeze_time
from sqlalchemy import select

from src.account.models import Account, PasswordHash
from src.profile.models import Profile
from src.shared.database import Base
from src.shared.datetime import utcnow
from tests.utils.dirty_equals import PositiveInt, UtcDatetime, UtcDatetimeStr
from tests.utils.mocks.models import __eq__


@pytest.mark.anyio
@pytest.mark.fixtures({"client": "client", "db": "db_empty"})
async def test_create_account_returns_201_with_correct_body(f):
with freeze_time("2023-10-30T20:00:00.000000"):

result = await f.client.post(
"/accounts",
json={
"account": {
"email": "john.doe@mail.com",
"login": "john_doe",
"password": "qwerty123",
},
"profile": {
"name": "John Doe",
"description": "I'm the best guy for your mocks.",
},
result = await f.client.post(
"/accounts",
json={
"account": {
"email": "john.doe@mail.com",
"login": "john_doe",
"password": "qwerty123",
},
)
"profile": {
"name": "John Doe",
"description": "I'm the best guy for your mocks.",
},
},
)

assert result.status_code == 201
assert result.json() == {
"account": {
"id": dirty_equals.IsPositiveInt,
"created_at": "2023-10-30T20:00:00.000000Z",
"id": PositiveInt(like=42),
"created_at": UtcDatetimeStr(like="2023-06-17T11:47:02.823Z"),
"email": "john.doe@mail.com",
"login": "john_doe",
},
"profile": {
"account_id": dirty_equals.IsPositiveInt,
"account_id": PositiveInt(like=42),
"avatar_id": None,
"description": "I'm the best guy for your mocks.",
"name": "John Doe",
Expand All @@ -56,7 +53,6 @@ async def test_create_account_returns_201_with_correct_body(f):
@pytest.mark.fixtures({"client": "client", "db": "db_empty"})
async def test_create_account_creates_objects_in_db_correctly(f):
with (
freeze_time("2023-10-30T20:00:00.000000"),
patch.object(bcrypt, "hashpw", lambda *_: b"password-hash"),
):

Expand All @@ -79,28 +75,28 @@ async def test_create_account_creates_objects_in_db_correctly(f):
accounts = (await f.db.execute(select(Account))).scalars().all()
assert accounts == [
Account(
created_at=datetime(2023, 10, 30, 20, 0, tzinfo=timezone.utc),
created_at=UtcDatetime(like=utcnow()),
email="john.doe@mail.com",
id=dirty_equals.IsPositiveInt,
id=PositiveInt(like=42),
login="john_doe",
updated_at=datetime(2023, 10, 30, 20, 0, tzinfo=timezone.utc),
updated_at=UtcDatetime(like=utcnow()),
),
]
profiles = (await f.db.execute(select(Profile))).scalars().all()
assert profiles == [
Profile(
account_id=dirty_equals.IsPositiveInt,
account_id=PositiveInt(like=42),
avatar_id=None,
description="I'm the best guy for your mocks.",
name="John Doe",
updated_at=datetime(2023, 10, 30, 20, 0, tzinfo=timezone.utc),
updated_at=UtcDatetime(like=utcnow()),
),
]
password_hashes = (await f.db.execute(select(PasswordHash))).scalars().all()
assert password_hashes == [
PasswordHash(
account_id=dirty_equals.IsPositiveInt,
updated_at=datetime(2023, 10, 30, 20, 0, tzinfo=timezone.utc),
account_id=PositiveInt(like=42),
updated_at=UtcDatetime(like=utcnow()),
value=b"password-hash",
),
]
Expand Down
106 changes: 106 additions & 0 deletions tests/utils/dirty_equals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Customized dirty_equals classes useful for testing.
Most of them have rigid structure suitable for our application only.
Since they serve only for testing convenience we didn't want
to make them flexible and extensible. We just "hard-coded" all
things we wanted them to check in tests for our application.
"""

from __future__ import annotations

from datetime import timezone
from typing import TYPE_CHECKING

import dirty_equals

from src.shared.datetime import DATETIME_FORMAT


if TYPE_CHECKING:
from datetime import datetime
from typing import Any, Self


class PositiveInt(dirty_equals.IsPositiveInt):
"""Customized dirty_equals.IsPositive."""

def __init__(self: Self, *, like: int | None = None) -> None:
"""Initialize object.
:param like: example value (required)
:raises AssertionError: `like` value is not correct
"""
assert like is not None, "Provide correct example value in `like` argument."

self._like = like
super().__init__()

try:
like_equals = self.equals(self._like)

except TypeError:
msg = f"`like` argument has type {type(self._like)}, but expected {self.allowed_types}."
raise AssertionError(msg) from None

assert like_equals, "Example value from `like` argument is not correct."


class UtcDatetime(dirty_equals.IsDatetime):
"""Customized dirty_equals.IsDatetime to change timezone-aware datetime objects with UTC timezone."""

def __init__(self: Self, *, like: datetime | None = None) -> None:
"""Initialize object.
:param like: example value (required)
:raises AssertionError: `like` value is not correct
"""
assert like is not None, "Provide correct example value in `like` argument."

self._like = like
super().__init__(iso_string=False)

try:
like_equals = self.equals(self._like)

except TypeError:
msg = f"`like` argument has type {type(self._like)}, but expected {self.allowed_types}."
raise AssertionError(msg) from None

assert like_equals, "Example value from `like` argument is not correct."

def equals(self: Self, other: Any) -> bool: # noqa: ANN401
"""Return True if `self` "dirty equals" to `other` or False otherwise."""
return super().equals(other) and other.tzinfo == timezone.utc


class UtcDatetimeStr(dirty_equals.IsDatetime):
"""Customized dirty_equals.IsDatetime to check timezone-aware ISO formatted datetime strings with UTC timezone."""

expected_format = DATETIME_FORMAT

def __init__(self: Self, *, like: str | None = None) -> None:
"""Initialize object.
:param like: example value (required)
:raises AssertionError: `like` value is not correct
"""
assert like is not None, "Provide correct example value in `like` argument."

self._like = like
super().__init__(format_string=self.expected_format)

try:
like_equals = self.equals(self._like)

except TypeError:
msg = f"`like` argument has type {type(self._like)}, but expected {self.allowed_types}."
raise AssertionError(msg) from None

except ValueError:
msg = f"`like` argument '{self._like}' does not match expected format '{self.expected_format}'."
raise AssertionError(msg) from None

assert like_equals, "Example value from `like` argument is not correct."

0 comments on commit 3cec731

Please sign in to comment.