Python Unit Testing

Preventing friday night failures

Bri Hatch Personal Work
bri@ifokr.org Dropzone AI
daethnir@dropzone.ai

Copyright 2024, Bri Hatch, Creative Commons BY-NC-SA License

Testing Evolution

def min_coins(target_value, coins):
    """Compute fewest coins to add to target value.

    Input is a list of coin values.
    target_value is value they should sum to

    Returns a dict of coins and quantities.
    Returns None if not possible.
    """
    
    for coin in coins:
        ...
    return sumthin
        

Testing Evolution

$ ipython3
>>> from mycode import min_coins
>>> min_coins(121, [5, 2, 1])
{5: 24, 1: 1}

Testing Evolution (cont)

$ tail -4 mycode.py

if __name__ == '__main__':
    print(min_coins(121, [5, 2, 1]), "should be {5: 24, 1: 1}")
    print(min_coins(24, [7, 3]), "should be {7: 3, 3: 1}")
    print(min_coins(25, [4, 2]), "should be None")

$ ./mycode.py
{5: 24, 1: 1} should be {5: 24, 1: 1}
{7: 3, 3: 1} should be {7: 3, 3: 1}
None should be None

Unit Tests!

$ less tests.py
#!/usr/bin/env python3

import unittest
from mycode import min_coins

class Tests(unittest.TestCase):

    def test_min_coin_one(self):
        self.assertEqual(
            min_coins(121, [5, 2, 1]),
            {5: 24, 1: 1}
        )

if __name__ == '__main__':
    unittest.main()

Unit Tests!

$ ./tests.py
.
----------------------------------------
Ran 1 test in 0.001s

OK

$ echo $?
0

Unit Tests!

$ ./tests.py -v
test_min_coin_one (__main__.Tests) ... ok

----------------------------------------
Ran 1 test in 0.001s

OK

$ echo $?
0

Unit testing basics

Unittest Failures

"Failure" is any time an exception is raised.

Commonly used assertions:

Unittest Failures (cont)

Execution built-in assertions: Example:
    self.assertRaises(RuntimeError, min_coin, 125, [])

    with self.self.assertLogs(level='INFO') as cm:
        min_coins(121, [-1])
    self.assertEqual(cm.output,
        ['ERROR: bozo sent us a negative coin value'])

More test cases

$ less tests.py
class Tests(unittest.TestCase):

    def test_min_coin_bigendian(self):
        self.assertEqual(min_coins(121, [5, 2, 1]), {5: 24, 1: 1})

    def test_min_coin_unnecessary(self):
        self.assertEqual(min_coins(121, [5, 2, 1]), {1: 1,  5: 24})

    def test_min_coin_littlendian(self):
        self.assertEqual(min_coins(121, [1, 2, 5]), {1: 1,  5: 24})

if __name__ == '__main__':
    unittest.main()

More test cases (cont)

$ ./tests.py
.F.
======================================================================
FAIL: test_min_coin_littlendian (__main__.Tests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "./tests.py", line 17, in test_min_coin_littlendian
    self.assertEqual(min_coins(121, [1, 2, 5]), {1: 1,  5: 24})
AssertionError: {1: 121} != {1: 1, 5: 24}
- {1: 121}
+ {1: 1, 5: 24}

----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1)
$ echo $?
1

More test cases (cont)

$ ./tests.py -v 
test_min_coin_bigendian (__main__.Tests) ... ok
test_min_coin_littlendian (__main__.Tests) ... FAIL
test_min_coin_unnecessary (__main__.Tests) ... ok

======================================================================
FAIL: test_min_coin_littlendian (__main__.Tests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "./tests.py", line 17, in test_min_coin_littlendian
    self.assertEqual(min_coins(121, [1, 2, 5]), {1: 1,  5: 24})
AssertionError: {1: 121} != {1: 1, 5: 24}
- {1: 121}
+ {1: 1, 5: 24}

----------------------------------------------------------------------
Ran 3 tests in 0.002s

FAILED (failures=1)

Multiple tests per case

$ less tests.py
class Tests(unittest.TestCase):
    def test_coins(self):
        test_data = [
           [121, [1, 2, 5], {1: 1,  5: 24}],
           [121, [5, 1, 2], {1: 1,  5: 24}],
           [121, [5, 1, 2], {1: 1,  5: 24}],
           [24,  [7, 3],    {7: 3,  3:  1}],
           [24,  [3, 7],    {7: 3,  3:  1}],
           [25,  [4, 2],    None]
        ]

        for data in test_data:
            self.assertEqual(
                min_coins(data[0], data[1]),
                data[2]
            )

Using external testdata

$ cat testdata/min_coins.yml
coin_tests:
- total: 121
  coins: [1, 2, 5]
  expected: 
     1: 1
     5: 24

- total: 121
  coins: [5, 1, 2]
  expected: 
     1: 1
     5: 24

- total: 24
  coins: [7, 3]
  expected: 
     7: 3
     3: 14

Using external testdata (cont)

class Tests(unittest.TestCase):
    def test_coins(self):
        with open(os.path.join(
            os.path.dirname(__file__), 'testdata', 'min_coins.yml'),
            'r'
        ) as _:
            self.test_data = yaml.safe_load(_)

        for data in self.test_data['coin_tests']:
            self.assertEqual(
                min_coins(data['total'], data['coins']),
                data['expected']
            )

setUp() / tearDown()

setUp() / tearDown()

Useful to

setUp() / tearDown() (cont)

class Tests(unittest.TestCase):
    def setUp(self):
        with open(os.path.join(
            os.path.dirname(__file__), 'testdata', 'min_coins.yml'),
            'r'
        ) as _:
            self.test_data = yaml.safe_load(_)
    
    def test_coins(self):
        for data in self.test_data['coin_tests']:
            self.assertEqual(
                min_coins(data['total'], data['coins']),
                data['expected']
            )

    def test_dice(self):
        data = self.test_data['dice_tests']
        ...

A Harder example

A program that magically gets HR data:

$ get-hr-data
name,start_date,end_date
Alice Anderson,2023-04-01,
Bob Brown,2018-03-15,2022-01-01
Charlie Chapman,2021-04-07,2022-02-02
Diana Doyle,2017-05-10,
Evan Evans,2022-04-15,
Fiona Fisher,2020-07-20,2023-01-01
George Green,2019-04-10,
Holly Hunt,2015-12-05,
Ivan Ivanov,2021-04-01,
Julia James,2010-06-30,
Kevin King,2016-08-25,
Lily Long,2019-04-01,
Molly Morris,2015-04-20,
Nancy Newman,2020-02-28,
Oliver Owens,2017-04-30,

A Harder example (cont)

def say_happy_anniversary():
    now = datetime.now()
    proc = subprocess.run(
        ['get-hr-data'], capture_output=True, text=True
    )

    reader = csv.DictReader(proc.stdout.splitlines())
    for row in reader:
        start = datetime.strptime(
            row['start_date'], '%Y-%m-%d'
        )
        if start.month == now.month and start.year < now.year:
            print(f"Happy anniversary, {row['name']}!")

$ python3 happy_anniversary.py
Happy anniversary, Alice Anderson!
Happy anniversary, Charlie Chapman!
Happy anniversary, Evan Evans!
Happy anniversary, George Green!
...

A Harder example (cont)

def say_happy_anniversary():
    for name in get_anniversary_people():
        print(f"Happy anniversary, {name}!")

def get_anniversary_people():
    now = datetime.now()
    proc = subprocess.run(
        ['get-hr-data'], capture_output=True, text=True
    )

    reader = csv.DictReader(proc.stdout.splitlines())
    names = []
    for row in reader:
        start = datetime.strptime(
            row['start_date'], '%Y-%m-%d'
        )
        if start.month == now.month and start.year < now.year:
            names.append(row['name'])
    return names

A Harder example (cont)

Will this work?
class Tests(unittest.TestCase):
    def test_anniversary(self):
        expected = ['Alice Anderson', 'Charlie Chapman', 'Evan Evans',
            'George Green', 'Ivan Ivanov', 'Lily Long', 'Molly Morris',
            'Oliver Owens']

        self.assertEqual(
            happy_anniversary.get_anniversary_people(),
            expected,
            msg="get_anniversary_people has a bug, oh noes!"
        )


$ ./happy_anniversary_tests.py
Ran 1 test in 0.004s
OK

A Harder example (cont)

What problems did we miss?

A Harder example (cont)

Problems:

External dependencies are annoying

$ faketime 2024-05-01 ./happy_anniversary_tests.py
First differing element 0:
'Diana Doyle'
'Alice Anderson'

Second list contains 7 additional elements.
First extra element 1:
'Charlie Chapman'

- ['Diana Doyle']
+ ['Alice Anderson',
+  'Charlie Chapman',
+  'Evan Evans',
+  'George Green',
+  'Ivan Ivanov',
+  'Lily Long',
+  'Molly Morris',
+  'Oliver Owens'] : get_anniversary_people has a bug, oh noes!

External dependencies are annoying (cont)

from unittest.mock import patch, MagicMock
class Tests(unittest.TestCase):

    def setUp(self):
        with open(os.path.join(
            os.path.dirname(__file__), 'testdata', 'hrdata.csv'), 'r'
        ) as _:
            self.hrdata = _.read()

...

External dependencies are annoying (cont)

from unittest.mock import patch, MagicMock
class Tests(unittest.TestCase):
...

    @patch('happy_anniversary.subprocess')
    def test_anniversary(self, mock_subprocess):

        mock_proc = MagicMock()
        mock_proc.stdout = self.hrdata
        mock_subprocess.run.return_value = mock_proc

        expected = ['Alice Anderson', 'Charlie Chapman', 'Evan Evans',
        ...

$ ./happy_anniversary_tests.py
Ran 1 test in 0.004s
OK

External Dependencies (cont)

from unittest.mock import patch, MagicMock
...

    @patch('happy_anniversary.subprocess')
    @patch('happy_anniversary.datetime.now')
    def test_anniversary(self, mock_datetime, mock_subprocess): 

        mock_datetime.now.return_value = datetime(2024, 4, 19)

        mock_proc = MagicMock()
        mock_proc.stdout = self.hrdata
        mock_subprocess.run.return_value = mock_proc
        ...

External Dependencies (cont)

from unittest.mock import patch, MagicMock
...

    @patch('happy_anniversary.subprocess')
    @patch('happy_anniversary.datetime.now')
    def test_anniversary(self, mock_datetime, mock_subprocess): 

        mock_datetime.now.return_value = datetime(2024, 4, 19)

        mock_proc = MagicMock()
        mock_proc.stdout = self.hrdata
        mock_subprocess.run.return_value = mock_proc
        ...

$ ./happy_anniversary_tests.py
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/usr/lib/python3.8/unittest/mock.py", line 1490, in __enter__
TypeError: can't set attrs of built-in type 'datetime.datetime'

Mock Mocking Me

Some things are harder to mock than others.

Many unittest.mock examples at https://docs.python.org/3/library/unittest.mock-examples.html

@patch('module_under_test.date')
def funcname(self, mock_date):
    mock_date.today.return_value = date(2024, 4, 19)
    mock_date.side_effect = lambda *args, **kw: date(*args, **kw)

Mocking class methods

Mocking class methods

Mocking class methods (cont)

class HappyAnniversary():
    def hrdata(self):
        print("I AM RUNNING HRDATA()")
        proc = subprocess.run(['get-hr-data'], capture_output=True, text=True)
        return proc.stdout.splitlines()

    def now(self):
        print("I AM RUNNING NOW()")
        return datetime.now()

    def get_anniversary_people(self):
        now = self.now()
        reader = csv.DictReader(self.hrdata())
        names = []
        for row in reader:
            start = datetime.strptime(
                row['start_date'], '%Y-%m-%d')
            if (start.month == now.month and start.year < now.year):
                names.append(row['name'])
        return names

Mocking class methods (cont)

    def say_happy_anniversary(self):
        for name in self.get_anniversary_people():
            print(f"Happy anniversary, {name}!")

if __name__ == "__main__":
    ha = HappyAnniversary()
    ha.say_happy_anniversary()


$ ./happy_anniversary.py
I AM RUNNING NOW()
I AM RUNNING HRDATA()
Happy anniversary, Alice Anderson!
Happy anniversary, Charlie Chapman!
Happy anniversary, Evan Evans!
Happy anniversary, George Green!
Happy anniversary, Ivan Ivanov!
Happy anniversary, Lily Long!
Happy anniversary, Molly Morris!
Happy anniversary, Oliver Owens!

Mocking class methods (cont)

#!/usr/bin/env python3 

import os
import unittest

import happy_anniversary

class Tests(unittest.TestCase):

    def setUp(self):
        with open(os.path.join(
            os.path.dirname(__file__), 'testdata', 'hrdata.csv'), 'r'
        ) as _:
            self.hrdata = _.read()

        self.ha = happy_anniversary.HappyAnniversary()
        self.ha.now = lambda: datetime(2024, 5, 1)
        self.ha.hrdata = lambda: self.hrdata.splitlines()

Mocking class methods (cont)

    def test_anniversary(self):
        expected = ['Diana Doyle']

        self.assertEqual(
            self.ha.get_anniversary_people(),
            expected
        )

if __name__ == '__main__':
    unittest.main()

All's quiet on the testing front!

$ ./tests.py
.
----------------------------------------
Ran 1 test in 0.001s

Thanks!

Presentation: https://www.ifokr.org/bri/presentations/lfnw-2024-python-unit-testing/

PersonalWork
Bri Hatch
bri@ifokr.org

Bri Hatch
Dropzone AI
daethnir@dropzone.ai

Copyright 2024, Bri Hatch, Creative Commons BY-NC-SA License

MISC