pytest
Integration
What is pytest
?
pytest
is a Python testing framework designed primarily for testing Python
libraries and applications (docs).
We have repurposed it for testing what its perspective is an “external program”, HTCondor.
Because of its plugin-oriented architecture, this does not involve “hacking on the internals”,
and so far, all of the necessary repurposing has been accomplished by plugin-style code.
Test Suite Execution
A pytest
test suite is a directed acyclic graph (DAG) with two kinds of nodes:
tests and fixtures. Tests and fixtures may depend on fixtures, but neither can
depend on tests.
Thus, tests form the leaves the DAG, while the fixtures are the internal nodes.
Fixtures have scope. Fixtures may only depend on other fixtures which have
the same or higher-level scope. Fixture return values are cached over the lifetime
specified by their scope.
The available scopes are: session
, module
, class
, and function
(function
is the default).
Tests and fixtures are both represented by Python functions.
To depend on a fixture, put the name of a fixture in the argument list
of the function.
When pytest
executes, it “collects” your tests, constructs the DAG by following
the dependencies specified locally in tests and fixtures, and determines an
execution order for fixtures and tests that is consistent with the scoping
rules of the fixtures and the dependencies between them.
For example, this code:
import pytest
@pytest.fixture()
def A(): ...
@pytest.fixture()
def B(A): ...
@pytest.fixture()
def C(A): ...
def test_1(A): ...
def test_2(B, C): ...
def test_3(B): ...
corresponds to this DAG:
digraph pytest_example_suite { "Fixture A" -> {"Fixture B" "Fixture C" "Test 1"}; "Fixture B" -> {"Test 2" "Test 3"}; "Fixture C" -> {"Test 2"}; "Fixture A" [shape=rectangle] "Fixture B" [shape=rectangle] "Fixture C" [shape=rectangle] }and pytest
would execute the suite in this order:
SETUP F A
test.py::test_1 (fixtures used: A)
TEARDOWN F A
SETUP F A
SETUP F B (fixtures used: A)
SETUP F C (fixtures used: A)
test.py::test_2 (fixtures used: A, B, C)
TEARDOWN F C
TEARDOWN F B
TEARDOWN F A
SETUP F A
SETUP F B (fixtures used: A)
test.py::test_3 (fixtures used: A, B)
TEARDOWN F B
TEARDOWN F A
Notice how the same fixtures are “set up” and “torn down” multiple times due to
the scoping rules: because they have the default function
scope, they cannot
be re-used between test functions.
If fixture A
was session-scoped
import pytest
@pytest.fixture(scope = 'session')
def A(): ...
the execution order would be
SETUP S A
test.py::test_1 (fixtures used: A)
SETUP F B (fixtures used: A)
SETUP F C (fixtures used: A)
test.py::test_2 (fixtures used: A, B, C)
TEARDOWN F C
TEARDOWN F B
SETUP F B (fixtures used: A)
test.py::test_3 (fixtures used: A, B)
TEARDOWN F B
TEARDOWN S A
Notice how A
is now only set up and torn down once.
Making Assertions
Every test should function should ideally make one (or more, but hopefully just one)
assertion. pytest
reuses Python’s built-in assertion mechanism, which
is the assert
keyword, documented
here.
An assertion looks like assert <statement>
.
If the <statement>
is “truthy”, the assertion passes;
if it is “falsey”, an AssertionError
is raised.
pytest
catches the AssertionError
and produces detailed error output.
Truthiness is fairly complicated in Python, and use of implicit truthiness
can make tests hard to read.
We recommend always explicitly producing a boolean value to assert on.
For example, if you want to assert that a list x
is not empty,
write assert len(x) > 0
,
not assert x
(even though a non-empty list is “truthy”).
How does ornithology
integrate with pytest
?
ornithology
itself mostly does not reference pytest
. Instead, integration is
provided by hooking into pytest
via standard pytest
configuration files,
namely src/condor_tests/conftest.py
. This file pre-defines several useful
fixtures, and sets up several hooks that slightly modify how pytest
executes.
Ornithology also provides a set of domain-specific fixture decorators.
These functions produce standard pytest
fixtures as described above, but
with settings pre-configured to help write standardized HTCondor tests.
Fixture-Defining Decorators
Ornithology provides domain-specific fixture decorators help you write tests
that use language closer to our familiar HTCondor idioms: config()
,
standup()
, and action()
.
If your tests do not need a personal condor, you can likely bypass all of this
and just use standard pytest fixtures.
Configuration happen before the test’s condors are launched. Standup is the process of launching the test’s condors. Actions happen after condor is ready to receive instructions. Generally, a test file will have a few configuration fixtures, a single standup fixture, and many actions.
All config()
fixtures run before all standup()
fixtures, which run
before all action()
fixtures.
The standup()
fixtures should generally yield an instance of
Condor
.
The test_dir()
fixture becomes available (via fixture scoping rules)
starting with standup()
.
To use these scoping rules correctly, all tests must be written as part of a
test class.
For example, this code:
from conftest import config, standup, action
@config
def determine_params(): ...
@standup
def condor(determine_params): ...
@action
def submit_jobs(condor): ...
class TestJobs:
def test_job_results(self, submit_jobs): ...
corresponds to this DAG:
digraph dsl { subgraph cluster_config { label = "Config" style = dotted "Fixture determine_params" [shape=rectangle] } subgraph cluster_standup { label = "Standup" style = dotted "Fixture condor" [shape=rectangle] } subgraph cluster_action { label = "Action" style = dotted "Fixture submit_jobs" [shape=rectangle] } "Fixture determine_params" -> {"Fixture condor"}; "Fixture condor" -> {"Fixture submit_jobs"}; "Fixture submit_jobs" -> {"Test job_results"}; }and would be executed in this order:
SETUP M determine_params
SETUP C condor (fixtures used: determine_params)
SETUP C submit_jobs (fixtures used: condor)
test.py::TestJobs::test_job_results (fixtures used: condor, determine_params, submit_jobs)
TEARDOWN C submit_jobs
TEARDOWN C condor
TEARDOWN M determine_params
A more complicated example might use multiple config()
and action()
fixtures, and those fixtures might feed multiple tests:
from conftest import config, standup, action
@config
def determine_params(): ...
@config
def slot_config(): ...
@standup
def condor(determine_params, slot_config): ...
@action
def submit_jobs(condor): ...
@action
def finished_jobs(submit_jobs): ...
@action
def analyze_job_queue_log(condor, finished_jobs): ...
class TestJobs:
def test_submit_command_succeeded(self, submit_jobs): ...
def test_job_results(self, finished_jobs): ...
def test_job_queue_log_results(self, analyze_job_queue_log): ...
Corresponding DAG:
digraph dsl { subgraph cluster_config { label = "Config" style = dotted "Fixture determine_params" [shape=rectangle] "Fixture slot_config" [shape=rectangle] } subgraph cluster_standup { label = "Standup" style = dotted "Fixture condor" [shape=rectangle] } subgraph cluster_action { label = "Action" style = dotted "Fixture submit_jobs" [shape=rectangle] "Fixture finished_jobs" [shape=rectangle] "Fixture analyze_job_queue_log" [shape=rectangle] } "Fixture determine_params" -> {"Fixture condor"}; "Fixture slot_config" -> {"Fixture condor"}; "Fixture condor" -> {"Fixture submit_jobs" "Fixture analyze_job_queue_log"}; "Fixture submit_jobs" -> {"Test submit_command_succeeded" "Fixture finished_jobs"}; "Fixture finished_jobs" -> {"Test job_results"}; "Fixture analyze_job_queue_log" -> {"Test job_queue_log_results"}; }Execution order:
htcondor/src/condor_tests/test.py::TestJobs::test_submit_command_succeeded
SETUP M determine_params
SETUP M slot_config
SETUP C condor (fixtures used: determine_params, slot_config)
SETUP C submit_jobs (fixtures used: condor)
test.py::TestJobs::test_submit_command_succeeded (fixtures used: condor, determine_params, slot_config, submit_jobs)
htcondor/src/condor_tests/test.py::TestJobs::test_job_results
SETUP C finished_jobs (fixtures used: submit_jobs)
test.py::TestJobs::test_job_results (fixtures used: condor, determine_params, finished_jobs, slot_config, submit_jobs)
htcondor/src/condor_tests/test.py::TestJobs::test_job_queue_log_results
SETUP C analyze_job_queue_log (fixtures used: condor, finished_jobs)
test.py::TestJobs::test_job_queue_log_results (fixtures used: analyze_job_queue_log, condor, determine_params, finished_jobs, slot_config, submit_jobs)
TEARDOWN C analyze_job_queue_log
TEARDOWN C finished_jobs
TEARDOWN C submit_jobs
TEARDOWN C condor
TEARDOWN M slot_config
TEARDOWN M determine_params
Note how tests run as soon as possible, possibly before actions which they don’t depend on.