How to Effortlessly Generate Unit Test Cases with Pytest Parameterized Tests

If you’re a developer using TDD (test-driven development), you’re familiar with the importance of Unit Tests.

Perhaps you want to test several sets of input data and edge cases. Imagine writing a unit test for each of the infinite inputs.

Fortunately, there’s a better way.

Pytest parameterized testing is a powerful technique that allows you to efficiently run the same tests with multiple sets of input data, eliminating the need for redundant test code.

With parameterized testing, you can easily cover different scenarios and edge cases and provide better test coverage.

In this article, we will explore the benefits of Pytest parameterized tests and how it simplifies the process of writing comprehensive and concise tests.

We’ll look at how to do parameterized testing of functions, classes and even fixtures.

We’ll see how fixtures differ from parameters and how to parameterize your fixtures with varying input data.

Lastly, we’ll also learn how to mark parameterized tests with markers such as xfail and skip.

With this knowledge, you’ll be able to write robust tests that have higher coverage, less duplication and improved readability.

Let’s get into it.

Link To GitHub Repo

Objectives

By the end of this tutorial, you should

  • Have a foundational understanding of Pytest parameters
  • Be able to leverage Pytest parameterized tests to write concise tests with comprehensive coverage
  • Understand the difference between fixtures and parameters and how to parameterize fixtures
  • Parameterise testing functions, classes and fixtures with one or more input values.
  • Use parameters to cover a wide range of test inputs and write robust tests.

Before we dive into some code examples, let’s look at what Pytest parameters are and why they are used.

What Are Pytest Parameters?

Pytest parameters, in the context of the Pytest testing framework, are a powerful feature to create flexible and efficient test cases.

You can define test functions that accept different input data sets, enabling the generation of multiple test cases from a single test function.

By using decorators such as @pytest.mark.parametrize, you can specify the inputs and expected outputs for your test cases.

Parameters can be any type of value, such as numbers, strings, lists, or dictionaries.

The decorator takes care of iterating through the data, executing the test function multiple times with different inputs, and ensuring each case is thoroughly tested.

This approach simplifies test creation, reduces code duplication, and enhances test coverage.

Here is an interesting way to think about pytest parameters:

  • Imagine that you are a doctor testing a new drug. You want to make sure that the drug works for various patients, so you give it to different people with different conditions. The different patients are like the different parameters in a pytest test.
  • Or, imagine that you are a software engineer testing a new website. You want to make sure that the website works correctly for different browsers, so you test it with different browsers. The different browsers are like the different parameters in a pytest test.

These simple analogies help us to understand the concept of parameters and how they apply within your TDD framework.

You may ask, why not hard code the input values within your test? Or run multiple assertions?

OK, imagine you had 1000 unit tests, each one with 10 inputs. Now that would mean writing a LOT OF code.

Not to mention really messy and hard to maintain during the lifecycle of the project.

Pytest parameterized tests allow you to define a wide range of inputs and apply them to your tests.

We covered generating test input data in our article on Pytest Hypothesis testing so I encourage you to take a look (although it’s more advanced).

Difference Between Pytest Fixtures And Parameters

If you read this website, you’ll probably see the use of Pytest Fixtures more than once.

That’s because fixtures are an important and common functionality of Pytest.

Let’s look at exactly what the difference is between the two.

FeatureFixturesParameters
PurposeTo set up and tear down resourcesTo pass values to tests
SharingCan be shared across multiple testsCannot be shared across multiple tests
Type of valuesAny type of valueNumbers, strings, lists, dictionaries
ScopeVarious scopes, such as function, class, module, or sessionPrimarily used within a test function and don’t have specific scopes
UsageUtilized by test functions through function argumentsDefined using pytest markers and Pytest generates separate test instances, each using a different set of parameters.

In summary, fixtures are used for test setup and resource management, while parameters enable data-driven testing.

Both features contribute significantly to making test code clean, concise, and maintainable.

Project Set Up

Getting Started

The project has the following structure

pytest-parameterized-tests-repo

Prerequisites

To achieve the above objectives, the following is recommended:

  • Basic knowledge of the Python programming language
  • Basics of Pytest and fixtures

To get started, clone the repo here, or you can create your own repo by creating a folder and running git init to initialise it.

In this project, we’ll be using Python 3.11.4.

Create a virtual environment and install the requirements (packages) using

1
pip install -r requirements.txt

Example Code

To get a better understanding of Pytest parameterised testing, let’s look at some example code.

src/examples.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Mathematical Functions  
def addition(a: int | float, b: int | float) -> int | float:
return a + b

def subtraction(a: int | float, b: int | float) -> int | float:
return a - b

# String Functions
def reverse_string(string: str) -> str:
return string[::-1]

def capitalize_string(string: str) -> str:
return string.upper()

def clean_string(string: str) -> str:
return string.strip().lower()

Here we have a few simple functions.

  1. Mathematical functions — addition, subtraction
  2. String functions — reverse, capitalize, clean

Let’s look at how to test the above using Pytest parameters.

Test Example Code

Navigating to the /tests/unit directory you’ll see 3 files

  1. test_function_parameterization.py
  2. test_class_parameterization.py
  3. test_fixture_parameterization.py

We’ll go through each file and what tests they contain.

Let’s start with test_function_parameterization.py

Parameterizing Functions

tests/unit/test_function_parameterization.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
"""  
Test Function Parameterization.
"""
import pytest
from src.examples import (
addition,
subtraction,
reverse_string,
capitalize_string,
clean_string,
)

# Test Math Functions
@pytest.mark.parametrize( "a, b, expected",
[
(1, 2, 3),
(5, -1, 4),
],)
def test_addition(a, b, expected):
assert addition(a, b) == expected


@pytest.mark.parametrize( "a, b, expected",
[
(1, 2, -1),
(5, -1, 6),
],)
def test_subtraction(a, b, expected):
assert subtraction(a, b) == expected


# Test String Functions
@pytest.mark.parametrize( "string, expected",
[
("hello", "olleh"),
("world", "dlrow"),
],)
def test_reverse_string(string, expected):
assert reverse_string(string) == expected


@pytest.mark.parametrize( "string, expected",
[
("hello", "HELLO"),
("world", "WORLD"),
],)
def test_capitalize_string(string, expected):
assert capitalize_string(string) == expected


@pytest.mark.parametrize( "string, expected",
[
(" hello ", "hello"),
(" WoRlD ", "world"),
],)
def test_clean_string(string, expected):
assert clean_string(string) == expected

The above code includes unit tests for the various math and string functions.

What’s new is the use of the @pytest.mark.parameterize parameter.

Let’s break this down.

1
2
3
4
5
@pytest.mark.parametrize( "a, b, expected",  
[
(1, 2, 3),
(5, -1, 4),
],)

In the above decorator, we define 2 arguments a and b and the expected response.

We then proceed to define a list containing tuples where each element of the tuple is a value of a , b and expected (in that order).

You can include as many tuples as you wishn within the list. Pytest will then execute a test session for each of the input arguments.

It will first evaluate a=1 , b=2 and expected=3 so on.

This way, we’re able to test several inputs using the same test code.

pytest-parameterized-tests-functions

Parameterizing Classes

Similar to functions, we can pass parameters to classes. Here’s a simple example.

src/examples.py

1
2
3
4
5
6
7
8
9
10
# Class Example  
class Greeting:
def __init__(self, name: str) -> None:
self.name = name

def say_hello(self) -> str:
return f"Hello {self.name}"

def say_goodbye(self) -> str:
return f"Goodbye {self.name}"

The above example code contains a class with some method to print a greeting message.

Parameters can be passed to the class directly like this.

tests/unit/test_class_parameterization.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
"""  
Test Class Parameterization.
"""
from src.examples import Greeting
import pytest

@pytest.mark.parametrize( "name",
[
"John Doe",
"Jane Doe",
"Foo Bar",
],)
class TestGreeting:
"""Test the Greeting class."""

def test_say_hello(self, name):
"""Test the say_hello method."""
greeting = Greeting(name)
assert greeting.say_hello() == f"Hello {name}"

def test_say_goodbye(self, name):
"""Test the say_goodbye method."""
greeting = Greeting(name)
assert greeting.say_goodbye() == f"Goodbye {name}"

Here we have passed a list of names to the class directly as the parameter.

Pytest will run tests for each of the names in the list. You can see how extremely powerful this is.

pytest-parameterized-tests-classes

Parameterizing Fixtures

From the above section, we understood the differences between fixtures and parameters.

That’s great. Fixtures are very powerful and sharing them across tests enables us to pass valuable code and states.

But what if we could combine the two? Parameterised fixtures!

If you’re as excited as I am, yes it is possible. Let’s see how.

Let’s define a fixture in our conftest file.

If you’re unfamiliar with conftest I highly recommend checking this article on Pytest Conftest where we go into depth explaining how Pytest conftest allows you to share fixtures between tests.

tests/unit/conftest.py

1
2
3
4
@pytest.fixture()  
def user(name, username, password) -> dict:
"""Return a user object."""
return {"name": name, "username": username, "password": password}

The above fixture is a simple one and given some properties, returns a user object.

We can pass parameters to this fixture as below.

tests/unit/test_fixture_parameterization.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"""  
Test Fixture Parameterization.
"""
import pytest

@pytest.mark.parametrize( "name, username, password",
[
("John Doe", "johndoe", "password"),
("Jane Doe", "janedoe", "secret"),
("Foo Bar", "foobar", "barfoo"),
],)
def test_login(user, name, username, password):
"""Test the login functionality with different users."""
assert user["name"] == name
assert user["username"] == username
assert user["password"] == password

This will conveniently pass various inputs of the name , username and password to the user fixture on each test run.

pytest-parameterized-tests-fixtures

Generating Test Input Data

Using range()

Sometimes hardcoding the parameter values themselves may not be enough for your use case.

How about if you wanna generate your own input fixtures using the Python inbuilt range function?

You can easily do that too like this

tests/unit/test_function_parameterization.py

1
2
3
4
5
# Parameterized testing with 2 arguments and ranges  
@pytest.mark.parametrize("a", range(5))
@pytest.mark.parametrize("b", range(10))
def test_addition_2_args(a, b):
assert addition(a, b) == a + b

This is another way to do it and gives us granular control over the a and b parameters. And you don’t have to use range() you can use anything.

pytest-parameterized-tests-range

Using pytest_generate_tests

Appreciate that all test inputs are not integers and the range() function is less useful in other cases.

Pytest also has an inbuilt hook called pytest_generate_tests which receives the metafunc object as an argument.

The metafunc object provides information about the test function, such as its name, the arguments it takes, and the fixtures it requires.

You can use the metafunc object to generate parametrized tests by calling the metafunc.parametrize() method.

For e.g.

tests/unit/test_function_parameterization.py

1
2
3
4
5
6
7
8
# Using Pytest generate tests to create tests  
def pytest_generate_tests(metafunc):
if 'x' in metafunc.fixturenames and 'y' in metafunc.fixturenames:
metafunc.parametrize('x', [1, 2, 3])
metafunc.parametrize('y', [5, 6, 7])

def test_addition_generate_tests(x, y):
assert addition(x, y) == x + y

This code will create three new test functions, one for each combination of the values 1, 2, and 3 for x and the values '5', '6', and '7' for y.

pytest-parameter-generate-tests

We can then use them in our unit test.

Although this works, I find using the @pytest.mark.parametrize marker way cleaner and more convenient.

Apply Markers To Individual Tests When Using Parameters

How about if you want to use Pytest parameters, but still use markers like xfail or skip test?

If you need a quick refresher, this article on Pytest Xfail and Skip test covers how to intentionally fail or skip tests.

Let’s see how we can mark tests when using Pytest parameters.

tests/unit/test_function_parameterization.py

1
2
3
4
5
6
7
8
9
# Simple test to demo how to mark a pytest parameter test  
@pytest.mark.parametrize( "string, expected",
[
(" hello ", "hello"),
(" WoRlD ", "world"),
pytest.param(None, 42, marks=pytest.mark.xfail(reason="some bug")),
],)
def test_clean_string_marked(string, expected):
assert clean_string(string) == expected

In this case, we’ve passed 3 input parameters to string and the 3rd one is actually None and expects an int 42 i.e. intentionally fail.

Here we have marked the test to fail using Pytest xfail with a reason.

pytest-parameterized-tests-xfail-marker

This can be handy when you want to skip tests (perhaps for a different OS) or fail them (perhaps not ready).

The official documentation contains more information.

Conclusion

OK, that’s a wrap.

In this article, we covered how to dynamically generate test cases using Pytest parameterised testing.

You learnt the difference between Pytest fixtures and parameters and how to parameterise functions, classes and fixtures.

Lastly, we learnt how to generate test data and mark individual tests when using parameters with markers like xfail and skip .

Leveraging the knowledge of Pytest parameters and Hypothesis, you’ll be in a great position to improve your test coverage, catch outliers and produce well-tested Python code.

If you have ideas for improvement or like for me to cover anything specific, please send me a message via Twitter, GitHub or Email.

Till the next time… Cheers!

Additional Reading

How to parametrize fixtures and test functions - pytest documentation

Deepdive into Pytest parametrization

Python parametrized testing - The Blue Book

https://miguendes.me/how-to-use-fixtures-as-arguments-in-pytestmarkparametrize