How Not to Write Tests in Python. Part 1

Using global variables in tests

Global variables — these are variables declared outside of functions or classes, and are accessible in any part of the program. In testing, this might look something like this:

# Глобальная переменная
global_state = 0

def increment_global_state():
    global global_state
    global_state += 1

def test_increment_global_state():
    global global_state
    global_state = 0  # сброс состояния
    increment_global_state()
    assert global_state == 1

At first glance, this approach even seems convenient. But that's only until your tests start interacting with each other through these global states.

Global variables can be changed in one test and persist those changes to other tests. This leads to the fact that tests become dependent on each other and the result of one test can affect the result of another.

Global variables violate the single responsibility principle because the logic for changing state is scattered throughout the code rather than encapsulated in one place.

Solutions

Instead of using global variables, pass dependencies explicitly through function parameters or class constructors. This way the tests will be independent and isolated:

def increment_state(state):
    return state + 1

def test_increment_state():
    state = 0
    new_state = increment_state(state)
    assert new_state == 1

If used pytest, fixtures will help to create a predefined state for each test, which gives some isolation and repeatability:

import pytest

@pytest.fixture
def initial_state():
    return 0

def test_increment_state(initial_state):
    state = initial_state
    new_state = increment_state(state)
    assert new_state == 1

Encapsulate states inside objects or use local variables to ensure that changes in one test don't affect others:

class StateManager:
    def __init__(self):
        self.state = 0

    def increment(self):
        self.state += 1
        return self.state

def test_state_manager():
    manager = StateManager()
    assert manager.increment() == 1
    assert manager.increment() == 2

Not using context managers when working with files

There are several ways to open a file in Python. Let's compare the default approach of explicitly closing the file and using context managers.

Opening a file without a context manager:

file = open('example.txt', 'r')
try:
    data = file.read()
finally:
    file.close()

In this example, a file is opened, read, and then explicitly closed using a block finallyto ensure that the file is closed even if an exception occurs.

Opening a file using the context manager:

with open('example.txt', 'r') as file:
    data = file.read()

Context manager with automatically manages the opening and closing of a file. Once a block of code is inside with ends, the file is automatically closed, even if an error occurs inside the block.

Problems

When opening a file without using the context manager, it is easy to forget to call close()especially when exceptions occur. All this can lead to leaking file descriptors, which will eventually exhaust the available system resources.

With the traditional approach, we often have to explicitly close the file, which adds code, makes it more complex, and increases the likelihood of errors.

Solution

Naturally, you need to use it context managers:

with open('example.txt', 'r') as file:
    data = file.read()

Also you can create your own custom context managersusing methods __enter__ And __exit__:

class FileManager:
    def __init__(self, filename, mode):
        self.filename = filename
        self.mode = mode

    def __enter__(self):
        self.file = open(self.filename, self.mode)
        return self.file

    def __exit__(self, exc_type, exc_value, traceback):
        self.file.close()

with FileManager('example.txt', 'r') as file:
    data = file.read()

Mixing different return types in functions

Let's look at another common anti-pattern: mixing different return value types in functions.

Mixing different return types is a situation where a function can return values ​​of different types depending on conditions. For example:

def get_data(condition):
    if condition == 'int':
        return 42
    elif condition == 'str':
        return 'Hello'
    elif condition == 'list':
        return [1, 2, 3]
    else:
        return None

Function get_data returns either an integer, a string, a list, or None. So the user needs to check the return value type every time.

When a function can return different data types, debugging becomes more difficult. Additional checks and conditions will need to be added to correctly handle each possible return value type.

Code that uses such functions becomes difficult to understand. The reader is forced to understand various branches of logic, which makes the code especially difficult to maintain in the long run.

Solution

Try to the function returned values ​​of one type:

def get_data(condition):
    if condition == 'int':
        return 42
    elif condition == 'str':
        return '42'  # возвращаем строку
    elif condition == 'list':
        return ','.join(map(str, [1, 2, 3]))  # возвращаем строку
    else:
        return ''

If a function needs to return complex data, consider using classes or namedtuple to package the data into a single object:

from collections import namedtuple

Result = namedtuple('Result', ['type', 'value'])

def get_data(condition):
    if condition == 'int':
        return Result('int', 42)
    elif condition == 'str':
        return Result('str', 'Hello')
    elif condition == 'list':
        return Result('list', [1, 2, 3])
    else:
        return Result('unknown', None)

If a function cannot return a valid value, it is better to throw an exception than to return a value of a different type:

def get_data(condition):
    if condition == 'int':
        return 42
    elif condition == 'str':
        return 'Hello'
    elif condition == 'list':
        return [1, 2, 3]
    else:
        raise ValueError("Invalid condition")

You can specify multiple data types using Union And Optional:

from typing import Union, Optional

def get_data(condition: str) -> Optional[Union[int, str, list]]:
    if condition == 'int':
        return 42
    elif condition == 'str':
        return 'Hello'
    elif condition == 'list':
        return [1, 2, 3]
    else:
        return None

In conclusion, I would like to remind you about the open lesson on July 22nd.First Step in Django: Create Your First Web Project.” In this lesson you will learn:

  • Django Basics: A quick overview of Django's architecture, installing Django, and creating a new project.

  • Your first application: defining and registering a simple data model, creating a view and route to display information on a page.

  • Working with templates: Use templates to display data in the browser.

Sign up for the lesson using the link.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *