Effective Python 2E

Chapter 9: Testing and Debugging

David Ye @ Houzz


Outline


repr Strings

Item 75: Use repr Strings for Debugging Output

printable representation of an object

my_value = '123' print(str(my_value)) # 123 print('%s' % my_value) # 123 print(f'{my_value}') # 123 print(format(my_value)) # 123 print(my_value.__format__('s')) # 123 print(my_value.__str__()) # 123
print(f'{my_value!r}') # '123'

repr of built-in Python types can be passed to eval to get back the original value:

a = '\x07' b = eval(repr(a)) # repr(): get the repr string of the input assert a == b # true

Define the special method __repr__:

class SomeClass: def __init__(self, x, y): self.x = x self.y = y obj = SomeClass(1, 'foo') print(f'{obj!r}') # <__main__.SomeClass object at 0x10963d6d0> print(obj) # <__main__.SomeClass object at 0x10963d6d0>
class SomeClass: ... def __repr__(self): return f'SomeClass({self.x!r}, {self.y!r})' obj = SomeClass(2, 'bar') print(f'{obj!r}') # SomeClass(2, 'bar') print(obj) # SomeClass(2, 'bar')

Note:

<__main__.SomeClass object ...> is the default repr string.
When __str__ is not defined, it will fallback to repr string.


The unittest Module

Built-in Python unit test module.


# utils.py def to_str(data): if isinstance(data, str): return data elif isinstance(data, bytes): return data.decode('utf-8') else: raise TypeError('Must supply str or bytes, ' 'found: %r' % data)
# utils.py from unittest import TestCase, main from utils import to_str # we want to test this method class UtilsTestCase(TestCase): def test_to_str_bytes(self): self.assertEqual('hello', to_str(b'hello')) # helper method in TestCase class def test_to_str_str(self): self.assertEqual('hello', to_str('hello')) def test_failing(self): # a test will fail! self.assertEqual('incorrect', to_str('hello')) if __name__ == '__main__': main()

with a helpful error message about what went wrong:

$ python3 utils_test.py
F..
===============================================================
FAIL: test_failing (__main__.UtilsTestCase)
---------------------------------------------------------------
Traceback (most recent call last):
  File "utils_test.py", line 15, in test_failing
    self.assertEqual('incorrect', to_str('hello'))
AssertionError: 'incorrect' != 'hello'
- incorrect
+ hello

Define own helper method:

class MyTestCase(TestCase): def assertValidTriangle(self, edges): self.assertEqual(len(edges), 3) perimeter = sum(edges) for e in edges: self.assertGreater(perimeter - e, e) def test_triangle_generator(self): my_triangle = TriangleGenerator().generate() self.assertValidTriangle(my_triangle.edges)

subTest defining multiple test in a single test method:

from utils import to_str class DataDrivenTestCase(TestCase): def test_good(self): good_cases = [ (b'my bytes', 'my bytes'), ('no error', b'no error'), # This one will fail ('other str', 'other str'), ... ] for value, expected in good_cases: with self.subTest(value): # subtests: self.assertEqual(expected, to_str(value))

Item 77: Isolate Tests from Each Other with setUp, tearDown, setUpModule, and tearDownModule

Test harness: have the test environment set up before test methods can be run

Before / After each test method:

class EnvironmentTest(TestCase): def setUp(self): self.test_dir = TemporaryDirectory() self.test_path = Path(self.test_dir.name) def tearDown(self): self.test_dir.cleanup() def test_modify_file(self): with open(self.test_path / 'data.bin', 'w') as f: ...

Before / After all test methods in this module:

# for some integration test: def setUpModule(): createConnection() def tearDownModule(): closeConnection()

Execution order:

def setUpModule(): print('* Module setup') def tearDownModule(): print('* Module clean-up') class MyTest(TestCase): def setUp(self): print('* Test setup') def tearDown(self): print('* Test clean-up') def test_end_to_end1(self): print('* Test 1') def test_end_to_end2(self): print('* Test 2')
“* Module setup
* Test setup
* Test 1
* Test clean-up
.* Test setup
* Test 2
* Test clean-up
.* Module clean-up

Item 78: Use Mocks to Test Code with Complex Dependencies

Mock:

  • simulate slow / real things. e.g. DB Connection
class DatabaseConnection: ... def get_animals(database, species): ... # query db
from unittest.mock import Mock mock = Mock(spec=get_animals) # minic the get_animals function expected = [ ('Spot', datetime(2019, 6, 5, 11, 15)), ('Fluffy', datetime(2019, 6, 5, 12, 30)), ('Jojo', datetime(2019, 6, 5, 12, 45)), ] mock.return_value = expected
database = object() result = mock(database, 'Jojo') mock.assert_called_once_with(database, 'Jojo') # pass mock.assert_called_once_with(database, 'Giraffe') # AssertionError: expected call not found. from unittest.mock import ANY mock.assert_called_once_with(database, ANY)

Mock exception:

mock.side_effect = MyError('Boom!') result = mock(database, 'Jojo') # raise MyError

Patch module / class attribute:

from unittest.mock import patch with patch('__main__.get_animals'): print(get_animals) # a MagicMock instance

However, it cannot mock build-in C-extension module:

with patch('datetime.datetime.utcnow'): datetime.utcnow.return_value = datetime(2019, 6, 5, 15, 45) # raise TypeError: can't set attributes of built-in/extension type

approach 1: add wrapper

def get_do_rounds_time() return datetime.utcnow() with patch('__main__.get_do_rounds_time'): ...

approach 2: reserved kw argument

def do_rounds(database, species, *, utcnow=datetime.utcnow): now = utcnow() ...
from unittest.mock import DEFAULT with patch.multiple('__main__', # create multiple mocks in 1 call autospec=True, get_food_period=DEFAULT, # create a Mock for this function get_animals=DEFAULT, feed_animal=DEFAULT): now_func = Mock(spec=datetime.utcnow) now_func.return_value = datetime(2019, 6, 5, 15, 45) get_food_period.return_value = timedelta(hours=3) get_animals.return_value = [ ... ]

assertions:

result = do_rounds(database, 'Meerkat', utcnow=now_func) # pass the mocked datetime function assert result == 2 food_func.assert_called_once_with(database, 'Meerkat') animals_func.assert_called_once_with(database, 'Meerkat') feed_func.assert_has_calls( [ call(database, 'Spot', now_func.return_value), call(database, 'Fluffy', now_func.return_value), ], any_order=True)

Item 79: Encapsulate Dependencies to Facilitate Mocking and Testing

use a wrapper object to encapsulate the database’s interface

class ZooDatabase: def get_animals(self, species): ... def get_food_period(self, species): ... def feed_animal(self, name, when):

before:

def do_rounds(database, species): # a Database connection feeding_timedelta = get_food_period(database, species) animals = get_animals(database, species) ...

after:

def do_rounds(database, species): # ZooDatabase feeding_timedelta = database.get_food_period(species) animals = database.get_animals(species) ...
  • can create a Mock instance to represent a ZooDatabase

write an end-to-end with a mid-level integration test

  • still need a way to inject a mock ZooDatabase into the program
  • by creating a helper function that acts as a seam for dependency injection.
DATABASE = None def get_database(): global DATABASE if DATABASE is None: DATABASE = ZooDatabase() return DATABASE def main(argv): database = get_database() species = argv[1] count = do_rounds(database, species) print(f'Fed {count} {species}(s)') return 0

Ref: item 86: Consider Module-Scoped Code to Configure Deployment Environments


mock ZooDatabase with patch -> now we can test the main func:

with patch('__main__.DATABASE', spec=ZooDatabase): DATABASE.get_food_period.return_value = timedelta(hours=3) DATABASE.get_animals.return_value = [...] fake_stdout = io.StringIO() with contextlib.redirect_stdout(fake_stdout): main(['program name', 'Meerkat']) found = fake_stdout.getvalue() expected = 'Fed 2 Meerkat(s)\n' assert found == expected

pdb Interactive Debugging

Item 80: Consider Interactive Debugging with pdb

pdb: The Python Debugger

  • was: import pdb; pdb.set_trace()
  • after 3.7: breakpoint()
def some_func: ... breakpoint() # will enter interactive console here ...

Common pdb Commands

  • where: print current call stack
  • up / down move up or down call stack
  • step run 1 line
  • next run until next line of this function
  • return run until this function return
  • continue run until next breakpoint
  • quit

post-mortem debugging

This enables you to debug a program after it’s already raised an exception and crashed.

$ python -m pdb -c continue main.py
...(some error)

Uncaught exception. Entering post mortem debugging
Running 'cont' or 'step' will restart the program
> postmortem_breakpoint.py(16)compute_rmse()
-> rmse = math.sqrt(mean_err)
(pdb)

or by code: import pdb; pdb.pm()


tracemalloc Understand Memory Usage

Item 81: Use tracemalloc to Understand Memory Usage and Leaks

gc.get_objects: list every object currently known by the garbage collector

import gc import waste_memory # a custom memory wasting module found_objects = gc.get_objects() print('Before:', len(found_objects)) hold_reference = waste_memory.run() found_objects = gc.get_objects() print('After: ', len(found_objects)) for obj in found_objects[:3]: print(repr(obj)[:100])
Before: 6207
After:  16801

<waste_memory.MyObject object at 0x10390aeb8>
<waste_memory.MyObject object at 0x10390aef0>
<waste_memory.MyObject object at 0x10390af28>

However, gc does not tell how objects were allocated


tracemalloc take snapshot on memory usage:

import tracemalloc tracemalloc.start(10) # Set stack depth time1 = tracemalloc.take_snapshot() # Before snapshot ... time2 = tracemalloc.take_snapshot() # After snapshot stats = time2.compare_to(time1, 'lineno') # Compare snapshots for stat in stats[:3]: print(stat) # print comparison:
waste_memory.py:5: size=2392 KiB (+2392 KiB), count=29994
➥(+29994), average=82 B
waste_memory.py:10: size=547 KiB (+547 KiB), count=10001
➥(+10001), average=56 B
waste_memory.py:11: size=82.8 KiB (+82.8 KiB), count=100
➥(+100), average=848 B

The End

Thanks for listening!

Select a repo