how to make a calculator part 5

I want to use the things I know to make the tests for the calculator program better


preview

Here are the tests I have by the end of the chapter

  1import random
  2import src.calculator
  3import unittest
  4
  5
  6def a_random_number():
  7    return random.triangular(-1000.0, 1000.0)
  8
  9
 10class TestCalculator(unittest.TestCase):
 11
 12    def setUp(self):
 13        self.random_first_number = a_random_number()
 14        self.random_second_number = a_random_number()
 15
 16    def test_addition(self):
 17        self.assertEqual(
 18            src.calculator.add(
 19                self.random_first_number,
 20                self.random_second_number
 21            ),
 22            self.random_first_number+self.random_second_number
 23        )
 24
 25    def test_subtraction(self):
 26        self.assertEqual(
 27            src.calculator.subtract(
 28                self.random_first_number,
 29                self.random_second_number
 30            ),
 31            self.random_first_number-self.random_second_number
 32        )
 33
 34    def test_multiplication(self):
 35        self.assertEqual(
 36            src.calculator.multiply(
 37                self.random_first_number,
 38                self.random_second_number
 39            ),
 40            self.random_first_number*self.random_second_number
 41        )
 42
 43    def test_division(self):
 44        try:
 45            self.assertEqual(
 46                src.calculator.divide(
 47                    self.random_first_number,
 48                    self.random_second_number
 49                ),
 50                self.random_first_number/self.random_second_number
 51            )
 52        except ZeroDivisionError:
 53            self.assertEqual(
 54                src.calculator.divide(self.random_first_number, 0),
 55                'undefined: I cannot divide by 0'
 56            )
 57
 58    def test_calculator_sends_message_when_input_is_not_a_number(self):
 59        error_message = 'Excuse me?! Numbers only. Try again...'
 60
 61        for data in (
 62            None,
 63            True, False,
 64            str(),
 65            tuple(),
 66            list(),
 67            set(),
 68            dict(),
 69        ):
 70            with self.subTest(i=data):
 71                self.assertEqual(
 72                    src.calculator.add(data, a_random_number()),
 73                    error_message
 74                )
 75                self.assertEqual(
 76                    src.calculator.divide(data, a_random_number()),
 77                    error_message
 78                )
 79                self.assertEqual(
 80                    src.calculator.multiply(data, a_random_number()),
 81                    error_message
 82                )
 83                self.assertEqual(
 84                    src.calculator.subtract(data, a_random_number()),
 85                    error_message
 86                )
 87
 88    def test_calculator_w_list_items(self):
 89        a_list = [self.random_first_number, self.random_second_number]
 90
 91        self.assertEqual(
 92            src.calculator.add(a_list[0], a_list[1]),
 93            self.random_first_number+self.random_second_number
 94        )
 95        self.assertEqual(
 96            src.calculator.divide(a_list[-2], a_list[-1]),
 97            self.random_first_number/self.random_second_number
 98        )
 99        self.assertEqual(
100            src.calculator.multiply(a_list[1], a_list[-1]),
101            self.random_second_number*self.random_second_number
102        )
103        self.assertEqual(
104            src.calculator.subtract(a_list[-2], a_list[0]),
105            self.random_first_number-self.random_first_number
106        )
107        self.assertEqual(
108            src.calculator.add(*a_list),
109            self.random_first_number+self.random_second_number
110        )
111        self.assertEqual(
112            src.calculator.divide(*a_list),
113            self.random_first_number/self.random_second_number
114        )
115        self.assertEqual(
116            src.calculator.multiply(*a_list),
117            self.random_first_number*self.random_second_number
118        )
119        self.assertEqual(
120            src.calculator.subtract(*a_list),
121            self.random_first_number-self.random_second_number
122        )
123
124    def test_calculator_raises_type_error_when_given_more_than_two_inputs(self):
125        not_two_numbers = [0, 1, 2]
126
127        with self.assertRaises(TypeError):
128            src.calculator.add(*not_two_numbers)
129        with self.assertRaises(TypeError):
130            src.calculator.divide(*not_two_numbers)
131        with self.assertRaises(TypeError):
132            src.calculator.multiply(*not_two_numbers)
133        with self.assertRaises(TypeError):
134            src.calculator.subtract(*not_two_numbers)
135
136
137# Exceptions seen
138# AssertionError
139# NameError
140# AttributeError
141# TypeError
142# ZeroDivisionError
143# SyntaxError

open the project

  • I change directory to the calculator folder

    cd calculator
    

    the terminal shows I am in the calculator folder

    .../pumping_python/calculator
    
  • I activate the virtual environment

    source .venv/bin/activate
    

    Attention

    on Windows without Windows Subsystem for Linux use .venv/bin/activate.ps1 NOT source .venv/bin/activate

    .venv/scripts/activate.ps1
    

    the terminal shows

    (.venv) .../pumping_python/calculator
    
  • I use pytest-watch to run the tests

    pytest-watch
    

    the terminal shows

    rootdir: .../pumping_python/calculator
    collected 7 items
    
    tests/test_calculator.py .......                                     [100%]
    
    ============================ 7 passed in X.YZs =============================
    
  • I hold ctrl on the keyboard and click on tests/test_calculator.py to open it in the editor


how to make sure the calculator tests use new numbers for every test

I used the setUp method in list comprehensions to make sure that I had a new list and iterable for every test. I want to do the same thing with the calculator, to make sure that each test uses 2 new different random numbers, not the same random numbers for every test

REFACTOR: make it better

I add the setUp method to the TestCalculator class

10class TestCalculator(unittest.TestCase):
11
12    def setUp(self):
13        self.random_first_number = a_random_number()
14        self.random_second_number = a_random_number()
15
16    def test_addition(self):

the test is still green. The setUp method runs before every test, giving random_first_number and random_second_number new random values for each test


a better way to test the calculator with inputs that are NOT numbers

I tested the calculator functions with None, strings and lists, I want to test them with the other basic Python data types: booleans, tuples, sets and dictionaries.

Since I know how to use a for loop and list comprehensions, I can do this with one test for all of them instead of a different test for each data type

RED: make it fail

I add a new assertion to test_calculator_sends_message_when_input_is_not_a_number

58    def test_calculator_sends_message_when_input_is_not_a_number(self):
59        error_message = 'Excuse me?! Numbers only! try again...'
60
61        for data in (
62            None,
63            True, False,
64            str(),
65            tuple(),
66            list(),
67            set(),
68            dict(),
69        ):
70            self.assertEqual(
71                src.calculator.add(data, a_random_number()),
72                'BOOM!!!'
73            )
74
75        self.assertEqual(
76            src.calculator.add(None, None),
77            error_message
78        )

the terminal shows AssertionError

AssertionError: 'Excuse me?! Numbers only! try again...' != 'BOOM!!!'

Lovely! The if statement in the only_takes_numbers function in calculator.py is doing its job, the calculator only takes numbers

GREEN: make it pass

  • I change the expectation to match

    70            self.assertEqual(
    71                src.calculator.add(data, a_random_number()),
    72                error_message
    73            )
    

    the terminal shows AssertionError

    AssertionError: ABC.DEFGHIJKLMNOPQR != 'Excuse me?! Numbers only! try again...'
    

    there is a problem. One of the data types I am testing is being allowed by the if statement, which means one of them is also an integer or is a float. I need a way to know which one is causing the problem

  • the unittest.TestCase class has a way to tell which item is causing my failure when I am using a loop, I add it to the test

    68            dict(),
    69        ):
    70            with self.subTest(i=data):
    71                self.assertEqual(
    72                    src.calculator.add(data, a_random_number()),
    73                    error_message
    74                )
    75
    76        self.assertEqual(
    77            src.calculator.add(None, None),
    78            error_message
    79        )
    

    the terminal shows AssertionError for two of the data types I am testing

    tests/test_calculator.py:72: AssertionError
    ============= short test summary info ==============
    SUBFAILED(i=True) tests/test_calculator.py::TestCalculator::test_calculator_sends_message_when_input_is_not_a_number - AssertionError: UVW.XYZABCDEFGHIJKL != 'Excuse ...
    SUBFAILED(i=False) tests/test_calculator.py::TestCalculator::test_calculator_sends_message_when_input_is_not_a_number - AssertionError: MNO.PQRSTUVWXYZABCD != 'Excuse ...
    =========== 2 failed, 7 passed in X.YZs ============
    

    the unittest.TestCase.subTest method runs the code in its context as a sub test, showing the values I give in i=data so that I can see which one caused the error

  • I add a condition for booleans in the only_takes_numbers function in calculator.py

    4        error_message = 'Excuse me?! Numbers only! try again...'
    5
    6        if isinstance(first_input, bool) or isinstance(second_input, bool):
    7            return error_message
    8        if not (isinstance(first_input, good_types) and isinstance(second_input, good_types)):
    

    the test passes

REFACTOR: make it better

  • I can use a for loop to make the if statements simpler

     1def only_takes_numbers(function):
     2    def wrapper(first_input, second_input):
     3        good_types = (int, float)
     4        error_message = 'Excuse me?! Numbers only. Try again...'
     5
     6        # if isinstance(first_input, bool) or isinstance(second_input, bool):
     7        #     return error_message
     8        # if not (isinstance(first_input, good_types) and isinstance(second_input, good_types)):
     9        #     return error_message
    10
    11        for value in (first_input, second_input):
    12            if isinstance(value, bool) or not isinstance(value, good_types):
    13                return error_message
    14
    15        try:
    16            return function(first_input, second_input)
    17        except TypeError:
    18            return error_message
    19    return wrapper
    

    the test is still green

  • I remove the commented lines

     1def only_takes_numbers(function):
     2    def wrapper(first_input, second_input):
     3        good_types = (int, float)
     4        error_message = 'Excuse me?! Numbers only. Try again...'
     5
     6        for value in (first_input, second_input):
     7            if isinstance(value, bool) or not isinstance(value, good_types):
     8                return error_message
     9
    10        try:
    11            return function(first_input, second_input)
    12        except TypeError:
    13            return error_message
    14    return wrapper
    

    still green

  • I add another assertion for the divide function in test_calculator.py

    70            with self.subTest(i=data):
    71                self.assertEqual(
    72                    src.calculator.add(data, a_random_number()),
    73                    error_message
    74                )
    75                self.assertEqual(
    76                    src.calculator.divide(data, a_random_number()),
    77                    'BOOM!!!'
    78                )
    

    the terminal shows AssertionError for all the data types in the test

    AssertionError: 'Excuse me?! Numbers only! try again...' != 'BOOM!!!'
    
  • I think you can tell what will happen next. I change the expectation to match

    75                self.assertEqual(
    76                    src.calculator.divide(data, a_random_number()),
    77                    error_message
    78                )
    

    the test passes

  • I add an assertion for multiplication

    75                self.assertEqual(
    76                    src.calculator.divide(data, a_random_number()),
    77                    error_message
    78                )
    79                self.assertEqual(
    80                    src.calculator.multiply(data, a_random_number()),
    81                    'BOOM!!!'
    82                )
    

    the terminal shows AssertionError for each data type

    AssertionError: 'Excuse me?! Numbers only! try again...' != 'BOOM!!!'
    
  • I change the expectation

    79                self.assertEqual(
    80                    src.calculator.multiply(data, a_random_number()),
    81                    error_message
    82                )
    

    the test passes

  • I add an assertion for subtraction

    79                self.assertEqual(
    80                    src.calculator.multiply(data, a_random_number()),
    81                    error_message
    82                )
    83                self.assertEqual(
    84                    src.calculator.subtract(data, a_random_number()),
    85                    'BOOM!!!'
    86                )
    

    the terminal shows AssertionError for all the data types I am testing

    AssertionError: 'Excuse me?! Numbers only! try again...' != 'BOOM!!!'
    
  • I change the expectation to match

    83                self.assertEqual(
    84                    src.calculator.subtract(data, a_random_number()),
    85                    error_message
    86                )
    

    the test passes

  • I remove the other assertions in the test because they are now covered by the for loop

    58    def test_calculator_sends_message_when_input_is_not_a_number(self):
    59        error_message = 'Excuse me?! Numbers only! try again...'
    60
    61        for data in (
    62            None,
    63            True, False,
    64            str(),
    65            tuple(),
    66            list(),
    67            set(),
    68            dict(),
    69        ):
    70            with self.subTest(i=data):
    71                self.assertEqual(
    72                    src.calculator.add(data, a_random_number()),
    73                    error_message
    74                )
    75                self.assertEqual(
    76                    src.calculator.divide(data, a_random_number()),
    77                    error_message
    78                )
    79                self.assertEqual(
    80                    src.calculator.multiply(data, a_random_number()),
    81                    error_message
    82                )
    83                self.assertEqual(
    84                    src.calculator.subtract(data, a_random_number()),
    85                    error_message
    86                )
    87
    88    def test_calculator_w_list_items(self):
    

    Using a for loop saved me having to write a lot of tests

  • I can add more data to the iterable without having to add more tests

    58    def test_calculator_sends_message_when_input_is_not_a_number(self):
    59        error_message = 'Excuse me?! Numbers only! try again...'
    60
    61        for data in (
    62            None,
    63            True, False,
    64            str(), 'text',
    65            tuple(), (0, 1, 2, 'n'),
    66            list(), [0, 1, 2, 'n'],
    67            set(), {0, 1, 2, 'n'},
    68            dict(), {'key': 'value'},
    69        ):
    70            with self.subTest(i=data):
    

    the test is still green

  • I could also write the test with a list comprehension, though it looks ugly

     84            self.assertEqual(
     85                src.calculator.subtract(data, a_random_number()),
     86                error_message
     87            )
     88
     89    [
     90        self.assertEqual(
     91            src.calculator.add(data, a_random_number),
     92            'BOOM!!!'
     93        ) for data in (
     94            None, True, False, str(), 'text',
     95            tuple(), (0, 1, 2, 'n'),
     96            list(), [0, 1, 2, 'n'],
     97            set(), {0, 1, 2, 'n'},
     98            dict(), {'key': 'value'},
     99        )
    100    ]
    101
    102def test_calculator_w_list_items(self):
    

    the terminal shows AssertionError

    AssertionError: 'Excuse me?! Numbers only! try again...' != 'BOOM!!!'
    

    There are a few problems with doing it this way

    • I make a list when I do not need it

    • I would not have been able to tell which data type failed since I cannot use the subTest method with this

    • I would have to repeat all those lines for each function in the calculator program

  • I remove it from the test and things are green again

I know a better way to test the calculator with inputs that are NOT numbers


close the project

  • I close test_calculator.py and calculator.py in the editors

  • I click in the terminal and exit the tests with ctrl+c on the keyboard, the terminal shows

    (.venv) .../pumping_python/calculator
    
  • I deactivate the virtual environment

    deactivate
    

    the terminal goes back to the command line, (.venv) is no longer on the left side

    .../pumping_python/calculator
    
  • I change directory to the parent of calculator

    cd ..
    

    the terminal shows

    .../pumping_python
    

    I am back in the pumping_python directory


review

I ran tests to show I can make a list from an iterable with

I can use functions and conditions with list comprehensions to make a list with one line. I think of it as [process(item) for item in iterable if condition/NOT condition]

I can also do this with dictionaries, it is called a dict comprehension and the syntax is any mix of the following

{
    a_process(key): another_process(value)
    for key/value in iterable
    if condition/not condition
}

How many questions can you answer after going through this chapter?


code from the chapter

Do you want to see all the CODE I typed in this chapter?


what is next?

you know

Would you like to test if a boolean is an integer or a float?


rate pumping python

If this has been a 7 star experience for you, please leave a 5 star review. It helps other people get into the book too