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 where IsolatedAsyncioTestCase isn’t available).

For example:

class TestBand(TableTest):
    tables = [Band]

    def test_band(self):
        ...