Fixture factory for conciseness

My most beloved pattern for testinfra

George Shuklin
OpsOps
3 min readOct 23, 2021

--

When you have a lot of tests, the last thing you want is to copy-paste non-trivial code again and again.

If you have few copies of ‘almost the same’, refactoring becomes slow and ineffective.

In my case, testinfra is covering more than 30 applications, so, there is a lot of separate test files, and I really want to avoid any unnecessary repetition.

The ideal testinfra test looks like this:

def test_foo_init_time(host):
assert host.get_foo().startup_time < 42

In reality it quickly becomes messy:

import json
from dateutil.parser import isoparse
def test_foo_init_time(host):
foo_path = host.ansible.inventory.get('foo')
with host.sudo():
output = json.loads(host.run(f'{foo_path} status -f json')).stdout
start_time = isoparse(output['daemon']['startup']['time'])
ready_time = isoparse(output['daemon']['mon']['restart_time'])
assert (start_time - ready_time).total_seconds() < 42

Nasty, right? Imagine you have 30 of those ‘foos’ and your code is littered with boilerplate, and refactoring become a boring-yet-demanding marathon.

Embrace factory pattern

There is a pattern in programming, called ‘factory’, when you have a function which ‘generates’ functions. I won’t go into all gory of that pattern and cut it down to the simplest form (it’s a pure Python, no pytest yet).

def factory(foo):
def inner_function():
return foo.result()
return inner_function

And it’s use:

foo_get_result = factory(foo)
print(foo_get_result())

inner_function is a closure, and it captures foo argument for the factory. When factory function returns inner_function (yes, you can have local functions inside a function as a return value) “capture” value of foo and store it ‘somewhere’ (this is the closure, function with ‘something to store values).

In a pure python it looks … redundant. But let’s add some pytest and testinfra!

import json
from dateutil.parser import isoparse
@pytest.fixture(scope='function')
def startup_time(host):
def startup_time(app_name):
app_path = host.inventory.get(app_name)
with host.sudo():
output = json.loads(host.run(f'{foo_path} status -f json')).stdout
start_time = isoparse(output['daemon']['startup']['time'])
ready_time = isoparse(output['daemon']['mon']['restart_time'])
return (start_time - ready_time).total_seconds()
return startup_time

(note, I’m cheating here a bit: ‘inner_function’ here has the same name as outer function, I love this trick).

It does not look nice until you use it:

def test_foo_init_time(startup_time):
assert startup_time('foo') < 42

def test_bar_init_time(startup_time):
assert startup_time('bar') < 12

What a serenity!

But wait, there is more!

conftest.py

If you have a single test file, you can put fixture there. But if you have many, having the same fixture in each file is not much of the difference. What to do?

You can put those fixtures (and those imports!) into conftest.py file in top-level directory for all test files.

In this case, this IS the literal testfile

testinfra_hosts = ['ansible://foobar']
def test_foo_init_time(startup_time):
assert startup_time('foo') < 42

def test_bar_init_time(startup_time):
assert startup_time('bar') < 12

Yep. If you have 30 of those files, this trick is absolute salvation.

Why not imports

The clever reader may suggest to use ‘import’ with startup_time function. Put it as a module, use it as a module. This is Python, isn’t it?

Tests, especially infra tests, are a bit special. They can’t afford to have ‘module’ luxury. If your application is called ‘foo-bar’, your test will be in /tests/foo-bar directory, and that breaks everything (`foo-bar` is invalid name for the module). You can try to wiggle around, but imports are just too demanding to be convenient. Therefore, conftest.py is the best way to store fixtures.

But fixtures can’t be called as function. They only can be passed magically by pytest into tests as arguments. Therefore, ‘factory’ here, is THE single option (factory itself may be implemented in many ways, you may return class, object, module, etc, but the core idea of having closure stays).

--

--

George Shuklin
OpsOps

I work at Servers.com, most of my stories are about Ansible, Ceph, Python, Openstack and Linux. My hobby is Rust.