What is Unit Testing?

Unit test­ing is a fun­da­men­tal aspect of soft­ware deve­lo­p­ment, cri­ti­cal for veri­fy­ing the inte­grity of an application’s indi­vi­dual com­pon­ents. It con­sists of iso­la­ting units, such as func­tions, methods, or clas­ses, and sub­jec­ting them to auto­ma­ted tests to ensure they func­tion inde­pendently. In this test­ing pro­cess, units are eva­lua­ted sepa­ra­tely, often with the sup­port of stubs, mocks, or fakes to mimic depen­den­cies. This prac­tice ensu­res that each unit per­forms as expec­ted, unaf­fec­ted by exter­nal ele­ments of the application.

Aims and benefits

The pri­mary goal of unit test­ing is to check that each unit ope­ra­tes cor­rectly accor­ding to the spe­ci­fi­ca­ti­ons. It is cru­cial for code modi­fi­ca­tion, cat­ching regres­si­ons so that exis­ting func­tion­a­lity remains unaf­fec­ted by chan­ges. It also helps to reduce the num­ber of bugs during the inte­gra­tion of the units and can pro­vide examp­les of how the code is meant to be used.

Unit test­ing pro­vi­des imme­diate feed­back on design issues, signal­ing when a piece of code is poten­ti­ally dif­fi­cult to test and may need refac­to­ring, i.e. using depen­dency injec­tion. Bugs detec­ted during unit test­ing are gene­rally less cos­tly to fix than those found in later stages. It is also a cri­ti­cal com­po­nent of Con­ti­nuous Inte­gra­tion (CI) and Con­ti­nuous Deli­very (CD) practices.

Unit­test vs. Pytest

When deve­lo­pers face the decis­ion of sel­ec­ting a unit test­ing frame­work in Python, key play­ers are unittest and pytest. The choice hin­ges on various fac­tors inclu­ding the desi­red test struc­ture, asser­tion style, setup pro­ce­du­res, and dis­co­very methods. Also the avai­la­bi­lity of exter­nal com­pon­ents in the tar­get envi­ron­ment comes into play, espe­ci­ally when working with legacy code in clo­sed systems.

unittest is the tra­di­tio­nal choice, embedded within Python’s stan­dard library, offe­ring a fami­liar object-ori­en­ted approach. Tests are neatly orga­ni­zed into methods that reside within clas­ses deri­ved from unittest.TestCase. This frame­work pro­vi­des a suite of asser­tion methods tail­o­red for various sce­na­rios, along­side setUp and tearDown methods that are invo­ked before and after each test, respec­tively. Howe­ver, this comes at the cost of ver­bo­sity and a some­what rigid test dis­co­very pro­cess. Since ver­sion 3.3, the mock package was inte­gra­ted into the stan­dard library as unittest.mock. This offers rather com­plex, but very powerful mocking and stub­bing functionalities.

In con­trast, pytest pres­ents a modern alter­na­tive, known for its sim­pli­city and ele­gance. It breaks free from the class-based struc­ture, enab­ling deve­lo­pers to write tests as plain func­tions. Asser­ti­ons are sim­pli­fied, lever­aging the stan­dard assert state­ment, making the tests easier to write and under­stand. The setup and cle­a­nup are hand­led by fix­tures that offer more fle­xi­ble and powerful sco­ping opti­ons. While pytest is not part of the stan­dard library and thus requi­res an addi­tio­nal instal­la­tion, it com­pen­sa­tes with a highly intui­tive test dis­co­very sys­tem and exten­sive plugin sup­port, fos­te­ring a dyna­mic and exten­si­ble test­ing envi­ron­ment. Mocking is typi­cally done with mon­key­patching, a very straight-for­ward mecha­nism built into pytest (see below).

Why pytest is the bet­ter option

Typi­cally, a pytest test case will be signi­fi­cantly shorter in terms of code amount than a unit­test test case with the same func­tion­a­lity. There can be cases, when the stan­dard pytest tool­box will be insuf­fi­ci­ently in func­tion­a­lity and advan­ced methods such as unittest.mock is nee­ded. Luckily, pytest pro­vi­des a plugin to uti­lize these directly from within pytest. Fur­ther­more, pytest will run tests on exis­ting unit­test-style test sets. This makes a tran­si­tion from exis­ting unittest code to pytest very straight-forward.

Each frame­work has its own set of trade-offs, and the sel­ec­tion often boils down to the spe­ci­fic requi­re­ments of the pro­ject and per­so­nal pre­fe­rence. unittest appeals to those see­king the con­sis­tency and avai­la­bi­lity of a stan­dard library tool, while pytest attracts those loo­king for more con­cise code and advan­ced features.

Coverage: Asses­sing Test Effectiveness

Coverage mea­su­res how much of the code­base the tests cover. It shi­nes a light on untes­ted parts of the code, encou­ra­ges com­plete test­ing, and sets goals for coverage metrics. By high­light­ing what code is not tes­ted, it indi­ca­tes where more test­ing might be nee­ded and helps main­tain the health of the code by poin­ting out unu­sed or red­un­dant seg­ments. Though a 100 % code coverage should be the goal of unit tests, one must be careful with the inter­pre­ta­tion. Full code coverage does not mean a full coverage of all advi­sa­ble test cases.

Coverage reports are pro­du­ced with coverage python package. Luckily, there is a pytest plugin pytest-cov, that pro­vi­des the full func­tion­a­lity of the package from within pytest. This way, deve­lo­pers can test and deter­mine the coverage in one go. There are dif­fe­rent report­ing styles, from simple com­mand line inter­face out­put to detailed visua­liza­tion as html docu­ments. An exam­ple of the lat­ter can be seen below, show­ing the covered and unco­vered lines in green and red, respec­tively, as well as the total coverage of the file.

Shows the html represenation of a coverage report
HTML based visua­li­zed cevoerage report of code.

Enhan­cing Test­ing with Fixtures

Pytest intro­du­ces fix­tures for effi­ci­ent setup and tear­down in tests. Fix­tures pro­mote reusa­bi­lity and allow for dif­fe­rent sco­pes to con­trol how often they are exe­cu­ted. They also sup­port para­me­ter­iza­tion to test with various inputs easily and offer lazy eva­lua­tion, only run­ning when requi­red. Depen­dency injec­tion through fix­tures sim­pli­fies tests by pro­vi­ding resour­ces directly, and the frame­work includes a mix of built-in and cus­tom fix­tures for broad applicability.

Fix­ture may invoke other fix­tures and by defi­ning fix­tures with the yield key­word (rather than return), one can imple­ment tear­down code after the fix­ture has done its job. Here is an exam­ple of a fix­ture that returns a data­base con­nec­tion and requests ses­sion, clo­sing the ses­sion afterwards:

Code ecaple of a pytest fixture.
The fix­ture pro­vi­des a db con­nec­tion and a requests ses­sion, as well as a tear­down mechanism

The fix­ture can be used in a test sim­ply by pro­vi­ding it as a para­me­ter to the test function.

Mon­key­patching: Tem­po­rary Code Adjustment

Mon­key­patching in Pytest allows tem­po­rary, dyna­mic chan­ges to the code during run­time, which are rever­sed once the test con­cludes. This ensu­res test iso­la­tion and mini­mi­zes the risk of side effects across tests. It’s a built-in fea­ture that is easy to use and does not require exter­nal libra­ries, offe­ring fle­xi­bi­lity to alter methods, func­tions, attri­bu­tes, or envi­ron­ment varia­bles just for the dura­tion of a test.

A mon­key­patch can be used in a test by pas­sing the mon­keytest fix­ture to the func­tion. From there, all methods to patch pro­gram parts are available. The exam­ple below patches the rand­int method of the radom package, because test­ing is not very relia­ble when the pro­gram uses ran­dom num­bers. The func­tion is repla­ced with a lambda func­tion to always return the inte­ger 5.

A monkepatch code example
The mon­key­patch replaces the rand­int method with a lambda function

Final Thoughts

Pytest stands out for its simp­ler syn­tax that redu­ces boi­ler­p­late, advan­ced fix­ture manage­ment, and easy test dis­co­very that doesn’t require expli­cit con­fi­gu­ra­tion. Para­me­ter­iza­tion is straight­for­ward, pro­vi­ding the ability to run tests with mul­ti­ple data sets. Asser­ti­ons are infor­ma­tive, giving clear feed­back for debug­ging. The eco­sys­tem of plug­ins for pytest allows for exten­sive exten­si­bi­lity. Addi­tio­nally, pytest can run unittest tests, making inte­gra­tion smoother.

Howe­ver, pytest is an exter­nal resource, and in some sce­na­rios, cli­ents or stake­hol­ders might pre­fer or require the use of the stan­dard library’s unittest due to its built-in nature. This depen­dency on an exter­nal tool is some­ti­mes a con­side­ra­tion when choo­sing a test­ing framework.