How to debug pytest test cases without code modifying

Everybody in python knows the power of import pdb;pdb.set_trace() and breakpoint().

Alas, in many cases, it is not convenient to modify the source: project could be redeployed and your lines are gone, modified source requires additional efforts for simple git pull, and so on.

Let's see how to debug w/o any intervention in source code.

Here is the simple script to debug:

~/WORK/AQA/PDB$ cat -n do_banana.py  
     1    a = {"a": 1, "b" : [2,3, {"c": 45}], "c": None}
     2
     3    for key in a:
     4        print(a[key] * 3)

Let's run it:

~/WORK/AQA/PDB$ python3 do_banana.py
3
[2, 3, {'c': 45}, 2, 3, {'c': 45}, 2, 3, {'c': 45}]
Traceback (most recent call last):
  File "do_banana.py", line 4, in <module>
    print(a[key] * 3)
TypeError: unsupported operand type(s) for *: 'NoneType' and 'int'

dkravchenko@dkravchenko:~/WORK/AQA/PDB$

Now, will try to use pdb (which is the part of Python Standard Library) as module, to fall into debugger in case of error:

(consider that pdb makes pause before real running to allow you to set up breakpoints, if you do not need it, just make c(ontinue))

~/WORK/AQA/PDB$ python3 -m pdb do_banana.py

> /home/dkravchenko/WORK/AQA/PDB/do_banana.py(1)<module>()
-> a = {"a": 1, "b" : [2,3, {"c": 45}], "c": None}

(Pdb) c
3
[2, 3, {'c': 45}, 2, 3, {'c': 45}, 2, 3, {'c': 45}]
Traceback (most recent call last):
  File "/usr/lib/python3.8/pdb.py", line 1705, in main
    pdb._runscript(mainpyfile)
  File "/usr/lib/python3.8/pdb.py", line 1573, in _runscript
    self.run(statement)
  File "/usr/lib/python3.8/bdb.py", line 580, in run
    exec(cmd, globals, locals)
  File "<string>", line 1, in <module>
  File "/home/dkravchenko/WORK/AQA/PDB/do_banana.py", line 1, in <module>
    a = {"a": 1, "b" : [2,3, {"c": 45}], "c": None}
TypeError: unsupported operand type(s) for *: 'NoneType' and 'int'
Uncaught exception. Entering post mortem debugging
Running 'cont' or 'step' will restart the program
> /home/dkravchenko/WORK/AQA/PDB/do_banana.py(1)<module>()
-> a = {"a": 1, "b" : [2,3, {"c": 45}], "c": None}

(Pdb) l
  1  ->    a = {"a": 1, "b" : [2,3, {"c": 45}], "c": None}
  2
  3      for key in a:
  4  >>        print(a[key] * 3)
[EOF]

(Pdb) key
'c'

(Pdb) q
Post mortem debugger finished. The /home/dkravchenko/WORK/AQA/PDB/do_banana.py will be restarted
> /home/dkravchenko/WORK/AQA/PDB/do_banana.py(1)<module>()
-> a = {"a": 1, "b" : [2,3, {"c": 45}], "c": None}

(Pdb) q
~/WORK/AQA/PDB$

Now, let's set unconditional breakpoint at line 4:

~/WORK/AQA/PDB$ python3 -m pdb do_banana.py
> /home/dkravchenko/WORK/AQA/PDB/do_banana.py(1)<module>()
-> a = {"a": 1, "b" : [2,3, {"c": 45}], "c": None}

(Pdb) b 4
Breakpoint 1 at /home/dkravchenko/WORK/AQA/PDB/do_banana.py:4

(Pdb) c
> /home/dkravchenko/WORK/AQA/PDB/do_banana.py(4)<module>()
-> print(a[key] * 3)

(Pdb) key
'a'

(Pdb)

Breakpoint could be conditional as well:

~/WORK/AQA/PDB$ python3 -m pdb do_banana.py
> /home/dkravchenko/WORK/AQA/PDB/do_banana.py(1)<module>()
-> a = {"a": 1, "b" : [2,3, {"c": 45}], "c": None}

(Pdb) b 4, key=='b'
Breakpoint 1 at /home/dkravchenko/WORK/AQA/PDB/do_banana.py:4

(Pdb) c
3
> /home/dkravchenko/WORK/AQA/PDB/do_banana.py(4)<module>()
-> print(a[key] * 3)

(Pdb) key
'b'

Besides breakpoints, pdb module provides many useful commands and options. It is good to be, at least, aware of what pdb can.

Now, proceed with pytest.

~/WORK/AQA/PYTEST_PDB$ cat -n test_banana.py
     1    def test_banana():
     2        a=6
     3        b=11
     4        res = a / (b - 11)
     5        assert True

Many people know pytest --pdb CLI option (other options in sample are needed to produce minimal output and specify exact test case; also, consider '!' required to distinguish variable from pdb commands a(rgs) and b(reak)):

~/WORK/AQA/PYTEST_PDB$ pytest -q --pdb --tb=short --show-capture=no -k banana

F
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> traceback >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
test_banana.py:4: in test_banana
    res = a / (b - 11)
E   ZeroDivisionError: division by zero
>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>

>>>>>>>>>>>>> PDB post_mortem (IO-capturing turned off) >>>>>>>>>>>>>
> /home/dkravchenko/WORK/AQA/PYTEST_PDB/test_banana.py(4)test_banana()
-> res = a / (b - 11)

(Pdb) l
  1      def test_banana():
  2          a=6
  3          b=11
  4  ->        res = a / (b - 11)
  5          assert True
[EOF]

(Pdb) !a
6

(Pdb) !b
11

It is nice, but how can one add arbitrary breakpoint in test case? And what to do if project consists of multiple files? The answer is to run pdb and pytest as module both (consider that pdb should be first):

~/WORK/AQA/PYTEST_PDB$ python3 -m pdb -m pytest -q --tb=short --show-capture=no -s -k banana
> /usr/local/lib/python3.8/dist-packages/pytest/__main__.py(1)<module>()
-> """The pytest entry point."""

(Pdb) b test_banana.py:3, b > 5
Breakpoint 1 at /home/dkravchenko/WORK/AQA/PYTEST_PDB/test_banana.py:3

(Pdb) c
> /home/dkravchenko/WORK/AQA/PYTEST_PDB/test_banana.py(3)test_banana()
-> b=11

It works on any files considered by pytest, including conftest.py and package files as well:

~/WORK/AQA/PYTEST_PDB$ cat -n my/my_shiny_tools.py
     1    SEVEN = 7
     2    ANY = -1

~/WORK/AQA/PYTEST_PDB$ cat -n test_banana.py
     1    import my.my_shiny_tools as mylib
     2
     3    def test_banana():
     4        a=mylib.SEVEN - 1
     5        b=11
     6        res = a / (b - 11)
     7        assert True
~/WORK/AQA/PYTEST_PDB$ python3 -m pdb -m pytest -q --tb=short --show-capture=no -s -k banana
> /usr/local/lib/python3.8/dist-packages/pytest/__main__.py(1)<module>()
-> """The pytest entry point."""

(Pdb) b my/my_shiny_tools.py:2
Breakpoint 1 at /home/dkravchenko/WORK/AQA/PYTEST_PDB/my/my_shiny_tools.py:2

(Pdb) c
> /home/dkravchenko/WORK/AQA/PYTEST_PDB/my/my_shiny_tools.py(2)<module>()
-> ANY = -1

Finally, one is able to make it's breakpoints less ephemeral and keep it in .pdbrc file (which is useful to be added in project's .gitignore):

~/WORK/AQA/PYTEST_PDB$ cat ./.pdbrc 
b my/my_shiny_tools.py:2
~/WORK/AQA/PYTEST_PDB$ python3 -m pdb -m pytest -q --tb=short --show-capture=no -s -k banana
Breakpoint 1 at /home/dkravchenko/WORK/AQA/PYTEST_PDB/my/my_shiny_tools.py:2
> /usr/local/lib/python3.8/dist-packages/pytest/__main__.py(1)<module>()
-> """The pytest entry point."""
(Pdb) c
> /home/dkravchenko/WORK/AQA/PYTEST_PDB/my/my_shiny_tools.py(2)<module>()
-> ANY = -1
(Pdb)

...Aaaaand that's the way the News goes! (c)