Python debugging inside testing

18 minute read

Published:

Table of Contents:


Preamble and helpful links

Firstly, here’s some sites elsewhere on the web that cover the same / similar things:

Let’s start by making the following assumptions:

  • You’re working on a Python codebase, and this codebase uses a test suite based on the pytest testing framework.
  • You’re relatively new to both software testing and Python debugging in general.
  • You have checked out the code, started adding a new feature, and have run the tests. However, the tests have broken, and in a complex and non-obvious way.

You may be asking: How can I get started inspecting or debugging what the issue is? Let’s answer that.


pdb: the Python debugger

First, let’s ignore testing (pytest) for a moment and discuss debugging.

Many code development programs (often called Integrated Development Environments (IDEs)) include their own integrated features for debugging Python code: for VS Code see here, for PyCharm see here, and for Spyder see here, etc. These can be easy or difficult to use, depending on the choice of IDE, how complex your Python installation environment is, how you’ve setup your tests, etc.

One thing you can always rely on is that Python also includes its own support for debugging inside every Python install; this is called pdb, and you can find the docs here https://docs.python.org/3/library/pdb.html#module-pdb. Even if you mainly program in an aforementioned IDE, you can always open a command line, activate your Python environment, and then use pdb to debug your code. This includes using it on remote servers, wherever your code is installed (such as over ssh)!

The most common way to use pdb for debugging is to insert the following function call into your code, anywhere. Note that this is one of the few built-in functions which are always available (see here), and therefore you do not need to import anything for it (assuming you’re using Python 3.7 or later)!

breakpoint()

Let’s start using a real example. Let’s say you are trying to make changes to hnn_core’s Network class (here). Let’s also say that you have built your own little run script that you’ve been using for testing and using your changes to Network, based off of our firing pattern example code here. Then let’s say that you want to inspect certain attributes in an instantiated Network object, just to confirm that your changes have been applied. Let’s look at the code from lines 22-29 of hnn-core/examples/howto/plot_firing_pattern.py: https://github.com/jonescompneurolab/hnn-core/blob/master/examples/howto/plot_firing_pattern.py#L22-L29:

###############################################################################
# Now let's build the network. We have used the same weights as in the
# :ref:`evoked example <sphx_glr_auto_examples_plot_simulate_evoked.py>`.
import matplotlib.pyplot as plt

net = jones_2009_model()

###############################################################################

I want to inspect what this Network object looks like after it’s created, but before the drives are added, and one way I can do this is by adding breakpoint() into this file, changing the code to look like this:

###############################################################################
# Now let's build the network. We have used the same weights as in the
# :ref:`evoked example <sphx_glr_auto_examples_plot_simulate_evoked.py>`.
import matplotlib.pyplot as plt

net = jones_2009_model()

breakpoint()

###############################################################################

Next, I run the file like normal:

python plot_firing_pattern.py

Instead of the usual output, instead I’m “dropped” into “pdb debugger console” that looks like the following:

> /Users/austinsoplata/rep/brn/hnn-core/examples/howto/plot_firing_pattern.py(29)<module>()
-> breakpoint()
(Pdb)

The program is now waiting on your input; this is similar to, but a little different from, a regular Python console or IPython console. The debugger has run the code up until breakpoint(), but not the code afterwards.

From here, you can do a lot of things, and those are shown by sending the command h to the debugger console (which is short for help):

> /Users/austinsoplata/rep/brn/hnn-core/examples/howto/plot_firing_pattern.py(29)<module>()
-> breakpoint()
(Pdb) h

Documented commands (type help <topic>):
========================================
EOF    cl         disable     ignore    n        return  u          where
a      clear      display     interact  next     retval  unalias
alias  commands   down        j         p        run     undisplay
args   condition  enable      jump      pp       rv      unt
b      cont       exceptions  l         q        s       until
break  continue   exit        list      quit     source  up
bt     d          h           ll        r        step    w
c      debug      help        longlist  restart  tbreak  whatis

Miscellaneous help topics:
==========================
exec  pdb

(Pdb)

h prints out the help menu, including all the commands that you can run inside the debugger console. Again, think of the debugger console as a slightly different version of the “console” you get when you run python by itself. This is very powerful, and I don’t have time to explain everything, but I will review some of the most common commands. See the list of websites at the beginning of this post for helpful explanations of all the commands.

l (short for list) shows us which line of code the debugger is currently stopped at, using a little arrow:

(Pdb) l
 24     # :ref:`evoked example <sphx_glr_auto_examples_plot_simulate_evoked.py>`.
 25     import matplotlib.pyplot as plt
 26
 27     net = jones_2009_model()
 28
 29  -> breakpoint()
 30
 31     ###############################################################################
 32     # ``net`` does not have any driving inputs and only defines the local network
 33     # connectivity. Let us go ahead and first add a distal evoked drive.
 34     # We need to define the AMPA and NMDA weights for the connections. An

If you also use an IDE, this is analogous to when you click and add a breakpoint to a specific line, then run your IDE’s debugger, and then your IDE highlights and stops at that line.

The function dir() (notice the parens) will print out all the names in our namespace, including any variables we have:

(Pdb) dir()
['__annotations__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__pdb_convenience_variables', '__spec__', 'hnn_core', 'jones_2009_model', 'net', 'op', 'plt', 'read_spikes', 'simulate_dipole', 'tempfile']

Recall that we were interested in our Network object. Similarly to a python or ipython console, we can type out the name of a variable to print out its value:

(Pdb) net
<Network | 35 L2_basket cells
100 L2_pyramidal cells
35 L5_basket cells
100 L5_pyramidal cells>

We can also inspect nested attributes or methods of an object in the console as well:

(Pdb) net.connectivity[1]
L2_pyramidal -> L2_pyramidal
cell counts: 100 srcs, 100 targets
connection probability: 1.0
loc: 'proximal'; receptor: 'ampa'
weight: 0.0005; delay: 1.0; lamtha: 3.0

This could then be where you inspect to make sure that your code changes have had their desired effect.

Now, let’s say we want to continue through the plot_firing_pattern.py file, but ONLY to the point after the first net.add_evoked_drive(...) function is run. There are many ways to do this but here are two:

You can use the command n in the debugger to advance to the “next” line:

(Pdb) n
> /Users/austinsoplata/rep/brn/hnn-core/examples/howto/plot_firing_pattern.py(38)<module>()
-> weights_ampa_d1 = {'L2_basket': 0.006562, 'L2_pyramidal': 7e-6,
(Pdb) l
 33     # connectivity. Let us go ahead and first add a distal evoked drive.
 34     # We need to define the AMPA and NMDA weights for the connections. An
 35     # "evoked drive" defines inputs that are normally distributed with a certain
 36     # mean and standard deviation.
 37
 38  -> weights_ampa_d1 = {'L2_basket': 0.006562, 'L2_pyramidal': 7e-6,
 39                        'L5_pyramidal': 0.142300}
 40     weights_nmda_d1 = {'L2_basket': 0.019482, 'L2_pyramidal': 0.004317,
 41                        'L5_pyramidal': 0.080074}
 42     synaptic_delays_d1 = {'L2_basket': 0.1, 'L2_pyramidal': 0.1,
 43                           'L5_pyramidal': 0.1}

See how the arrow shown after l indicates that the program is now at line 38. Let’s run n a few more times, then see where we end up:

(Pdb) n
> /Users/austinsoplata/rep/brn/hnn-core/examples/howto/plot_firing_pattern.py(39)<module>()
-> 'L5_pyramidal': 0.142300}
(Pdb) n
> /Users/austinsoplata/rep/brn/hnn-core/examples/howto/plot_firing_pattern.py(38)<module>()
-> weights_ampa_d1 = {'L2_basket': 0.006562, 'L2_pyramidal': 7e-6,
(Pdb) l
 33     # connectivity. Let us go ahead and first add a distal evoked drive.
 34     # We need to define the AMPA and NMDA weights for the connections. An
 35     # "evoked drive" defines inputs that are normally distributed with a certain
 36     # mean and standard deviation.
 37
 38  -> weights_ampa_d1 = {'L2_basket': 0.006562, 'L2_pyramidal': 7e-6,
 39                        'L5_pyramidal': 0.142300}
 40     weights_nmda_d1 = {'L2_basket': 0.019482, 'L2_pyramidal': 0.004317,
 41                        'L5_pyramidal': 0.080074}
 42     synaptic_delays_d1 = {'L2_basket': 0.1, 'L2_pyramidal': 0.1,
 43                           'L5_pyramidal': 0.1}

Huh???? We just ran the “run next line” command several times, but we’re still at line 38! What gives? Well, as it turns out, debuggers frequently do this for any command that spans multiple lines. They proceed through each line of the multiline command before “finishing” at the first line of the command. Only then do they proceed to the next “real” line of code. If we type n again, we will see that the program will not go to line 39, but instead end up at line 40 (the beginning of the next “real” command):

(Pdb) n
> /Users/austinsoplata/rep/brn/hnn-core/examples/howto/plot_firing_pattern.py(40)<module>()
-> weights_nmda_d1 = {'L2_basket': 0.019482, 'L2_pyramidal': 0.004317,
(Pdb) l
 35     # "evoked drive" defines inputs that are normally distributed with a certain
 36     # mean and standard deviation.
 37
 38     weights_ampa_d1 = {'L2_basket': 0.006562, 'L2_pyramidal': 7e-6,
 39                        'L5_pyramidal': 0.142300}
 40  -> weights_nmda_d1 = {'L2_basket': 0.019482, 'L2_pyramidal': 0.004317,
 41                        'L5_pyramidal': 0.080074}
 42     synaptic_delays_d1 = {'L2_basket': 0.1, 'L2_pyramidal': 0.1,
 43                           'L5_pyramidal': 0.1}
 44     net.add_evoked_drive(
 45         'evdist1', mu=63.53, sigma=3.85, numspikes=1, weights_ampa=weights_ampa_d1,

As expected, now we’re at the next weights... setting line.

However, as we said before, where we really want to be is after the net.add_evoked_drive(... call. Another faster way to get there is the following: let’s set a new, additional breakpoint from inside the debugger itself (the command b) at a later line (line 54):

(Pdb) b 54
Breakpoint 1 at /Users/austinsoplata/rep/brn/hnn-core/examples/howto/plot_firing_pattern.py:54

Then, we can use c to continue the program until the next breakpoint (or until the end of the program, whichever comes first):

(Pdb) c
> /Users/austinsoplata/rep/brn/hnn-core/examples/howto/plot_firing_pattern.py(54)<module>()
-> weights_ampa_p1 = {'L2_basket': 0.08831, 'L2_pyramidal': 0.01525,
(Pdb) l
 49     ###############################################################################
 50     # The reason it is called an "evoked drive" is it can be used to simulate
 51     # waveforms resembling evoked responses. Here, we show how to do it with two
 52     # proximal drives which drive current up the dendrite and one distal drive
 53     # which drives current down the dendrite producing the negative deflection.
 54 B-> weights_ampa_p1 = {'L2_basket': 0.08831, 'L2_pyramidal': 0.01525,
 55                        'L5_basket': 0.19934, 'L5_pyramidal': 0.00865}
 56     synaptic_delays_prox = {'L2_basket': 0.1, 'L2_pyramidal': 0.1,
 57                             'L5_basket': 1., 'L5_pyramidal': 1.}
 58
 59     # all NMDA weights are zero; pass None explicitly

To be clear, when a program being debugged arrives at a line with a breakpoint like line 54 above, you can think of the breakpoint as “up to, but not including” the line. In other words, line 54 has not been executed yet, but all the code before it has. We can confirm this by testing if weight_ampa_p1 has been created:

(Pdb) weights_ampa_p1
*** NameError: name 'weights_ampa_p1' is not defined

The final thing I will review for typical pdb usage is that, just like a regular python or ipython console, you can re-define variables in the middle of the program being executed. For example, we can do the following, which will eventually break our program:

(Pdb) net = ['asdf']
(Pdb) net
['asdf']

In the middle of the program, we have changed net from a valid Network object to one that is a list with a silly string in it. If we use c to continue the program, it will try to run the program to the end (since there are no more breakpoints), but our program will break the next time that we use net, since net has been significantly changed:

(Pdb) c
Traceback (most recent call last):
  File "/Users/austinsoplata/rep/brn/hnn-core/examples/howto/plot_firing_pattern.py", line 60, in <module>
    net.add_evoked_drive(
    ^^^^^^^^^^^^^^^^^^^^
AttributeError: 'list' object has no attribute 'add_evoked_drive'

Speaking of pdb in general, there are other ways to invoke pdb, as mentioned in its documentation, but breakpoint() is probably the most frequent and easy. For example, we could start our program already inside the debugger without any pre-established breakpoints using.

python -m pdb plot_firing_pattern.py

Enhancing pdb

There are at least several attempts to make pdb and related usage more user-friendly, convenient, and powerful, including both:

  • pdbpp: https://github.com/pdbpp/pdbpp, my personal favorite, which is very configurable and provides things like syntax highlighting, better tab completion, “sticky mode”, and seems to work in-place with pytest support. You can install it with pip install pdbpp. It specifically advertises itself as a “drop-in replacement”, meaning that it will take the place of your regular pdb usage by default (which is very convenient).
  • ipdb: https://github.com/gotcha/ipdb

Invoking pytest

“Okay Austin”, you say, “that’s great but you’ve gotten off-topic from pytest” Correct. Let’s do the converse: let’s ignore pdb for now and only discuss how to control execution of our tests.

Most uses of pytest testing framework involve running the command pytest, followed specifically by the files where your tests are located, followed by options, such as shown here and pasted below:

pytest ./hnn_core/tests/ -m "uses_mpi"

This command is assumed to be running from the top-level directory of where your https://github.com/jonescompneurolab/hnn-core source code is located (i.e. inside hnn-core, not inside its sub-directory hnn_core). Let’s ignore the -m "uses_mpi part of the pytest call.

You could run all of our tests by using the following from the command line, and from inside hnn-core:

pytest ./hnn_core/tests/

This runs all of the tests inside the hnn-core/hnn_core/tests directory.

However, let’s say that you’re only interested in a single one of the test files. You could run only the tests in that specific file by passing only that filename in the invocation (instead of the directory), using the following:

pytest ./hnn_core/tests/test_network.py

Let’s go further, and say you’re only interested in one specific test from that file. You can actually run only that specific test through the command-line as well! For example, let’s say I only want to run the test called test_network_models() (code here) inside the file mentioned above. I would then add ::<function_name> to the end of my command from above, as shown below:

pytest ./hnn_core/tests/test_network.py::test_network_models

If you run the above, then as part of the pytest output, you should see collected 1 item followed by the filename, followed by the test status. This enables you to examine or re-run any specific test without having to re-run the entire test suite!


Using pdb with pytest

Finally, it’s time to combine our knowledge. Fortunately, pytest has built-in and convenient pdb support! All you have to do is add the --pdb option to your pytest invocation. See docs here.

The shortest way to get started to use the following from the command line:

pytest --pdb

However, as before, let’s provide a better, more real example. Let’s say I make a local change to hnn_core/tests/test_network.py::test_network_models (see here), where I change line 97 from this:

    net_law = law_2021_model()

to this:

    net_law = list()

When I next run my function-specific test, this will fail:

pytest ./hnn_core/tests/test_network.py::test_network_models

What are we to do? Simply add --pdb to the end of our command!

pytest ./hnn_core/tests/test_network.py::test_network_models --pdb

When (and WHERE) the test fails, instead of just reporting a failed test, pytest will “drop” us down into a pdb debug console like before, but exactly where the test fails:

(hc13) {15:08}~/rep/brn/hnn-core:master ✗ ➭ pytest ./hnn_core/tests/test_network.py::test_network_models --pdb
================================================================================================ test session starts =================================================================================================
platform darwin -- Python 3.13.2, pytest-8.4.0, pluggy-1.6.0
rootdir: /Users/austinsoplata/rep/brn/hnn-core
configfile: pytest.ini
plugins: xdist-3.7.0, anyio-4.9.0, cov-6.2.1
collected 1 item

hnn_core/tests/test_network.py F
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> traceback >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

    def test_network_models():
        """ "Test instantiations of the network object"""
        # Make sure critical biophysics for Law model are updated
        net_law = list()
        # instantiate drive events for NetworkBuilder
>       net_law._instantiate_drives(
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^
            tstop=net_law._params["tstop"], n_trials=net_law._params["N_trials"]
        )
E       AttributeError: 'list' object has no attribute '_instantiate_drives'

hnn_core/tests/test_network.py:99: AttributeError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> PDB post_mortem (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /Users/austinsoplata/rep/brn/hnn-core/hnn_core/tests/test_network.py(99)test_network_models()
-> net_law._instantiate_drives(
(Pdb)

This is awesome! We can now examine what the state of all the variables are when the tests fail. However, if we try to use c to continue the program in our pdb session, we’ll just get the same output as before.

Another way that pytest supports pdb is with breakpoint() insertion like normal Python code. Let’s go inside our test_network.py file, and reverse the line 97 change. Then, let’s add a breakpoint() after it, such that our lines 97 and 98 look like:

97    net_law = law_2021_model()
98    breakpoint()

Now, let’s try to get to our breakpoint using the pytest invocation from above:

pytest ./hnn_core/tests/test_network.py::test_network_models --pdb

We get a pdb debug console, same as before:

(hc13) {15:26}~/rep/brn/hnn-core:master ✗ ➭ pytest ./hnn_core/tests/test_network.py::test_network_models
================================================================================================ test session starts =================================================================================================
platform darwin -- Python 3.13.2, pytest-8.4.0, pluggy-1.6.0
rootdir: /Users/austinsoplata/rep/brn/hnn-core
configfile: pytest.ini
plugins: xdist-3.7.0, anyio-4.9.0, cov-6.2.1
collected 1 item

hnn_core/tests/test_network.py
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> PDB set_trace (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /Users/austinsoplata/rep/brn/hnn-core/hnn_core/tests/test_network.py(98)test_network_models()
-> breakpoint()
(Pdb) l
 93
 94     def test_network_models():
 95         """ "Test instantiations of the network object"""
 96         # Make sure critical biophysics for Law model are updated
 97         net_law = law_2021_model()
 98  ->     breakpoint()
 99         # instantiate drive events for NetworkBuilder
100         net_law._instantiate_drives(
101             tstop=net_law._params["tstop"], n_trials=net_law._params["N_trials"]
102         )
103
(Pdb)

Thus, we can use breakpoint() to investigate our test code just like we can use it for normal code. Note that the --pdb option isn’t strictly necessary in this instance (we’ve explicitly provided a breakpoint()), but anytime you are debugging tests, it is good to have it.

If you want to get to a pdb debug console at the beginning of the test, rather than waiting until things break, you can pass --trace to pytest instead of --pdb: see here in the pytest docs.

In my personal experience, debugging stand-alone scripts or code using IDE debuggers tends to be pretty straightforward, but debugging pytest calls from an IDE tend to be much harder to configure and get working. If you are trying to debug pytest tests specifically, you may find it easier to use these above pdb-based methods instead of your IDE. They’ll also work everywhere.

Note: whenever you are debugging your tests, it is recommended to not run independent tests in parallel, such as what we do by default using -n auto in our default pytest calls here https://github.com/jonescompneurolab/hnn-core/blob/master/Makefile#L60 (-n auto is provided by pytest-xdist, not regular pytest). If you insert a breakpoint() or try to invoke a pdb debug console session for tests that are running fully parallel, you may get multiple cores entering a debug console session simultaneously, and the input/output may be garbled. Instead, it is recommended to 1. only run the most specific tests you can at a time (such as a single function or file), and 2. run them without the -n auto arguments, so that all tests are run serially.