BACKGROUND
“Testing leads to failure and failure leads to understanding”
– Burt Rutan
“Smalltalk has suffered because it lacked a testing culture.” was Kent Beck’s first statement in the introductory section of his paper – SIMPLE SMALL TALK TESTING: WITH PATTERNS. In this paper, he explained a testing strategy and a framework to support the strategy. Kent also differentiated between a failure and an error. When we test, it is important to be able to distinguish between errors being checked for (FAILURE), like getting six as the sum of two and three, and errors not anticipated (ERROR), like subscripts being out of bounds or messages not being understood.
When we write tests, we check for only expected results and if we get a different answer, that is a failure. The framework makes testing for expected cases simple. There is not so much that can be done about unanticipated errors. If you did something about them, they wouldn’t be unanticipated any more, would they?. The unit test module, which is the standard unit testing framework in Python and which comes with the standard library, is based on this testing framework.
WHY SHOULD YOU WRITE GOOD TESTS?
Before discussing some major concepts of the testing framework, we need to know why we need to write tests. I felt that writing tests was a waste of time, the first time I heard about it. I felt the tests would have just been an unnecessary addition to my program. Using print statements to debug felt really nice to me then. I took this mentality on until I got on a large project and using print statements became very tedious. This was when I identified the importance of writing tests.
It’s clear to me now that good tests gives confidence that updates and tweaks to my program has not led to any unintended consequences to my entire program in any way. That is, it makes it easy to check if adding just a single or multiple lines of code to my program hasn’t disrupted my program.
Consequently, this saves time and headache when debugging. Testing with print statements is bad practice because this isn’t automated and it makes it hard to maintain. The framework helps to solve some of these problems. The framework supports test automation, sharing of setup and shutdown code for tests, aggregation of tests into collections and independence of the tests from the reporting framework. We would write code to implement this soon.
HOW DO YOU START WRITING TESTS?
When we start writing our own tests, we may not know where to start from. We would like to be absolutely complete so that we can be sure that the software will work. However, we’ll never get started if this is our approach. Kent recommends that it is far better to start with a single configuration whose behavior is predictable. This configuration is called a fixture. He mentioned that by choosing a fixture, you are saying what you will and won’t test for.
Also, when a catastrophic error occurs, the framework stops running the test case (an individual unit of testing), records the error and runs the next test case. Since each test case has its own fixture, the error in the previous case will not affect the next. Consequently, buttressing the point about independence of tests. As soon as you have two test cases running, you’ll want to run them both one after the other without having to execute two do it’s. The testing framework provides an object to represent “a bunch of tests“, TestSuite. A TestSuite takes advantage of polymorphism by running a collection of test cases and reporting all their results at once. TestSuites can also contain other TestSuites.
It’s Time to Code with Python!
LET’S CREATE WHAT WE WILL BE TESTING
Firstly, in order to implement these capabilities, we need to have what we want to test. Therefore, let’s define a simple class.
class CreateAccount(): interest_rate = 0.01 new_account_charge = 100 def __init__(self, first_name, last_name, initial_deposit): self.account_name = first_name + ' ' + last_name if initial_deposit < 1000: raise ValueError('account can\'t be created') else: self.initial_deposit = initial_deposit self.balance = initial_deposit - CreateAccount.new_account_charge def deposit(self, amount): if amount <= 0: raise ValueError('deposit amount must be greater than 0') else: self.balance += amount return self.balance def add_interest(self): self.balance *= (1 + CreateAccount.interest_rate) return self.balance
Above, we defined a class CreateAccount initialized with the first_name, last_name and initial_deposit of the new client. The constructor takes the instance variables (first_name and last_name) to create an account_name. This also ensures that the initial deposit must be at least 1000. This raises an exception if the initial deposit is less than 1000. Finally, the constructor sets the balance in the account by subtracting 100 from the initial deposit, where 100 is the charge on a new account. Also, the CreateAccount also has two extra methods – deposit and add_interest.
The deposit method accepts an amount argument which must be greater than 0. This raises an exception if the amount is less than or equal to 0. The amount is added to the balance and the new balance is returned, if the amount is greater than 0. The add_interest method also updates the balance with the interest_rate earned and returns the new balance.
let’s test!!!
Next, let’s write tests for the class above. Reminder, we write tests for expected results and if we get a different answer, then it’s a failure. Hence, we would be writing tests that check for the expected results. In order to create a Test Case, we first need to create a class that inherits from unittest.TestCase. Inheriting from the unittest.TestCase gives us access to different testing capabilities related to the framework described above including access to the assert methods. To write a test, we define a method in the class and the method needs to start with test_. We need the naming convention to ensure that it actually knows which methods represents test cases when we run this. Also, setUp and tearDown methods contain instructions that should be implemented before and after each test case respectively.
import unittest from createaccount import CreateAccount class TestCreateAccount(unittest.TestCase): def setUp(self): print('\nsetUp') self.new_client = CreateAccount(first_name = 'Ayoyinka', last_name = 'Obisesan', initial_deposit = 1000) def tearDown(self): print('tearDown\n') def test_constructor(self): print('test_constructor is being executed') self.assertEqual(self.new_client.account_name, 'Ayoyinka Obisesan') self.assertEqual(self.new_client.balance + self.new_client.new_account_charge, self.new_client.initial_deposit) with self.assertRaises(ValueError): CreateAccount('Ayoyinka', 'Obisesan', 900) def test_deposit(self): print('test_deposit is being executed') self.assertEqual(self.new_client.deposit(50), 950) self.assertRaises(ValueError, self.new_client.deposit, -10) self.assertRaises(ValueError, self.new_client.deposit, 0) def test_add_interest(self): print('test_add_interest is being executed') self.assertEqual(self.new_client.add_interest(), 900*1.01) if __name__ == '__main__': unittest.main()
Firstly, we imported the unittest module from the Python standard library and the CreateAccount class from the createaccount module (NOTE: these files are in the same directory). Next, we created the TestCreateAccount class (a TestSuite) and inherited testing capabilities from unittest.TestCase. Then, we defined the setUp and tearDown methods. Here, the setUp and tearDown methods contains a print statement, just to show how this work which will be revealed when we see the output. Also, the setUp method creates a new client for every of our test cases. These methods have more practical applications. Here, I just tried to show what they do and I hope it’s clear. Lastly, we defined our test cases which all follow the naming convention of starting with test_.
-
FIRST TEST CASE
The first test case checks if the the account_name is as expected. It also checks if the balance added to the new account charge is equal to the initial_deposit. Finally, when we create an account with less than 1000, the test case ensures that an exception is raised.
-
SECOND TEST CASE
The second test case tests the deposit method. It asserts that the balance is updated with the amount deposited. Then, it asserts that an exception is thrown if we try to deposit any amount less than or equal to zero. It can be noticed that the assertRaises has been written quite differently from the way it was in test_constructor method (where a context manager has been used). Here in the test_deposit method, the arguments that should be passed to the deposit method have not been directly put into it the deposit method. Consequently, we have shown two ways of writing these kind of assertions in our test cases.
-
third TEST CASE
Finally, we have the test_add_interest method which is the last test case, meaning we have three test cases in the TestSuite (TestCreateAccount). The test case asserts that balance is updated when the interest_rate is added. The 3 test cases contain print statements. This shows when they are being executed.
Bonus
__name__ is a special variable in Python with the value ‘__main__’. The if __name__ == ‘__main__’ conditional statement is commonly used in our modules because when we import a module in Python, all the code in the module gets executed even if we have imported just a function from the module. So, this conditional statement is used to ensure that some code (we don’t want to get executed) won’t actually get executed when we import the module. Therefore, code in the conditional block if __name__ == ‘__main__’ will only get executed when we run the module directly. Also, this helps us run the test module using:
>> python3 <module_name>
instead of:
>> python3 -m unittest <module_name>
OUTPUT
And this is the output of running our code:
setUp test_add_interest is being executed tearDown . setUp test_constructor is being executed tearDown . setUp test_deposit is being executed tearDown . ---------------------------------------------------------------------- Ran 3 tests in 0.012s OK
First, we observe that the three test cases have been executed with “Ran 3 tests in 0.012s” and they are all successful with the OK. This displays FAILED (failures=<number_of_failures>) if one of our test cases failed. The setup and tearDown methods get executed for each test case. So, the output displays setUp and tearDown 3 times.
The dots also show that the test case is successful. A failed test case will show F instead of dots. It’s clear that our tests have been automated because we can re-run this anytime to ensure that our code is still working properly. Also, we have corroborated the sharing of setup and shutdown code for tests and aggregation of tests into collections and independence of the tests.
We have come to the end! Before we go, take this:
Code without tests is broken as designed.
Thank you for reading!