What is Unit Testing?
Unit testing is a fundamental aspect of software development, critical for verifying the integrity of an application’s individual components. It consists of isolating units, such as functions, methods, or classes, and subjecting them to automated tests to ensure they function independently. In this testing process, units are evaluated separately, often with the support of stubs, mocks, or fakes to mimic dependencies. This practice ensures that each unit performs as expected, unaffected by external elements of the application.
Aims and benefits
The primary goal of unit testing is to check that each unit operates correctly according to the specifications. It is crucial for code modification, catching regressions so that existing functionality remains unaffected by changes. It also helps to reduce the number of bugs during the integration of the units and can provide examples of how the code is meant to be used.
Unit testing provides immediate feedback on design issues, signaling when a piece of code is potentially difficult to test and may need refactoring, i.e. using dependency injection. Bugs detected during unit testing are generally less costly to fix than those found in later stages. It is also a critical component of Continuous Integration (CI) and Continuous Delivery (CD) practices.
Unittest vs. Pytest
When developers face the decision of selecting a unit testing framework in Python, key players are unittest
and pytest
. The choice hinges on various factors including the desired test structure, assertion style, setup procedures, and discovery methods. Also the availability of external components in the target environment comes into play, especially when working with legacy code in closed systems.
unittest
is the traditional choice, embedded within Python’s standard library, offering a familiar object-oriented approach. Tests are neatly organized into methods that reside within classes derived from unittest.TestCase
. This framework provides a suite of assertion methods tailored for various scenarios, alongside setUp
and tearDown
methods that are invoked before and after each test, respectively. However, this comes at the cost of verbosity and a somewhat rigid test discovery process. Since version 3.3, the mock
package was integrated into the standard library as unittest.mock
. This offers rather complex, but very powerful mocking and stubbing functionalities.
In contrast, pytest
presents a modern alternative, known for its simplicity and elegance. It breaks free from the class-based structure, enabling developers to write tests as plain functions. Assertions are simplified, leveraging the standard assert
statement, making the tests easier to write and understand. The setup and cleanup are handled by fixtures that offer more flexible and powerful scoping options. While pytest
is not part of the standard library and thus requires an additional installation, it compensates with a highly intuitive test discovery system and extensive plugin support, fostering a dynamic and extensible testing environment. Mocking is typically done with monkeypatching, a very straight-forward mechanism built into pytest
(see below).
Why pytest is the better option
Typically, a pytest test case will be significantly shorter in terms of code amount than a unittest test case with the same functionality. There can be cases, when the standard pytest toolbox will be insufficiently in functionality and advanced methods such as unittest.mock is needed. Luckily, pytest
provides a plugin to utilize these directly from within pytest
. Furthermore, pytest will run tests on existing unittest-style test sets. This makes a transition from existing unittest
code to pytest
very straight-forward.
Each framework has its own set of trade-offs, and the selection often boils down to the specific requirements of the project and personal preference. unittest
appeals to those seeking the consistency and availability of a standard library tool, while pytest
attracts those looking for more concise code and advanced features.
Coverage: Assessing Test Effectiveness
Coverage measures how much of the codebase the tests cover. It shines a light on untested parts of the code, encourages complete testing, and sets goals for coverage metrics. By highlighting what code is not tested, it indicates where more testing might be needed and helps maintain the health of the code by pointing out unused or redundant segments. Though a 100 % code coverage should be the goal of unit tests, one must be careful with the interpretation. Full code coverage does not mean a full coverage of all advisable test cases.
Coverage reports are produced with coverage
python package. Luckily, there is a pytest
plugin pytest-cov
, that provides the full functionality of the package from within pytest. This way, developers can test and determine the coverage in one go. There are different reporting styles, from simple command line interface output to detailed visualization as html documents. An example of the latter can be seen below, showing the covered and uncovered lines in green and red, respectively, as well as the total coverage of the file.
Enhancing Testing with Fixtures
Pytest introduces fixtures for efficient setup and teardown in tests. Fixtures promote reusability and allow for different scopes to control how often they are executed. They also support parameterization to test with various inputs easily and offer lazy evaluation, only running when required. Dependency injection through fixtures simplifies tests by providing resources directly, and the framework includes a mix of built-in and custom fixtures for broad applicability.
Fixture may invoke other fixtures and by defining fixtures with the yield keyword (rather than return), one can implement teardown code after the fixture has done its job. Here is an example of a fixture that returns a database connection and requests session, closing the session afterwards:
The fixture can be used in a test simply by providing it as a parameter to the test function.
Monkeypatching: Temporary Code Adjustment
Monkeypatching in Pytest allows temporary, dynamic changes to the code during runtime, which are reversed once the test concludes. This ensures test isolation and minimizes the risk of side effects across tests. It’s a built-in feature that is easy to use and does not require external libraries, offering flexibility to alter methods, functions, attributes, or environment variables just for the duration of a test.
A monkeypatch can be used in a test by passing the monkeytest fixture to the function. From there, all methods to patch program parts are available. The example below patches the randint method of the radom package, because testing is not very reliable when the program uses random numbers. The function is replaced with a lambda function to always return the integer 5.
Final Thoughts
Pytest stands out for its simpler syntax that reduces boilerplate, advanced fixture management, and easy test discovery that doesn’t require explicit configuration. Parameterization is straightforward, providing the ability to run tests with multiple data sets. Assertions are informative, giving clear feedback for debugging. The ecosystem of plugins for pytest allows for extensive extensibility. Additionally, pytest can run unittest
tests, making integration smoother.
However, pytest is an external resource, and in some scenarios, clients or stakeholders might prefer or require the use of the standard library’s unittest
due to its built-in nature. This dependency on an external tool is sometimes a consideration when choosing a testing framework.