Testing
pytest against real, isolated infrastructure
Encore.py apps are tested with pytest, run through encore test. Encore gives the test process its own databases, buckets, Pub/Sub topics and Redis keys, created when the tests first use them and removed when the process exits. Your tests use the same declarations and the same SDK calls as your service, against real Postgres and real emulators, so most tests of Encore code are integration tests.
Running tests
Run all tests from anywhere in the app:
encore test
For each Python service, encore test runs pytest in the service's directory, through the package manager detected from its lockfile: uv run pytest with a uv.lock, poetry run pytest with a poetry.lock, and so on, or python3 -m pytest without one. With several services, it tests them in parallel and prefixes each line of output with the service's name. To change the command, set a custom test in encore.service.json.
To choose which services to test, pass a service-set expression with --services, and limit how many run at once with --concurrency:
encore test --services ordersencore test --services 'orders | billing' --concurrency 1
To pass arguments to pytest, give the whole command after encore test. Without --services, the command runs as given, in the current directory; with --services, it runs in each selected service's directory:
# From the service's directory:encore test uv run pytest -k create_order -x# In the orders service's directory, wherever you are in the app:encore test --services orders uv run pytest tests/test_orders.py
Always run tests through encore test. Importing encore initializes the SDK's runtime, which gets its configuration from Encore; running pytest on its own fails at that import.
Setting up pytest
Add pytest to the service's development dependencies, together with pytest-asyncio for testing async code. Most SDK methods are async, so most tests are too:
pyproject.toml[dependency-groups]
dev = ["pytest>=8", "pytest-asyncio>=0.24"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
pythonpath = ["."] lets tests in tests/ import the service's modules, such as from resources import db, the same way the service does.
Mark async tests with @pytest.mark.asyncio, and declare async fixtures with @pytest_asyncio.fixture. To skip the markers, set asyncio_mode = "auto" in [tool.pytest.ini_options], and pytest-asyncio treats every async def test and fixture as asyncio.
Testing database code
Test code queries the database through its declaration, as the service does:
tests/test_users.pyfrom uuid import uuid4
import pytest
from resources import db
@pytest.mark.asyncio
async def test_add_user() -> None:
name = f"user-{uuid4().hex}"
await db.exec("INSERT INTO users (name) VALUES ($1)", name)
row = await db.query("SELECT name FROM users WHERE name = $1", name).one()
assert row == {"name": name}
Each test process gets its own copy of each database, with every migration applied, including rows that migrations insert. Encore migrates a template database once and clones it for each test process, so starting from a fresh database is fast. The template is reused across runs until the migrations change.
What Encore sets up
Nothing is created until a test uses it. On first use in a test process, Encore creates an instance of the resource for that process:
When the test process exits, Encore removes everything it created for it. Test databases run on a separate Postgres cluster, configured without durability (fsync is off) since tests don't need it. Encore keeps the cluster running for a while after a test run, so the next run starts quickly.
During encore test, the environment type is "test", which encore.app_meta() reports as environment.type.
Keeping tests independent
Isolation is per test process, not per test: every test in one pytest run shares the same database, bucket, topics and Redis namespace. Encore.py doesn't yet have per-test databases or fixtures for saved test data.
So write tests that don't depend on each other's data:
- Use unique keys, names and IDs in each test, for example with
uuid4(). - Clean up in a fixture, so the cleanup also runs when a test fails.
- Don't assert on totals across a table, such as
count(*), that other tests also write to. Filter on the rows the test created.
This fixture gives each object storage test a key of its own, and removes the object afterwards:
tests/test_objects.pyfrom contextlib import suppress
from uuid import uuid4
import pytest
import pytest_asyncio
from encore.storage import objects
from resources import uploads
@pytest_asyncio.fixture
async def object_key():
key = f"tests/{uuid4().hex}.txt"
yield key
with suppress(objects.ObjectNotFound):
await uploads.remove(key)
@pytest.mark.asyncio
async def test_upload_and_download(object_key: str) -> None:
attrs = await uploads.upload(object_key, b"abcdef", content_type="text/plain")
assert attrs.size == 6
assert await uploads.download(object_key) == b"abcdef"
Separate test processes are isolated from each other, so services tested in parallel by encore test, or pytest workers in separate processes, don't see each other's data.
With Hypothesis, a function-scoped fixture runs once per test, not once per generated example. Create the key inside the test instead, for example with an async context manager that cleans up after each example.
Testing Pub/Sub subscriptions
Subscription handlers only consume messages once encore.start() has been awaited, and a test process doesn't call it for you. To test a handler's logic, call the handler function directly.
Testing Django apps
Django tests run under encore test like any other pytest suite. Configure Django before the first test, for example by importing the WSGI application, and apply Django's migrations to the test database yourself, since Encore only applies Encore-managed migrations:
tests/test_orm.pyfrom uuid import uuid4
import pytest
from django.core.management import call_command
from django.db import connections
from django.test import Client
from config.wsgi import application # noqa: F401 (configures Django)
from storage.models import Note
@pytest.fixture(scope="module")
def migrated_database():
# Encore supplies the isolated database; Django owns its schema.
call_command("migrate", interactive=False, verbosity=0)
yield
connections.close_all()
@pytest.fixture
def note_title(migrated_database):
title = f"note-{uuid4().hex}"
yield title
Note.objects.filter(title=title).delete()
def test_create_note(note_title):
response = Client().post("/notes/", {"title": note_title})
assert response.status_code == 201
assert Note.objects.get(pk=response.json()["id"]).title == note_title
The database Encore provides is already isolated, so this uses plain pytest and Django's test Client, not Django's test runner, which would create a test database of its own. Synchronous tests call the SDK's async methods through asgiref.sync.async_to_sync, as synchronous Django views do:
from asgiref.sync import async_to_sync
from storage.resources import db
def test_note_row(note_title):
note = Note.objects.create(title=note_title)
row = async_to_sync(
db.query("SELECT title FROM storage_note WHERE id = $1", note.pk).one
)()
assert row == {"title": note_title}