Introduction to software testing

Software tests are tests that are performed on a piece of code or an entire software to validate that the behavior of the software is as expected. The main purpose of testing is to detect errors in the code in order to correct them and ensure the quality of the software. Having an extensive and complete set of tests that can be launched automatically is one of the main steps to have a continuous software delivery (CD).

Software testing can be performed at different levels. At the first level are unit tests. These focus on testing individual units of code, such as a function or a class. Unit tests isolate the component from other components and external systems to focus on validating only the functionality of the unit.

At the next level, integration tests are dedicated to verify the relationships between the different components of the software. These tests help, for example, to check whether two classes interact correctly with each other or whether the sequence of applying two functions one after the other returns the expected result. These tests can be performed on all or almost all software components at once or on smaller assemblies. It is often interesting to perform integration tests in isolation from external systems (e.g. a database), replacing the connection with a fake connection. These imitations of connections or components are called mock.

Finally, once all integration tests have been passed, the entire software is evaluated with a system test. These tests test how the software interacts with external systems and the environment. An example could be testing how the system behaves when one of the components communicates with a database or when another component receives an API call.

In some cases, a fourth level called acceptance testing is defined, which consists of testing that the system meets the functionality required by the end user of the software.

Python unittest library: What is it?

There are different libraries for different programming languages that provide a series of functions and classes that allow tests to be written in a simple way. They facilitate the execution and analysis of the results in case, for example, the test fails and does not return the expected result.

One such library is unittest, which is part of the Python standard libraries. To use it, a class that inherits from TestCase is generated. The methods of this class whose name begins with test will be treated as a test. In this example, the assertEqual method of TestCase is used to verify that 1 plus 2 is 3.

import unittest


class MyTests(unittest.TestCase):

    def test_addition(self):
        self.assertEqual(3, 1 + 2)

To run the tests, the following command can be used via console:

python -m unittest discovery

By default, this command searches for instances of TestCase in files that meet the following pattern for test_*.py and that are in python packages in the current directory. You can change the search directory and file pattern with the -s and -p options, respectively.

Running the above test returns the following result:

If the expected result is changed to 4, the failed test is displayed as follows:

Use Case

The purpose of this use case is to test an ETL (Extract, Transform, Load) pipeline application. For simplicity, the data will be a single number and each of the pipeline steps are simple functions. The application consists of the following code:

from random import randint


def get_data_from_database(connection):
    # Do something with connection
    # ...

    return randint(1, 6)  # Use random int to simulate db connection


def add_one(x):
    if x is None:
        return 1
    return x + 1



def times_two(x):
    return 2 * x


def write_data_to_database(x, connection):
    # Do something with connection
    # ...
    return
    
    
def pipeline():
    x = get_data_from_database('SourceDatabase')
    x = add_one(x)
    x = times_two(x)
    write_data_to_database(x, 'SinkDatabase')

Unit tests

This section verifies the functionality of the add_one and times_two transformations, isolating them from the other functions of the pipeline.

In the following tests add_one is tested:

import unittest

from my_app.my_app import add_one


class AddOneTest(unittest.TestCase):

    def test_add_one(self):
        input_data    = [-4, 0, 1, 3, 10]
        expected_data = [-3, 1, 2, 4, 11]

        for x, y in zip(input_data, expected_data):
            with self.subTest(input=x):
                self.assertEqual(y, add_one(x))

    def test_none(self):
        x = None
        y = 1

        self.assertEqual(y, add_one(x))

The self.subTest method is used to test different cases. The None case has been separated into a separate test since we want to see a specific functionality of the transformation.

In the following tests times_two is tested, also using the subtests:

import unittest

from my_app.my_app import times_two


class TimesTwoTest(unittest.TestCase):

    def test_times_two(self):
        input_data    = [-4, 0, 1, 3, 10]
        expected_data = [-8, 0, 2, 6, 20]

        for x, y in zip(input_data, expected_data):
            with self.subTest(input=x):
                self.assertEqual(y, times_two(x))

    def test_none(self):
        x = None
        self.assertRaises(TypeError, times_two, x)

In this case, the value None is not specifically handled in the function. Multiplying this value by 2 is not a permitted operation in Python and raises the TypeError exception. We want this to be the behavior of the application. Then you check that this error is returned using self.assertRaises.

Integration tests

In this section the functionality of the pipeline is checked, verifying that all the components work correctly with each other. In this case, we do not want to depend on database connections and we want to run the integration test locally. To achieve this purpose, you use unittest.mock, which allows you to replace parts of the code. 

In the following example, the @patch decorator is used to replace two parts of the code. This decorator passes by parameter the mock object that replaces the specified part of the code. Then we can specify the behavior that we replace using this parameter inside the test.

import unittest
from unittest import mock

from my_app.my_app import pipeline


class PipelineTest(unittest.TestCase):

    @mock.patch('my_app.my_app.write_data_to_database')
    @mock.patch('my_app.my_app.randint')
    def test_pipeline(self, mock_randint, mock_write_data_to_database):
        input_data    = [-4, 0, 1, 3, 10]
        expected_data = [-6, 2, 4, 8, 22]

        for x, y in zip(input_data, expected_data):
            with self.subTest(input=x):
                mock_randint.return_value = x
                mock_write_data_to_database.side_effect = \
                    lambda z, *args, **kwargs: self.assertEqual(y, z)

                pipeline()

    @mock.patch('my_app.my_app.write_data_to_database')
    @mock.patch('my_app.my_app.randint')
    def test_none(self, mock_randint, mock_write_data_to_database):
        mock_randint.return_value = None
        mock_write_data_to_database.side_effect = \
            lambda x, *args, **kwargs: self.assertEqual(2, x)

        pipeline()

The first function that is replaced is randint, which is used in get_data_from_database (simulates a connection to a database). In this case, this function is replaced in the context of my_app.my_app, and not in the random library. This is because in the my_app.py file randint is imported and then it has no way of knowing that the function has been replaced by a mock object. In the case of this function the returned value (return_value) is replaced.

The second function that is replaced is write_data_to_database. In this case, when this function is called, the test will be evaluated with self.assertEqual (side_effect), instead of writing to the database.

In summary, the pipeline has been tested by replacing the input and output with mock objects, thus testing the add_one and times_two transformations together. The mock objects also have other functionalities to check how these objects have been called. For example, assert_not_called allows to ensure that the replaced function or object has not been called.

Conclusion

This post has introduced the basics of software testing, as well as the Python unittest library. Tests help to implement more robust code and allow for smoother deployment. Although only very simple examples have been seen and using a single programming language, the concepts apply to more complex software and the methodologies are similar to others used in other libraries and languages.

That’s it for today’s post. If you found it interesting, we encourage you to visit the Software category to see similar articles and to share it in networks with your contacts. See you soon!
Guillermo Camps
Guillermo Camps
Articles: 16