Unit Testing in Python

Tanja Adžić
23 min readDec 7, 2023

--

Introduction

In the world of software development, a practice known as Test-Driven Development (TDD) plays a vital role in identifying and rectifying code issues. What exactly is TDD, and why is it an essential practice?

Test-Driven Development, often abbreviated as TDD, is an approach to software construction. It fundamentally alters the way developers craft their code by encouraging the creation of tests for each functional component before the actual code is composed. In essence, TDD revolutionizes the conventional development process by placing a strong emphasis on designing and validating test cases that define the behavior of the code.

TDD’s principle is simple: it requires the development and execution of tests for each specific functionality of your software before coding it. When these tests fail, you proceed to write just enough code to make them pass, promoting a lean and duplication-free codebase.

TDD is highly beneficial for many reasons:

  • Early Detection of Bugs: TDD encourages developers to write tests before writing code, which helps identify and address issues early in the development process, reducing the cost and effort required for later fixes.
  • Improved Code Quality: TDD leads to cleaner, more focused code that is easier to understand, maintain, and extend.
  • Enhanced Software Design: TDD promotes modular and well-structured software design, making complex applications more manageable.
  • Regression Testing: With a comprehensive suite of tests, changes or new features can be quickly verified, ensuring the stability of the software.
  • Increased Confidence: TDD provides a sense of confidence in the code; passing tests indicate that the code works as expected.
  • Documentation: Test cases serve as documentation, describing how the code should behave, which aids in understanding and working with the codebase.
  • Faster Development: TDD can speed up development by quickly identifying issues and facilitating prompt fixes.
  • Customer Satisfaction: TDD reduces the likelihood of bugs reaching production, enhancing customer satisfaction.
  • Collaboration and Refactoring: TDD encourages collaboration among developers and provides a safety net for code refactoring.
  • Continuous Integration: TDD aligns well with continuous integration practices, ensuring code is continuously tested, improving software quality.

In this blog post, I’ll explore Unit Testing in Python, a vital component of TDD, and explore the principles, benefits, and practical applications of Test-Driven Development.

Photo by Sigmund on Unsplash

Unit Testing and Test-Driven Development

Levels of Testing

There are several levels of testing, starting from the lowest level:

  1. Unit Testing: These are the lowest level tests. Unit tests validate individual functions in the production code. They are generally the most comprehensive tests and should cover all positive and negative test cases for a function.
  2. Integration / Component Level Testing: Component level testing examines the external interfaces for individual components. Components are essentially collections of functions.
  3. System Level Testing: System level testing assesses the external interfaces at a system level. Systems can be collections of components or subsystems.
  4. Performance Testing: Performance testing is the final stage. It evaluates systems and subsystems under expected production loads to verify that response times and resource utilization (such as memory, CPU, and disk usage) are acceptable.

Basic Concepts of Unit Testing

Correspondence to Test Cases: Each unit test is designed to correspond to a specific test case for a function within our software. These test cases are a comprehensive set of scenarios, both normal and edge cases, that the function should be able to handle. The goal is to evaluate the function’s behavior under various conditions. For instance, if we have a function that calculates the square root of a number, we’d want test cases to include valid inputs (positive and zero) as well as potential troublemakers like negative numbers or non-numeric inputs.

Organization with Test Suites: As the number of unit tests grows, organizing them becomes crucial. This is where test suites come into play. A test suite is essentially a collection of related unit tests. For example, we might have a suite dedicated to testing mathematical functions and another for input validation. Organizing tests into suites not only aids in keeping our testing efforts structured but also allows us to run specific sets of tests when needed, such as focusing on particular functionality or during continuous integration processes.

Development Environment vs. Production Environment: Unit tests should run exclusively within our development environment. This separation is vital for a few reasons. Firstly, the development environment is a controlled and safe space where we can execute tests without affecting the live system. This isolation prevents accidental data corruption, unexpected behavior, or performance issues that could result from running tests in a production setting. Additionally, the development environment offers the flexibility to test frequently, allowing us to identify and rectify issues as they emerge during the development process. It’s a controlled, sandboxed area for testing and debugging.

Automation for Efficiency: Automation is at the core of effective unit testing. Manually executing unit tests for every code change quickly becomes impractical as our project grows. Automation streamlines the testing process and ensures that unit tests can be run easily and consistently. Ideally, a simple action like clicking a button initiates the build and execution of unit tests. Automated tests can be integrated into the development workflow, continuous integration systems, and version control, ensuring that they’re run consistently and reliably. Automation simplifies the testing process, reduces the potential for human error, and accelerates the feedback loop, allowing us to catch issues early in the development cycle.

An example of a Unit Test structure, using a simple function as production code, is shown below:

#production code (length of a string)
def str_len(theStr):
return len(theStr)


#a Unit Test for the production code
def test_string_lenght():

#Step 1 - Setup
testStr = '1'

#Step 2 - Action
result = str_len(testStr)

#Step 3 - Assert
assert result = 1

In this example we have a production code function that calculates the length of a given string. The unit test, in this case, is a single positive test case “1”, verifying that a string with one character returns a length of one. Unit tests typically follow a common structure, involving three steps: a setup phase, an action step that invokes the production code, and an assertion phase where the test validates the results. This structure forms the backbone of all unit tests.

Basic Concepts of Test-Driven Development

TDD, or Test-Driven Development, is a disciplined process that places the responsibility for code quality on the shoulders of the developer. The essence of this practice is to write unit tests before crafting the actual production code. This approach may initially seem unconventional, but as we become more accustomed to it, we’ll find it hard to imagine coding any other way.

One key point to understand is that even though tests are created prior to the production code, it doesn’t mean that all the tests are written at once. Instead, the process involves writing one unit test for one specific test case and then authoring the production code necessary to make that test pass. This pattern continues, with incremental additions of tests and code, creating a structured and feedback-driven development cycle.

This cycle is crucial in TDD as it provides immediate feedback on the code. This feedback loop is one of the defining features of TDD and significantly contributes to the quality of the software.

What are the benefits of adopting TDD in our development process?

First and foremost, TDD instills confidence in making changes to our code. With the requirement to write tests before implementing changes, we have a built-in safety net to verify that the code is still functional. This confidence is a result of the rapid feedback provided by the tests after each incremental change.

Moreover, unit tests serve as documentation for our code. They describe what the production code is supposed to do, and they can be a valuable resource for new developers looking to understand the codebase.

Additionally, TDD encourages good object-oriented design principles. Writing unit tests first motivates us to create classes and functions that are testable in isolation, which often leads to the creation of interfaces, breaking dependencies, and promoting clean code.

The TDD workflow is structured into three phases:

  • The Red Phase — a failing unit test is authored for the functionality we intend to implement next.
  • The Green Phase — involves writing just enough production code to make the failing test pass.
  • TheRefactor Phase — focuses on improving the unit test and production code, removing duplication, and ensuring compliance with coding standards and best practices.

To uphold TDD principles, Uncle Bob (Robert Martin) formulated three laws of TDD in his book “Clean Code: A Handbook of Agile Software Development”:

  1. You may not write any production code until you have first created a failing unit test.
  2. You may not write more of a unit test than is sufficient to fail.
  3. You may not write more production code than is sufficient to pass the currently failing unit test.

These laws guide developers to maintain a tight loop of writing small tests that fail and then writing just enough production code to make them pass. Each iteration is typically short, lasting only a few minutes, and tests are run continuously to ensure code stability.

FizzBuzz Example

Here is a real Test-Driven Development (TDD) coding example using FizzBuzz (FizzBuzz is a simple coding exercise often used for practice):

  1. Understanding Fizzbuzz: Fizzbuzz is a test in which we’ll create a function. This function will take an integer as input and return specific strings based on certain conditions.

2. TDD Phases: We’ll follow the TDD process, which has three main phases: Red, Green, and Refactor.

  • In the Red Phase, we start by writing a failing unit test that defines what we want our code to do.
  • In the Green Phase, we write the minimum amount of code necessary to make that test pass. This is often a very specific implementation.
  • In the Refactor Phase, we clean up the code, remove duplication, and ensure it follows coding standards.

3. Example Test Cases: We’ll follow a series of test cases:

  • The initial test case ensures that we can call the Fizzbuzz function.
  • Subsequent test cases gradually increase in complexity, testing various conditions. For example, we’ll make the code return “fizz” for multiples of three, “buzz” for multiples of five, and “fizz buzz” for multiples of both three and five. If the value is not a multiple of 3 or 5, it should return the value as a string.

4. TDD Iterations: We’ll repeat this process for each test case, ensuring that we always start with a failing test (Red Phase), make the simplest change to pass the test (Green Phase), and then consider refactoring (Refactor Phase).

5. Refactoring: During refactoring, we look for any code duplication and clean up the code as needed.

6. Utility Functions: You’ll notice the creation of utility functions to simplify the code and make it cleaner.

7. Completing Test Cases: After each test case is implemented and passes, mark it as complete and move on to the next one.

Here is an example of a code for this exercise:

def fizzbuzz(n):
if n % 3 == 0 and n % 5 == 0:
return "fizz buzz"
elif n % 3 == 0:
return "fizz"
elif n % 5 == 0:
return "buzz"
else:
return str(n)

#test cases for the FizzBuzz function
def test_fizzbuzz():
assert fizzbuzz(1) == "1"
assert fizzbuzz(3) == "fizz"
assert fizzbuzz(5) == "buzz"
assert fizzbuzz(15) == "fizz buzz"
assert fizzbuzz(7) == "7"

#running the test cases
if __name__ == "__main__":
test_fizzbuzz()
print("All test cases passed!")

Python Virtual Environments

By default, when you install Python packages, they are placed in a single directory on your system. This setup can lead to problems when you’re working on multiple Python projects with different dependencies. For instance, Project A might require version one of a particular library, while Project B needs version two of the same library. This can become problematic because the Python runtime can’t have both versions of the library installed simultaneously.

Python virtual environments provide a solution to this issue. They create isolated Python environments customized for each project. This isolation is achieved by generating a new directory for each virtual environment, which contains links or copies of the Python executable, library, and tools. Subdirectories are also added to hold the installed packages specific to that virtual environment. When a virtual environment is activated, the path environment variable is updated to point to the virtual environment’s bin directory. As a result, the Python instance within the virtual environment is executed, and the packages installed within that environment are utilized.

Setting up a virtual environment in Python 3 is straightforward and can be done using the built-in venv module. Follow these steps to create and activate a virtual environment in Python 3 (on MacOs):

1. Access your Terminal

2. Navigate to Your Project Directory:

  • Use the cd command to navigate to the directory where you want to create the virtual environment.

3. Create a Virtual Environment:

  • Run the following command to create a new virtual environment. Replace myenv with the name you want to give to your virtual environment:
python3 -m venv myenv
  • This command tells Python 3 to use the venv module and create a virtual environment named myenv in your current directory.

4. Activate the Virtual Environment:

source myenv/bin/activate
  • After activation, your command prompt or terminal should show the virtual environment’s name in the prompt. This indicates that the virtual environment is active.

5. Work in Your Virtual Environment:

  • While the virtual environment is active, any Python packages you install or commands you run will be isolated within the virtual environment. This ensures that your project uses only the packages installed in this environment.

6. Deactivate the Virtual Environment:

  • To exit the virtual environment and return to the global Python environment, simply run the following command:
deactivate

7. Delete the Virtual Environment (Optional):

  • To delete the virtual environment and its associated files, close the virtual environment (if it’s active) and delete its directory. Be cautious when doing this to avoid losing important data.

PyTest Overview

PyTest is a Python unit testing framework. It equips us with the capability to create tests, test modules, test classes, and test fixtures. Notably, PyTest leverages the built-in Python assert statement, making the implementation of unit tests significantly more straightforward compared to other Python unit testing frameworks. Moreover, it has a variety of useful command-line arguments, which aid in specifying which tests should be executed and in what sequence.

How do we create a unit test in Python using PyTest?

In PyTest, individual tests are represented as Python functions with names beginning with “test.” These unit tests are responsible for executing the production code and employing the standard Python assert statement to validate results. Similar tests can be grouped together within the same module or class to maintain organization and structure. Here is an example:

def test_assert_true():
assert True

In this specific example, we created a very basic unit test named test_assert_true. This test, as the name suggests, simply asserts the truth.

Next, we run pytest -v from the command line. The -v flag instructs PyTest to run in verbose mode, providing detailed information about which unit tests are executed and their pass/fail status. When executed, PyTest discovers the test_assert_true unit test, runs it, and reports the results, which, in this case, is a successful pass.

PyTest Automatic Test Discovery

With PyTest, we can conveniently and automatically locate our unit tests. PyTest does this when executed from the command line, and it follows specific naming conventions for test files, test classes, and test functions.

Firstly, in PyTest, test function names should start with “test.” For test classes containing test methods, the class name should begin with a capital “T”: “Test” at the start. It’s essential to note that these classes should not have an __init__ method. Furthermore, test module file names should either start with "test_" or end with "_test."

XUnit-Style Setup and Teardown

One of the essential features of unit test frameworks is the ability to execute setup code before and after tests. PyTest offers this capability through XUnit-style setup and teardown functions, along with PyTest fixtures.

These XUnit-style setup and teardown functions allow us to execute code both before and after various levels of tests: test modules, test functions, test classes, and test methods within classes. The beauty of using these functions lies in their ability to reduce code duplication. By defining setup and teardown code at different levels, we avoid the need to repeat the same code within each individual unit test. This leads to cleaner and more manageable code.

Let’s examine some examples to understand how this works.

In our first example, we will focus on the setup and teardown of individual unit test functions that are not part of a class. We have two unit tests, namely “test1” and “test2,” as well as “setup_function” and “teardown_function” functions. When using PyTest, the setup function is automatically called before each unit test in the module (that is not within a class), and the teardown function follows after each unit test has completed. These functions receive information about the specific unit test being executed, allowing for customization when needed.

When we run this with PyTest, we add the -v (verbose) argument to provide detailed output, and the -s argument to prevent the capture of console output, allowing us to see print statements in the console.

def setup_function(function):
if function == test1:
print('\nSetting up test1.')
elif function == test2:
print('\nSetting up test2.')
else:
print('\nSetting up an unknown test.')

def teardown_function(function):
if function == test1:
print('\nTearing down test1.')
elif function == test2:
print('\nTearing down test2.')
else:
print('\nTearing down an unknown test.')

def test1():
print('Executing test1.')
assert True

def test2():
print('Executing test2.')
assert True

The PyTest output demonstrates that the setup function is called before “test1” and “test2” and the teardown function is invoked after each unit test, all while maintaining the flexibility to customize setup and teardown code for each unit test:

collected 2 items                                                                                                                                       

test_xunit_setup_teardown.py::test1
Setting up test1.
Executing test1.
PASSED
Tearing down test1.

test_xunit_setup_teardown.py::test2
Setting up test2.
Executing test2.
PASSED
Tearing down test2.

Now, let’s expand on this example to include setup and teardown functions for the module itself. These functions are executed once before any of the unit tests in the module commence and again after all the unit tests have completed. By introducing setup_module and teardown_module functions, we can observe how PyTest handles them by running PyTest once more.

def setup_module(module):
print('Setup Module.')

def teardown_module(module):
print('Teardown Module.')

def setup_function(function):
if function == test1:
print('\nSetting up test1.')
elif function == test2:
print('\nSetting up test2.')
else:
print('\nSetting up an unknown test.')

def teardown_function(function):
if function == test1:
print('\nTearing down test1.')
elif function == test2:
print('\nTearing down test2.')
else:
print('\nTearing down an unknown test.')

def test1():
print('Executing test1.')
assert True

def test2():
print('Executing test2.')
assert True

The PyTest output clearly shows that the setup_module function is called once before any unit tests or their setup and teardown functions. Similarly, the teardown_module function is called once after all unit tests and their setup and teardown functions have concluded.

collected 2 items                                                                                                                                       

test_xunit_setup_teardown.py::test1 Setup Module.

Setting up test1.
Executing test1.
PASSED
Tearing down test1.

test_xunit_setup_teardown.py::test2
Setting up test2.
Executing test2.
PASSED
Tearing down test2.
Teardown Module.

In the final example, we’ve organized the “test1” and “test2” unit test functions within a class called “TestClass.” Additionally, we’ve introduced the methods setup_class, teardown_class, setup_method, and teardown_method. It's worth noting that the @classmethod decorator is applied to the setup_class and teardown_class methods, as they receive the instantiated class object rather than a unique instance of the class. The setup_class method runs before any unit tests in the class, while the teardown_class method runs after all unit tests within the class have executed. The setup_method method is called before each unit test in the class, while the teardown_method method follows after each unit test has concluded.

class TestClass:
@classmethod
def setup_class(cls):
print('Setup Module.')

@classmethod
def teardown_class(cls):
print('Teardown Module.')

def setup_method(self, method):
if method == self.test1:
print('\nSetting up test1.')
elif method == self.test2:
print('\nSetting up test2.')
else:
print('\nSetting up an unknown test.')

def teardown_method(self, method):
if method == self.test1:
print('\nTearing down test1.')
elif method == self.test2:
print('\nTearing down test2.')
else:
print('\nTearing down an unknown test.')

def test1(self):
print('Executing test1.')
assert True

def test2(self):
print('Executing test2.')
assert True

Upon running these tests in PyTest, we witness the order in which these functions execute. Firstly, the setup_class method is called, followed by the setup_method before "test1" The teardown_method executes after "test1" and this pattern repeats for "test2" Finally, the teardown_class method is called.

collected 2 items                                                                                                                                       

test_xunit_setup_teardown.py::TestClass::test1 Setup Module.

Setting up test1.
Executing test1.
PASSED
Tearing down test1.

test_xunit_setup_teardown.py::TestClass::test2
Setting up test2.
Executing test2.
PASSED
Tearing down test2.
Teardown Module.

Test Fixtures

Text Fixtures are essential in testing, as they allow us to prepare the environment and resources needed for our tests. We’ll explore how test fixtures differ from the classic XUnit-style setup and teardown functions and dive into practical examples.

Like the XUnit-style setup and teardown functions, test fixtures enable us to reuse code across tests by specifying functions that should be executed before a unit test runs. To indicate that a function is a test fixture, we apply the @pytest.fixture decorator to that function. Individual unit tests can then specify which fixtures they require by including the fixture name in their parameter list or by using the @pytest.mark.usefixtures decorator. If a fixture has its autouse parameter set to True, it will be automatically executed before all tests in its scope.

@pytest.fixture():
def math():
return Math()

def test_Add(math):
assert math.add(1,1) == 2

In the first example, we demonstrate a straightforward use of PyTest fixtures. We have one test fixture named “setup” and two unit tests, “test1” and “test2”. Currently, neither test specifies that it wants the fixture to run before executing. After running this code in PyTest, we observe that the “setup” method is not called.

@pytest.fixture()
def setup():
print('\nSetup')

def test1():
print('Test 1 executing.')
assert True

def test2():
print('Test 2 is executing')
assert True

We can enhance our unit tests by specifying which fixtures they require. In the example, we modify “test1” to use the “setup” fixture by including it in the parameter list. As a result, the “setup” fixture is executed before “test1”. Next, we update “test2” to use the “setup” fixture by using the @pytest.mark.usefixtures decorator. Now, "setup" is called before both "test1" and "test2".

@pytest.fixture()
def setup():
print('\nSetup')

def test1(setup):
print('Test 1 executing.')
assert True

@pytest.mark.usefixtures('setup')
def test2():
print('Test 2 is executing')
assert True

In some cases, it’s more convenient for all tests to run the same test fixture automatically. The autouse parameter of the test fixture can be set to True to achieve this. In the example, we enable the "autouse" parameter for the "setup" fixture, and when we execute the tests, we see that "setup" is executed before both "test1" and "test2":

@pytest.fixture(autouse = True)
def setup():
print('\nSetup')

def test1():
print('Test 1 executing.')
assert True

def test2():
print('Test 2 is executing')
assert True

Text Fixture Teardown

Test fixtures also support teardown or cleanup after testing. PyTest provides two methods for specifying teardown code: the yield keyword and the request context object's addfinalizer method.

The yield keyword is simpler, as code following the yield statement is executed after the fixture goes out of scope. The request context object's addfinalizer method allows us to specify multiple finalization functions.

Let’s see the use of the yield keyword and the request context object's addfinalizer method in an example. We have two test fixtures, "setup1" and "setup2" as well as two unit tests, "test1" and "test2" .When we run the tests, we can see that the teardown code for "setup1" is called after "test1", and the two teardown functions for "setup2" are called after "test2".

@pytest.fixture()
def setup1():
print('\nSetup 1')
yield
print('\nTeardown 1')

@pytest.fixture()
def setup2(request):
print('\nSetup 2')

def teardown_a()
print('\nTeardown A')

def teardown_b()
print('\nTeardown B')

request.addfinalizer(teardown_a)
request.addfinalizer(teardown_b)

def test1(setup1):
print('Test 1 is executing.')
assert True

def test2(setup2):
print('Test 2 is executing')
assert True

Text Fixtures Scope

Fixture scope determines which tests a fixture applies to and how often it runs. PyTest provides four default fixture scopes:

  1. Function: The fixture is called for all tests in the module.
  2. Class: The fixture is executed once per test class.
  3. Module: The fixture is executed once per module.
  4. Session: The fixture is executed once when PyTest starts.

Text Fixture Return Objects and Params

In PyTest, fixtures can optionally return data that tests can use. To achieve this, we use the params attribute within the @pytest.fixture decorator. This attribute specifies one or more values that should be passed to the test. When a fixture has multiple values in its params list, the test is called once for each value.

In the last example, we define a module with one test fixture named “setup” and a unit test named “test1”. The “setup” fixture has a params attribute with values 1, 2, and 3. The fixture function uses the request.param value as the return value. When we run the test, we observe that the test is executed once for each value in the params list. This feature allows for running tests with different values, but it is important to ensure that test cases have unique names for easy identification when failures occur.

@pytest.fixture(params = [1, 2, 3]
def setup(request):
ret_val = request.param
print('\nSetup! ret_val = {}'.format(ret_val))
return ret_val

def test1(setup):
print('\nSetup = {}'.format(setup))
assert True

PyTest fixtures are a valuable feature for organizing and preparing your testing environment. You can use them to manage setup, teardown, and data for your unit tests.

Assert Statements and Exceptions

PyTest allows us to use the standard Python assert statement for performing verifications within our unit tests. This means that we can use the usual comparison operators, such as less than, greater than, less than or equal, greater than or equal, equal, and not equal, to validate various Python data types.

PyTest enhances the messages reported for assert failures, providing more context in the test results. This helps us quickly identify the specific issues when tests fail.

When it comes to validating floating-point values, there can be challenges, as these values are stored internally as binary fractions. Due to this internal representation, some comparisons that we expect to pass may fail. To address this, PyTest offers the approx function. It allows us to validate that two floating-point values are approximately the same, within a default tolerance of one times ten to the power of negative six. This tolerance accounts for small differences due to floating-point representation.

In certain test cases, we need to ensure that a function raises an exception under specific conditions. PyTest provides the raises helper to facilitate this verification using the with keyword. When the raises helper is used, the unit test will fail if the specified exception is not thrown within the code block after the raises line. This feature is valuable for testing error conditions in your code.

Let’s see an example of these concepts.

In the first example, we have a test module with five unit tests. Each test verifies the use of the assert statement for comparing different common Python data types. When we run this in PyTest, all the tests pass successfully.

def test_IntAssert():
assert 1 == 1

def test_StrAssert():
assert "str" == "str"

def test_FloatAssert():
assert 1.0 == 1.0

def test_ArrayAssert():
assert [1, 2, 3] == [1, 2, 3]

def test_DictAssert():
assert {'1':1} == {'1':1}

In the second example, we deliberately create a failing floating-point unit test. This test compares a fractional floating-point value, which, due to its internal binary representation, does not align precisely with the expected value. As expected, this test fails when we run it in PyTest.

def test_float():
assert (0.1 = 0.2) == 0.3

To address the issue, we modify the code to use the approx statement, ensuring that it now passes successfully. This demonstrates how the approx function helps manage floating-point comparisons effectively.

from pytest import approx

def test_float():
assert (0.1 = 0.2) == approx(0.3)

In the last example, we have a unit test that verifies whether a function raises an exception. The code that should raise the exception is initially commented out in order to confirm that when we run this test in PyTest, it fails because the expected exception is not thrown, confirming that the exception-checking mechanism is working.

from pytest import raises

def raisesValueException():
pass
#raise ValueError

def test_exception():
with raises(ValueError):
raisesValieException()

When we uncomment the code and allow it to raise the expected exception, , the test passes as anticipated, demonstrating the success of exception handling in PyTest.

PyTest Command Line Arguments

By default, PyTest runs all the tests it discovers in the current working directory and subdirectories using the naming conventions for automatic test discovery. However, there are several PyTest command-line arguments that provide flexibility in selecting which tests to execute.

We can specify the module name using the command-line argument, executing only the unit tests in that particular module. Alternatively, passing a directory path allows PyTest to run only the tests in that directory.

For finer-grained selection, the -k option allows us to specify an evaluation string based on keywords such as module name, class name, and function name.

Furthermore, the -m option enables us to execute tests with a PyTest mark decorator that matches the specified expression string. This makes it convenient to select and run specific sets of tests.

Here are some additional command-line arguments that can prove to be very useful:

  • The -v option enables verbose output from PyTest.
  • The -q option runs tests quietly or with minimal output, which can be helpful for performance when running numerous tests.
  • The -s option instructs PyTest not to capture console output, allowing us to view the printouts from the tests.
  • The --ignore option allows you to specify a path to be ignored during test discovery.
  • The --maxfail option defines the maximum number of test failures after which PyTest should stop.

Case Study: The Supermarket Checkout

Source: Kata09: Back to the Checkout

The Checkout Class

I’ll be implementing a Checkout class that maintains a list of items that are being added during a checkout at a supermarket. This class should provide interfaces for:

  • setting the price of individual items
  • adding individual items to the checkout
  • the current total cost for all the items added
  • add and apply discounts on select items when N number are purchased

The Test Cases

The Checkout class has the following test cases that all go through as I’m implementing the class with TDD:

  • can create an instance of the Checkout class
  • can add an item price
  • can add an item
  • can calculate the current total
  • can add multiple items and get correct total
  • can add discount rule
  • can apply discount rules to the total
  • exception is trhrown for item added without a price

Code for this case study can be found in the following repository: Unit Testing with Supermarket Checkout.

Test Doubles

What are Test Doubles? In most cases, our code relies on other pieces of code within the system. However, these dependencies might not be available in the unit testing environment, or they might be too slow, which could lead to impractical tests. This is where test doubles come into play.

Test doubles are objects created during testing to replace the real production system collaborators. There are various types of test doubles, including:

  • Dummy objects: Simple placeholders not intended to be used but can generate exceptions if called.
  • Fake objects: They have simplified implementations, suitable for testing but not for production.
  • Stubs: These provide implementations that expect to be called and respond with canned responses.
  • Spies: They record the values passed into them, allowing tests to validate the code.
  • Mocks: The most sophisticated type, with pre-programmed expectations about calls, the number of times functions will be called, and the values passed in. They generate exceptions if expectations aren’t met.

To create these test doubles at runtime, we use mock frameworks, which provide easy-to-use APIs for specifying mocking expectations in unit tests. This approach is more efficient than implementing custom mock objects.

In Python, unittest.mock is a powerful mocking framework that's built into the standard library for Python 3.3 and newer. The mock class within this framework can be used to create various types of test doubles such as fakes, stubs, spies, or true mocks for classes or functions.

def test_example():
example1 = Mock()
functionForExample1(example1)
example1.assert_called_once()

When working with mock objects, you can specify their behavior using various initialization parameters. You can also verify how these objects were used in your tests by using built-in functions and attributes.

For example, you can assert if the mock was called, called once, or called with specific parameters. You can also check the number of times the mock was called and access details about its call arguments.

In addition to the basic mock class, there's also the MagicMock class, derived from mock, which provides default implementations for Python's magic methods.

Best Practices

When practicing unit testing with test-driven development (TDD), we aim to follow best practices that enhance our productivity and ensure high-quality code. These practices are vital for maintaining clean, understandable code. Let’s explore some of these key principles.

  1. Start with the Simplest Test Case: Begin with the simplest test case to incrementally build and refactor your code. Avoid jumping to complex cases too quickly, as it can disrupt the short feedback cycle TDD aims for and lead to suboptimal design.
  2. Use Descriptive Test Names: Clear and descriptive test names are essential. The code is read far more than it’s written, so well-named tests serve as valuable documentation for fellow developers, helping them understand your code’s intended behavior.
  3. Optimize for Speed: TDD thrives on fast feedback. Ensure that your unit tests build and execute rapidly, ideally within a few seconds. Minimize or eliminate console output to keep tests fast and focused. Mock out slow collaborators using fast test doubles.
  4. Leverage Code Coverage Analysis: After writing all your test cases, employ code coverage analysis tools to identify areas of your code that may be overlooked, including negative test cases. Strive for 100% code coverage on functions with significant logic.
  5. Run Tests Multiple Times in Random Order: Running tests multiple times helps identify flaky tests that might fail intermittently. Execute tests in random order to uncover dependencies between them, using tools like pytest-random-order and pytest-repeat plugins.
  6. Utilize Static Code Analysis Tools: Employ static code analysis tools, such as Pylint, to regularly analyze your code for bugs, code formatting, and other quality concerns. Static analysis tools can enhance code quality and even generate UML diagrams based on code analysis.
  7. Focus on Behavior, Not Implementation: When writing unit tests, prioritize testing the behavior of the production code rather than the implementation. Behavior-focused tests remain robust when the implementation changes. Occasionally, it’s necessary to test the implementation details, especially when dealing with external collaborators.

As we apply these best practices in your TDD process, we’ll be better equipped to produce reliable, maintainable, and thoroughly tested code. It’s essential to understand when to focus on behavior and when to test implementation intricacies. This comprehensive approach ensures that your code remains robust as it evolves.

Conclusion

In conclusion, unit testing in Python is a valuable tool for ensuring the robustness and reliability of software. With the power of PyTest and a solid TDD approach, we can catch bugs before they ever threaten our applications. TDD is about designing better, more maintainable code. It fosters confidence in our software’s capabilities. As always, practice makes perfect, so I believe implementing these practices can significantly contribute to becoming a more pragmatic programmer. Happy testing everyone!

Disclaimer: The notes and information presented in this blog post were compiled during the course “Unit Testing and Test Driven Development in Python” and are intended to provide an educational overview of the subject matter for personal use.

--

--

Tanja Adžić

Data Scientist and aspiring Data Engineer, skilled in Python, SQL. I love to solve problems.