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 finally
to 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.