Unit testing is probably one of the most important as well as one of the most neglected software development.
I for most part of my development career did bother writing tests for my code even though I knew what they were.
Why do developers not write unit tests?
There are a number of reasons why unit testing is not a very active part of the software development workflow in a lot of places.Since you already have a benchmark against what
- Culture: A lot of companies and organizations are struggling to survive or are operating at such a fast pace that writing features and shipping them out are such a dire priority that nobody bothers/sees value in writing tests.
- Technical challenges: There are some systems that are not easy to test and writing tests for them can be as tedious as making the feature itself.
- A known unknown: While it is good to write tests, and they will the development team operate at a much higher rate in the future they have simple never tried unit testing their code and don’t know about any specific value that can be gained out of it.
There can be many other reasons that might cause one not to write tests however, they are more or less variations of one of the above three reasons listed.
Now, I understand a lot of people might write tests (even frown at the idea of people not writing tests) however this is a reality that happens at a lot of places.
It happened to me as well. I belonged to a company which was growing rapidly and writing code was itself becoming a bottleneck, and thus nobody was really bothered about writing tests.
Why should you write tests?
While there might be some very obvious reasons(you don’t break anything!!!) for you to write tests for your code , there are also some merits which may not evident from the beginning
- Better code quality: Since you will be the one testing your own code, you automatically make an effort to modularize your code so that it can be easily tested. No more 400 line long functions.
- Refractoring becomes much easier: If you have ever tried refractoring a very large code base, you will be familiar with the horror of trying to ensure that you did not end up breaking anything.
- Iteration becomes faster: Since you have a very clear way of figuring out if something broke, you can commit and deploy much more confidently than not having tests at all. Also in case you do end up breaking something tests provide you with a very pin pointed feedback about what exactly broke.
These are some of the the benefits of writing tests. I really think that ease of refractoring and the confidence with which I can iterate is more than enough for me to take the pain of writing unit tests
Also in dynamically typed languages like python tests are essentially since the compiler/interpreter does not really know what type of object/param is to be expected where. Hence it can lead to a lot of unexpected moments in production.
Writing tests in Python
I was used to believe that tests can be written after main development has been done, however over the last six months I have figured out that writing tests along with development(or in short TDD) is the only way you maximize the effectiveness of your tests. Writing tests after you are done developing feels a little like redtapism.
Choosing Python testing framework: Unittest
There are a number of different frameworks we can use for testing in python with pytest and nose being the top contenders. We however are going to stick to the inbuilt testing library in python unittest. I choose this since it does not have any dependency and is in built.
Arranging your tests
While a lot of people argue that tests can be put anywhere I follow a simple(cleaner) approach of putting the tests of a package neatly inside another folder called tests inside the package. Some what like this:
The above app is a sub app inside a Django project. I think this is a neat way to actually write tests.
Testing Python code – Writing unit tests for your code
I am going to use simple python function as my guinea pig for testing.
import requests def get_url(url): """ Function for getting response for the HTTP url """ curr_response = requests.get(url) response_dict = {'response': curr_response.text, 'status': curr_response.status_code} return response_dict
Now from the looks of it this function seems very basic, however there are a number of different scenarios that must be tested for this(As a rule of thumb follow the order below while trying to write tests for a function/class/routine/thunk)
First we get a around to writing a basic testing suite for this function. Lets call it FuncTester.
import unittest class FuncTester(unittest.TestCase): """ Class for testing get_url function """ def setUp(self): """ This function is executed once before all the tests in the testsuite are executed """ pass
Now that we have our basic code ready we can start writing tests for the function. Try to write and assert only one/one type in a test to make sure if a test fails you clearly know what failed.
Check valid scenario
Start by testing a valid scenarios and the input/output response format for the function
import unittest class FuncTester(unittest.TestCase): """ Class for testing get_url function """ def setUp(self): """ This function is executed once before all the tests in the testsuite are executed """ pass def test_get_url_valid_scenario(self): """ Test a valid scenario """ response = get_url("https://google.com") self.assertEqual(type(response), 'dict') self.assertEqual(set(response.keys()), {'response', 'status'}) self.assertEqual(type(response.get('status')), int)
Here we are a couple of very basic things:
- The output format, which is a dict
- The format or the type inside the response from the function.
These types of tests are paramount. I can’t stress enough how many issues such bugs can cause in production if someone gets the return types all messed up. This is the exact kind of code that python let’s shoot in the foot with.
Check by giving an invalid scenario
This can now be broken down into further cases:
- Inputs that should cause an exception
- Inputs that should get evaluated readily but not give back any response
we could for example give a completely junk URL and see how it works
import unittest class FuncTester(unittest.TestCase): """ Class for testing get_url function """ def setUp(self): """ This function is executed once before all the tests in the testsuite are executed """ pass def test_get_url_valid_scenario(self): """ Test a valid scenario """ response = get_url("https://google.com") self.assertEqual(type(response), 'dict') self.assertEqual(set(response.keys()), {'response', 'status'}) self.assertEqual(type(response.get('status')), int) def test_get_url_junk_url(self): """ Test scenario with invalid url """ response = get_url("random_string") self.assertEquals(type(response), 'dict')
Now this will throw a MissingSchema exception which comes from the requests module. Now since this code path is discovered we have two options either we can handle it by adding a try except block or let it get thrown and then handle it somewhere downstream in the code.
However this will raise enough noise for a developer to know where is something failing.
We can also use unittest to check if a particular exception is getting raised. So assuming we choose not handle to this exception and let it get thrown, we can actually check the code so that it throws and exception in case of the same type of an input.
import requests import unittest class FuncTester(unittest.TestCase): """ Class for testing get_url function """ def setUp(self): """ This function is executed once before all the tests in the testsuite are executed """ pass def test_get_url_valid_scenario(self): """ Test a valid scenario """ response = get_url("https://google.com") self.assertEqual(type(response), 'dict') self.assertEqual(set(response.keys()), {'response', 'status'}) self.assertEqual(type(response.get('status')), int) def test_get_url_junk_url(self): """ Test scenario with invalid url """ with self.assertRaises(requests.exceptions.MissingSchema): get_url('random_string')
Similar to this the above scenario we can try giving a different schema as compared to HTTP , and it should raise an InvalidSchema exception which can yet again be asserted similar to the code shown above.
We will try testing it with a different application layer protocol, eg ftp
import requests import unittest class FuncTester(unittest.TestCase): """ Class for testing get_url function """ def setUp(self): """ This function is executed once before all the tests in the testsuite are executed """ pass def test_get_url_valid_scenario(self): """ Test a valid scenario """ response = get_url("https://google.com") self.assertEqual(type(response), 'dict') self.assertEqual(set(response.keys()), {'response', 'status'}) self.assertEqual(type(response.get('status')), int) def test_get_url_junk_url(self): """ Test scenario with invalid url """ with self.assertRaises(requests.exceptions.MissingSchema): get_url('random_string') def test_get_url_diff_proto_url(self): """ Test scenario with invalid url """ with self.assertRaises(requests.exceptions.InvalidSchema): get_url('ftp://technokeeda.com')
The last test will tests exception raise of another kind.
This will give you a basic input/output sanity check of the code , if all code paths are exercised will ensure that there are lesser to none run time level type not matching issues.
Conclusion and further
While all these tests take care of the input and output formats as well as the exceptions that can possibly get raised. We can also use mock object for testing very specific functions and behaviors using the mocking library in python.