What Are Pytest Fixture Scopes? (How To Choose The Best Scope For Your Test)

Writing repeat code is likely one of the things you least enjoy. At least it’s the case for me.

In a world striving for time, cost and resource efficiency less is more.

How can we apply this same philosophy to Unit Testing?

We often need to reuse resources (like input data, database connections, shared objects and so on) across our Unit Tests.

Pytest fixtures are a powerful tool for writing modular and reusable tests.

They allow you to define functions that provide data or resources to your tests, without having to repeat the setup and teardown code in each test function.

This saves a lot of time and makes tests readable, maintainable and faster.

It’s nice to share fixtures, but what about the state management, set up and teardown?

Should you start and stop a DB connection n times? what about resetting an authentication cookie?

Pytest fixture scopes control how often a fixture is set up and torn down during your test session.

In this article, we delve into the realm of Pytest fixture scopes, uncovering their significance and ways to revolutionize your testing approach.

We’ll explore the various scopes using a Real Example so you become crystal clear on how this works and how to leverage the power of fixtures.

You’ll not only enhance the reliability of your test suite but also elevate your overall development process.

Let’s get into it.

Link To GitHub Repo

About Pytest Fixtures

As we’ve already covered Pytest Fixtures in great depth in another article, we’ll do a brief overview here.

Imagine you’re testing a piece of code that needs a database connection or a specific configuration.

Instead of repeating the code in every test, you can create a Pytest Fixture that handles this setup once and shares it across all tests.

This not only reduces redundancy but also ensures consistent test conditions.

You can define fixtures in a central place and use them across multiple test files, maintaining a uniform testing environment.

We’ll define fixtures using conftest.py so I highly recommend you review this article on Pytest Conftest if you’re not familiar with it.

Objectives

By the end of this tutorial, you should

  • Have a strong understanding of Pytest fixtures and how to leverage them to write robust, concise, readable and maintainable unit tests.
  • Learn and understand Pytest fixture scopes and be able to use them to optimise your test suite.

With this in mind, let’s dive in.

Pytest Fixture Scope Levels

Fixture scopes in Pytest control the lifetime of fixtures. They define how often a fixture gets created and destroyed.

Pytest provides four levels of fixture scopes:

  • Function (Set up and torn down once for each test function)
  • Class (Set up and torn down once for each test class)
  • Module (Set up and torn down once for each test module/file)
  • Session (Set up and torn down once for each test session i.e comprising one or more test files)

Fixture scopes control how often a fixture is set up and torn down.

The default scope is function, which means that the fixture is set up and torn down for each test function that uses it.

This is fine for most fixtures, but it can be inefficient for fixtures that are expensive to set up or tear down.

By harnessing fixture scopes effectively, you can prevent tests from taking ages to initialize, interfering with each other and achieving optimal testing performance.

This all sounds good in theory, how do you apply it practically?

That’s what we’ll discuss in the next bit.

Project Set Up

Prerequisites

To achieve the above objectives and wholly understand Pytest fixture scopes, the following is recommended:

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

Getting Started

Here’s our repo structure

pytest-fixture-scope-repo

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 any requirements (packages) using

1
pip install -r requirements.txt

Example Code

To better understand various Pytest fixture scopes, let’s look at some example code.

Let’s go straight to our conftest.py file.

tests/conftest.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pytest  
import smtplib

@pytest.fixture(scope='function', autouse=True)
def smtp_connection() -> smtplib.SMTP:
"""
A fixture to create an SMTP connection.

Returns:
An SMTP connection
"""
print("SMTP Connection fixture start")
yield smtplib.SMTP("smtp.gmail.com", 587, timeout=60)
print("SMTP Connection Tear Down")

This here is a simple Pytest fixture that established an SMTP connection to the Gmail server using the smtplib package.

Simple Mail Transfer Protocol (SMTP) is an Internet standard communication protocol for email transmission.

The science and working of SMTP are not too relevant to this article and I’ve used it mainly due to the network latency which is ideal to showcase the workings of Pytest fixture scopes.

This fixture starts a connection and tears it down after use.

Our test file contains 2 simple tests

  1. Test EHLO (Extended hello)
  2. Test NOOP (No operation)

The workings are not relevant, just keep in mind that these are some health and connection checks to the Gmail server.

tests/test_smtp.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
def test_ehlo(smtp_connection) -> None:  
"""
Test the SMTP EHLO command

Args:
smtp_connection: A fixture to create an SMTP connection

Returns:
None
"""
response, msg = smtp_connection.ehlo()
print("Test EHLO 1 ")
assert response == 250
assert b"smtp.gmail.com" in msg

def test_noop(smtp_connection) -> None:
"""
Test the SMTP NOOP command

Args:
smtp_connection: A fixture to create an SMTP connection

Returns:
None
"""
response, msg = smtp_connection.noop()
print("Test NOOP 1 ")
assert response == 250
assert b"OK" in msg

The smtp_connection fixture is passed to these tests and we assert the response code and message.

Pytest Fixture Scope — Function

By setting the scope='function' in the smtp_connection fixture, let’s run the Unit Test.

pytest-fixture-scope-function

We can see from the SMTP Connection fixture start and SMTP Connection Tear Down print statements that our fixture was started/torn down for each individual test function.

This is the default fixture scope in Pytest. I’m not too sure why it was selected but perhaps to ensure isolation for each test function.

While this may be beneficial for fewer tests, and where maintaining state is not essential, it’s slow and inefficient if you need to share fixtures.

So now it’s clear how the function scope works, how can we share the smtp_connection fixture?

Pytest Fixture Scope — Module

To clearly illustrate the working of module scope, let’s first change the scope in the fixture.

tests/conftest.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pytest  
import smtplib

@pytest.fixture(scope='module', autouse=True)
def smtp_connection() -> smtplib.SMTP:
"""
A fixture to create an SMTP connection.

Returns:
An SMTP connection
"""
print("SMTP Connection fixture start")
yield smtplib.SMTP("smtp.gmail.com", 587, timeout=60)
print("SMTP Connection Tear Down")

A module in Pytest is a single test file.

Let’s make a copy of the tests/test_smtp.py file. You’ll see why in a minute.

tests/test_smtp2.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
def test_ehlo(smtp_connection) -> None:  
"""
Test the SMTP EHLO command

Args:
smtp_connection: A fixture to create an SMTP connection

Returns:
None
"""
response, msg = smtp_connection.ehlo()
print("Test EHLO 2 ")
assert response == 250
assert b"smtp.gmail.com" in msg


def test_noop(smtp_connection) -> None:
"""
Test the SMTP NOOP command

Args:
smtp_connection: A fixture to create an SMTP connection

Returns:
None
"""
response, msg = smtp_connection.noop()
print("Test NOOP 2 ")
assert response == 250
assert b"OK" in msg

The purpose of this second file is merely to have more tests that use the same fixture.

We’ve used messages such as “Test EHLO 2” and “Test NOOP 2” to separate the two.

Now let’s run Pytest

pytest-fixture-scope-module

Observing the execution run, you can see that the fixture was started and maintained its state during the tests in a single module (or Pytest file) before finally being torn down.

You can see how this works for 2 test files. Each test file/module gets its own version of the fixture.

Pytest Fixture Scope — Session

Extending the same but with session scope, let’s see what happens.

pytest-fixture-scope-session

The results are intriguing.

The fixture stayed up during the entire test session i.e. multiple test modules used the same fixture.

You can see how interesting this is. Especially when dealing with fixtures like database or network connections.

Pytest Fixture Scope — Class

Now what if we don’t need the fixture at the module or session level but we’re dealing with Pytest classes?

Can you share the fixture with a class?

If we used the function scope, our fixture would be reset per function, within the class.

Indeed we can solve this using the class scope.

tests/test_smtp.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
class TestSMTP1:  
def test_ehlo(self, smtp_connection) -> None:
"""
Test the SMTP EHLO command in a class

Args:
smtp_connection: A fixture to create an SMTP connection

Returns:
None
"""
response, msg = smtp_connection.ehlo()
print("Test EHLO 1 in Class...")
assert response == 250
assert b"smtp.gmail.com" in msg

def test_noop(self, smtp_connection) -> None:
"""
Test the SMTP NOOP command in a class

Args:
smtp_connection: A fixture to create an SMTP connection

Returns:
None
"""
response, msg = smtp_connection.noop()
print("Test NOOP 1 in Class...")
assert response == 250
assert b"OK" in msg

pytest-fixture-scope-class

Running this test you can see the fixture being shared across the class.

Note — If you use a fixture with class scope in a standard Pytest function it works the same way as function scope, however, the function scope fixture holds greater priority than class scope.

Now that we’ve learnt so much about Pytest fixture scopes, how would you choose the best scope for your Unit Tests?

Choosing The Right Fixture Scope

Choosing the right scope for your Pytest fixtures is important to ensure that your tests run efficiently, reliably, and with the desired level of isolation.

The scope determines how long a fixture’s setup and teardown code is executed in relation to your test functions.

Here are a few thumb rules.

Function Scope (default):

  • Use function scope when the fixture data should be isolated for each test function.
  • Recommended for fixtures that are quick to set up and tear down and don’t have side effects that affect other tests.

Class Scope:

  • Use class scope when you want to share fixture data across all test methods within a test class.
  • Useful for reducing setup and teardown overhead when multiple test methods need the same initial state.
  • Be cautious of potential side effects between test methods in the same class.

Module Scope:

  • Use module scope when the fixture data needs to be shared across all test functions within a module.
  • Suitable for reducing setup and teardown overhead when multiple test functions require the same initial state.
  • Be mindful of potential interactions between different modules.

Session Scope:

  • Use session scope when the fixture data needs to be shared across all test functions in the entire test session.
  • Useful for scenarios where setting up the data is time-consuming and it’s more efficient to reuse the same data.
  • Be aware of potential side effects and interactions, as changes made by one test could impact subsequent tests.

When deciding on the scope, consider the following factors:

  • Isolation: How isolated should the fixture data be between different test functions?
  • Performance: How much setup and teardown overhead is associated with the fixture?
  • Dependencies: Are there dependencies between fixtures or tests that require a specific scope?
  • Side Effects: Could changes made by a test affect other tests?
  • Data Integrity: Does the fixture data need to be consistent across a single test run or multiple runs?
  • Efficiency: Can you reuse setup data to improve test execution speed?

Execution Order Of Pytest Fixture Scopes

In Pytest, fixture scopes dictate the order in which fixture setup and teardown functions are executed in relation to test functions.

The execution order follows a hierarchy based on fixture scope levels: session, module, class, and function.

  1. Session Scope: Set up once per entire test session and teardown after all tests are complete. Shared across all test functions and modules.
  2. Module Scope: Setup and teardown occur once per module. Shared among all test functions within the same module.
  3. Class Scope: Executes fixture setup and teardown per test class. Shared across all methods within the class.
  4. Function Scope (Default): Runs fixture setup and teardown before and after each individual test function. Provides maximum isolation.

Fixture setup is performed in the reverse order of fixture scope levels, ensuring dependencies are satisfied.

Teardown follows the same order but in the reverse direction.

Careful consideration of fixture scope ensures efficient, isolated, and reliable test execution.

Reusing The Same Fixture With Different Scopes?

How about if you want to use the same fixture with different scopes?

For sure, you could define it several times, and change the scope parameter. But is there a more elegant way?

Sure there is. And the Pytest community has found it.

tests/conftest.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import pytest  
import smtplib
from contextlib import contextmanager

# Demonstrate how to share a base fixture with different scopes
@contextmanager
def smtp_connection_base() -> smtplib.SMTP:
"""
A fixture to create an SMTP connection.

Returns:
An SMTP connection
"""
print("SMTP Connection fixture start")
yield smtplib.SMTP("smtp.gmail.com", 587, timeout=60)
print("SMTP Connection Tear Down")

# Fixture with module scope
@pytest.fixture(scope="module")
def smtp_connection():
with smtp_connection_base() as result:
yield result

Let’s say we have a base fixture smtp_connection_base which contains the logic for the fixture.

We can now extend it using the @contextmanager ,

1
2
with smtp_connection_base() as result:  
yield result

Here we’ve set the scope of the smtp_connection fixture as module but you can have one for function , class scopes and so on.

This way we only have to define and maintain the base fixture and don’t have to change the code in 4 places should the fixture logic change.

This issue on the Pytest GitHub nicely covers it.

Conclusion

In this article, we’ve covered a lot on Pytest fixture scopes.

While initially confusing, after playing around with the various scopes it starts to make total sense and leveraging it becomes a superpower.

Not just for local testing but also in your CI/CD pipelines.

We covered the various Pytest fixture scopes and the difference between set up and tear down using a real example while defining fixtures in conftest.py .

We also learnt about the execution order and lastly how to define the fixture once and extend its scope without redefining it.

Last but not least we learnt how to choose fixture scopes based on your test use case.

It all depends on how expensive fixtures are to set up / tear down and your test isolation needs. With this knowledge you should be able to write faster, maintainable and more readable tests.

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

pytest fixtures: explicit, modular, scalable - pytest documentation

How I Use Pytest Fixtures for System Testing

Five Advanced Pytest Fixture Patterns

https://www.testim.io/blog/using-pytest-fixtures