From a0310ea3b1c6d1231136028f79a10543d8ff5f75 Mon Sep 17 00:00:00 2001 From: Vladyslav Fedoriuk Date: Sat, 29 Jul 2023 00:17:38 +0200 Subject: [PATCH] Set up tests --- .github/workflows/app.yaml | 2 +- .pre-commit-config.yaml | 1 + app/conftest.py | 60 +++++++++++++++++++ app/database.py | 9 ++- app/factories.py | 12 ++++ app/models.py | 19 ++++++ app/tests/test_database.py | 22 +++++++ ...dd_repo_dependency_and_repodependency_.py} | 7 ++- pyproject.toml | 32 +++++----- requirements/test.txt | 17 ++++++ 10 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 app/factories.py create mode 100644 app/models.py create mode 100644 app/tests/test_database.py rename migrations/versions/{7ee6dc0ae743_add_repo_dependency_and_repodependency_.py => d8fc955c639b_add_repo_dependency_and_repodependency_.py} (91%) diff --git a/.github/workflows/app.yaml b/.github/workflows/app.yaml index fe84b6b..420ed80 100644 --- a/.github/workflows/app.yaml +++ b/.github/workflows/app.yaml @@ -32,4 +32,4 @@ jobs: python -m pyupgrade --py311-plus - name: Lint with pyproject-fmt run: | - python -m pyproject_fmt --stdout --check + python -m pyproject_fmt --stdout --check --indent=4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 302e627..7f4b73b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,6 +37,7 @@ repos: rev: "0.11.1" hooks: - id: pyproject-fmt + args: ["--indent=4"] - repo: https://github.com/jorisroovers/gitlint rev: 'v0.19.1' hooks: diff --git a/app/conftest.py b/app/conftest.py index b575a77..379aaf4 100644 --- a/app/conftest.py +++ b/app/conftest.py @@ -1 +1,61 @@ """The application-level conftest.""" +import asyncio +from collections.abc import AsyncGenerator +from typing import Literal + +import pytest +from pytest_mock import MockerFixture +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import async_session_maker + + +@pytest.fixture +def anyio_backend() -> Literal["asyncio"]: + """Use asyncio as the async backend.""" + return "asyncio" + + +@pytest.fixture(autouse=True) +def test_db(mocker: MockerFixture) -> None: + """Use the in-memory database for tests.""" + mocker.patch("app.database.DB_PATH", "sqlite+aiosqlite:///") + + +@pytest.fixture(scope="session") +def event_loop( + request: pytest.FixtureRequest, +) -> AsyncGenerator[asyncio.AbstractEventLoop, None]: + """ + Create an instance of the default event loop for a session. + + An event loop is destroyed at the end of the test session. + https://docs.pytest.org/en/6.2.x/fixture.html#fixture-scopes + """ + loop = asyncio.get_event_loop_policy().get_event_loop() + try: + yield loop + finally: + loop.close() + + +@pytest.fixture +async def test_db_session() -> AsyncGenerator[AsyncSession, None]: + """Use the in-memory database for tests.""" + from app.database import Base, engine + + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + try: + async with async_session_maker() as session: + yield session + finally: + await session.flush() + await session.rollback() + await session.close() + finally: + # for AsyncEngine created in function scope, close and + # clean-up pooled connections + await engine.dispose() diff --git a/app/database.py b/app/database.py index 7f3ae86..d94709f 100644 --- a/app/database.py +++ b/app/database.py @@ -22,6 +22,7 @@ from typing import Final from sqlalchemy import ForeignKey, String from sqlalchemy.ext.asyncio import ( AsyncAttrs, + AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine, @@ -32,9 +33,11 @@ DB_PATH: Final[PurePath] = PurePath(__file__).parent.parent / "db.sqlite3" SQLALCHEMY_DATABASE_URL: Final[str] = f"sqlite+aiosqlite:///{DB_PATH}" -engine = create_async_engine(SQLALCHEMY_DATABASE_URL) +engine: Final[AsyncEngine] = create_async_engine(SQLALCHEMY_DATABASE_URL) -async_session_maker = async_sessionmaker(engine, expire_on_commit=False) +async_session_maker: Final[async_sessionmaker[AsyncSession]] = async_sessionmaker( + engine, expire_on_commit=False, autoflush=False, autocommit=False +) async def get_async_session() -> AsyncGenerator[AsyncSession, None]: @@ -65,7 +68,7 @@ class Dependency(Base): __tablename__ = "dependency" id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) repos: Mapped[list["Repo"]] = relationship( "Repo", secondary="repo_dependency", back_populates="dependencies" ) diff --git a/app/factories.py b/app/factories.py new file mode 100644 index 0000000..a94e5b9 --- /dev/null +++ b/app/factories.py @@ -0,0 +1,12 @@ +"""Factories for creating models for testing.""" +from polyfactory.factories.pydantic_factory import ModelFactory +from polyfactory.pytest_plugin import register_fixture + +from app.models import RepoCreateData + + +@register_fixture +class RepoCreateDataFactory(ModelFactory[RepoCreateData]): + """Factory for creating RepoCreateData.""" + + __model__ = RepoCreateData diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..cece9e5 --- /dev/null +++ b/app/models.py @@ -0,0 +1,19 @@ +"""Module contains the models for the application.""" +from typing import NewType + +from pydantic import AnyUrl, BaseModel + +RepoId = NewType("RepoId", int) +DependencyId = NewType("DependencyId", int) + + +class RepoCreateData(BaseModel): + """A repository that is being tracked.""" + + url: AnyUrl + + +class DependencyCreateData(BaseModel): + """A dependency of a repository.""" + + name: str diff --git a/app/tests/test_database.py b/app/tests/test_database.py new file mode 100644 index 0000000..26d1e4f --- /dev/null +++ b/app/tests/test_database.py @@ -0,0 +1,22 @@ +"""Test the operations on the database models.""" +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app import database +from app.factories import RepoCreateDataFactory + +pytestmark = pytest.mark.anyio + + +async def test_create_repo_no_dependencies( + test_db_session: AsyncSession, repo_create_data_factory: RepoCreateDataFactory +) -> None: + """Test creating a repo.""" + repo_create_data = repo_create_data_factory.build() + repo = database.Repo(url=str(repo_create_data.url)) + test_db_session.add(repo) + await test_db_session.commit() + await test_db_session.refresh(repo) + assert repo.id is not None + assert repo.url == str(repo_create_data.url) + assert (await repo.awaitable_attrs.dependencies) == [] diff --git a/migrations/versions/7ee6dc0ae743_add_repo_dependency_and_repodependency_.py b/migrations/versions/d8fc955c639b_add_repo_dependency_and_repodependency_.py similarity index 91% rename from migrations/versions/7ee6dc0ae743_add_repo_dependency_and_repodependency_.py rename to migrations/versions/d8fc955c639b_add_repo_dependency_and_repodependency_.py index 49f41af..a83e310 100644 --- a/migrations/versions/7ee6dc0ae743_add_repo_dependency_and_repodependency_.py +++ b/migrations/versions/d8fc955c639b_add_repo_dependency_and_repodependency_.py @@ -1,15 +1,15 @@ """Add Repo, Dependency, and RepoDependency tables -Revision ID: 7ee6dc0ae743 +Revision ID: d8fc955c639b Revises: -Create Date: 2023-07-28 22:41:31.438931 +Create Date: 2023-07-28 23:41:00.169286 """ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. -revision = "7ee6dc0ae743" +revision = "d8fc955c639b" down_revision = None branch_labels = None depends_on = None @@ -22,6 +22,7 @@ def upgrade() -> None: sa.Column("id", sa.Integer(), nullable=False), sa.Column("name", sa.String(length=255), nullable=False), sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), ) op.create_table( "repo", diff --git a/pyproject.toml b/pyproject.toml index 104e893..7f6d989 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,27 +7,30 @@ authors = [ ] requires-python = ">=3.11" classifiers = [ - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", ] dependencies = [ - "aiosqlite", - "alembic", - "fastapi[all]", - "sqlalchemy[asyncio]", + "aiosqlite", + "alembic", + "fastapi[all]", + "sqlalchemy[asyncio]", ] [project.optional-dependencies] dev = [ - "black", - "isort", - "pip-tools", - "pre-commit", - "pyproject-fmt", - "pyupgrade", - "ruff", + "black", + "isort", + "pip-tools", + "pre-commit", + "pyproject-fmt", + "pyupgrade", + "ruff", ] test = [ - "pytest", + "polyfactory", + "pytest", + "pytest-anyio", + "pytest-mock", ] [tool.setuptools] @@ -93,6 +96,7 @@ extend-select = [ # Ignore missing docstrings in migrations and alembic files "**/migrations/*.py" = ["D"] "**/migrations/env.py" = ["ERA001"] +"**/tests/*.py" = ["S101"] [tool.ruff.pydocstyle] convention = "numpy" diff --git a/requirements/test.txt b/requirements/test.txt index 81c2c0e..50128ac 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -13,6 +13,7 @@ annotated-types==0.5.0 anyio==3.7.1 # via # httpcore + # pytest-anyio # starlette # watchfiles certifi==2023.7.22 @@ -25,6 +26,8 @@ dnspython==2.4.1 # via email-validator email-validator==2.0.0.post2 # via fastapi +faker==19.2.0 + # via polyfactory fastapi[all]==0.100.0 # via awesome-fastapi-projects (pyproject.toml) greenlet==2.0.2 @@ -62,6 +65,8 @@ packaging==23.1 # via pytest pluggy==1.2.0 # via pytest +polyfactory==2.7.0 + # via awesome-fastapi-projects (pyproject.toml) pydantic==2.1.1 # via # fastapi @@ -74,7 +79,16 @@ pydantic-extra-types==2.0.0 pydantic-settings==2.0.2 # via fastapi pytest==7.4.0 + # via + # awesome-fastapi-projects (pyproject.toml) + # pytest-anyio + # pytest-mock +pytest-anyio==0.0.0 # via awesome-fastapi-projects (pyproject.toml) +pytest-mock==3.11.1 + # via awesome-fastapi-projects (pyproject.toml) +python-dateutil==2.8.2 + # via faker python-dotenv==1.0.0 # via # pydantic-settings @@ -85,6 +99,8 @@ pyyaml==6.0.1 # via # fastapi # uvicorn +six==1.16.0 + # via python-dateutil sniffio==1.3.0 # via # anyio @@ -100,6 +116,7 @@ typing-extensions==4.7.1 # via # alembic # fastapi + # polyfactory # pydantic # pydantic-core # sqlalchemy