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 orders
encore 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.py
from 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:

ResourceIsolation
DatabaseA clone of the migrated template database.
BucketA key prefix of its own in the local object storage.
Pub/Sub topic and subscriptionNames of their own in the local broker.
Redis databaseA namespace of its own on the local Redis server.

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.py
from 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.py
from 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}