As our software is expected to do more, it inevitably becomes more complicated. When this happens, adequate functional testing becomes significantly more difficult. This doesn't mean that we shouldn't employ functional testing for complex software (quite the opposite). But it does mean that we need a way to reduce our dependence on functional testing for verification of the internal details of our code. Let's work through an example. Say we have the program below, we can use functional testing to verify it without too much trouble.
import sys
def say_hello(first_name, last_name):
print('Hello, ' + first_name + ' ' + last_name)
if len(sys.argv) == 2:
say_hello(sys.argv[1], '')
else:
say_hello(sys.argv[1], sys.argv[2])
There's really only one path through the code, we just need to give it a name, or even just an obvious series of characters ("ABC") and verify that it prints "Hello, ABC" (or whatever). But what if we make the program just a bit more complicated, like so?
import sys
class Person:
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name
def full_name(self):
if last_name == '':
return self.first_name
else:
return self.first_name + ' ' + self.last_name
def say_hello(person: Person):
print('Hello, ' + person.full_name())
if len(self.argv) == 2:
p = Person(self.argv[1], '')
say_hello(p)
elif len(self.argv) == 3:
p = Person(self.argv[1], self.argv[2])
say_hello(p)
So what's the difference? Well, it's a bit subtle, but now we have logic in two different places. We have the same basic function we had before ("say_hello"), but instead of the name formatting happening there, it happens inside the "Person" class. We could certainly continue to use functional tests to verify the entire program at once, but that means we'll have to make sure that the functional test cases we use cover all possible code paths through both the "say_hello" function and the "Person" class. Additionally, if someone changes the "Person" class to format the name differently it will cause our functional tests to fail for no good reason.
Instead, we could test the "Person" class by itself and revise our functional tests so that they don't rely on the specific behavior of the "Person" class. The new tests for the "Person" class will be what are called "unit tests" because they will cover a single, isolated "unit" (piece) of code.
In order to test the "Person" class by itself we will use a separate Python script. A very simple version might look like this:
import Person from person
p = Person('First', '')
assert p.first_name == 'First'
assert p.last_name == ''
assert p.full_name() == 'First'
p = Person('First', 'Last')
assert p.first_name == 'First'
assert p.last_name == 'Last'
assert p.full_name() == 'First Name'
So now we can run this script to verify that our "Person" class works as we expect it to work. Now we don't have to worry about whether or not the name will be formatted correctly in our main program because we've tested it as part of its unit, the "Person" class.
But how do we know when to use unit tests and when to stick to functional tests? Unfortunately, that decision is really more of an art than a science. But your goal, which can help guide your decisions, should be to have robust tests. This means that they don't fail unless there's an actual problem.
In the example above, I noted that someone might change the "Person" class independently of the rest of the program. In a large piece of software the "Person" class might even be defined in a separate library or package and might be maintained by an entirely different group of people than the rest of the program. In this case, testing "Person" separately probably makes sense.