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
calculatorfoldercd calculatorthe terminal shows I am in the
calculatorfolder.../pumping_python/calculatorI make a new test file for the website
touch tests/test_calculator_website.pyI add Flask to the
requirements.txtfileecho "flask" >> requirements.txtFlask 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.txtthe terminal shows it installed the Python packages
I use
pytest-watcherto run the testsuv run pytest-watcher . --nowthe 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.pyfrom thetestsfolder in the editorI add a test to
test_calculator_website.py1import unittest 2 3 4class TestCalculatorWebsite(unittest.TestCase): 5 6 def test_home_page(self): 7 client = src.website.app.test_client()NameError: name 'src' is not definedI 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
I add an import statement for the
websitemodule1import src.website 2import unittestthe terminal shows ModuleNotFoundError
ModuleNotFoundError: No module named 'src.website'I add ModuleNotFoundError to the list of Exceptions seen
11# Exceptions seen 12# NameError 13# ModuleNotFoundErrorthen I add
website.pyto thesrcfolder and the terminal shows AttributeErrorAttributeError: module 'src.website' has no attribute 'app'I add AttributeError to the list of Exceptions seen
11# Exceptions seen 12# NameError 13# ModuleNotFoundError 14# AttributeErrorI make the Flask app in
website.py1import flask 2 3 4app = flask.Flask(__name__)the test passes
REFACTOR: make it better
I add an assertion to test_home_page in
test_calculator_website.py7 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# AssertionErrorI change the expectation in the assertion
10 self.assertEqual(response.status_code, 404)the test passes.
client = src.website.app.test_client()points the nameclientto the Flask app inwebsite.pyresponse = client.get('/')points the nameresponseto the result of the call to thegetmethod of the client objectthe
getmethod calls the GET request method which is an HTTP request method to get information from a serverclient.get('/')returns a response object'/'is short for root or home in this case, the homepage of the website I am making also known asindex.htmlresponse.status_codegets thestatus_codeattribute or the response objectthe above can also be written as
src.website.app.test_client().get('/').status_code404is HTTP status code, it is short for 404 Not Found which means the page cannot be foundI want a
200HTTP 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 != 200I add a function for the homepage in
website.py1import 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 != 200it also shows jinja2.exceptions.TemplateNotFound
jinja2.exceptions.TemplateNotFound: index.htmlI have to make a template file for
index.html@app.routeis 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 asindex.html
I add jinja2.exceptions.TemplateNotFound to the list of Exceptions seen
# Exceptions seen # NameError # ModuleNotFoundError # AttributeError # AssertionError # jinja2.exceptions.TemplateNotFoundI add a new folder to the
srcfolder namedtemplates, the terminal still shows the same ExceptionI add a new file in the
templatesfolder namedindex.html, the terminal still shows the same ExceptionI go back to
test_calculator_website.pythen 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 --debugthe 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-GHIit might also show a dialog box like this, and I click on
Open in Browser
or I use ctrl on the keyboard and click on
http://127.0.0.1:5000with 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.py7 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.htmlb''is the empty bytes objectbytes objects are like strings , they have a
bbefore the quotes, for exampleb'single quote bytes' b'''triple single quote bytes''' b"double quote bytes" b"""triple double quote bytes"""
I open
index.htmlfrom thetemplatesfolder in the editor, then add some HTML1<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 makeHEADINGa heading
I go back to
test_calculator_website.pyand use ctrl+s on the keyboard to run the test, the terminal shows AssertionErrorAssertionError: 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 showsCalculator
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.
client.postcalls thepostmethodwhich calls the POST request method, an HTTP request method that sends information to a server. I am using it to test sending numbers to the website to do a calculation
the
postmethod is called with 2 inputs in this case'/calculate'- the path to the function inwebsite.pydata- a dictionary with the inputs I want the function to process
GREEN: make it pass
I add a new function to
website.py7 @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.routeis a decorator function that routes the pages of the website to the function it wraps'/calculate'is the route I want to point to thecalculatefunction['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 serverflask.request.form.get(NAME)uses the get method to get the value of theNAMEkey from the dictionary when the user makes a request<h2>SMALLER HEADING</h2>tells the computer to makeSMALLER HEADINGa heading that is smaller thanh1headings
NameError: name 'calculator' is not defined. Did you mean: 'calculate'?I add an import statement for the
calculatormodule at the top of the file1import calculator 2import flaskthe terminal shows ModuleNotFoundError
ModuleNotFoundError: No module named 'calculatorbecause the test cannot find the
calculator.pymoduleI change the import statement
1import src.calculator 2import flaskthe test passes and the terminal for the website shows ModuleNotFoundError
ModuleNotFoundError: No module named 'src'this is a problem
website.pyis in thesrcfolder withcalculator.pysoimport src.calculatorwill not work for the websitetest_calculator_website.pyis in thetestsfolder soimport calculatorwill 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 flaskthe 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 flaskI 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 flaskthe pathlib module is also part of The Python Standard Library
I can use it to get paths(addresses) for files and folders on the computer without going to the terminal
I use pathlib to add the path for
calculator.pyto the system path which is a list of strings where Python looks for modules when I write an import statement1import pathlib 2import sys 3sys.path.insert( 4 0, str(pathlib.Path(__file__).resolve().parent) 5) 6 7import calculator 8import flaskthe test passes and the terminal for the browser shows no errors
the path to
calculator.pyis now correct forwebsite.pyandtest_calculator.pysys.path.insertuses the insert method of lists to place thesrcfolder as the first item in the list for Python to look for modulespathlib.Path(__file__).resolve().parentreturns the parent of the current file -srcin this case__file__is a variable with the name of the filewebsite.pyin this casethe route to
/calculatenow exists
REFACTOR: make it better
I add an assertion to make sure I get the right result back in
test_calculator_website.py13 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
0and1are getting converted to something the calculator does not likeI change
first_inputto a float inwebsite.py19@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')TypeError: unsupported operand type(s) for +: 'float' and 'str'I change
second_inputto a float19@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.py16 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_calculatormodule1import src.website 2import tests.test_calculator 3import unittestI point a variable to the result of a call to the
a_random_numberfunction oftest_calculator17 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 syntaxI 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>'response.data.decode()returns the result of calling the decode method of thedataattribute of theresponseobjectthe decode method is part of the bytes data type, it converts bytes to strings
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
yto the f-string in the expectation31 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 seenI add a key to the
datadictionary32 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 result34 # 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.py17 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.htmlin thetemplatesfolder in thesrcfolder1 <h1>Calculator</h1> 2 <form method="post" action="/calculate"> 3 </form>method="post"means the form will use the POST request methodaction="/calculate"means the form will send the data to thecalculatefunction inwebsite.py
I go to the website and click refresh, there is no change
I click on
test_calculator_website.pyand use ctrl+s on the keyboard to run the tests again, the terminal shows AssertionErrorAssertionError: 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.htmlin thetemplatesfolder in thesrcfolder2<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
I use ctrl+s on the keyboard in
test_calculator_website.pyto run the tests, still greenI add another input for the second number in
index.html2<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
I use ctrl+s on the keyboard in
test_calculator_website.pyto run the tests, greenI add options for the operation in
index.html2<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
I use ctrl+s on the keyboard in
website.pyto run the tests, they are still greenI want the operation to show up between the 2 numbers, I change the order in
index.html2<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
I use ctrl+s on the keyboard in
website.pyto run the tests. GreenI add the other operations to
index.html2<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
I enter
10000as the first number and20000as the second number, with*as the operation
I click
calculateand the browser shows
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.pyfrom thesrcfolderI add an exception handler to the divide function in
calculator.py17@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.py54 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 seenthe 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
clientvariable and the commented line11 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
clientvariable19 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
clientvariable54 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 seengreen
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 Symbolto changextoself.x20 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.x20 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 Symbolfeature to changextoself.xin test_website_handling_zero_division_error54 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.pyfrom thetestsfolderI 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):TypeError: unsupported operand type(s) for /: 'float' and 'dictfor 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_inputdecorator function incalculator.py1 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_inputto the subTest method158 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 Symbolfeature to changebad_inputtoxandsecond_inputtoy136 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.pyandindex.htmlin the editorI 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
calculatorcd ..the terminal shows
.../pumping_pythonI am back in the
pumping_pythondirectoryI 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
I can use Flask with `Test Driven Developments`_
I can make routes, handle forms, and use the existing calculator
Error messages from the calculator appear in the browser
code from the 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