Mocking Techniques (Unit Test)
Unit test is a great tool which allows the programmer to quickly run multiple tests on the code and ensure that pushing new code doesn't break it. Writing unit test can be challenging in some programs such as database clients due to accessbility issues. In those cases, one can utilize the built-in unittest.mock
library which allows replacing parts of the system with a mock object to make assertions and ensure the logic is correct.
setup
SQLiteClient is tested using Pytest and the built-in unittest.mock
library, so it is required to install pytest
. Also, a coverage report can be generated by installing the pytest-cov
, which is recommended, but is optional. Coverage report shows the percentage of the code that is being tested, and if some lines are missing, it prints them as well, so it can be useful to ensure full coverage.
Pytest Fixtures
Test fixtures can be accessed by test functions, and they provide consistent and reuseable baseline objects which can significantly reduce test code. They can also be utilized within methods of a test class. Both options are shown below.
Usage in Functions
Test functions can easily access Pytest Fixtures by simply adding them as parameters in the function signature as shown below:
import pytest from mocking.src.sql import SQLiteClient from unittest.mock import patch # Define a pytest fixture which can be used by test functions @pytest.fixture def db_client(): # Patching the connection - can be ignored for now with patch("mocking.src.sql.sqlite3.connect") as mocked_connection: # Establish connection to database. # Since it was mocked, the connection will not be established, # but tests can still be executed to ensure correct logic. with SQLiteClient("test.db", timeout=20) as conn: # That's the value that will be passed to functions # that utilize `db_client` (listed as a parameter). # In this case, its a tuple! yield mocked_connection, conn # Test Function - Note that the parameter db_client # has the same name as the fixture above. def test_connection(db_client): # Unpacking the tuple mocked_connection, conn = db_client # Running the test to ensure correct arguments while calling method. assert mocked_connection.called_once_with("test.db", timeout=20)
The example above uses unittest.mock.patch
to mock the connection and prevent a db file form being generated, but it will be explained later on. Note that the name of the fixture, db_client
, was passed as a parameter to the test function, test_connection
. Pytest will automatically assign that value, and will execute the assertion to ensure that the function was called with the correct arguments.
Usage in a Test Class
There are two ways to utilize pytest fixture within a Test Class, both are explained below.
-
External Fixture - One must register the variables (using
request
as a parameter to the fixture) and use a special decorator@pytest.mark.usefixtures
before test class definition in order to allow class methods to recognize and access the fixture. Consider the following example:import pytest from mocking.src.sql import SQLiteClient from unittest import TestCase from unittest.mock import patch # Creating a SQLiteClient fixture to be used in the methods # of the SQLiteClientTestCase class. @pytest.fixture def mocked_connection(request): """ Mocking connection to database. Note that since tests are done using a testing class, then `request` must be included as a parameter. The usage of request is necessary in order to define attributes that will be used inside the class. For example, `request.cls.mock_conn = ...` as appears below enables calling this parameter as an attribute: `self.mock_conn.assert_called_once()` Note: `yield` is used instead of return because return would have ended the `with` clause which would delete the mocking instance. """ with patch("mocking.src.sql.sqlite3.connect") as mocked_connection: with SQLiteClient("test.db", timeout=20) as conn: # Connection mock instance. # Is used to test calls and arguments request.cls.mock_conn = mocked_connection # Connection instance. # Is used to execute SQLiteClient methods # so testing can be done before and after calling. request.cls.conn = conn yield @pytest.mark.usefixtures("mocked_connection") class SQLiteClientTestCase(TestCase): # Testing Attributes def test_attributes_1(self): """ Testing pytest fixture attributes """ assert self.conn.name == "test.db" assert self.conn.timeout == 20
Note the usage of
request
within the fixture. It appears as a parameter to the fixture namedmocked_connection
and is being used to register class attributes by assigning a value in the following formatrequest.cls.ATTRTIBUTE_NAME =...
. In our case, there are two attributes,mock_conn
(request.cls.mock_conn =...
) andconn
(request.cls.conn =...
).Then, the test class
SQLiteClintTestCase
is connected to the fixture using the decorator@pytest.mark.usefixtures(FIXTURE_NAME)
which allows us to use the registered attributes:assert self.conn.name == "test.db"
. -
Internal Fixture - In this case, the fixture is defined as a method inside the scope of the test class as shown below:
class SQLiteClientTestCase(TestCase): @pytest.fixture(autouse=True) def setup(self): with patch("mocking.src.sql.sqlite3.connect") as mocked_connection: with SQLiteClient("test.db", timeout=20) as conn: # Connection mock instance. # Is used to test calls and arguments self.mock_conn = mocked_connection # Connection instance. # Is used to execute SQLiteClient methods # so testing can be done before and after calling. self.conn = conn yield # Testing Attributes def test_attributes_1(self): """ Testing pytest fixture attributes """ assert self.conn.name == "test.db" assert self.conn.timeout == 20
Note the use of
autouse=True
which means that the fixture will automatically be executed before each test method, somock_conn
andconn
will be defined as class attributes, and can be used for assertions.
In both cases, note that the yield
was utilized. The reason is that return
statement would have closed the SQLiteClient session, so conn
would be defined as None
. yield
, however, continues to the end of the test method.
Mocking
Find the path
Looking at the examples in the previous section (with patch("mocking.src.sql.sqlite3.connect") as mocked_connection
) one can find the usage of the following path: mocking.src.sql.sqlite3.connect
, however, looking at the package structure, and file's content, there is no such path. In other words, folder structure is: mocking.src
and the file name is sql.py
, so that explain the beginning of the string: mocking.src.sql
. In the import statements there is only one relevant statment: import sqlite3
, which expands the string: mocking.src.sql.sqlite
.
The last component (connect
), appears inside a method that we would like to test, but we can't without mocking. To put it simply, the method __enter__
tries to connect to a sqlite3
database when it is invoked (using with
clause). However, if the job is ran in a server with no database access, e.g. gitlab/github pipeline, then the job will fail. To avoid such scenario, connection mocking is required, and is applied to the following line:self.__conn = sqlite3.connect(self.name, timeout=self.timeout)
.
That's one way to find out the correct path to mock an object. Another way is utilizing the function dir
. In the case discuss above, one could print the methods of sqlite3
with the following lines:
from mocking.src import sql print(dir(sql.sqlite3))
One of the printed options is connect
, and that's what we were looking for.
Patch
Patch is utilized to mock the object:
from mocking.src.sql import SQLiteClient from unittest.mock import patch with patch("PATH") as mocked_object: # Execute code ...
mocked_object
is used to run assertions on the object under test. There are several assertion methods available, among them are: assert_called_once
, assert_called_once_with
, and assert_called_with
.
One can print available assertion methods by using the dir
function: print(dir(mocked_object))
. If used in pytest, then run pytest with -s
option which tells pytest to show print statements.
Also, one can mock a function of a mocked object. Here is an example taken from SQLiteClientTestCase class:
def test__exec_commit(self): """Testing CIUD""" self.mock_conn.assert_called_with("test.db", timeout=20) mock_cursor = self.mock_conn.return_value.cursor() self.conn._SQLiteClient__exec("TEST QUERY", parameters=(), rows=0) mock_cursor.execute.assert_called_once_with("TEST QUERY", ()) self.mock_conn.return_value.commit.assert_called_once()
Above, the mock_conn
was setup by the pytest fixture, and an assert_called_with
was executed to ensure arguments passed to the connect
call are correct.
Then, mock_cursor
is mocking the conn.cursor()
object, with the help of return_value
. In order to test mock_cursor
, a method is executed by using the connection object (self.conn
). Note that __exec
is a private method, so to access it one must add (_SQLliteClient
). Finally, mock_cursor.execute
is tested using assert_called_once_with
. Additionally, conn.commit()
is tested by asserting that it was called once using the mock_conn
object. Syntax is a bit tricky, and I find dir
function to be very helpful in understanding the scope of variables within a function.
Patching with return value
Sometimes testing return statements is required to ensure logic is correct. For that, one can utilize the return_value
argument in patch:
@patch("mocking.src.sql.SQLiteClient._SQLiteClient__exec", return_value=[1, 2]) def test_read_execution_3(self, mock_exec): """ Testing execution of `__exec` method when calling `read` method with return value set for the mocking in order to test the return statement """ test = self.conn.read("SELECT * FROM TABLE WHERE name = ?", ("test",), 2) mock_exec.assert_called_once_with( "SELECT * FROM TABLE WHERE name = ?", ("test",), 2 ) assert test == [1, 2]
The example above is also taken from the SQLiteClientTestClass, but the fixture was not used. Instead, a new patch is used to test the returned value. Also note that the path to patch is different the fixture defined previously. The return value is set to [1, 2]
which means that the __exec
method will return that list, which should be returned from read
as well, and that's what the assertion is testing. Once again, the __exec
method was defined as private (using double underscore __
), so in order to patch it, one must add underscore and the name of the class (_SQLiteClient
) before the name of the method.
Patching with side effect
side_effect
argument can be used to raise errors, and test error logic. Simply setß side_effect
to the desired error, and it will raise it, so the logic in the scope of the except ...
clause can be tested:
@patch("mocking.src.sql.SQLiteClient._SQLiteClient__exec", side_effect = ConnectionError("Testing logic when ConnectionError is raised!")) def test_a(): ...
Example
A test class was developed for the SQLiteClient, and can be viewed in the gitlab repository. Coverage report was printed as part of the pipeline, and it shows 100% coverage:
To create & print coverage report simply run the following commands:
python3 -m pytest --cov-report=html --cov=PATH_TO_SOURCE_FOLDER python3 -m coverage report -m