Testing¶
Piccolo provides a few tools to make testing easier.
Test runner¶
Piccolo ships with a handy command for running your unit tests using pytest. See the tester app.
You can put your test files anywhere you like, but a good place is in a tests
folder within your Piccolo app. The test files should be named like
test_*. py
or *_test.py
for pytest to recognise them.
Model Builder¶
When writing unit tests, it’s usually required to have some data seeded into
the database. You can build and save the records manually or use
ModelBuilder
to generate
random records for you.
This way you can randomize the fields you don’t care about and specify
important fields explicitly and reduce the amount of manual work required.
ModelBuilder
currently supports all Piccolo column types and features.
Let’s say we have the following schema:
from piccolo.columns import ForeignKey, Varchar
class Manager(Table):
name = Varchar(length=50)
class Band(Table):
name = Varchar(length=50)
manager = ForeignKey(Manager, null=True)
You can build a random Band
which will also build and save a random
Manager
:
from piccolo.testing.model_builder import ModelBuilder
# Band instance with random values persisted:
band = await ModelBuilder.build(Band)
Note
ModelBuilder.build(Band)
persists the record into the database by default.
You can also run it synchronously if you prefer:
manager = ModelBuilder.build_sync(Manager)
To specify any attribute, pass the defaults
dictionary to the build
method:
manager = ModelBuilder.build(Manager)
# Using table columns:
band = await ModelBuilder.build(
Band,
defaults={Band.name: "Guido", Band.manager: manager}
)
# Or using strings as keys:
band = await ModelBuilder.build(
Band,
defaults={"name": "Guido", "manager": manager}
)
To build objects without persisting them into the database:
band = await ModelBuilder.build(Band, persist=False)
To build objects with minimal attributes, leaving nullable fields empty:
# Leaves manager empty:
band = await ModelBuilder.build(Band, minimal=True)
Creating the test schema¶
When running your unit tests, you usually start with a blank test database, create the tables, and then install test data.
To create the tables, there are a few different approaches you can take.
create_db_tables
/ drop_db_tables
¶
Here we use create_db_tables
and
drop_db_tables
to create and drop the
tables.
Note
The sync equivalents are create_db_tables_sync
and drop_db_tables_sync
, if
you need your tests to be synchronous for some reason.
from unittest import IsolatedAsyncioTestCase
from piccolo.table import create_db_tables, drop_db_tables
from piccolo.conf.apps import Finder
TABLES = Finder().get_table_classes()
class TestApp(IsolatedAsyncioTestCase):
async def setUp(self):
await create_db_tables(*TABLES)
async def tearDown(self):
await drop_db_tables(*TABLES)
async def test_app(self):
# Do some testing ...
pass
You can remove this boiler plate by using
AsyncTransactionTest
,
which does this for you.
Run migrations¶
Alternatively, you can run the migrations to setup the schema if you prefer:
from unittest import IsolatedAsyncioTestCase
from piccolo.apps.migrations.commands.backwards import run_backwards
from piccolo.apps.migrations.commands.forwards import run_forwards
class TestApp(IsolatedAsyncioTestCase):
async def setUp(self):
await run_forwards("all")
async def tearDown(self):
await run_backwards("all", auto_agree=True)
async def test_app(self):
# Do some testing ...
pass
Testing async code¶
There are a few options for testing async code using pytest.
run_sync
¶
You can call any async code using Piccolo’s run_sync
utility:
from piccolo.utils.sync import run_sync
async def get_data():
...
def test_get_data():
rows = run_sync(get_data())
assert len(rows) == 1
It’s preferable to make your tests natively async though.
pytest-asyncio
¶
If you prefer using pytest’s function based tests, then take a look at
pytest-asyncio. Simply
install it using pip install pytest-asyncio
, then you can then write tests
like this:
async def test_select():
rows = await MyTable.select()
assert len(rows) == 1
IsolatedAsyncioTestCase
¶
If you prefer class based tests, and are using Python 3.8 or above, then have
a look at IsolatedAsyncioTestCase
from Python’s standard library. You can then write tests like this:
from unittest import IsolatedAsyncioTestCase
class MyTest(IsolatedAsyncioTestCase):
async def test_select(self):
rows = await MyTable.select()
assert len(rows) == 1
Also look at the IsolatedAsyncioTestCase
subclasses which Piccolo provides
(see AsyncTransactionTest
and AsyncTableTest
below).
TestCase
subclasses¶
Piccolo ships with some unittest.TestCase
subclasses which remove
boilerplate code from tests.
- class piccolo.testing.test_case.AsyncTransactionTest(methodName='runTest')¶
Wraps each test in a transaction, which is automatically rolled back when the test finishes.
Warning
Python 3.11 and above only.
If your test suite just contains
AsyncTransactionTest
tests, then you can setup your database tables once before your test suite runs. Any changes made to your tables by the tests will be rolled back automatically.Here’s an example:
from piccolo.testing.test_case import AsyncTransactionTest class TestBandEndpoint(AsyncTransactionTest): async def test_band_response(self): """ Make sure the endpoint returns a 200. """ band = Band({Band.name: "Pythonistas"}) await band.save() # Using an API testing client, like httpx: response = await client.get(f"/bands/{band.id}/") self.assertEqual(response.status_code, 200)
We add a
Band
to the database, but any subsequent tests won’t see it, as the changes are rolled back automatically.
- class piccolo.testing.test_case.AsyncTableTest(methodName='runTest')¶
Used for tests where we need to create Piccolo tables - they will automatically be created and dropped.
For example:
class TestBand(AsyncTableTest): tables = [Band] async def test_band(self): ...
- class piccolo.testing.test_case.TableTest(methodName='runTest')¶
Identical to
AsyncTableTest
, except it only work for sync tests. Only use this if you can’t make your tests async (perhaps you’re on Python 3.7 whereIsolatedAsyncioTestCase
isn’t available).For example:
class TestBand(TableTest): tables = [Band] def test_band(self): ...