Scripting

Unit Testing in Python: A Quick Overview

Unit Testing in Python A Quick Overview

Testing at different phases of SDLC is very crucial. Tests can be Unit Tests, Integration Tests, Load Tests, Stress Tests, etc. The tests are important to ensure the stability of the system in different scenarios and input conditions.

Here is what happened to a space mission, which could have been avoided(hopefully) with sufficient unit testing and linting(process to measure code quality and adherence to standards). Gives goosebumps, isn’t it?

Unit Testing is unarguably among the most important skills that every developer should have. Any large application with thousands or millions of moving parts cannot be tested for regressions manually.

Unit Testing is a fully automated way of testing the smallest piece of code also known as a unit. The main goal here is to break the code on local machines rather than in production.

Unit Testing differs from traditional manual testing in a few ways:

  1. It is fully automated. The test cases written are coded and are run automatically or manually (shown in this article). In work, one may notice that the build or deployment pipelines run the tests before merging and/or deploying your code.
  2. Tests the smallest unit of code. Often the smallest unit means a method or a function, but it can also be a single line of code.
  3. It allows “test-driven development”, an agile development strategy to write tests before any amount of code is done and check if the tests pass after development is done.

Benefits of Unit Testing

Improves the quality of code

Writing tests forces us to create functional components which can be easily tested. This practice makes code modular and maintainable.

Detects software bugs in early stages

No code is without bugs and a good developer should know which condition can result in a bug. Unit tests can detect scenarios that can be considered rare while manually testing a software, however, in Live such scenarios may occur more often.

Edge case scenarios are tested beforehand

Conditions like Integer Overflow, IndexOutOfBound, or even NullPointers can be identified well before the code is deployed in live environments. We should also consider that the code is supposed to throw errors at certain scenarios and unit tests are a good way to test if those errors are being actually thrown.

Reduces both cost and development time

Writing extensive unit tests does take a considerable amount of time, however, they pay off, in the long run, considering the benefits they provide. Bugs not caught early in the development stage are skipped to testing and production to spend more time in debugging, bug fixing, and deployment.

Simplify the documentation

Unit tests act as documentation on their own for the developer’s code. Every class or file for which unit tests are being written covers all different scenarios and reading the unit tests should give enough insight into what the component is trying to achieve.

Fortified Deployment and Code Submit Pipelines

Deployment pipelines can be set up to run unit tests before a system is being deployed and even well before deployment, while we submit our code to a version control system (Git, for example). This ensures that the deployments shall not fail and any new feature being added shall not break what is already working.

The unittest Package

Python comes with the unittest package, originally inspired by JUnit. However, we don’t need to install any packages. There are certain important concepts which unittest supports like in Junit.

test fixture

If one is coming from a Java and Junit background they must be familiar with setUp() and tearDown() used in unit tests. In python, test fixtures do the same thing. They can be used to create temporary directories, database connections, fakes, mocks etc in setUp() and destroy those objects and temp directories and connections in tearDown().

test case

A test case is analogous to a class we create for testing a functionality. It checks for the responses returned for a combination of inputs.

test suite

While running tests for a code, one may want to group multiple tests under a single umbrella, so that they run together. We can group test cases and/or test suites.

test runner

The test runner does the magic of orchestrating the execution of tests. Later in the article you may notice, the outcome rendered can be in textual format and/or a graphical interface.

Enough Theory!! Let’s do some testing…..

The below table lists some most commonly used assert methods provided by the TestCase class to check and report for failures.

Method Checks That
assertEqual(a, b) a == b
assertNotEqual(a, b) a != b
assertTrue(x) bool(x) is True
assertFalse(x) bool(x) is False
assertIs(a, b) a is b
assertIsNot(a, b) a is not b
assertIsNone(x) x is None
assertIsNotNone(x) x is not None
assertIn(a, b) a in b
assertNotIn(a, b) a not in b
assertIsInstance(a, b) isinstance(a, b)
assertNotIsInstance(a, b) not isinstance(a, b)

A Basic Example

Let’s try to implement a few for testing Calculator.py

class Calculator:

    def add(self, a: int, b: int) -> int:
        return a + b

    def sub(self, a: int, b: int) -> int:
        return a - b

    def mul(self, a: int, b: int) -> int:
        return a * b

    def div(self, a: int, b: int) -> float:
        return a / b

Calculator_test.py tests our calculator logic. Notice we consider negative scenarios as well.

import unittest

from Calculator import Calculator

class CalculatorTest(unittest.TestCase):

    def setUp(self) -> None:
        super().setUp()
        self.calculator = Calculator()

    def test_add(self):
        # Check add(5, 6) gives 11 or not
        self.assertEqual(11, self.calculator.add(5, 6),
                         "Testing Calculator.add()")

    def test_sub(self):
        # Check sub(5, 6) gives -1 or not
        self.assertEqual(-1, self.calculator.sub(5, 6),
                         "Testing Calculator.sub()")

    def test_sub_fail(self):
        # This test case will fail as sub(5, 6) is -1 not 10
        self.assertEqual(10, self.calculator.sub(5, 6), "Testing Calculator.sub()")

    def test_mul(self):
        # Run single test multiple times with different parameters
        # Parameter Source

params = [

{

'a': 5,

'b': 6,

'ret': 30

},

{

'a': 5,

'b': -10,

'ret': -50

},

{

'a': 0,

'b': 6,

'ret': 0

},

{

'a': 9,

'b': -6,

'ret': -54

},

]

for param in params:

self.assertEqual(param['ret'], self.calculator.mul(

param['a'], param['b']), "Testing Calculator.mul()")

def test_div(self):

# Check div(40, 2) gives 20 or not

self.assertEqual(20, self.calculator.div(

40, 2), "Testing Calculator.div()")

# Check div(20, 40) gives 0.5 or not

self.assertEqual(20/40, self.calculator.div(20, 40),

"Testing Calculator.div()")

# Check div(10, 3) gives 3.3333333 or not (rounded upto 7 decimal places)

self.assertAlmostEqual(3.3333333, self.calculator.div(10, 3),

msg="Testing Calculator.div()")

# Check whether Divide by 0 throws exception ZeroDivisionError

self.assertRaises(ZeroDivisionError, self.calculator.div, 20, 0)

if __name__ == "__main__":

unittest.main()

A test case inherits unittest.TestCase. setUp() prepares the test fixture and will run before executing each test method. The name of individual test methods needs to start with test_ to be recognized by the test runner.
subTest() context manager is used to distinguish test iterations inside the body of a test method.

The output for our test looks like this:

All test cases ran as expected.

But if you are using IDE like Pycharm or VSCode then there is a GUI test runner, which shows success and failures graphically.

In VSCode open the test file, press Shift + P and select Python: Run Current Test File

You should see failed and passed test cases

And the output is a bit more descriptive:

Best Practices

There are few best practices we should follow while writing unit tests.

Arrange, Act and Assert

Arrange: Every test we write, first we arrange our data for the test.

Act: In this stage we act on the method or component we intend to test

Assert: Here we assert the output we receive from our component.

Make sure there is a line space after each stage of our test and code for every stage can be grouped together.

Before and After

Make sure the common code which is required for each and every test should be added in test fixtures. Use setUp() for running logic before every test and tearDown() for logic to be run after tests.

Fakes Vs Mocks

Always try to avoid mocking wherever possible and use the actual components themselves. However, if this cannot be avoided, try to use fakes, where we can fake a component, service or some I/O operation. Mocking should be the last resort taken while testing since mocking makes a lot of assumptions which may or may not hold true in real scenarios.

When test methods involve RPC (REST/GraphQL/gRPC) or database queries, we mock the wire calls. But that’s out of the scope of this article and will be covered in future articles.

Meaningful Names

Make sure your tests have meaningful names and the reader can guess what is happening just by reading the name of the test.

Conclusion

Enough for today. Do remember bugs are introduced only when you write code. So either test every line of code you add or do not write code at all! Happy Coding! 💝

Similar Posts