Testing Click applications with Pytest

August 20, 2019 · 3 minute read

It’s good practice to, as much as possible, write tests for your code. If you’re working with Python, pytest makes the process of writing and running tests much smoother. I wrote a few posts some time back on getting started with testing with pytest, so if you’re completely new to it, you might want to take a look at them:

For testing CLI apps, Click provides a convenient module: click.testing which has some useful functions (notably CliRunner()) to help us invoke commands and check their behavior.

We’ll go ahead and test each part of our app - creating, reading, updating and deleting.

Installing pytest and writing the first test

pytest can be installed via pip:

(env) $ pip install pytest

After installing pytest, create a tests folder in the root directory and add the first test file:

(env) $ mkdir tests && cd tests

(env) $ touch test_app.py

In the test_app file, add the following code for a start:

def test_add():
    pass

To run the test, run pytest on the terminal:

(env) $ pytest
================== test session starts ====================
platform linux -- Python 3.7.3, pytest-5.1.0, py-1.8.0, pluggy-0.12.0
rootdir: /home/wangonya/code/contacts-cli
collected 1 item

tests/test_app.py .                              [100%]

================== 1 passed in 0.04s =======================

Testing the add command

Let’s edit the test_app file to add a test to see if the add command adds a new contact:

from click.testing import CliRunner

from app import add

runner = CliRunner()

def test_add():
    response = runner.invoke(add, ["test-user", "-m", "0"])
    assert response.exit_code == 0
    assert "Contact test-user added!" in response.output
    assert "{'mobile': '0'}" in response.output

First, we invoke the command as we would on the terminal, passing in the required arguments and options: response = runner.invoke(add, ["test-user", "-m", "0"]).

We then check that the command executes successfully: assert response.exit_code == 0.

If the command executes successfully, we expect a success message should be returned in the response with the values we added:

assert "Contact test-user added!" in response.output
assert "{'mobile': '0'}" in response.output

The rest of the tests will pretty much follow the same format.

Testing the list command

def test_list():
    response = runner.invoke(list)
    assert response.exit_code == 0
    assert "Here\'s a list of all your contacts:" in response.output
    assert "'test-user': {'mobile': '0'}" in response.output

The list command doesn’t take any arguments or options so we just call it directly: response = runner.invoke(list).

Testing the view command

def test_view():
    response = runner.invoke(view, "test-user")
    assert response.exit_code == 0
    assert "{'mobile': '0'}" in response.output

Testing the update command

def test_update():
    response = runner.invoke(update, ["test-user", "-m", "12345"])
    assert response.exit_code == 0
    assert "Contact updated!" in response.output
    assert "{'mobile': '12345'}" in response.output

Testing the delete command

def test_delete():
    response = runner.invoke(delete, "test-user")
    assert response.exit_code == 0
    assert "Contact deleted!" in response.output

    # call view on test-user to confirm it doesn't exist
    response = runner.invoke(view, "test-user")
    assert response.exit_code == 0
    assert "The contact you searched for doesn't exist" in response.output

Improvements

As your application grows, you may want to consider using fixtures and set up things like runner in a conftest.py file. We got away with it here because our tests were simple and all in a single file. Once multiple test files are introduced, following the approach we used here would lead to a lot of unnecessarily duplicated code.

Also, we made direct calls to our API in the tests. This operation should ideally be mocked.