Test Fixtures

Slash includes a powerful mechanism for parametrizing and composing tests, called fixtures. This feature resembles, and was greatly inspired by, the feature of the same name in py.test.

To demonstrate this feature we will use test functions, but it also applies to test methods just the same.

What is a Fixture?

A fixture refers to a certain piece of setup or data that your test requires in order to run. It generally does not refer to the test itself, but the base on which the test builds to carry out its work.

Slash represents fixtures in the form of arguments to your test function, thus denoting that your test function needs this fixture in order to run:

def test_microwave_turns_on(microwave):
    microwave.turn_on()
    assert microwave.get_state() == STATE_ON

So far so good, but what exactly is microwave? Where does it come from?

The answer is that Slash is responsible of looking up needed fixtures for each test being run. Each function is examined, and telling by its arguments, Slash goes ahead and looks for a fixture definition called microwave.

The Fixture Definition

The fixture definition is where the logic of your fixture goes. Let’s write the following somewhere in your file:

import slash

...

@slash.fixture
def microwave():
    # initialization of the actual microwave instance
    return Microwave(...)

In addition to the test file itself, you can also put your fixtures in a file called slashconf.py, and put it in your test directory. Multiple such files can exist, and a test automatically “inherits” fixtures from the entire directory hierarchy above it.

Fixture Cleanups

You can control what happens when the lifetime of your fixture ends. By default, this happens at the end of each test that requested your fixture. To do this, add an argument for your fixture called this, and call its add_cleanup method with your cleanup callback:

@slash.fixture
def microwave(this):
    returned = Microwave()
    this.add_cleanup(returned.turn_off)
    return returned

Note

Ths this variable is also available globally while computing each fixture as the slash.context.fixture global variable.

Opting Out of Fixtures

In some cases you may want to turn off Slash’s automatic deduction of parameters as fixtures. For instance in the following case you want to explicitly call a version of a base class’s before method:

>>> class BaseTest(slash.Test):
...     def before(self, param):
...         self._construct_case_with(param)

>>> class DerivedTest(BaseTest):
...     @slash.parametrize('x', [1, 2, 3])
...     def before(self, x):
...         param_value = self._compute_param(x)
...         super(DerivedTest, self).before(x)

This case would fail to load, since Slash will assume param is a fixture name and will not find such a fixture to use. The solution is to use slash.nofixtures() on the parent class’s before method to mark that param is not a fixture name:

>>> class BaseTest(slash.Test):
...     @slash.nofixtures
...     def before(self, param):
...         self._construct_case_with(param)

Fixture Needing Other Fixtures

A fixture can depend on other fixtures just like a test depends on the fixture itself, for instance, here is a fixture for a heating plate, which depends on the type of microwave we’re testing:

@slash.fixture
def heating_plate(microwave):
    return get_appropriate_heating_plate_for(microwave)

Slash takes care of spanning the fixture dependency graph and filling in the values in the proper order. If a certain fixture is needed in multiple places in a single test execution, it is guaranteed to return the same value:

def test_heating_plate_usage(microwave, heating_plate):
    # we can be sure that heating_plate matches the microwave,
    # since `microwave` will return the same value for the test
    # and for the fixture

Fixture Parametrization

Fixtures become interesting when you parametrize them. This enables composing many variants of tests with a very little amount of effort. Let’s say we have many kinds of microwaves, we can easily parametrize the microwave class:

@slash.fixture
@slash.parametrize('microwave_class', [SimpleMicrowave, AdvancedMicrowave]):
def microwave(microwave_class, this):
    returned = microwave_class()
    this.add_cleanup(returned.turn_off)
    return returned

Now that we have a parametrized fixture, Slash takes care of multiplying the test cases that rely on it automatically. The single test we wrote in the beginning will now cause two actual test cases to be loaded and run – one with a simple microwave and one with an advanced microwave.

As you add more parametrizations into dependent fixtures in the dependency graph, the actual number of cases being run eventually multiples in a cartesian manner.

Fixture Requirements

It is possible to specify requirements for fixture functions, very much like test requirements. Fixtures for which requirements are not met will prevent their dependent tests from being run, being skipped instead:

@slash.fixture
@slash.requires(condition, 'Requires a specific flag')
def some_fixture():
    ...

See also

Requirements

Fixture Scopes

By default, a fixture “lives” through only a single test at a time. This means that:

  1. The fixture function will be called again for each new test needing the fixture
  2. If any cleanups exist, they will be called at the end of each test needing the fixture.

We say that fixtures, by default, have a scope of a single test, or test scope.

Slash also supports session and module scoped fixtures. Session fixtures live from the moment of their activation until the end of the test session, while module fixtures live until the last test of the module that needed them finished execution. Specifying the scope is rather straightforward:

@slash.fixture(scope='session')
def some_session_fixture(this):
    @this.add_cleanup
    def cleanup():
        print('Hurray! the session has ended')


@slash.fixture(scope='module')
def some_module_fixture(this):
    @this.add_cleanup
    def cleanup():
        print('Hurray! We are finished with this module')

Test Start/End for Widely Scoped Fixtures

When a fixture is scoped wider than a single test, it is useful to add custom callbacks to the fixtures to be called when a test starts or ends. This is done via the this.test_start and this.test_end callbacks, which are specific to the current fixture.

@slash.fixture(scope='module')
def background_process(this):
    process = SomeComplexBackgroundProcess()

    @this.test_start
    def on_test_start():
        process.make_sure_still_running()

    @this.test_end
    def on_test_end():
        process.make_sure_no_errors()

    process.start()

    this.add_cleanup(process.stop)

Note

Exceptions propagating out of the test_start or test_end hooks will fail the test, possibly preventing it from starting properly

Autouse Fixtures

You can also “force” a fixture to be used, even if it is not required by any function argument. For instance, this example creates a temporary directory that is deleted at the end of the session:

@slash.fixture(autouse=True, scope='session')
def temp_dir():
    """Create a temporary directory"""
    directory = '/some/directory'
    os.makedirs(directory)

    @this.add_cleanup
    def cleanup():
        shutil.rmtree(directory)

The use_fixtures Decorator

In some cases, you may want to use a certain fixture but don’t need its return value. In such cases, rather than using the fixture as an unused argument to your test function you can use the use_fixtures decorator. This decorator receives a list of fixture names and indicates that the decorated test needs them to run:

@slash.fixture()
def used_fixture1():
    """do something"""
    pass

@slash.fixture()
def used_fixture2():
    """do another thing"""
    pass

@slash.use_fixtures(["used_fixture1, used_fixture2"])
def test_something():
    pass

Aliasing Fixtures

In some cases you may want to name your fixtures descriptively, e.g.:

@slash.fixture
def microwave_with_up_to_date_firmware(microwave):
    microwave.update_firmware()
    return microwave

Although this is a very nice practice, it makes tests clumsy and verbose:

def test_turning_off(microwave_with_up_to_date_firmware):
    microwave_with_up_to_date_firmware.turn_off()
    assert microwave_with_up_to_date_firmware.is_off()
    microwave_with_up_to_date_firmware.turn_on()

Fortunately, Slash allows you to alias fixtures, using the slash.use() shortcut:

def test_turning_off(m: slash.use('microwave_with_up_to_date_firmware')):
    m.turn_off()
    assert m.is_off()
    m.turn_on()

Note

Fixture aliases rely on function argument annotation

Misc. Utilities

Yielding Fixtures

Fixtures defined as generators are automatically detected by Slash. In this mode, the fixture is run as a generator, with the yielded value acting as the fixture value. Code after the yield is treated as cleanup code (similar to using this.add_cleanup):

@slash.fixture
def microwave(model_name):
    m = Microwave(model_name)
    yield m
    m.turn_off()

Generator Fixtures

slash.generator_fixture() is a shortcut for a fixture returning a single parametrization:

@slash.generator_fixture
def model_types():
    for model_config in all_model_configs:
        if model_config.supported:
            yield model_config.type

In general, this form:

@slash.generator_fixture
def fixture():
    yield from x

is equivalent to this form:

@slash.fixture
@slash.parametrize('param', x)
def fixture(param):
    return param

Listing Available Fixtures

Slash can be invoked with the list command and the --only-fixtures flag, which takes a path to a testing directory. This command gets the available fixtures for the specified testing directory:

$ slash list –only-fixtures path/to/tests

temp_dir

Create a temporary directory

Source: path/to/tests/utilities.py:8