Min 23-24 — Testing with Earth 🌍¶
Earth is Heaven's built-in test client. No ports, no sockets, no httpx — it drives your app in-process and hands you back the same three objects your handlers see.
req, res, ctx = await app.earth.GET('/users')
That return shape is the point: you can assert on the response and also inspect the context your hooks built, which is normally invisible from outside.
Integration tests¶
app.earth.test() is an async context manager that runs your startup hooks on entry and your shutdown hooks on exit.
from unittest import IsolatedAsyncioTestCase
from main import app
class TestUsers(IsolatedAsyncioTestCase):
async def test_create_user(self):
async with app.earth.test() as earth:
req, res, ctx = await earth.POST('/users', body={'name': 'Ada'})
self.assertEqual(res.status, 201)
self.assertEqual(res.json['name'], 'Ada')
A dict or list body is JSON-encoded and given the right content type automatically. Cookies and sessions are tracked across requests within a block, so a login followed by a protected request works as it would in a browser.
Available: GET, POST, PUT, PATCH, DELETE, plus upload() and SOCKET().
Earth skips Heaven's final response step
Earth calls the router directly rather than going through the full ASGI entry point, so a few things that happen in production do not happen in tests:
- a
dict/listbody is not serialized —res.bodyis still adict, andres.headershas noContent-Type res.defer()callbacks never run- the debug error page is never rendered
In practice: assert against res.json (which handles both) rather than res.body, and test deferred work by calling the callback directly.
Unit-testing a handler¶
Skip routing entirely and build the three objects yourself:
from main import create_user
async def test_handler_logic():
req = app.earth.req(url='/users', body={'name': 'Ada'})
res = app.earth.res()
ctx = app.earth.ctx()
await create_user(req, res, ctx)
assert res.status == 201
This is where the (req, res, ctx) signature pays off — a handler is a plain function, so calling it needs no framework machinery.
Replacing dependencies¶
Swap a lifecycle hook¶
Point startup at a test database instead of the real one:
app.earth.swap(connect_prod_db, connect_test_db)
Skip a hook¶
Rate limiters and auth guards usually get in the way of tests:
app.earth.bypass(rate_limiter)
Mock app state¶
async with app.earth.test() as earth:
original = app.unkeep('db')
app.keep('db', MockDatabase())
try:
req, res, ctx = await earth.GET('/users')
finally:
app.keep('db', original) # always restore
app.keep outlives the test
Application state is not reset between tests. Overwrite it without restoring and every later test in the process gets your mock. The try/finally above is not optional.
Subdomains, sockets and uploads¶
# a subdomain, with no DNS involved
req, res, ctx = await earth.GET('/users', subdomain='api')
# a websocket
ws = await earth.SOCKET('/chat').connect()
await ws.send('hello')
assert await ws.receive() == 'world'
await ws.close()
# a multipart upload
req, res, ctx = await earth.upload('/avatar',
files={'file': ('image.png', b'\x89PNG...')},
data={'userid': '123'},
)
Running the suite¶
Heaven's own tests use unittest.IsolatedAsyncioTestCase and run under pytest:
$ python -m pytest tests/ -v
Next: Work that happens outside a request → Min 25-26 — Background Work