Testing your code

Published

2023-08-01

Testing your code

It’s important to test your code. In fact one famous dictum of programming is:

If it hasn’t been tested, it’s broken.

When writing code, try to anticipate odd or non-conforming input, and then test your program to see how it handles such input.

If your code has multiple branches, it’s probably a good idea to test each branch. Obviously, with larger programs this could get unwieldy, but for small programs with few branches, it’s not unreasonable to try each branch.

Some examples

Let’s say we had written a program that is intended to take pressure in pounds per square inch (psi) and convert this to bars. A bar is a unit of pressure and 1 bar is equivalent to 14.503773773 psi.

Without looking at the code, let’s test our program. Here are some values that represent reasonable inputs to the program.

input (in psi) expected output (bars) actual output (bars)
0 0
14.503773773 1.0
100.0 ~ 6.894757293

Here’s our first test run.

Enter pressure in psi: 0 
Traceback (most recent call last):
  File "/.../pressure.py", line 15, in <module>
    bars = psi_to_bars(psi)
  File "/.../pressure.py", line 8, in psi_to_bars
    return PSI_PER_BAR / p
TypeError: unsupported operand type(s) for /: 'float' and 'str'

Oh dear! Already we have a problem. Looking at the last line of the error message we see

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

What went wrong?

Obviously we’re trying to do arithmetic—division—where one operand is a float and the other is a str. That’s not allowed, hence the type error.

When we give this a little thought, we realize it’s likely we didn’t convert the user input to a float before attempting the calculation (remember, the input() function always returns a string).

We go back to our code and fix it so that the string we get from input() is converted to a float using the float constructor, float(). Having made this change, let’s try the program again.

Enter pressure in psi: 0
Traceback (most recent call last):
  File "/.../pressure.py", line 15, in <module>
    bars = psi_to_bars(psi)
  File "/.../pressure.py", line 8, in psi_to_bars
    return PSI_PER_BAR / p
ZeroDivisionError: float division by zero

Now we have a different error:

ZeroDivisionError: float division by zero

How could this have happened? Surely if pressure in psi is zero, then pressure in bars should also be zero (as in a perfect vacuum).

When we look at the code (you can see the offending line in the traceback above), we see that instead of taking the value in psi and dividing by the number of psi per bar, we’ve got our operands in the wrong order. Clearly we need to divide psi by psi per bar to get the correct result. Again you can see from the traceback, above, that there’s a constant PSI_PER_BAR, so we’ll just reverse the operands. This has the added benefit of having a non-zero constant in the denominator, so after this change, this operation can never result in a ZeroDivisionError ever again.

Now let’s try it again.

Enter pressure in psi: 0
0.0 psi is equivalent to 0.0 bars.

That works! So far, so good.

Now let’s try with a different value. We know, from the definition of bar that one bar is equivalent to 14.503773773 psi. Therefore, if we enter 14.503773773 for psi, the program should report that this is equivalent to 1.0 bar.

Enter pressure in psi: 14.503773773
14.503773773 psi is equivalent to 1.0 bars.

Brilliant.

Let’s try a different value. How about 100? You can see in the table above that 100 psi is approximately equivalent to ~6.894757293 bars.

Enter pressure in psi: 100
100.0 psi is equivalent to 6.894757293178307 bars.

This looks correct, though we can see now that we’re displaying more digits to the right of the decimal point than are useful.

Let’s say we went back to our code and added format specifiers to that both psi and bars are displayed to four decimal places of precision.

Enter pressure in psi: 100
100.0000 psi is equivalent to 6.8948 bars.

This looks good.

Returning to our table, and filling in the actual values, now we have

input (in psi) expected output (bars) actual output (bars)
0 0.0 0.0000
14.503773773 1.0 1.0000
100.0 ~ 6.894757293 6.8948

All our observed, actual outputs agree with our expected outputs.

What about negative values for pressure? Yes, there are cases where a negative pressure value makes sense. Take, for example, an isolation room for biomedical research. The air pressure in the isolation room should be lower than pressure in the outside hallways or adjoining rooms. In this way, when the door to an isolation room is opened, air will flow into the room, not out of it. This helps prevent contamination of uncontrolled outside environments. It’s common to express the difference in pressure between the isolation room and the outside hallway as a negative value.

Does our program handle such values? Let’s expand our table:

input (in psi) expected output (bars) actual output (bars)
0 0.0 0.0000
14.503773773 1.0 1.0000
100.0 ~ 6.894757293 6.8948
-0.01 ~ -0.000689476 ??

Does our program handle this correctly?

Enter pressure in psi: -0.01
-0.0100 psi is equivalent to -0.0007 bars.

Again, this looks OK.

Now let’s try to break our program to test its limits. Let’s try some large values. The atmospheric pressure on the surface of Venus is 1334 psi. We’d expect a result in bars of approximately 91.9761 bars. The pressure at the bottom of the Mariana Trench in the Pacific Ocean is 15,750 psi, or roughly 1,086 bars.

input (in psi) expected output (bars) actual output (bars)
0 0.0 0.0000
14.503773773 1.0 1.0000
100.0 ~ 6.894757293 6.8948
-0.01 ~ -0.000689476 0.0007
1334 ~ 91.9761 ??
15,750 ~ 1086 ??

Let’s test:

Enter pressure in psi: 1334
1334.0000 psi is equivalent to 91.9761 bars.

This one passes, but what about the next one (with the string containing the comma)? Will the conversion of the string '15,750' (notice the comma) be converted correctly to a float? Alas, this fails:

Traceback (most recent call last):
  File "/.../pressure.py", line 13, in <module>
    psi = float(input("Enter pressure in psi: "))
ValueError: could not convert string to float: '15,750'

Later, we’ll learn how to create a modified copy of such a string with the commas removed, but for now let it suffice to say this can be fixed. Notice however, that if we hadn’t checked this large value, which could reasonably be entered by a human user with the comma as shown, we might not have realized that this defect in our code existed! Always test with as many ways the user might enter data as you can think of!

With that fix in place, all is well.

Enter pressure in psi: 15,750
15750.0000 psi is equivalent to 1085.9243 bars.

By testing these larger values, we see that it might make sense to format the output to use commas as thousands separators for improved readability. Again, we might not have noticed this if we hadn’t tested larger values. To fix this, we just change the format specifiers in our code.

Enter pressure in psi: 15,750
15,750.0000 psi is equivalent to 1,085.9243 bars.

Splendid.

This prompts another thought: what if the user entered psi in scientific notation like 1E3 for 1,000? It turns out that the float constructor handles inputs like this—but it never hurts to check!

Notice that by testing, we’ve been able to learn quite a bit about our code without actually reading the code! In fact, it’s often the case that the job of writing tests for code falls to developers who aren’t the ones writing the code that’s being tested! One team of developers writes the code, a different team writes the tests for the code.

The important things we’ve learned here are:

  • Work out in advance of testing (by using a calculator, hand calculation, or other method) what the expected output of your program should be on any given input. Then you can compare the expected value with the actual value and thus identify any discrepancies.

  • Test your code with a wide range of values. In cases where inputs are numeric, test with extreme values.

  • Don’t forget how humans might enter input values. Different users might enter 1000 in different ways: 1000, 1000.0000, 1E3, 1,000, 1,000.0, etc. Equivalent values for inputs should always yield equivalent outputs!

Another example: grams to moles

If you’ve ever taken a chemistry course, you’ve converted grams to moles. A mole is a unit which measures quantity of a substance. One mole is equivalent to 6.02214076 \times 10^{23} elementary entities, where an elementary entity may be an atom, an ion, a molecule, etc. depending on context. For example, a reaction might yield so many grams of some substance, and by converting to moles, we know exactly how many entities this represents. In order to convert moles to grams, one needs the mass of the entities in question.

Here’s an example. Our reaction has produced 75 grams of water, . Each water molecule contains two hydrogen atoms and one oxygen atom. The atomic mass of hydrogen is 1.008 grams per mole. The atomic mass of oxygen is 15.999 grams per mole. Accordingly, the molecular mass of one molecule of is

2 \times 1.008 \; g / \text{mole} + 1 \times 15.999 \; g / \text{mole} = 18.015 \; g / \text{mole}.

Our program will require two inputs: grams, and grams per mole (for the substance in question). Our program should return the number of moles.

Let’s build a table of inputs and outputs we can use to test our program.

grams grams per mole expected output (moles) actual output (moles)
0 any 0
75 18.015 ~ 4.16319 E0
245 16.043 ~ 1.527240 E1
3.544 314.469 ~ 1.12698 E-2
1,000 100.087 ~ 9.99130 E0

Let’s test our program:

How many grams of stuff have you? 75
What is the atomic weight of your stuff? 18.015
You have 4.1632E+00 moles of stuff!

That checks out.

How many grams of stuff have you? 245
What is the atomic weight of your stuff? 16.043
You have 1.5271E+01 moles of stuff!

Keep checking\ldots

How many grams of stuff have you? 3.544
What is the atomic weight of your stuff? 314.469
You have 1.1270E-02 moles of stuff!

Still good. Keep checking\ldots

How many grams of stuff have you? 1,000       
Traceback (most recent call last):
  File "/.../moles.py", line 9, in <module>
    grams = float(input("How many grams of stuff have you? "))
ValueError: could not convert string to float: '1,000'

Oops! This is the same problem we saw earlier: the float constructor doesn’t handle numeric strings containing commas. Let’s assume we’ve applied a similar fix and then test again.

How many grams of stuff have you? 1,000
What is the atomic weight of your stuff? 100.087
You have 9.9913E+00 moles of stuff!

Yay! Success!

Now, what happens if we were to test with negative values for either grams or atomic weight?

How many grams of stuff have you? -500 
What is the atomic weight of your stuff? 42
You have -1.1905E+01 moles of stuff!

Nonsense! Ideally, our program should not accept negative values for grams, and should not accept negative values or zero for atomic weight.

In any event, you see now how useful testing a range of values can be. Don’t let yourself be fooled into thinking your program is defect-free if you’ve not tested it with a sufficient variety of inputs.

Original author: Clayton Cafiero < [given name] DOT [surname] AT uvm DOT edu >

No generative AI was used in producing this material. This was written the old-fashioned way.

This material is for free use under either the GNU Free Documentation License or the Creative Commons Attribution-ShareAlike 3.0 United States License (take your pick).