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:
- Confidence that queries return the expected data under realistic conditions
- Fast feedback loops by isolating business logic from the database where possible
- Reproducible test runs through proper database setup and teardown
- Protection against regressions when refactoring models or relationships
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
- Use in-memory SQLite for unit and most integration tests. It's fast and requires no infrastructure. Reserve Postgres or MySQL for tests that depend on backend-specific features like JSONB or full-text search.
- Keep tests independent. Either truncate tables between tests or use a fresh database per test. Never rely on test execution order.
- Test the service layer, not the ORM. Don't write tests that just verify
Model.createworks — Tortoise already tests that. Test your business logic and custom queries. - Mock at the boundary. When unit-testing services, mock the model methods, not the database driver. This keeps tests resilient to ORM internals.
- Use factories or fixtures for test data. Avoid duplicating setup code. A small fixture that creates an author with articles saves time and improves readability.
- Run integration tests separately in CI. Tag them with
@pytest.mark.integrationso you can run fast unit tests on every save and slower integration tests on push. - Close connections properly. Failing to close Tortoise connections can leave event loops in a bad state and cause cryptic errors in subsequent test runs.
- Avoid shared global state. Module-level model instances or caches will leak between tests and produce flaky failures.
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.