All Articles

A Gentle Introduction to Testing with PyTest

A test is code that executes code. When you start developing a new feature for your Python project, you could formalize its requirements as code. When you do so, you not only document the way your implementation’s code shall be used, but you can also run all the tests automatically to always make sure your code matches your requirements. One such tool which assists you in doing this is pytest and it’s probably the most popular testing tool in the Python universe.

It’s all about assert

Let’s assume you have written a function that validates an email address. Note that we keep it simple here and don’t use Regular Expressions or DNS testing for validating email addresses. Instead, we just make sure that there is exactly one @ sign in the string to be tested and only Latin characters, numbers, and ., - and _ characters.

import string
def is_valid_email_address(s):
    s = s.lower()
    parts = s.split('@')
    if len(parts) != 2:
      # Not exactly one at-sign
      return False
    allowed = set(string.ascii_lowercase + string.digits + '.-_')
    for part in parts:
        if not set(part) <= allowed:
          # Characters other than the allowed ones are found
          return False
    return True

Now, we have some assertions to our code. For example, we assert that these email addresses are valid:

  • test@example.org
  • user123@subdomain.example.org
  • john.doe@email.example.org

On the other hand, we would expect that our function returns False for email addresses like:

  • not valid@example.org (includes a space)
  • john.doe (no @)
  • john,doe@example.org (includes a ,)

We can check that our function indeed behaves the way we expect:

print(is_valid_email_address('test@example.org'))               # True
print(is_valid_email_address('user123@subdomain.example.org'))  # True
print(is_valid_email_address('john.doe@email.example.org'))     # True
print(is_valid_email_address('not valid@example.org'))          # False
print(is_valid_email_address('john.doe'))                       # False
print(is_valid_email_address('john,doe@example.org'))           # False

These email address examples we come up with are called test cases. For each test case we expect a certain result. A testing tool like pytest can help automate test these assertions. Writing down these assertions can help you

  • document how your code is going to be used
  • make sure that future changes do not break other parts of your software
  • think about possible edge cases of your functionalities

To make that happen, we just create a new file for all of our tests and put a few functions in there.

def test_regular_email_validates():
    assert is_valid_email_address('test@example.org')
    assert is_valid_email_address('user123@subdomain.example.org')
    assert is_valid_email_address('john.doe@email.example.org')


def test_valid_email_has_one_at_sign():
    assert not is_valid_email_address('john.doe')

def test_valid_email_has_only_allowed_chars():
    assert not is_valid_email_address('john,doe@example.org')
    assert not is_valid_email_address('not valid@example.org')

Running tests

Easy example

So, we have two files in our project directory: validator.py and test_validator.py.

We can now simply run pytest from the command line. Its output should look something like this:

============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 3 items

test_validator.py ...                                                    [100%]

============================== 3 passed in 0.01s ===============================

pytest informs us that it has found three test functions inside test_validator.py and that all of these functions were passed (as indicated by the three dots ...).

The 100% indicator gives us a good feeling since we are confident that our validator works as expected. However, as outlined in the introduction, the validator function is far from perfect. And so are our test cases. Even without DNS testing, we would mark an email address like john.doe@example as valid while an address like john.doe+abc@gmail.com would be marked invalid.

Let’s add these test cases now to our test_validator.py

...
def test_valid_email_can_have_plus_sign():
    assert is_valid_email_address('john.doe+abc@gmail.com')

def test_valid_email_must_have_a_tld():
    assert not is_valid_email_address('john.doe@example')

If we run pytest again, we see failing tests:

============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 5 items

test_validator.py ...FF                                                  [100%]

=================================== FAILURES ===================================
_____________________ test_valid_email_can_have_plus_sign ______________________

    def test_valid_email_can_have_plus_sign():
>       assert is_valid_email_address('john.doe+abc@gmail.com')
E       AssertionError: assert False
E        +  where False = is_valid_email_address('john.doe+abc@gmail.com')

test_validator.py:17: AssertionError
_______________________ test_valid_email_must_have_a_tld _______________________

    def test_valid_email_must_have_a_tld():
>       assert not is_valid_email_address('john.doe@example')
E       AssertionError: assert not True
E        +  where True = is_valid_email_address('john.doe@example')

test_validator.py:20: AssertionError
=========================== short test summary info ============================
FAILED test_validator.py::test_valid_email_can_have_plus_sign - AssertionErro...
FAILED test_validator.py::test_valid_email_must_have_a_tld - AssertionError: ...
========================= 2 failed, 3 passed in 0.05s ==========================

Note that we got two FF in addition to out three ... dots to indicate that two test functions failed.

In addition, we get a new FAILURES section in our output which explains in detail at which point our test failed. That’s pretty helpful for debugging.

Note on Designing Tests

Our small validator example is a testament to the importance of designing tests.

We wrote our validator function first and then came up with some test cases for it. Soon we noticed that these test cases are by no means comprehensive. Instead, we missed some essential aspects of validating an email address.

You may have heard about Test Driven Development (TDD), which advocates for the exact opposite: Getting your requirements right by writing your test cases first and not start implementing a feature before you feel you have covered all test cases. This way of thinking has always been a good idea but has gained even more importance over time since software projects have increased complexity.

I will write another blog post about TDD soon to cover it in depth.

Configuration

Usually, a project setup is much more complicated than just a single file with a validator function in it.

You may have a Python package structure for your project, or your code relies on external dependencies like a database.

Fixtures

Setup and Tear Down

You might have used the term fixture in different contexts. For example, for the Django Webframework, fixtures refer to a collection of initial data to be loaded into the database. However, in pytest’s context, fixtures only refer to functions run by pytest before and/or after the actual test functions.

We can create such functions using the pytest.fixture() decorator. We do this inside the test_validator.py file for now.

import​ pytest

@pytest.fixture()
def database_environment():
    setup_database()
    yield
    teardown_database()

Note that setting up the database and tearing it down happens in the same fixture. The yield keyword inidcates the part where pytest running the actual tests.

To have the fixture actually be used by one of your test, you simply add the fixture’s name as an argument, like so (still in test_validator.py):

def test_world(database_environment):
    assert 1 == 1

Getting Data from Fixtures

Instead of using yield a fixture function can also return arbitrary values:

import pytest

@pytest.fixture()
def my_fruit():
    return "apple"

Again, requesting that fixture from a test function is done by providing the fixtures name as a parameter:

def test_fruit(my_fruit):
    assert my_fruit == "apple"

Configuration Files

pytest can read its project-specific configuration from one of these files:

  • pytest.ini
  • tox.ini
  • setup.cfg

Which file to use depends on what other tooling you might use in your project. If you have packaged your project, you should use the setup.cfg file. If you use tox to test your code in different environments, you can put the pytest configuration into the tox.ini file. The pytest.ini file is used can be used if you do not want to utilize any additional tooling, but pytest.

The configuration file looks mostly the same for each of these three file types:

For using pytest.ini and tox.ini:

[pytest]
addopts = ​-rsxX -l --tb=short --strict​

**If you are using the setup.cfg file, the only difference is that you have to prefix the [pytest] section with tool: like so:

[tool:pytest]
addopts = ​-rsxX -l --tb=short --strict​

conftest.py

Each folder containing test files can contain a conftest.py file which is read by pytest. This is a good place to place your custom fixtures into as these could be shared between different test files.

The conftest.py file(s) can alter the behaviour of pytest on a per-project basis.

Apart from shared fixtures you could place external hooks and plugins or modifiers for the PATH used by pytest to discover tests and implementation code.

CLI / PDB

During development, mainly when you write your tests before your implementation, pytest can be a beneficial tool for debugging.

We will have a look at the most useful command-line options.

Running Only One Test

If you want to run one particular test only, you can reference that test via the test_ file it is in and the function’s name:

pytest test_validator.py::test_regular_email_validates

Collect Only

Sometimes you just want to have a list of the test collection rather than executing all test functions.

pytest --collect-only
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 5 items

<Module test_validator.py>
  <Function test_regular_email_validates>
  <Function test_valid_email_has_one_at_sign>
  <Function test_valid_email_has_only_allowed_chars>
  <Function test_valid_email_can_have_plus_sign>
  <Function test_valid_email_must_have_a_tld>

========================== 5 tests collected in 0.01s ==========================

Exit on the first error

You can force pytest to stop executing further tests after a failed one:

pytest -x

Run the last failed test only

If you want to run only the tests that failed the last time, you can do so using the --lf flag:

pytest --lf

Run all tests, but run the last failed ones first

pytest --ff

Show values of local variables in the output

If we set up a more complex test function with some local variables, we can instruct pytest to display these local variables with the -l flag.

Let’s rewrite our test function like so:

...
def test_valid_email_can_have_plus_sign():
    email = 'john.doe+abc@gmail.com'
    assert is_valid_email_address('john.doe+abc@gmail.com')
...

Then,

pytest -l

will give us this output:

============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 5 items

test_validator.py ...FF                                                  [100%]

=================================== FAILURES ===================================
_____________________ test_valid_email_can_have_plus_sign ______________________

    def test_valid_email_can_have_plus_sign():
        email = 'john.doe+abc@gmail.com'
>       assert is_valid_email_address('john.doe+abc@gmail.com')
E       AssertionError: assert False
E        +  where False = is_valid_email_address('john.doe+abc@gmail.com')

email      = 'john.doe+abc@gmail.com'

test_validator.py:18: AssertionError
_______________________ test_valid_email_must_have_a_tld _______________________

    def test_valid_email_must_have_a_tld():
>       assert not is_valid_email_address('john.doe@example')
E       AssertionError: assert not True
E        +  where True = is_valid_email_address('john.doe@example')


test_validator.py:21: AssertionError
=========================== short test summary info ============================
FAILED test_validator.py::test_valid_email_can_have_plus_sign - AssertionErro...
FAILED test_validator.py::test_valid_email_must_have_a_tld - AssertionError: ...
========================= 2 failed, 3 passed in 0.09s ==========================

Using pytest with a debugger

pdb is a command line debugger built into Python. You can pytest to debug your test function’s code.

If you start pytest with --pdb, it will start a pdb debugging session right after an exception is raised in your test. Most of the time this is not particularly useful as you might want to inspect each line of code before the raised exception.

Another option is the --trace flag for pytest which will set a breakpoint at each test function’s first line. This might become a bit unhandy if you have a lot of tests. So, for debugging purposes, a good combination is --lf --trace which would start a debug session with pdb at the beginning of the last test that failed:

pytest --lf --trace
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example, configfile: pytest.ini
collected 2 items
run-last-failure: rerun previous 2 failures

test_validator.py
>>>>>>>>>>>>>>>>>>>> PDB runcall (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>>>
> /Users/bascodes/Code/blogworkspace/pytest-example/test_validator.py(17)test_valid_email_can_have_plus_sign()
-> email = 'john.doe+abc@gmail.com'
(Pdb)

CI / CD

In modern software projects, software is developed according to Test Driven Development principles and delivered through a Continuous Integration / Continuous Deployment pipeline that includes automated testing.

A typical setup is that commits to the main/master branch are rejected unless all test functions pass.

If you want to know more about using pytest in a CI/CD environment, stay tuned as I am planning a new article on that topic.