How we automated taking screenshots of Django applications

Both our documentation as well as our website contain a number of screenshots of our software. Taking these screenshots manually is really tedious, since you first need to populate your database with sensible test data, select a proper display resolution and then take screenshots separately for every language our software supports, so we can use them in the localized websites properly.

Once you’re finally done with it, your clock is ticking until the first major software change comes around that forces you to do it all over again. For example, we changed our application’s color scheme this May, added new languages starting in June and just last week reworked our sidebar navigation. Obviously, we’d be very interested in being able to re-create all screenshots easily and automatically.

We found a solution for this that we think of being very elegant, and we’d love to share it with you. We’ll need a few things in the process, so I’m going to explain them first.

Our ingredients

Selenium

If you haven’t heard of Selenium, the shortest way to describe it is as a remote control for browsers. It allows you to send high-level commands like “open this web page”, “click on this button” or “scroll down” to most major browsers. Selenium is usually used for integration and functional testing of frontend-heavy web applications, but comes in really handy here as well.

Chrom(e/ium) headless

Now that we’ve got a remote control for browsers, we also need a browser to control remotely. Just using Firefox or any other browser works, but has some real disadvantages: Running a browser usually requires a display server and a graphical environment, which is usually not available on servers. You can fake it with tools with xvfb, but it isn’t a lot of fun. Also, you don’t really want the size of your screenshot to depend on the size of your display or the theme of your operating system that controls how much of the screen is taken by the browser’s controls.

In the past, the way to go was a headless browser like PhantomJS, but PhantomJS has not only been deprecated this year, it also lacked a lot in terms of screenshot rendering, especially when it comes to font kerning.

Fortunately, Chrome gained the possibility to run headless without a display server last year, starting with Chrome 59. This way, we can make use of Chrome’s top-notch rendering while still being able to run this on servers efficiently.

py.test

py.test is a popular unit test runner for Python. Basically, it auto-discovers all test functions in your code and runs them in a flexible and configurable way. With py.test, tests are just simple Python functions, for example:

def inc(x):
    return x + 1

def test_answer():
    assert inc(3) == 5

When running py.test on that file, you will get an output report of the results that looks like this:

$ py.test
======= test session starts ========
collected 1 item
test_sample.py F
======= FAILURES  ========
_______ test_answer ________
...
test_sample.py:5: AssertionError
======= 1 failed in 0.12 seconds ========

py.test also has a feature called fixtures, that allows you to define certain functions as pre-conditions for a test function. When a test function then defines an input parameter of the same name that a fixture has, py.test will automatically execute the fixture function and pass the return value as the input parameter to the test function:

import pytest

@pytest.fixture
def smtp():
    import smtplib
    return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)

def test_ehlo(smtp):
    response, msg = smtp.ehlo()
    assert response == 250

pytest-django

The package pytest-django provides a set of fixtures and test markers that enable useful features such as resetting the database after every test and running a live HTTP server during tests, which will come in very handy.

The setup

py.test looks for a configuration file named pytest.ini in its current drectory, which we can use to change the terminology from tests to screenshots – that doesn’t make a lot of a difference, but it looks nicer:

[pytest]
DJANGO_SETTINGS_MODULE=pretix.testutils.settings
python_functions = shot_*
python_files = scene_*
python_classess = *Scene
addopts =--driver Chrome

Then, we use the conftest.py file to create a number of useful fixtures. For example, we create fixtures for things we want to have in our database when we do the screenshots:

@pytest.fixture
def user():
    return User.objects.create_user(
        'john@example.org', 'john',
        fullname='John Doe'
    )

But we also create fixtures for our general technical setup, such as a fixture that starts up a live HTTP server and a chrome browser and then already logs a user into the system:

def logged_in_client(live_server, selenium, user):
    selenium.get(live_server.url + '/control/login')
    selenium.implicitly_wait(10)

    selenium.find_element_by_css_selector(
        "form input[name=email]"
    ).send_keys(user.email)
    selenium.find_element_by_css_selector(
        "form input[name=password]"
    ).send_keys('john')
    selenium.find_element_by_css_selector(
        "form button[type=submit]"
    ).click()
    return selenium

Next, we need a fixture that provides our Chrome settings, such as the desired screen size:

@pytest.fixture
def chrome_options(chrome_options):
    chrome_options.add_argument('headless')
    chrome_options.add_argument('window-size=1024x768')

Next, let’s create a parameterized fixture that will automatically be used for every test and runs the test with different active languages:

@pytest.yield_fixture(params=["en", "de"], autouse=True)
def locale(request):
    with translation.override(request.param):
        yield request.param

Finally, we create a short utility method that actually creates a screenshot:

def screenshot(client, name):
    time.sleep(1)
    os.makedirs(os.path.join('screens', os.path.dirname(name)), exist_ok=True)
    client.save_screenshot(os.path.join('screens', name))

Actually taking screenshots

With all of this setup, we can now define screenshots of specific pages in a very simple, declarative way where we just specify the objects we want to have in the database and the URL we want to visit:

@pytest.mark.django_db
def shot_organizer_list(live_server, organizer,
                        logged_in_client):
    logged_in_client.get(
        live_server.url + '/control/organizers/'
    )
    screenshot(logged_in_client, 'organizer/list.png')

Of course, we can also define more complex processes. For example, this is how we take screenshots of crating a new event within pretix, which is a multi-step process:

@pytest.mark.django_db
def shot_event_creation(live_server, organizer, event, logged_in_client):
    logged_in_client.get(
        live_server.url + '/control/events/add'
    )
    logged_in_client.find_element_by_css_selector(
        "input[name='foundation-organizer'][value='%d']"
        % organizer.pk
    ).click()
    logged_in_client.find_element_by_css_selector(
        "input[name='foundation-locales'][value='en']"
    ).click()
    screenshot(logged_in_client, 'event/create_step1.png')
    logged_in_client.find_element_by_css_selector(
        ".submit-group .btn-primary"
    ).click()
    logged_in_client.find_element_by_css_selector(
        "input[name='basics-name_0']"
    ).send_keys("Demo Conference")
    logged_in_client.find_element_by_css_selector(
        "input[name='basics-slug']"
    ).send_keys("democon")
    logged_in_client.find_element_by_css_selector(
        "input[name='basics-date_from']"
    ).send_keys("2018-02-01 08:00:00")
    screenshot(logged_in_client, 'event/create_step2.png')
    logged_in_client.find_element_by_css_selector(
        ".submit-group .btn-primary"
    ).click()
    # …

Taking a set of screenshots now is as easy as running a single command:

$ py.test scenes
…
Results (45.80s):
       9 passed

If we change our design again, that’s all it takes to replace all screenshots in our design and documentation. If you want to see how it all works together in practice, of course our tool is available on GitHub for you to have a look at. Let us know what you think!