← Back to DevBytes

Testing Tortoise-ORM Applications: Unit Tests to Integration

Testing Tortoise-ORM Applications: From Unit Tests to Integration

Tortoise-ORM is a popular async ORM for Python that works seamlessly with frameworks like FastAPI, Starlette, and aiohttp. While building async applications with Tortoise is straightforward, testing them properly requires understanding how async database interactions differ from traditional synchronous testing. This tutorial walks you through everything from isolated unit tests to full integration tests, with practical examples you can drop into your own projects.

Why Testing Tortoise-ORM Matters

Async ORM code introduces subtle challenges that synchronous ORMs like SQLAlchemy's classic API don't face. Connection pools, event loops, transaction state, and model initialization all behave differently in an async context. Without a deliberate testing strategy, you risk tests that pass locally but fail in CI, or worse, tests that pollute each other's state and produce flaky results.

A robust testing approach for Tortoise-ORM gives you:

Project Setup

Let's start with a sample project structure. We'll use pytest along with pytest-asyncio for async test support.

# requirements.txt
tortoise-orm>=0.19.0
asyncpg>=0.27.0
pytest>=7.0.0
pytest-asyncio>=0.21.0
aiosqlite>=0.18.0

For local development and testing, aiosqlite is ideal because it runs in-memory and requires no external service. For integration tests, you can swap in asyncpg against a real Postgres instance.

Here's the example model we'll test throughout this tutorial:

# app/models.py
from tortoise import fields
from tortoise.models import Model


class Author(Model):
    id = fields.IntField(pk=True)
    name = fields.CharField(max_length=100)
    email = fields.CharField(max_length=200, unique=True)

    def __str__(self):
        return self.name


class Article(Model):
    id = fields.IntField(pk=True)
    title = fields.CharField(max_length=200)
    body = fields.TextField()
    published = fields.BooleanField(default=False)
    author = fields.ForeignKeyField("models.Author", related_name="articles")

    def __str__(self):
        return self.title

And a small service layer that wraps common operations:

# app/services.py
from app.models import Author, Article


async def create_author(name: str, email: str) -> Author:
    return await Author.create(name=name, email=email)


async def publish_article(author_id: int, title: str, body: str) -> Article:
    author = await Author.get_or_none(id=author_id)
    if author is None:
        raise ValueError(f"Author {author_id} not found")
    return await Article.create(
        title=title, body=body, published=True, author=author
    )


async def list_published_articles() -> list[Article]:
    return await Article.filter(published=True).prefetch_related("author")

Configuring pytest for Async

Tortoise-ORM tests run inside an event loop, so we need to tell pytest to handle async test functions. Create a pytest.ini (or add to pyproject.toml):

# pytest.ini
[pytest]
asyncio_mode = auto
testpaths = tests

Setting asyncio_mode = auto means any async def test_* function is automatically treated as an async test without needing the @pytest.mark.asyncio decorator.

Unit Tests: Isolating Logic from the Database

Unit tests should be fast and deterministic. The best way to unit-test code that uses Tortoise is to mock the model methods so the database is never touched. This lets you verify branching logic, validation, and orchestration in microseconds.

# tests/test_services_unit.py
from unittest.mock import AsyncMock, patch
import pytest
from app.services import publish_article, list_published_articles


async def test_publish_article_raises_when_author_missing():
    with patch("app.services.Author.get_or_none", new=AsyncMock(return_value=None)):
        with pytest.raises(ValueError, match="not found"):
            await publish_article(author_id=999, title="T", body="B")


async def test_publish_article_creates_when_author_exists():
    fake_author = AsyncMock(id=1, name="Ada")
    fake_article = AsyncMock(id=10, title="T", body="B", published=True)

    with patch("app.services.Author.get_or_none", new=AsyncMock(return_value=fake_author)):
        with patch("app.services.Article.create", new=AsyncMock(return_value=fake_article)):
            result = await publish_article(author_id=1, title="T", body="B")

    assert result.published is True
    assert result.title == "T"


async def test_list_published_articles_returns_only_published():
    fake_articles = [AsyncMock(published=True), AsyncMock(published=True)]
    with patch("app.services.Article.filter") as filter_mock:
        filter_mock.return_value.prefetch_related = AsyncMock(return_value=fake_articles)
        result = await list_published_articles()

    assert len(result) == 2

These tests run without a database, so they execute in milliseconds. They're perfect for verifying the control flow of your service layer. However, they don't catch issues like incorrect field names, missing relations, or query syntax errors — that's where integration tests come in.

Integration Tests: A Real Database per Test

Integration tests exercise the full stack: real queries, real constraints, real relationships. The key to making them reliable is initializing Tortoise once per test session and rolling back or recreating the schema between tests.

Setting Up the Database Fixture

Create a conftest.py that initializes Tortoise with an in-memory SQLite database before tests run and tears it down afterward:

# tests/conftest.py
import pytest
from tortoise import Tortoise

DB_URL = "sqlite://:memory:"


@pytest.fixture(scope="session")
def event_loop():
    import asyncio
    loop = asyncio.new_event_loop()
    yield loop
    loop.close()


@pytest.fixture(scope="session", autouse=True)
async def initialize_db():
    await Tortoise.init(
        db_url=DB_URL,
        modules={"models": ["app.models"]},
    )
    await Tortoise.generate_schemas()
    yield
    await Tortoise.close_connections()


@pytest.fixture(autouse=True)
async def clean_tables():
    from app.models import Author, Article
    yield
    await Article.all().delete()
    await Author.all().delete()

The initialize_db fixture runs once per session, generates the schema, and closes connections at the end. The clean_tables fixture runs after every test, ensuring each test starts with a clean slate. This pattern avoids cross-test contamination while keeping setup costs low.

Writing Integration Tests

Now we can write tests that hit the database for real:

# tests/test_services_integration.py
import pytest
from app.services import create_author, publish_article, list_published_articles
from app.models import Author, Article


async def test_create_author_persists_to_db():
    author = await create_author(name="Grace Hopper", email="grace@example.com")
    fetched = await Author.get(id=author.id)
    assert fetched.name == "Grace Hopper"
    assert fetched.email == "grace@example.com"


async def test_publish_article_links_author():
    author = await create_author(name="Ada Lovelace", email="ada@example.com")
    article = await publish_article(author.id, "Notes on Computing", "Body text")

    fetched = await Article.get(id=article.id)
    assert fetched.published is True
    assert (await fetched.author).id == author.id


async def test_publish_article_raises_for_missing_author():
    with pytest.raises(ValueError):
        await publish_article(99999, "Ghost", "Body")


async def test_list_published_articles_excludes_drafts():
    author = await create_author(name="Linus", email="linus@example.com")
    await publish_article(author.id, "Published One", "Body")
    await Article.create(title="Draft", body="Body", published=False, author=author)

    published = await list_published_articles()
    titles = [a.title for a in published]
    assert "Published One" in titles
    assert "Draft" not in titles

These tests verify that your queries actually work against a real database engine, that foreign keys resolve correctly, and that filters behave as expected. They're slower than unit tests but catch a much wider class of bugs.

Testing with Transactions for Isolation

An alternative to truncating tables between tests is wrapping each test in a transaction that rolls back at the end. Tortoise doesn't expose a public transaction context manager that's trivially safe across all backends, but you can use the underlying connection's transaction support. For SQLite, a simpler approach is to recreate the schema per test:

# tests/conftest_transactional.py
import pytest
from tortoise import Tortoise

DB_URL = "sqlite://:memory:"


@pytest.fixture(autouse=True)
async def db_session():
    await Tortoise.init(
        db_url=DB_URL,
        modules={"models": ["app.models"]},
    )
    await Tortoise.generate_schemas()
    yield
    await Tortoise.close_connections()

This fixture initializes a fresh in-memory database for every single test. It's slower than the session-scoped approach but offers perfect isolation, which is valuable when tests create complex graphs of related records.

Testing FastAPI Endpoints That Use Tortoise

If your Tortoise models back a FastAPI app, you'll want end-to-end tests that hit HTTP routes. Use FastAPI's TestClient with an async-aware setup. The trick is to initialize Tortoise on startup and clean up on shutdown, just like in production:

# app/main.py
from fastapi import FastAPI
from tortoise import Tortoise
from app.services import create_author, list_published_articles

app = FastAPI()


@app.on_event("startup")
async def startup():
    await Tortoise.init(
        db_url="sqlite://:memory:",
        modules={"models": ["app.models"]},
    )
    await Tortoise.generate_schemas()


@app.on_event("shutdown")
async def shutdown():
    await Tortoise.close_connections()


@app.post("/authors")
async def make_author(name: str, email: str):
    author = await create_author(name, email)
    return {"id": author.id, "name": author.name}


@app.get("/articles/published")
async def get_published():
    articles = await list_published_articles()
    return [{"title": a.title, "author": a.author.name} for a in articles]
# tests/test_api.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.models import Author, Article


@pytest.fixture
def client():
    with TestClient(app) as c:
        yield c


def test_create_and_list(client):
    response = client.post("/authors", params={"name": "Ada", "email": "ada@x.com"})
    assert response.status_code == 200
    author_id = response.json()["id"]

    # Use the running event loop via the app's startup
    import asyncio
    asyncio.get_event_loop().run_until_complete(
        Article.create(title="T", body="B", published=True, author_id=author_id)
    )

    response = client.get("/articles/published")
    assert response.status_code == 200
    data = response.json()
    assert any(item["title"] == "T" for item in data)

For more complex async scenarios, consider using httpx.AsyncClient with ASGITransport, which lets you write fully async tests without juggling event loops manually.

Best Practices

Conclusion

Testing Tortoise-ORM applications effectively means combining two complementary strategies: fast unit tests that mock the database to verify business logic, and integration tests that exercise real queries against an in-memory or containerized database to catch schema and query bugs. By structuring your test suite with clear fixtures, isolating state between tests, and mocking at the right boundaries, you get a feedback loop that's both fast and trustworthy. Start with the session-scoped in-memory database pattern, add transactional or per-test isolation where you need it, and layer in HTTP-level tests for your API endpoints. With these patterns in place, you can refactor your async data layer with confidence and ship changes knowing your tests reflect real-world behavior.

— Ad —

Google AdSense will appear here after approval

← Back to all articles