ORM Testing Patterns: How to Test SQLAlchemy, Prisma, and TypeORM Correctly
ORMs hide SQL behind object abstractions, which makes application code cleaner but creates a testing trap: it's tempting to mock the ORM and test nothing real. Mock-based ORM tests can show 100% coverage while completely missing query logic bugs, N+1 problems, and constraint violations that only appear with a real database.
This guide covers ORM testing patterns that actually catch bugs, with examples for SQLAlchemy (Python), Prisma (TypeScript/Node.js), and TypeORM (TypeScript).
The Core Problem with Mocking ORMs
Consider a typical repository method:
class OrderRepository:
def get_orders_with_items(self, customer_id: int):
return (
db.session.query(Order)
.options(joinedload(Order.items))
.filter(Order.customer_id == customer_id)
.filter(Order.status != 'cancelled')
.order_by(Order.created_at.desc())
.all()
)A mock-based test:
def test_get_orders_with_items_mock():
mock_session = MagicMock()
mock_session.query.return_value.options.return_value.filter.return_value\
.filter.return_value.order_by.return_value.all.return_value = [
Order(id=1, customer_id=42, status='completed')
]
repo = OrderRepository(mock_session)
result = repo.get_orders_with_items(42)
assert len(result) == 1 # ✓ passesThis test passes but doesn't verify:
- Whether the query actually filters cancelled orders
- Whether the joinedload prevents N+1 queries
- Whether the ORDER BY is applied correctly
- Whether the SQL generated is valid
The mock is just testing that you called the mock's methods in the right order.
Pattern 1: Real Database, Transaction Rollback
The most reliable ORM testing pattern uses a real database with transaction rollback to isolate tests:
SQLAlchemy
# conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from your_app.models import Base
@pytest.fixture(scope="session")
def engine():
return create_engine("postgresql://test:test@localhost/testdb")
@pytest.fixture(scope="session")
def tables(engine):
Base.metadata.create_all(engine)
yield
Base.metadata.drop_all(engine)
@pytest.fixture
def db_session(engine, tables):
"""Each test gets a transaction that rolls back at the end."""
connection = engine.connect()
transaction = connection.begin()
session = sessionmaker(bind=connection)()
# Make nested transactions work (for code that uses session.begin())
session.begin_nested()
yield session
session.close()
transaction.rollback()
connection.close()# tests/test_order_repository.py
from your_app.repositories import OrderRepository
from your_app.models import Customer, Order, OrderItem
def test_get_orders_excludes_cancelled(db_session):
"""Cancelled orders are not returned."""
customer = Customer(id=1, name="Test Customer")
db_session.add(customer)
db_session.add_all([
Order(id=1, customer_id=1, status="completed"),
Order(id=2, customer_id=1, status="cancelled"),
Order(id=3, customer_id=1, status="pending"),
])
db_session.flush()
repo = OrderRepository(db_session)
results = repo.get_orders_with_items(customer_id=1)
result_ids = {o.id for o in results}
assert result_ids == {1, 3}, "Cancelled order should be excluded"
assert 2 not in result_ids
def test_get_orders_sorted_by_created_at_desc(db_session):
"""Orders are returned newest first."""
customer = Customer(id=1, name="Test Customer")
db_session.add(customer)
from datetime import datetime, timedelta
base = datetime(2024, 1, 1)
db_session.add_all([
Order(id=1, customer_id=1, status="completed", created_at=base),
Order(id=2, customer_id=1, status="completed", created_at=base + timedelta(days=1)),
Order(id=3, customer_id=1, status="completed", created_at=base + timedelta(days=2)),
])
db_session.flush()
repo = OrderRepository(db_session)
results = repo.get_orders_with_items(customer_id=1)
assert [o.id for o in results] == [3, 2, 1], "Orders should be newest first"
def test_get_orders_for_other_customer_returns_empty(db_session):
"""Querying for customer with no orders returns empty list."""
repo = OrderRepository(db_session)
results = repo.get_orders_with_items(customer_id=99999)
assert results == []Prisma (TypeScript/Node.js)
// tests/setup.ts
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
const prisma = new PrismaClient({
datasources: {
db: { url: process.env.TEST_DATABASE_URL },
},
});
beforeAll(async () => {
// Apply migrations to test database
execSync('npx prisma migrate deploy', {
env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL },
});
});
afterEach(async () => {
// Clean up in reverse FK order
await prisma.orderItem.deleteMany();
await prisma.order.deleteMany();
await prisma.customer.deleteMany();
});
afterAll(async () => {
await prisma.$disconnect();
});
export { prisma };// tests/order-repository.test.ts
import { prisma } from './setup';
import { OrderRepository } from '../src/repositories/order-repository';
describe('OrderRepository', () => {
let repo: OrderRepository;
beforeEach(() => {
repo = new OrderRepository(prisma);
});
it('excludes cancelled orders', async () => {
const customer = await prisma.customer.create({
data: { id: 1, name: 'Test Customer', email: 'test@example.com' },
});
await prisma.order.createMany({
data: [
{ id: 1, customerId: customer.id, status: 'completed', total: 100 },
{ id: 2, customerId: customer.id, status: 'cancelled', total: 50 },
{ id: 3, customerId: customer.id, status: 'pending', total: 75 },
],
});
const orders = await repo.getOrdersWithItems(customer.id);
const ids = orders.map(o => o.id);
expect(ids).toContain(1);
expect(ids).toContain(3);
expect(ids).not.toContain(2);
});
it('loads items with orders (no N+1)', async () => {
const customer = await prisma.customer.create({
data: { id: 1, name: 'Test', email: 'test@example.com' },
});
const order = await prisma.order.create({
data: {
customerId: customer.id,
status: 'completed',
total: 200,
items: {
create: [
{ productId: 'p1', quantity: 2, price: 50 },
{ productId: 'p2', quantity: 1, price: 100 },
],
},
},
});
const orders = await repo.getOrdersWithItems(customer.id);
expect(orders[0].items).toHaveLength(2);
expect(orders[0].items.map(i => i.productId)).toEqual(
expect.arrayContaining(['p1', 'p2'])
);
});
});Pattern 2: Testing N+1 Query Detection
N+1 queries are the most common ORM performance bug. Test for them explicitly:
def test_no_n_plus_1_when_loading_orders_with_items(db_session):
"""Loading 10 orders with items should issue exactly 2 queries (1 + 1 join)."""
customer = Customer(id=1, name="Test")
db_session.add(customer)
for i in range(10):
order = Order(id=i+1, customer_id=1, status="completed")
order.items = [
OrderItem(product_id=f"p{i}", quantity=1, price=10.0)
]
db_session.add(order)
db_session.flush()
query_count = 0
def count_queries(conn, cursor, statement, parameters, context, executemany):
nonlocal query_count
query_count += 1
from sqlalchemy import event
event.listen(db_session.bind, "before_cursor_execute", count_queries)
repo = OrderRepository(db_session)
orders = repo.get_orders_with_items(customer_id=1)
# Touch all relationships to trigger any lazy loads
for order in orders:
_ = order.items
event.remove(db_session.bind, "before_cursor_execute", count_queries)
assert query_count <= 2, \
f"N+1 detected: {query_count} queries for 10 orders (expected ≤2)"
assert len(orders) == 10Pattern 3: Testing ORM Transactions
Test that your repository correctly uses transactions for operations that must be atomic:
def test_create_order_with_items_is_atomic(db_session):
"""If item creation fails, the order is also rolled back."""
customer = Customer(id=1, name="Test")
db_session.add(customer)
db_session.flush()
class FakeOrderItem:
"""Simulates an item that will cause a DB error."""
def __init__(self):
self.price = "not-a-number" # Invalid type
repo = OrderRepository(db_session)
with pytest.raises(Exception):
repo.create_order_with_items(
customer_id=1,
items=[FakeOrderItem()]
)
# Order should not exist (transaction rolled back)
order_count = db_session.query(Order).filter_by(customer_id=1).count()
assert order_count == 0, "Failed order creation should not leave orphaned order"Pattern 4: Testing Query Builders
Some ORMs let you build dynamic queries. Test the query builder logic separately:
class OrderQueryBuilder:
def __init__(self, session):
self.session = session
self._query = session.query(Order)
def for_customer(self, customer_id: int):
self._query = self._query.filter(Order.customer_id == customer_id)
return self
def with_status(self, *statuses: str):
self._query = self._query.filter(Order.status.in_(statuses))
return self
def created_after(self, date):
self._query = self._query.filter(Order.created_at >= date)
return self
def build(self):
return self._query.all()
def test_query_builder_chaining(db_session):
"""Query builder correctly combines multiple filters."""
# Setup
db_session.add_all([
Order(id=1, customer_id=1, status="completed", created_at=datetime(2024, 1, 1)),
Order(id=2, customer_id=1, status="pending", created_at=datetime(2024, 2, 1)),
Order(id=3, customer_id=1, status="completed", created_at=datetime(2024, 3, 1)),
Order(id=4, customer_id=2, status="completed", created_at=datetime(2024, 3, 1)),
])
db_session.flush()
# Query: customer 1, completed, after Feb 1
results = (
OrderQueryBuilder(db_session)
.for_customer(1)
.with_status("completed")
.created_after(datetime(2024, 2, 1))
.build()
)
assert len(results) == 1
assert results[0].id == 3Pattern 5: TypeORM Repository Testing
// tests/user-repository.test.ts
import { DataSource } from 'typeorm';
import { UserRepository } from '../src/repositories/user-repository';
import { User } from '../src/entities/user.entity';
describe('UserRepository', () => {
let dataSource: DataSource;
let userRepo: UserRepository;
beforeAll(async () => {
dataSource = new DataSource({
type: 'postgres',
url: process.env.TEST_DATABASE_URL,
entities: [User],
synchronize: true, // Use migrations in production, sync for tests
logging: false,
});
await dataSource.initialize();
});
afterAll(async () => {
await dataSource.destroy();
});
beforeEach(async () => {
await dataSource.getRepository(User).clear();
});
it('findActiveByEmail returns only active users', async () => {
userRepo = new UserRepository(dataSource);
await dataSource.getRepository(User).save([
{ email: 'active@test.com', name: 'Active User', isActive: true },
{ email: 'inactive@test.com', name: 'Inactive User', isActive: false },
]);
const result = await userRepo.findActiveByEmail('active@test.com');
expect(result).not.toBeNull();
expect(result!.email).toBe('active@test.com');
});
it('findActiveByEmail returns null for inactive user', async () => {
userRepo = new UserRepository(dataSource);
await dataSource.getRepository(User).save({
email: 'inactive@test.com',
name: 'Inactive User',
isActive: false,
});
const result = await userRepo.findActiveByEmail('inactive@test.com');
expect(result).toBeNull();
});
});When Mocking IS Appropriate
Mocking has its place in ORM testing:
Use real database for:
- Repository/DAO layer tests (these should always hit a real DB)
- Integration tests that test query logic
- Tests that verify constraint behavior
Mocks are acceptable for:
- Application service layer tests where you're testing business logic, not query logic
- Tests that would require complex data setup unrelated to what you're testing
- Unit tests of query builders (test the builder output, not the DB execution)
# OK: Testing business logic in a service, not query logic
def test_checkout_service_sends_confirmation_email(mock_order_repo, mock_email_service):
"""Checkout service sends email after successful order creation."""
mock_order_repo.create.return_value = Order(id=1, total=100.00)
service = CheckoutService(mock_order_repo, mock_email_service)
service.checkout(customer_id=1, cart=[{"product_id": "p1", "qty": 1}])
mock_email_service.send_confirmation.assert_called_once_with(
order_id=1,
customer_id=1
)CI/CD Setup
# .github/workflows/orm-tests.yml
name: ORM Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install pytest sqlalchemy psycopg2-binary alembic
- name: Run migrations
run: alembic upgrade head
env:
DATABASE_URL: postgresql://test:test@localhost/testdb
- name: Run ORM tests
run: pytest tests/ -v --tb=short
env:
DATABASE_URL: postgresql://test:test@localhost/testdbCommon ORM Testing Mistakes
Mocking the session/client: Testing db.session.query() calls on a mock tests nothing about your query logic.
Not testing N+1: Eager loading relationships is easy to forget. Add query count assertions for any query that loads related models.
Assuming test isolation: Tests that don't clean up after themselves create ordering dependencies. Use transactions with rollback or explicit cleanup.
Testing ORM internals: Don't test that SQLAlchemy generates the SQL you expect. Test the behavior — the rows returned, constraints enforced, counts correct.
No FK constraint testing: ORMs make it easy to skip FK validation. Test that your constraints actually reject bad data.
ORM testing is only useful if it uses a real database. The transaction rollback pattern makes real-database tests as fast as mock tests for most test suites — a full suite of 200 repository tests typically runs in under 30 seconds against a local Postgres instance. The payoff is tests that actually catch ORM bugs, query logic errors, and N+1 problems before they reach production.