Running and writing tests#
Pull requests (PRs) that modify code should either have new tests, or modify existing tests to fail before the PR and pass afterwards. Tests for a module should ideally cover all code in that module, i.e., statement coverage should be at 100%.
Before reading this article, you should have a basic understanding of pytest.
Running the tests#
If you need to run specific FlexGet tests locally, additional testing dependencies must be installed.
To run all tests, simply execute:
$ uv run pytest
If you want to run tests in parallel to speed up the process, run:
$ uv run pytest -n logical --dist loadgroup
If you want to run a specific test within a module or run all tests in a class, see specifying which tests to run.
Note
To avoid having to prepend uv run to your commands, you can activate the virtual
environment instead:
$ source .venv/bin/activate
$ Set-ExecutionPolicy Unrestricted -Scope CurrentUser
$ .venv\Scripts\activate.ps1
Testing a plugin#
We’ll go through an example, starting with creating a plugin and then writing tests for it.
Creating a plugin#
Create new file called flexget/plugins/output/hello.py.
Within this file we will add our plugin.
from flexget import plugin
from flexget.event import event
class Hello:
pass
@event("plugin.register")
def register_plugin():
plugin.register(Hello, "hello", api_ver=2)
Creating a test for it#
Write a new test case called tests/test_hello.py.
class TestHello:
config = """
tasks:
test:
mock: # let's use this plugin to create test data
- {title: 'foobar'} # we can omit url if we do not care about it, in this case mock will add random url
hello: yes # our plugin, no relevant configuration yet ...
"""
# The flexget test framework provides the execute_task fixture, which is a function to run tasks
def test_feature(self, execute_task):
# run the task
execute_task("test")
Try running the test with pytest:
$ uv run pytest tests/test_hello.py
Adding functionality to the plugin#
Now our example plugin will be very simple, we just want to add
new field to each entry called hello with value True.
from flexget import plugin
from flexget.event import event
class Hello:
def on_task_filter(self, task, config):
for entry in task.entries:
entry["hello"] = True
@event("plugin.register")
def register_plugin():
plugin.register(Hello, "hello", api_ver=2)
Adding more tests#
Let’s supplement the testsuite with the test:
class TestHello:
config = """
tasks:
test:
mock: # let's use this plugin to create test data
- {title: 'foobar'} # we can omit url if we do not care about it, in this case mock will add random url
hello: yes # our plugin, no relevant configuration yet ...
"""
def test_feature(self, execute_task):
# run the task
task = execute_task("test")
for entry in task.entries:
assert entry.get("hello") == True
Testing network-dependent code#
To ensure our test suite remains fast, deterministic, and capable of running in offline environments, we employ the vcrpy library to manage tests that rely on network I/O. This system works by recording real HTTP interactions to a file (a “cassette”) and replaying them on subsequent test runs.
Any test function that initiates a network connection must be decorated with @pytest.mark.online.
import pytest
@pytest.mark.online
def test_api_fetch_data():
# ... code that makes an HTTP request ...
assert response.status_code == 200
This decorator instruments the test to use vcrpy to capture all outgoing network interactions and serialize them into a human-readable YAML file called a “cassette”.
On the first run,
vcrpywill perform the actual network request and save the entire interaction (request and response) to a cassette file.On all subsequent runs,
vcrpyintercepts any network call and replays the saved response from the cassette, bypassing the network entirely.
This methodology provides several key advantages:
Determinism: Tests are perfectly repeatable as they always receive the exact same response, eliminating flakiness from network or service variability.
Performance: Bypassing network latency drastically reduces test execution time.
Offline Execution: The entire test suite can be run without an active internet connection.
A cassette file (located in tests/cassettes/) is considered an essential artifact of the test and must be committed to the repository.
If an API endpoint changes or a test requires an update, you must re-record the corresponding cassette. To do this, manually delete the cassette file and then re-run the test.
Provided fixtures and marks#
To streamline testing for FlexGet, we provide a collection of custom pytest fixtures and marks.
This document covers the most common utilities. For an exhaustive list, refer to the fixtures defined
in /tests/conftest.py and the marks registered in pyproject.toml.
Fixtures#
execute_task(task_name)#Use this fixture to execute a FlexGet task within a test.
Marks#
@pytest.mark.online#Tests that make external network requests must use this mark. It integrates
vcrpyto capture all network interactions into YAML files called “cassettes”. Subsequent test runs then replay these interactions from the cassette, eliminating the need for a live network connection. This approach guarantees deterministic test outcomes, improves execution speed, and enables offline testing.@pytest.mark.filecopy(source, destination)#Copies a file or directory before a test runs. Both
sourceanddestinationcan be astrorpathlib.Pathobject.@pytest.mark.require_optional_deps#Apply this mark to tests that rely on optional dependencies (as defined under the
allkey inpyproject.toml). This ensures the CI pipeline installs these dependencies before executing the test.
Mock input#
Using special input plugin called mock to produce almost any kind of
entries in a task. This is probably one of the best ways to test things.
Example:
tasks:
my-test:
mock:
- {title: 'title of test', description: 'foobar'}
my_custom_plugin:
do_stuff: yes
This will generate one entry in the task, notice that entry has two mandatory
fields title and url. If url is not defined the mock plugin will
generate random url for localhost. The description filed is just arbitrary
field that we define in here. We can define any kind of basic text, number, list
or dictionary fields in here.
Controlling plugin behavior in tests with task.options#
You can leverage the task.options dictionary to alter a plugin’s behavior during test
execution, which is particularly useful for debugging.
Plugin implementation#
The plugin checks for a specific option to decide whether to suppress an exception. In a normal run, it logs the error and continues. In a test run, it re-raises the exception so the test framework can catch it and fail the test correctly.
def on_task_output(self, task, config):
try:
# ... business logic ...
except Exception:
logger.exception('Found an error')
# If running under a test, re-raise the exception for clearer failure reports.
if task.options.test:
raise
Testing code#
The test case passes an options dictionary when calling execute_task.
This dictionary becomes accessible as task.options within the plugin.
def test_something(self, execute_task):
# Setting {'test': True} enables the special test-mode behavior in the plugin.
execute_task('task-name', options={'test': True})
The key test is just an example.
You can use any key-value pair (e.g., {'is_testing': True}) as a flag, as long as
your plugin and test code are consistent. The core idea is to pass a signal from the
test runner into the plugin’s execution context.
Code coverage#
We enforce two code coverage policies on every pull request:
codecov/patch: Mandates 100% test coverage for all changed code.codecov/project: Prevents any drop in the overall project coverage.
Inject#
The subcommand inject is very useful during development, assuming previous
example configuration you could try with some other title simply running following.
Example:
$ flexget inject "another test title"
The inject will disable any other inputs in the task. It is possible to set
arbitrary fields through inject much like with mock. See
full documentation.
Commandline values#
The argument --cli config may be useful
if you need to try bunch of different values in the configuration file. It allows placing
variables in the configuration file.
Example:
task:
my-test:
mock:
- {title: foobar}
regexp:
accept:
- $regexp
Run with command:
$ flexget execute --cli-config "regexp=foobar"