FlowFX

Speeding up Django unit tests with SQLite, reuse-db and RAMDisk

Recently, Harry wrote a post about how to speed up Django unit-testing by using a persistent in-memory SQLite database.

While Harry uses the default Django testrunner and a Linux machine, I use pytest with pytest-django on a Mac. This changes a few things, and this post will show the differences. So it's very specific to:

Create and mount the RAMDisk

/dev/shm is a Linux thing. But Harry already provided the link to a working solution on Stack Overflow to how to do this on macOS.

$ hdiutil attach -nomount ram://$((2 * 1024 * SIZE_IN_MB))
/dev/disk2

$ diskutil eraseVolume HFS+ RAMDisk /dev/disk2

This creates an in-memory Volume that can store the persistent SQLite database.

Use SQLite

Maybe I'm doing this all wrong, but because I use pytest, I configure the test database in a separate configuration file like this.

# config/settings/testing.py
from .common import *

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': ':memory:',
        'TEST': {}
    }
}

Use it in-memory

What --keepdb is for the default testrunner, --reuse-db is for pytest-django. I usually set this as the default for all test runs in pytest.ini.

# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE=config.settings.testing
addopts = --reuse-db

To not re-use the database, pytest-django's argument is --create-db. So Harry's

if 'keepdb' in sys.argv:

becomes

if not 'create-db' in sys.argv:

in my case. To use the RAMDisk:

# config/settings/testing.py

[...]

import sys
if not 'create-db' in sys.argv:
    # and this allows you to use --reuse-db to skip re-creating the db,
    # even faster!
    DATABASES['default']['TEST']['NAME'] = '/Volumes/RAMDisk/myfunnyproject.test.db.sqlite3'

When there's no RAMDisk

If there is no RAMDisk, for example after a reboot, then the test run fails as soon as it hits the database. Obviously.

>       conn = Database.connect(**conn_params)
E       django.db.utils.OperationalError: unable to open database file

I just throw another if statement in there to check for the existence of the RAMDisk.

if os.path.isdir('/Volumes/RAMDisk') and not 'create-db' in sys.argv:

Summary

You can check a full test configuration here:

https://github.com/FlowFX/unkenmathe.de/blob/master/src/config/settings/testing.py

Test-driven Python learning

Bugs are nice because I can learn from them. Recently I found a bug that taught me about floats in Python and how not to use them. I found it thanks to an automated test.

The test

In a legal contract I had to print a monetary amount in a format similar to

MXN $11,600.00 (eleven thousand, six hundred pesos 00/100 cents)

My tests check the LaTeX source of the resulting PDF, looking for the correctly formatted string 11,600.00, which represents an amount of 11,000 something plus a VAT of 16%, and also for the string eleven thousand, six hundred.

It essentially goes like this:

from app.models import LeaseAgreement

def test_contract_states_rent_plus_vat():
    """Test correct statement of rent amount with and without IVA (VAT (USt (MWSt)))."""
        
    contract = LeaseAgreement.build(    
        rent_amount=Decimal(10000.00)
    )

    tex = contract.render_tex()
    
    assert '10,000.00' in tex
    assert '11,600.00' in tex
    assert 'eleven thousand, six hundred' in tex

The first two asserts did not fail, the third one did. My code actually printed

MXN $11,600.00 (eleven thousand, five hundred and ninety-nine pesos 00/100 cents)

The bug

Turns out I had misused Python's Decimal class to calculate the amount plus VAT. Like this:

>>> x = Decimal(10000) * Decimal(1.16)

Printing the number in the desired format works fine.

>>> print('{:,.2f}'.format(x))
11,600.00

But rounding down for only printing the amount without decimal places does not.

>>> num2words(int(x))
'eleven thousand, five hundred and ninety-nine'

That is because floats are not exact.

>>> Decimal(1.16)
Decimal('1.1599999999999999200639422269887290894985198974609375')
>>> int(Decimal(1.16)*10000)
11599

The fix

Working with monetary amounts that round to 2 digits requires care. Thanks to Rami and others, I now know that I should have used e.g.

Decimal('1.16')

and

Decimal('1.16').quantize(Decimal('.01'), rounding=ROUND_HALF_UP)

when apropriate.

Several Ways To Live In Mexico City

There are several ways to live, and many more in Mexico City. Nick Farr and I have joined up to talk about life and food in Mexico. We named this English language podcast

Several Ways To Live In Mexico.

Today we released episode 2, and we have enough topics to produce quite some more.

Check it out, and give us feedback on the Twitter or Mastodon!

Test Django with Selenium, pytest and user authentication

When testing a Django app with Selenium, how do you authenticate the user and test pages that require to be logged in?

Of course: StackOverflow has the answer.

The following is the actual code that I use to make this work with pytest. It requires pytest-django.

pytest fixtures

In pytest everything is contained in neat test fixtures.

Browser

The first fixture provides a broser/webdriver instance with an anonymous user. The default way of using Selenium.

# system_tests/conftest.py

from selenium import webdriver

@pytest.fixture(scope='module')
def browser(request):
    """Provide a selenium webdriver instance."""
    # SetUp
    options = webdriver.ChromeOptions()
    options.add_argument('headless')

    browser_ = webdriver.Chrome(chrome_options=options)

    yield browser_

    # TearDown
    browser_.quit()

User

To be able to authenticate, I need a user in the database. Using Factory Boy:

# system_tests/conftest.py

from django.contrib.auth.hashers import make_password
from accounts.factories import UserFactory

TESTEMAIL = 'test-user@example.com'
TESTPASSWORD = 'a-super-secret-password'

@pytest.fixture()
def user(db):
    """Add a test user to the database."""
    user_ = UserFactory.create(
        name='I am a test user',
        email=TESTEMAIL,
        password=make_password(TESTPASSWORD),
    )

    return user_

Authenticated browser

To get the authenticated browser, the first two fixtures are required, plus the Django TestClient and LiveServer fixtures which, for pytest, are provided by pytest-django. Using the code from SO:

# system_tests/conftest.py

@pytest.fixture()
def authenticated_browser(browser, client, live_server, user):
    """Return a browser instance with logged-in user session."""
    client.login(email=TESTEMAIL, password=TESTPASSWORD)
    cookie = client.cookies['sessionid']

    browser.get(live_server.url)
    browser.add_cookie({'name': 'sessionid', 'value': cookie.value, 'secure': False, 'path': '/'})
    browser.refresh()

    return browser

The tests

Now the Selenium test can use the authenticated_browser fixture.

# system_tests/test_django.py

def test_django_with_authenticated_user(live_server, authenticated_browser):
    """A Selenium test."""
    browser = authenticated_browser

    # Open the home page
    browser.get(live_server.url)

To test logging in and out of the app, I use the unauthenticated browser instance plus the user fixture.

# system_tests/test_django.py

def test_login_of_anonymous_user(live_server, browser, user):
    # Open the home page
    browser.get(live_server.url)
    
    # Click the 'login' button
    browser.find_element_by_id('id_link_to_login')).click()

Summary

These are the pytest fixtures that I use to test a Django app with an authenticated user.

If there is a better way to do this, please tell me! I want to know.

pytest parameter matrices

A few months ago I explained how I efficiently test Django forms with pytest parameterization. Last week, I learned a new trick from Raphael Pierzina's post about ids for fixtures and parametrize, which is:

You you can add multiple parametrization markers to a test function which then create a test parameter matrix.

The list of test cases can thus be written much more clearly. Compare the example code from my previous post:

from django import forms

import pytest


class ExampleForm(forms.Form):
    name = forms.CharField(required=True)
    age = forms.IntegerField(min_value=18)


@pytest.mark.parametrize(
    'name, age, validity',
    [('Hugo', 18, True),
     ('Egon', 17, False),
     ('Balder', None, False),
     ('', 18, False),
     (None, 18, False),
     ])
     
def test_example_form(name, age, validity):
    form = ExampleForm(data={
        'name': name,
        'age': age,
    })

    assert form.is_valid() is validity

to the same test using multiple parameterization markers:

@pytest.mark.parametrize(
    'name, valid_name',
    [
        ('Hugo', True),
        ('', False),
        (None, False),
    ]
)
@pytest.mark.parametrize(
    'age, valid_age',
    [
        ('18', True),
        ('17', False),
        (None, False),
    ]
 )
def test_example_form(name, age, valid_name, valid_age):
    form = ExampleForm(data={
        'name': name,
        'age': age,
    })

    assert form.is_valid() is (valid_name and valid_age)

This tests all 9 possible combinations of the three test cases each for name and age. Maybe that's overkill, but on the other hand I can be sure that I touch all the relevant combinations.

Take note of the last line that checks that the form only validates when both input parameters are valid.

The main advantage I see is legibility. For each form field, I only have to understand the list of test parameters for exactly that field, and not any additional combinations with other fields.

I'm loving it!