how to make a calculator 9

I want to make a website for the calculator so that anyone can use it in their browser without installing Python or running code.


preview

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

 1import src.website
 2import tests.test_calculator
 3import unittest
 4
 5
 6class TestCalculatorWebsite(unittest.TestCase):
 7
 8    def setUp(self):
 9        self.client = src.website.app.test_client()
10        self.x = tests.test_calculator.a_random_number()
11
12    def test_home_page(self):
13        response = self.client.get('/')
14        self.assertEqual(response.status_code, 200)
15        self.assertIn(
16            b'<h1>Calculator</h1>',
17            response.data
18        )
19
20    def test_calculations(self):
21        y = tests.test_calculator.a_random_number()
22
23        operations = {
24            'add': '+',
25            'subtract': '-',
26            'divide': '/',
27            'multiply': '*',
28        }
29
30        for operation in operations:
31            with self.subTest(operation=operation):
32                response = self.client.post(
33                    '/calculate',
34                    data={
35                        'first_input': self.x,
36                        'second_input': y,
37                        'operation': operation,
38                    }
39                )
40                self.assertEqual(response.status_code, 200)
41
42                function = src.calculator.__getattribute__(
43                    operation
44                )
45                result = function(self.x, y)
46                self.assertEqual(
47                    response.data.decode(),
48                    (
49                        f'<h2>{self.x} {operations[operation]} {y} '
50                        f'= {result}</h2>'
51                    )
52                )
53
54    def test_website_handling_zero_division_error(self):
55        response = self.client.post(
56            '/calculate',
57            data={
58                'first_input': self.x,
59                'second_input': 0,
60                'operation': 'divide',
61            }
62        )
63        self.assertEqual(
64            response.data.decode(),
65            (
66                f'<h2>{self.x} / 0.0 = '
67                'brmph?! I cannot divide by 0. Try again...</h2>'
68            )
69        )
70
71
72# Exceptions seen
73# NameError
74# ModuleNotFoundError
75# AttributeError
76# AssertionError
77# jinja2.exceptions.TemplateNotFound
78# 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 make a new test file for the website

    touch tests/test_calculator_website.py
    
  • I add Flask to the requirements.txt file

    echo "flask" >> requirements.txt
    

    Flask is a Python library that is used for making websites, it is not part of The Python Standard Library

  • I install the Python packages I gave in the requirements file

    uv add --requirement requirements.txt
    

    the terminal shows it installed the Python packages

  • I use pytest-watcher to run the tests

    uv run pytest-watcher . --now
    

    the terminal shows

    rootdir: .../pumping_python/calculator
    configfile: pyproject.toml
    collected 5 items
    
    tests/test_calculator.py .....                                [100%]
    
    ======================== 5 passed in X.YZs =========================
    

test_home_page


RED: make it fail


  • I open test_calculator_website.py from the tests folder in the editor

  • I add a test to test_calculator_website.py

    1import unittest
    2
    3
    4class TestCalculatorWebsite(unittest.TestCase):
    5
    6    def test_home_page(self):
    7        client = src.website.app.test_client()
    

    the terminal shows NameError

    NameError: name 'src' is not defined
    
  • I add NameError to the list of Exceptions seen

     4class TestCalculatorWebsite(unittest.TestCase):
     5
     6    def test_home_page(self):
     7        client = src.website.app.test_client()
     8
     9
    10# Exceptions seen
    11# NameError
    

GREEN: make it pass



REFACTOR: make it better


  • I add an assertion to test_home_page in test_calculator_website.py

     7    def test_home_page(self):
     8        client = src.website.app.test_client()
     9        response = client.get('/')
    10        self.assertEqual(response.status_code, 'BOOM!!!')
    

    the terminal shows AssertionError

    AssertionError: 404 != 'BOOM!!!'
    
  • I add AssertionError to the list of Exceptions seen

    13# Exceptions seen
    14# NameError
    15# ModuleNotFoundError
    16# AttributeError
    17# AssertionError
    
  • I change the expectation in the assertion

    10        self.assertEqual(response.status_code, 404)
    

    the test passes.

    • client = src.website.app.test_client() points the name client to the Flask app in website.py

    • response = client.get('/') points the name response to the result of the call to the get method of the client object

    • the get method calls the GET request method which is an HTTP request method to get information from a server

    • client.get('/') returns a response object

    • '/' is short for root or home in this case, the homepage of the website I am making also known as index.html

    • response.status_code gets the status_code attribute or the response object

    • the above can also be written as src.website.app.test_client().get('/').status_code

    • 404 is HTTP status code, it is short for 404 Not Found which means the page cannot be found

    • I want a 200 HTTP status code, it is short for 200 OK and means the request was successful

  • I change the expectation in the assertion

     7    def test_home_page(self):
     8        client = src.website.app.test_client()
     9        response = client.get('/')
    10        self.assertEqual(response.status_code, 200)
    

    the terminal shows AssertionError

    AssertionError: 404 != 200
    
  • I add a function for the homepage in website.py

    1import flask
    2
    3
    4app = flask.Flask(__name__)
    5
    6
    7@app.route('/')
    8def home():
    9    return flask.render_template('index.html')
    

    the terminal shows AssertionError

    AssertionError: 500 != 200
    

    it also shows jinja2.exceptions.TemplateNotFound

    jinja2.exceptions.TemplateNotFound: index.html
    

    I have to make a template file for index.html

    • @app.route is a decorator function that routes the pages of the website to the function it wraps

    • '/' is short for root or home in this case, the homepage of the website I am making also known as index.html

  • I add jinja2.exceptions.TemplateNotFound to the list of Exceptions seen

    # Exceptions seen
    # NameError
    # ModuleNotFoundError
    # AttributeError
    # AssertionError
    # jinja2.exceptions.TemplateNotFound
    
  • I add a new folder to the src folder named templates, the terminal still shows the same Exception

  • I add a new file in the templates folder named index.html, the terminal still shows the same Exception

  • I go back to test_calculator_website.py then use ctrl+s on the keyboard to save the file which makes pytest-watcher run the tests again, and the test passes


how to view the website

  • I want to see what I just made. I open a new terminal, then use uv to run the Flask server

    uv run flask --app src/website run --debug
    

    the terminal shows

     * Serving Flask app 'src/website'
     * Debug mode: on
    WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
     * Running on http://127.0.0.1:5000
    Press CTRL+C to quit
     * Restarting with watchdog (inotify)
     * Debugger is active!
     * Debugger PIN: ABC-DEF-GHI
    
  • it might also show a dialog box like this, and I click on Open in Browser

    Confirm you want to run Flask Development Server

    or I use ctrl on the keyboard and click on http://127.0.0.1:5000 with the mouse to open the browser and it shows an empty page. Success!

  • I add another assertion for text from the website to test_home_page in test_calculator_website.py

     7    def test_home_page(self):
     8        client = src.website.app.test_client()
     9        response = client.get('/')
    10        self.assertEqual(response.status_code, 200)
    11        self.assertEqual(
    12            response.data,
    13            '<h1>Calculator</h1>'
    14        )
    

    the terminal shows AssertionError

    AssertionError: b'' != '<h1>Calculator</h1>'
    
    • there is nothing in src/templates/index.html

    • b'' is the empty bytes object

    • bytes objects are like strings , they have a b before the quotes, for example

      b'single quote bytes'
      b'''triple single quote bytes'''
      b"double quote bytes"
      b"""triple double quote bytes"""
      
  • I open index.html from the templates folder in the editor, then add some HTML

    1<h1>Calculator</h1>
    

    the terminal still shows AssertionError because pytest-watcher only checks my Python files, it does not run the tests when I change other files

    • <></> are called tags, they are enclosures

    • <h1>HEADING</h1> tells the computer to make HEADING a heading

  • I go back to test_calculator_website.py and use ctrl+s on the keyboard to run the test, the terminal shows AssertionError

    AssertionError: b'<h1>Calculator</h1>' != '<h1>Calculator</h1>'
    
  • I make the expectation a bytes object

    11        self.assertEqual(
    12            response.data,
    13            b'<h1>Calculator</h1>'
    14        )
    

    the test passes

  • I go back to the browser and click refresh, it shows Calculator

    Calculator Header

    Yes! I know how to make a website with Python!!


test_calculations


RED: make it fail


I add a new test for doing calculations with the website

 7      def test_home_page(self):
 8          client = src.website.app.test_client()
 9          response = client.get('/')
10          self.assertEqual(response.status_code, 200)
11          self.assertEqual(
12              response.data.decode(),
13              '<h1>Calculator</h1>'
14          )
15
16      def test_calculation(self):
17          client = src.website.app.test_client()
18          response = client.post(
19              '/calculate',
20              data={
21                  'first_input': 0,
22                  'second_input': 1,
23              }
24          )
25          self.assertEqual(response.status_code, 200)
26
27
28  # Exceptions seen

the terminal shows AssertionError

AssertionError: 404 != 200

this is like AttributeError, the address for calculate does not exist yet.


GREEN: make it pass


  • I add a new function to website.py

     7  @app.route('/')
     8  def home():
     9      return flask.render_template('index.html')
    10
    11
    12  @app.route('/calculate', methods=['POST'])
    13  def calculate():
    14      first_input = flask.request.form.get('first_input')
    15      second_input = flask.request.form.get('second_input')
    16
    17      result = calculator.add(first_input, second_input)
    18      return (
    19          f'<h2>{first_input} + {second_input} '
    20          f'= {result}</h2>'
    21      )
    
    • @app.route is a decorator function that routes the pages of the website to the function it wraps

    • '/calculate' is the route I want to point to the calculate function

    • ['POST'] is a list of HTTP request methods that I can send, in this case I am using the POST request method to send information to the server

    • flask.request.form.get(NAME) uses the get method to get the value of the NAME key from the dictionary when the user makes a request

    • <h2>SMALLER HEADING</h2> tells the computer to make SMALLER HEADING a heading that is smaller than h1 headings

    the terminal shows NameError

    NameError: name 'calculator' is not defined. Did you mean: 'calculate'?
    
  • I add an import statement for the calculator module at the top of the file

    1import calculator
    2import flask
    

    the terminal shows ModuleNotFoundError

    ModuleNotFoundError: No module named 'calculator
    

    because the test cannot find the calculator.py module

  • I change the import statement

    1import src.calculator
    2import flask
    

    the test passes and the terminal for the website shows ModuleNotFoundError

    ModuleNotFoundError: No module named 'src'
    

    this is a problem

    • website.py is in the src folder with calculator.py so import src.calculator will not work for the website

    • test_calculator_website.py is in the tests folder so import calculator will not work for the tests

    I need a way that allows both the tests and website to work

  • I change the import statement back

    1import calculator
    2import flask
    

    the terminal shows ModuleNotFoundError

    ModuleNotFoundError: No module named 'calculator'
    
  • I add an import statement for the sys module, it is part of The Python Standard Library

    1import sys
    2
    3import calculator
    4import flask
    

    I can use sys to get variables that the computer uses without going to the terminal

  • I add another import statement

    1import pathlib
    2import sys
    3
    4import calculator
    5import flask
    
  • I use pathlib to add the path for calculator.py to the system path which is a list of strings where Python looks for modules when I write an import statement

    1import pathlib
    2import sys
    3sys.path.insert(
    4    0, str(pathlib.Path(__file__).resolve().parent)
    5)
    6
    7import calculator
    8import flask
    

    the test passes and the terminal for the browser shows no errors

    • the path to calculator.py is now correct for website.py and test_calculator.py

    • sys.path.insert uses the insert method of lists to place the src folder as the first item in the list for Python to look for modules

    • pathlib.Path(__file__).resolve().parent returns the parent of the current file - src in this case

    • __file__ is a variable with the name of the file website.py in this case

    • the route to /calculate now exists


REFACTOR: make it better


  • I add an assertion to make sure I get the right result back in test_calculator_website.py

    13    def test_calculations(self):
    14        client = src.website.app.test_client()
    15        response = client.post(
    16            '/calculate',
    17            data={
    18                'first_input': 0,
    19                'second_input': 1,
    20            }
    21        )
    22        self.assertEqual(response.status_code, 200)
    23        self.assertEqual(response.data, 'BOOM!!!')
    

    the terminal shows AssertionError

    AssertionError: b'<h2>0 + 1 = brmph?! Numbers only. Try again...</h2>' != 'BOOM!!!'
    

    Ah! The calculator only works with numbers. The numbers I sent as values in the test 0 and 1 are getting converted to something the calculator does not like

  • I change first_input to a float in website.py

    19@app.route('/calculate', methods=['POST'])
    20def calculate():
    21    first_input = flask.request.form.get('first_input')
    22    first_input = float(first_input)
    23    second_input = flask.request.form.get('second_input')
    

    the terminal shows TypeError

    TypeError: unsupported operand type(s) for +: 'float' and 'str'
    
  • I change second_input to a float

    19@app.route('/calculate', methods=['POST'])
    20def calculate():
    21    first_input = flask.request.form.get('first_input')
    22    first_input = float(first_input)
    23    second_input = flask.request.form.get('second_input')
    24    second_input = float(second_input)
    25
    26    result = calculator.add(first_input, second_input)
    27    return (
    28        f'<h2>{first_input} + {second_input} '
    29        f'= {result}</h2>'
    30    )
    

    the terminal shows AssertionError

    AssertionError: b'<h2>0.0 + 1.0 = 1.0</h2>' != 'BOOM!!!'
    
  • I copy the result from the terminal and paste it in test_calculator_website.py

    16    def test_calculations(self):
    17        client = src.website.app.test_client()
    18        response = client.post(
    19            '/calculate',
    20            data={
    21                'first_input': 0,
    22                'second_input': 1,
    23            }
    24        )
    25        self.assertEqual(response.status_code, 200)
    26        self.assertEqual(
    27            response.data,
    28            b'<h2>0.0 + 1.0 = 1.0</h2>'
    29        )
    

    the test passes

  • I want to use random numbers in the test. I add an import statement for the test_calculator module

    1import src.website
    2import tests.test_calculator
    3import unittest
    
  • I point a variable to the result of a call to the a_random_number function of test_calculator

    17    def test_calculations(self):
    18        x = tests.test_calculator.a_random_number()
    19        client = src.website.app.test_client()
    
  • I use the new variable as the first input

    20        response = client.post(
    21            '/calculate',
    22            data={
    23                # 'first_input': 0,
    24                'first_input': x,
    25                'second_input': 1,
    26            }
    27        )
    

    the terminal shows AssertionError

    AssertionError: b'<h2>ABC.DEFGHIJKLMNOP + 1.0 = ABQ.DEFGHIJKLMNOP</h2>' != b'<h2>0.0 + 1.0 = 1.0</h2>'
    

    good, the input gets to the website

  • I try an f-string so I can use the variable in the assertion

    29        self.assertEqual(
    30            response.data,
    31            fb'<h2>0.0 + 1.0 = 1.0</h2>'
    32        )
    

    the terminal shows SyntaxError

    SyntaxError: invalid syntax
    

    I cannot make a bytes object an f-string

  • I add SyntaxError to the list of Exceptions seen

    35# Exceptions seen
    36# NameError
    37# ModuleNotFoundError
    38# AttributeError
    39# AssertionError
    40# jinja2.exceptions.TemplateNotFound
    41# SyntaxError
    

how to change a bytes object to a string

  • I change the bytes object to a string with bytes.decode

    29        self.assertEqual(
    30            response.data.decode(),
    31            f'<h2>0.0 + 1.0 = 1.0</h2>'
    32        )
    

    the terminal shows AssertionError

    AssertionError: '<h2>RS.TUVWXYZABCDEFG + 1.0 = RH.TUVWXYZABCDEFG</h2>' != '<h2>0.0 + 1.0 = 1.0</h2>'
    
  • I add the variable to the expectation

    29        self.assertEqual(
    30            response.data.decode(),
    31            f'<h2>{x} + 1.0 = {x+1}</h2>'
    32        )
    

    the test passes


  • I add another variable

    17    def test_calculations(self):
    18        x = tests.test_calculator.a_random_number()
    19        y = tests.test_calculator.a_random_number()
    20        client = src.website.app.test_client()
    
  • I use the variable for the second input

    21        response = client.post(
    22            '/calculate',
    23            data={
    24                # 'first_input': 0,
    25                'first_input': x,
    26                # 'second_input': 1,
    27                'second_input': y,
    28            }
    29        )
    

    the terminal shows AssertionError

    AssertionError: '<h2>HIJ.KLMNOPQRSTUVWX + YZA.BCDEFGHIJKLMNOP = QRS.TUVWXYZABCDEFG</h2>' != '<h2>-HIJ.KLMNOPQRSTUVWX + 1.0 = HIY.KLMNOPQRSTUVWX</h2>'
    
  • I add y to the f-string in the expectation

    31        self.assertEqual(
    32            response.data.decode(),
    33            f'<h2>{x} + {y} = {x+y}</h2>'
    34        )
    

    the test passes

  • I remove the comments

    17    def test_calculations(self):
    18        x = tests.test_calculator.a_random_number()
    19        y = tests.test_calculator.a_random_number()
    20        client = src.website.app.test_client()
    21        response = client.post(
    22            '/calculate',
    23            data={
    24                'first_input': x,
    25                'second_input': y,
    26            }
    27        )
    28        self.assertEqual(response.status_code, 200)
    29        self.assertEqual(
    30            response.data.decode(),
    31            f'<h2>{x} + {y} = {x+y}</h2>'
    32        )
    

    the test is still green

  • I want to test the other operations. I add a dictionary for the operations

    17    def test_calculations(self):
    18        x = tests.test_calculator.a_random_number()
    19        y = tests.test_calculator.a_random_number()
    20
    21        operations = {
    22            'add': '+',
    23            'subtract': '-',
    24            'divide': '/',
    25            'multiply': '*',
    26        }
    27
    28        client = src.website.app.test_client()
    
  • I add a for loop to the test

    28        client = src.website.app.test_client()
    29
    30        for operation in operations:
    31            with self.subTest(operation=operation):
    32                response = client.post(
    33                    '/calculate',
    34                    data={
    35                        'first_input': x,
    36                        'second_input': y,
    37                    }
    38                )
    39                self.assertEqual(response.status_code, 200)
    40                self.assertEqual(
    41                    response.data.decode(),
    42                    f'<h2>{x} + {y} = {x+y}</h2>'
    43                )
    44
    45
    46# Exceptions seen
    
  • I add a key to the data dictionary

    32                response = client.post(
    33                    '/calculate',
    34                    data={
    35                        'first_input': x,
    36                        'second_input': y,
    37                        'operation': operation,
    38                    }
    39                )
    
  • I add a variable for the calculator function

    40                self.assertEqual(response.status_code, 200)
    41
    42                function = src.calculator.__getattribute__(
    43                    operation
    44                )
    45                self.assertEqual(
    46                    response.data.decode(),
    47                    f'<h2>{x} + {y} = {x+y}</h2>'
    48                )
    
  • I add another variable for the expected result

    42                function = src.calculator.__getattribute__(
    43                    operation
    44                )
    45                result = function(x, y)
    46                self.assertEqual(
    47                    response.data.decode(),
    48                    f'<h2>{x} + {y} = {x+y}</h2>'
    49                )
    
  • I change the expectation in the assertion to use the new variables

    46                self.assertEqual(
    47                    response.data.decode(),
    48                    # f'<h2>{x} + {y} = {x+y}</h2>'
    49                    (
    50                        f'<h2>{x} {operations[operation]} {y} '
    51                        f'= {result}</h2>'
    52                    )
    53                )
    

    the terminal shows AssertionError

    AssertionError: '<h2>YZA.BCDEFGHIJKLMNO + PQR.STUVWXYZABCDEFG = HIJ.KLMNOPQRSTUVWXY</h2>' != '<h2>YZA.BCDEFGHIJKLMNO * PQR.STUVWXYZABCDEFG = ZABCD.EFGHIJKLMNO</h2>'
    
    • the subtest for addition passes

    • the operation symbols for the other 3 functions and their results changed

  • I add a variable for value the test passes for the operation

    19@app.route('/calculate', methods=['POST'])
    20def calculate():
    21    first_input = flask.request.form.get('first_input')
    22    first_input = float(first_input)
    23    second_input = flask.request.form.get('second_input')
    24    second_input = float(second_input)
    25    operation = flask.request.form.get('operation')
    26
    27    result = calculator.add(first_input, second_input)
    

    the test still shows AssertionError for the 3 sub tests

  • I add a dictionary for the operations

    25    operation = flask.request.form.get('operation')
    26
    27    operations = {
    28        'add': '+',
    29        'subtract': '-',
    30        'divide': '/',
    31        'multiply': '*',
    32    }
    33
    34    result = calculator.add(first_input, second_input)
    
  • I use the __getattribute__ method for the result

    34    # result = calculator.add(first_input, second_input)
    35    result = calculator.__getattribute__(operation)(
    36        first_input, second_input
    37    )
    38    return (
    39        f'<h2>{first_input} + {second_input} '
    40        f'= {result}</h2>'
    41    )
    
  • I change the return statement

    38    return (
    39        # f'<h2>{first_input} + {second_input} '
    40        f'<h2>{first_input} {operations[operation]} {second_input} '
    41        f'= {result}</h2>'
    42    )
    

    the test passes

  • I remove the commented lines

    19@app.route('/calculate', methods=['POST'])
    20def calculate():
    21    first_input = flask.request.form.get('first_input')
    22    first_input = float(first_input)
    23    second_input = flask.request.form.get('second_input')
    24    second_input = float(second_input)
    25    operation = flask.request.form.get('operation')
    26
    27    operations = {
    28        'add': '+',
    29        'subtract': '-',
    30        'divide': '/',
    31        'multiply': '*',
    32    }
    33
    34    result = calculator.__getattribute__(operation)(
    35        first_input, second_input
    36    )
    37    return (
    38        f'<h2>{first_input} {operations[operation]} {second_input} '
    39        f'= {result}</h2>'
    40    )
    

    the test is still green

  • I remove the comments from test_calculations in test_calculator_website.py

    17    def test_calculations(self):
    18        x = tests.test_calculator.a_random_number()
    19        y = tests.test_calculator.a_random_number()
    20
    21        operations = {
    22            'add': '+',
    23            'subtract': '-',
    24            'divide': '/',
    25            'multiply': '*',
    26        }
    27
    28        client = src.website.app.test_client()
    29
    30        for operation in operations:
    31            with self.subTest(operation=operation):
    32                response = client.post(
    33                    '/calculate',
    34                    data={
    35                        'first_input': x,
    36                        'second_input': y,
    37                        'operation': operation,
    38                    }
    39                )
    40                self.assertEqual(response.status_code, 200)
    41
    42                function = src.calculator.__getattribute__(
    43                    operation
    44                )
    45                result = function(x, y)
    46                self.assertEqual(
    47                    response.data.decode(),
    48                    (
    49                        f'<h2>{x} {operations[operation]} {y} '
    50                        f'= {result}</h2>'
    51                    )
    52                )
    53
    54
    55# Exceptions seen
    

how to make a form

HTML Forms are the most popular thing we see with websites, we use them when we sign in or put any information on a website. I can add a form to the website for users to do calculations since the route now exists.


RED: make it fail


  • I add more HTML to index.html in the templates folder in the src folder

    1  <h1>Calculator</h1>
    2  <form method="post" action="/calculate">
    3  </form>
    
    • <form></form> is an HTML tag (enclosure) for making forms

    • method="post" means the form will use the POST request method

    • action="/calculate" means the form will send the data to the calculate function in website.py

  • I go to the website and click refresh, there is no change

  • I click on test_calculator_website.py and use ctrl+s on the keyboard to run the tests again, the terminal shows AssertionError

    AssertionError: b'<h1>Calculator</h1>\n<form method="post" action="/calculate">\n</form>' != b'<h1>Calculator</h1>'
    

    the change made test_home_page fail because there is now more HTML


GREEN: make it pass


I change the assertion to look for the title and not the entire page

 8    def test_home_page(self):
 9        client = src.website.app.test_client()
10        response = client.get('/')
11        self.assertEqual(response.status_code, 200)
12        self.assertIn(
13            b'<h1>Calculator</h1>',
14            response.data
15        )
16
17    def test_calculations(self):

the test passes because the assertIn method of the unittest.TestCase class checks if the thing on the left is in the object on the right


REFACTOR: make it better


  • I add more HTML to index.html in the templates folder in the src folder

    2<form method="post" action="/calculate">
    3    <input type="number" name="first_input" required>
    4</form>
    
  • I go to the browser and click refresh to see the change, the website has a place where I can put numbers or use the up and down arrows to change the numbers

    Calculator form with first input
  • I use ctrl+s on the keyboard in test_calculator_website.py to run the tests, still green

  • I add another input for the second number in index.html

    2<form method="post" action="/calculate">
    3    <input type="number" name="first_input" required>
    4    <input type="number" name="second_input" required>
    5</form>
    
  • I go to the browser and click refresh to see the change, the website now has 2 places for me to put numbers

    Calculator form with second input
  • I use ctrl+s on the keyboard in test_calculator_website.py to run the tests, green

  • I add options for the operation in index.html

    2<form method="post" action="/calculate">
    3    <input type="number" name="first_input" required>
    4    <input type="number" name="second_input" required>
    5    <select name="operation">
    6        <option value="add">+</option>
    7    </select>
    8</form>
    
  • I go to the browser and click refresh to see the change, the website now has a place for me to choose the operation, even though + is the only option

    Calculator form with first operation
  • I use ctrl+s on the keyboard in website.py to run the tests, they are still green

  • I want the operation to show up between the 2 numbers, I change the order in index.html

    2<form method="post" action="/calculate">
    3    <input type="number" name="first_input" required>
    4    <select name="operation">
    5        <option value="add">+</option>
    6    </select>
    7    <input type="number" name="second_input" required>
    8</form>
    
  • I go to the browser and click refresh to see the change, the operation is now between the 2 numbers

    Calculator form with first operation redordered
  • I use ctrl+s on the keyboard in website.py to run the tests. Green

  • I add the other operations to index.html

     2<form method="post" action="/calculate">
     3    <input type="number" name="first_input" required>
     4    <select name="operation">
     5        <option value="add">+</option>
     6        <option value="subtract">-</option>
     7        <option value="divide">/</option>
     8        <option value="multiply">*</option>
     9    </select>
    10    <input type="number" name="second_input" required>
    11</form>
    

    the browser shows all the options when I click refresh

  • I add a button to the form so the user can submit the numbers and operation for a calculation

     1<h1>Calculator</h1>
     2<form method="post" action="/calculate">
     3    <input type="number" name="first_input" required>
     4    <select name="operation">
     5        <option value="add">+</option>
     6        <option value="subtract">-</option>
     7        <option value="divide">/</option>
     8        <option value="multiply">*</option>
     9    </select>
    10    <input type="number" name="second_input" required>
    11    <button type="submit">calculate</button>
    12</form>
    
  • I go to the browser and click refresh to see the change

    Calculator form with calculate button
  • I enter 10000 as the first number and 20000 as the second number, with * as the operation

    Calculator form first calculation

    I click calculate and the browser shows

    Calculator first result

ugly and it works


fix handling ZeroDivisionError in division

I want to make sure the division function returns a message when the second number is 0


RED: make it fail


I add a new test

17    def test_calculations(self):
18        x = tests.test_calculator.a_random_number()
19        y = tests.test_calculator.a_random_number()
20
21        operations = {
22            'add': '+',
23            'subtract': '-',
24            'divide': '/',
25            'multiply': '*',
26        }
27
28        client = src.website.app.test_client()
29
30        for operation in operations:
31            with self.subTest(operation=operation):
32                response = client.post(
33                    '/calculate',
34                    data={
35                        'first_input': x,
36                        'second_input': y,
37                        'operation': operation,
38                    }
39                )
40                self.assertEqual(response.status_code, 200)
41
42                function = src.calculator.__getattribute__(
43                    operation
44                )
45                result = function(x, y)
46                self.assertEqual(
47                    response.data.decode(),
48                    (
49                        f'<h2>{x} {operations[operation]} {y} '
50                        f'= {result}</h2>'
51                    )
52                )
53
54    def test_website_handling_zero_division_error(self):
55        x = tests.test_calculator.a_random_number()
56        client = src.website.app.test_client()
57
58        response = client.post(
59            '/calculate',
60            data={
61                'first_input': x,
62                'second_input': 0,
63                'operation': 'divide',
64            }
65        )
66        self.assertEqual(
67            response.data.decode(),
68            'brmph?! I cannot divide by 0. Try again...'
69        )
70
71
72# Exceptions seen

the terminal shows AssertionError in short test summary info

AssertionError: '<!doctype html>\n<html lang=en>\n<title>5[225 chars]p>\n' != 'brmph?! I cannot divide by 0.

and in the traceback it shows the AssertionError was caused by ZeroDivisionError

ZeroDivisionError: float division by zero

good


GREEN: make it pass


  • I open calculator.py from the src folder

  • I add an exception handler to the divide function in calculator.py

    17@check_input
    18def divide(first_input, second_input):
    19    try:
    20        return first_input / second_input
    21    except ZeroDivisionError:
    22        return 'brmph?! I cannot divide by 0. Try again...'
    23
    24
    25@check_input
    26def multiply(first_input, second_input):
    

    the terminal shows AssertionError

    AssertionError: '<h2>PQ.RSTUVWXYZABCDEF / 0.0 = brmph?! I cannot divide by 0. Try again...</h2>' != 'brmph?! I cannot divide by 0. Try again...'
    

    okay. It returns the right error message

  • I change the expectation of the assertion in test_calculator_website.py

    54    def test_website_handling_zero_division_error(self):
    55        x = tests.test_calculator.a_random_number()
    56        client = src.website.app.test_client()
    57
    58        response = client.post(
    59            '/calculate',
    60            data={
    61                'first_input': x,
    62                'second_input': 0,
    63                'operation': 'divide',
    64            }
    65        )
    66        self.assertEqual(
    67            response.data.decode(),
    68            (
    69                f'<h2>{x} / 0.0 = '
    70                'brmph?! I cannot divide by 0. Try again...</h2>'
    71            )
    72        )
    73
    74
    75# Exceptions seen
    

    the test passes


REFACTOR: make it better


  • I made the same client in each test. I add a class attribute for it in the setUp method

     6class TestCalculatorWebsite(unittest.TestCase):
     7
     8    def setUp(self):
     9        self.client = src.website.app.test_client()
    10
    11    def test_home_page(self):
    
  • I use the new class attribute in test_home_page

    11    def test_home_page(self):
    12        # client = src.website.app.test_client()
    13        client = self.client
    14        response = client.get('/')
    

    the test is still green

  • I use it directly in the response

    14        response = self.client.get('/')
    

    still green

  • I remove the client variable and the commented line

    11    def test_home_page(self):
    12        response = self.client.get('/')
    13        self.assertEqual(response.status_code, 200)
    14        self.assertIn(
    15            b'<h1>Calculator</h1>',
    16            response.data
    17        )
    18
    19    def test_calculations(self):
    

    green


  • I use the class attribute in test_calculations

    32        for operation in operations:
    33            with self.subTest(operation=operation):
    34                # response = client.post(
    35                response = self.client.post(
    36                    '/calculate',
    37                    data={
    38                        'first_input': x,
    39                        'second_input': y,
    40                        'operation': operation,
    41                    }
    42                )
    

    still green

  • I remove the commented line and the client variable

    19    def test_calculations(self):
    20        x = tests.test_calculator.a_random_number()
    21        y = tests.test_calculator.a_random_number()
    22
    23        operations = {
    24            'add': '+',
    25            'subtract': '-',
    26            'divide': '/',
    27            'multiply': '*',
    28        }
    29
    30        for operation in operations:
    31            with self.subTest(operation=operation):
    32                response = self.client.post(
    33                    '/calculate',
    34                    data={
    35                        'first_input': x,
    36                        'second_input': y,
    37                        'operation': operation,
    38                    }
    39                )
    40                self.assertEqual(response.status_code, 200)
    41
    42                function = src.calculator.__getattribute__(
    43                    operation
    44                )
    45                result = function(x, y)
    46                self.assertEqual(
    47                    response.data.decode(),
    48                    (
    49                        f'<h2>{x} {operations[operation]} {y} '
    50                        f'= {result}</h2>'
    51                    )
    52                )
    53
    54    def test_website_handling_zero_division_error(self):
    

    the test is still green


  • I use the class attribute in test_website_handling_zero_division_error

    54    def test_website_handling_zero_division_error(self):
    55        x = tests.test_calculator.a_random_number()
    56        client = src.website.app.test_client()
    57
    58        # response = client.post(
    59        response = self.client.post(
    60            '/calculate',
    61            data={
    62                'first_input': x,
    63                'second_input': 0,
    64                'operation': 'divide',
    65            }
    66        )
    

    still green

  • I remove the commented line and client variable

    54    def test_website_handling_zero_division_error(self):
    55        x = tests.test_calculator.a_random_number()
    56        response = self.client.post(
    57            '/calculate',
    58            data={
    59                'first_input': x,
    60                'second_input': 0,
    61                'operation': 'divide',
    62            }
    63        )
    64        self.assertEqual(
    65            response.data.decode(),
    66            (
    67                f'<h2>{x} / 0.0 = '
    68                'brmph?! I cannot divide by 0. Try again...</h2>'
    69            )
    70        )
    71
    72
    73# Exceptions seen
    

    green


  • I add class attributes for the random numbers to the setUp method

     8    def setUp(self):
     9        self.client = src.website.app.test_client()
    10        self.x = tests.test_calculator.a_random_number()
    11
    12    def test_home_page(self):
    
  • I use it in test_calculations

    20    def test_calculations(self):
    21        # x = tests.test_calculator.a_random_number()
    22        x = self.x
    23        y = tests.test_calculator.a_random_number()
    

    still green

  • I use the Rename Symbol to change x to self.x

    20    def test_calculations(self):
    21        # x = tests.test_calculator.a_random_number()
    22        self.x = self.x
    23        y = tests.test_calculator.a_random_number()
    24
    25        operations = {
    26            'add': '+',
    27            'subtract': '-',
    28            'divide': '/',
    29            'multiply': '*',
    30        }
    31
    32        for operation in operations:
    33            with self.subTest(operation=operation):
    34                response = self.client.post(
    35                    '/calculate',
    36                    data={
    37                        'first_input': self.x,
    38                        'second_input': y,
    39                        'operation': operation,
    40                    }
    41                )
    42                self.assertEqual(response.status_code, 200)
    43
    44                function = src.calculator.__getattribute__(
    45                    operation
    46                )
    47                result = function(self.x, y)
    48                self.assertEqual(
    49                    response.data.decode(),
    50                    (
    51                        f'<h2>{self.x} {operations[operation]} {y} '
    52                        f'= {result}</h2>'
    53                    )
    54                )
    

    the test is still green

  • I remove the commented line and self.x = self.x

    20    def test_calculations(self):
    21        y = tests.test_calculator.a_random_number()
    22
    23        operations = {
    24            'add': '+',
    25            'subtract': '-',
    26            'divide': '/',
    27            'multiply': '*',
    28        }
    29
    30        for operation in operations:
    31            with self.subTest(operation=operation):
    32                response = self.client.post(
    33                    '/calculate',
    34                    data={
    35                        'first_input': self.x,
    36                        'second_input': y,
    37                        'operation': operation,
    38                    }
    39                )
    40                self.assertEqual(response.status_code, 200)
    41
    42                function = src.calculator.__getattribute__(
    43                    operation
    44                )
    45                result = function(self.x, y)
    46                self.assertEqual(
    47                    response.data.decode(),
    48                    (
    49                        f'<h2>{self.x} {operations[operation]} {y} '
    50                        f'= {result}</h2>'
    51                    )
    52                )
    53
    54    def test_website_handling_zero_division_error(self):
    
  • I use the Rename Symbol feature to change x to self.x in test_website_handling_zero_division_error

    54    def test_website_handling_zero_division_error(self):
    55        self.x = tests.test_calculator.a_random_number()
    56        response = self.client.post(
    57            '/calculate',
    58            data={
    59                'first_input': self.x,
    60                'second_input': 0,
    61                'operation': 'divide',
    62            }
    63        )
    64        self.assertEqual(
    65            response.data.decode(),
    66            (
    67                f'<h2>{self.x} / 0.0 = '
    68                'brmph?! I cannot divide by 0. Try again...</h2>'
    69            )
    70        )
    

    still green

  • I remove self.x = tests.test_calculator.a_random_number()

    54    def test_website_handling_zero_division_error(self):
    55        response = self.client.post(
    56            '/calculate',
    57            data={
    58                'first_input': self.x,
    59                'second_input': 0,
    60                'operation': 'divide',
    61            }
    62        )
    63        self.assertEqual(
    64            response.data.decode(),
    65            (
    66                f'<h2>{self.x} / 0.0 = '
    67                'brmph?! I cannot divide by 0. Try again...</h2>'
    68            )
    69        )
    70
    71
    72# Exceptions seen
    

test_calculator_sends_message_when_inputs_are_not_numbers

time to fix the problem with the second input in test_calculator_sends_message_when_input_is_not_a_number


RED: make it fail


  • I open test_calculator.py from the tests folder

  • I add another assertion where the second input is not a number and the first input is a number

    136    def test_calculator_sends_message_when_input_is_not_a_number(self):
    137        for bad_input in (
    138            None,
    139            True, False,
    140            str(), 'text',
    141            tuple(), (0, 1, 2, 'n'),
    142            list(), [0, 1, 2, 'n'],
    143            set(), {0, 1, 2, 'n'},
    144            dict(), {'key': 'value'},
    145        ):
    146            for operation in self.calculator_tests:
    147                with self.subTest(
    148                    operation=operation,
    149                    bad_input=bad_input,
    150                ):
    151                    self.assertEqual(
    152                        src.calculator.__getattribute__(operation)(
    153                            bad_input, a_random_number()
    154                        ),
    155                        'brmph?! Numbers only. Try again...'
    156                    )
    157                    self.assertEqual(
    158                        src.calculator.__getattribute__(operation)(
    159                            a_random_number(), bad_input
    160                        ),
    161                        'brmph?! Numbers only. Try again...'
    162                    )
    163
    164    def test_calculator_functions(self):
    

    the terminal shows TypeError

    TypeError: unsupported operand type(s) for /: 'float' and 'dict
    

    for the 52 sub tests where the first input is a number and the second input is not a number


GREEN: make it pass


  • I add a condition to the if statement in the check_input decorator function in calculator.py

    1            list(), [0, 1, 2, 'n'],
    2            set(), {0, 1, 2, 'n'},
    3            dict(), {'key': 'value'},
    4        )
    5        for bad_input in (
    

    the test is still green

  • I change the first for loop

    146        # for bad_input in (
    147        #     None,
    148        #     True, False,
    149        #     str(), 'text',
    150        #     tuple(), (0, 1, 2, 'n'),
    151        #     list(), [0, 1, 2, 'n'],
    152        #     set(), {0, 1, 2, 'n'},
    153        #     dict(), {'key': 'value'},
    154        # ):
    155        for bad_input in bad_inputs:
    

    still green

  • I add another for loop and move everything after it to the right

    155        for bad_input in bad_inputs:
    156            for second_input in bad_inputs:
    157                for operation in self.calculator_tests:
    158                    with self.subTest(
    159                        operation=operation,
    160                        bad_input=bad_input,
    161                    ):
    162                        self.assertEqual(
    163                            src.calculator.__getattribute__(operation)(
    164                                bad_input, a_random_number()
    165                            ),
    166                            'brmph?! Numbers only. Try again...'
    167                        )
    

    green

  • I add second_input to the subTest method

    158                    with self.subTest(
    159                        operation=operation,
    160                        bad_input=bad_input,
    161                        y=second_input,
    162                    ):
    

    still green

  • I change the second input in the call to the calculator functions in the assertion

    163                        self.assertEqual(
    164                            src.calculator.__getattribute__(operation)(
    165                                # bad_input, a_random_number()
    166                                bad_input, second_input
    167                            ),
    168                            'brmph?! Numbers only. Try again...'
    169                        )
    

    the test is still green

  • I change the expectation to make sure the test still works

    163                        self.assertEqual(
    164                            src.calculator.__getattribute__(operation)(
    165                                # bad_input, a_random_number()
    166                                bad_input, second_input
    167                            ),
    168                            # 'brmph?! Numbers only. Try again...'
    169                            'BOOM!!!'
    170                        )
    

    the terminal shows AssertionError

    AssertionError: 'brmph?! Numbers only. Try again...' != 'BOOM!!!'
    

    for 676 failures that are all the ways the bad inputs can be sent with the operations, it works

  • I change the expectation back, and the test goes back to green

  • I remove the commented lines

  • I use the Rename Symbol feature to change bad_input to x and second_input to y

    136    def test_calculator_sends_message_when_input_is_not_a_number(self):
    137        bad_inputs = (
    138            None,
    139            True, False,
    140            str(), 'text',
    141            tuple(), (0, 1, 2, 'n'),
    142            list(), [0, 1, 2, 'n'],
    143            set(), {0, 1, 2, 'n'},
    144            dict(), {'key': 'value'},
    145        )
    146        for x in bad_inputs:
    147            for y in bad_inputs:
    148                for operation in self.calculator_tests:
    149                    with self.subTest(
    150                        operation=operation,
    151                        x=x, y=y,
    152                    ):
    153                        self.assertEqual(
    154                            src.calculator.__getattribute__(operation)(
    155                                x, y
    156                            ),
    157                            'brmph?! Numbers only. Try again...'
    158                        )
    159
    160    def test_calculator_functions(self):
    

close the project

  • I close test_calculator.py, test_calculator_website.py, calculator.py, website.py and index.html in the editor

  • I click in the first terminal, then use q on the keyboard to leave the tests. The terminal goes back to the command line

  • I change directory to the parent of calculator

    cd ..
    

    the terminal shows

    .../pumping_python
    

    I am back in the pumping_python directory

  • I click in the second terminal, then use ctrl+c on the keyboard to close the web server. The terminal goes back to the command line


review

I ran tests to show that

code from the chapter

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


what is next?

you know

  • how to make a test driven development environment

  • how to build a full calculator with TDD

  • how to turn it into a website with Flask


rate pumping python

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