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:
- Python TDD with Pytest -- Getting Started
- Asserting Exceptions with Pytest
- Capturing print statements while debugging
- Skipping tests
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.