Testing

Three pytest Features You Will Love

One of the most popular frameworks for Python is pytest, and it comes with several cool features. I’m going to show you three of them in this blog post:

  • Fixtures
  • Markers
  • Parametrize

As you’d expect, PyCharm has full support for pytest, including a dedicated test runner, code completion, and code navigation. To get started with pytest in PyCharm, you need to install and enable pytest as your test runner.

Get started for free

Fixtures

It’s not unusual to set up resources and conditions for our tests and then tear them down after our tests have finished. Fixtures in pytest allow us to set up these resources and conditions in a consistent, reliable, and repeatable way. This can include mocking/stubbing, database connections, file creation, dependency injection, and more. 

By automating your test preconditions in this way, you can better organize your code to ensure that your tests focus on business logic, not the setup.

In order to tell pytest that some code is a fixture, we need to add the @pytest.fixture decorator. In this example, we are using a fixture to get a character list, which is required for the function test_get_minimum_height.

@pytest.fixture
def fake_characters():
   return [
       {"name": "Luke", "height": 100},
       {"name": "Leia", "height": 50},
   ]

def test_get_minimum_height(fake_characters):
   result = swapi.get_minimum_height(fake_characters, 75)
   assert len(result) == 1

Fixtures in pytest enable you to maximize code reusability, be specific about your test set-up and tear-down, and define the scope you want them to apply across your test suite.

For example, you can use the decorator @pytest.fixture(scope="module") in this context:

@pytest.fixture(scope=”module”)
def fake_characters():
   return [
       {"name": "Luke", "height": 100},
       {"name": "Leia", "height": 50},
   ]

def test_get_minimum_height(fake_characters):
   result = swapi.get_minimum_height(fake_characters, 75)
   assert len(result) == 1

Now the fake_characters fixture is set up at the start of the test file or module and is cached for future use. All of our test functions will share the same instance of the fixture.

Markers

Sometimes one size doesn’t fit all. Perhaps we need to run a subset of tests when a certain condition is true or skip other tests when a different condition is false. We can do both of these with pytest. 

Let’s look at built-in pytest markers first. In this example, we’ve added the decorator @pytest.mark.skip(reason="skipping while I hunt bug-18463") to tell pytest not to run this test. You shouldn’t routinely skip tests, but sometimes it’s a good tool to have. For example, it’s more helpful than commenting out your test and accidentally checking that in to version control!

@pytest.mark.skip(reason="skipping while I hunt bug-18463")
def get_shortest(self, threshold):
   return [character for character in self if int(character['height']) < threshold]

You can also skip tests based on certain conditions by using the -skipif argument. 

For example, you can decorate a test with @pytest.mark.skipif() to say that a test does (or doesn’t) run on a specific operating system:

@pytest.mark.skipif(platform.system() == 'Darwin', reason="Test doesn't run on macOS")
def test_get_characters(fake_characters):
   assert fake_characters[0]["name"] == "Luke"

The above code would tell pytest to skip this test if the operating system is macOS, but run it for other operating systems. As a side note, the macOS identifier here is ‘Darwin’ because of its Linux heritage. You can check the identifier by using the platform.system() method. 

Another use for @pytest.mark.skipif is to define a minimum Python version for the test:

@pytest.mark.skipif(sys.version_info < (3, 9), reason="Test only runs on Python 3.9 and higher")
def test_get_characters(fake_characters):
   assert fake_characters[0]["name"] == "Luke"

This code says that the test only runs on Python 3.9 or higher. You can view a list of the built-in markers in pytest by running the following command in your terminal:

$ pytest --markers

You can also add your own custom metadata to markers. For example, we can add the pytest.mark.comparison() decorator to this test:

@pytest.mark.height(reason="This is a height test")
def get_shortest(self, threshold):
   return [character for character in self if int(character['height']) < threshold]

When we run this test, pytest will only run tests decorated with this marker by specifying the marker (height):

$ pytest -m height

While pytest will run your test for you, it will also warn you that custom marks should be registered in your configuration file. The pytest documentation for custom markers has instructions on how to do this. 

Parametrize

@parametrize is another marker decorator in pytest that allows you to tell pytest to run the same test with different input parameters. That means if you have a function that accepts different inputs and expected outputs, you can write one test and use the @parametrize decorator, which vastly simplifies your code and improves the readability.

For example, in this code, we have used @pytest.mark.parametrize() to pass three lots of arguments into the test_get_minimum_heights() test. 

def test_get_minimum_height(fake_characters):
   result = swapi.get_minimum_height(fake_characters, 75)
   assert len(result) == 1
@pytest.mark.parametrize('threshold, count', [
   [120, 0],
   [75, 1],
   [200, 2],
])
def test_get_minimum_heights(fake_characters, threshold, count):
   result = swapi.get_minimum_height(fake_characters, threshold)
   assert len(result) == count

When we run this test, pytest runs it three times, treating each run as a separate test:

The @parametrize decorator is helpful when you want one test to receive multiple inputs and expected outcomes.

Resources and further reading

If you want to learn more about fixtures, markers, and parametrize in pytest, here are some resources that you can check out:

image description