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 26, 2023
1 parent fabec66 commit f69de6b
Show file tree
Hide file tree
Showing 6 changed files with 115 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 `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
75 changes: 75 additions & 0 deletions tests/utils/dirty_equals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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):

def __init__(self: Self, *, like: int | None = None) -> None:
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):

def __init__(self: Self, *, like: datetime | None = None) -> None:
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 super().equals(other) and other.tzinfo == timezone.utc


class UtcDatetimeStr(dirty_equals.IsDatetime):
expected_format = DATETIME_FORMAT

def __init__(self: Self, *, like: str | None = None) -> None:
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 f69de6b

Please sign in to comment.