Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,28 @@ jobs:
run: |
uv sync --all-extras --all-packages
uvx tox -c ${TOXCFG} -e ${TOXENV}

custom-backend-tests:
name: Custom Backend Tests
needs: unit-tests
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v5
- name: Setup Bats and bats libs
id: setup-bats
uses: bats-core/bats-action@3.0.1

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install the latest version of uv
uses: astral-sh/setup-uv@v6

- name: Run tests
env:
BATS_LIB_PATH: ${{ steps.setup-bats.outputs.lib-path }}
working-directory: ./example/custom_backend
run: ./run_tests.sh
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
include tavern/_core/schema/tests.jsonschema.yaml
include tavern/_plugins/mqtt/jsonschema.yaml
include tavern/_plugins/rest/jsonschema.yaml
include tavern/_plugins/grpc/schema.yaml
include LICENSE
2 changes: 2 additions & 0 deletions example/custom_backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
hello.txt
some_other_file.txt
66 changes: 66 additions & 0 deletions example/custom_backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Tavern Custom Backend Plugin

This example demonstrates how to create a custom backend plugin for Tavern, a pytest plugin for API testing. The custom
backend allows you to extend Tavern's functionality with your own request/response handling logic.

## Overview

This example plugin implements a simple file touch/verification system:

- `touch_file` stage: Creates or updates a file timestamp (similar to the Unix `touch` command)
- `file_exists` stage: Verifies that a specified file exists

## Implementation Details

This example includes:

- `Request` class: Extends `tavern.request.BaseRequest` and implements the `request_vars` property and `run()` method
- `Response` class: Extends `tavern.response.BaseResponse` and implements the `verify()` method
- `Session` class: Context manager for maintaining any state
- `get_expected_from_request` function: Optional function to generate expected response from request
- `jsonschema.yaml`: Schema validation for request/response objects
- `schema_path`: Path to the schema file for validation

## Entry Point Configuration

In your project's `pyproject.toml`, configure the plugin entry point:

```toml
[project.entry-points.tavern_your_backend_name]
my_implementation = 'your.package.path:your_backend_module'
```

Then when running tests, specify the extra backend:

```bash
pytest --tavern-extra-backends=your_backend_name
# Or, to specify an implementation to override the project entrypoint:
pytest --tavern-extra-backends=your_backend_name=my_other_implementation
```

Or the equivalent in pyproject.toml or pytest.ini. Note:

- The entry point name should start with `tavern_`.
- The key of the entrypoint is just a name of the implementation and can be anything.
- The `--tavern-extra-backends` flag should *not* be prefixed with `tavern_`.
- If Tavern detects multiple entrypoints for a backend, it will raise an error. In this case, you must use the second
form to specify which implementation of the backend to use. This is similar to the build-in `--tavern-http-backend`
flag.

This is because Tavern by default only tries to load "grpc", "http" and "mqtt" backends. The flag registers the custom
backend with Tavern, which can then tell [stevedore](https://github.com/openstack/stevedore) to load the plugin from the
entrypoint.

## Example Test

```yaml
---
test_name: Test file touched

stages:
- name: Touch file and check it exists
touch_file:
filename: hello.txt
file_exists:
filename: hello.txt
```
Empty file.
39 changes: 39 additions & 0 deletions example/custom_backend/my_tavern_plugin/jsonschema.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
$schema: "http://json-schema.org/draft-07/schema#"

title: file touch schema
description: Schema for touching files

###

definitions:
touch_file:
type: object
description: touch a file
additionalProperties: false
required:
- filename

properties:
filename:
type: string
description: Name of file to touch

file_exists:
type: object
description: name of file which should exist
additionalProperties: false
required:
- filename

properties:
filename:
type: string
description: Name of file to check for

stage:
properties:
touch_file:
$ref: "#/definitions/touch_file"

file_exists:
$ref: "#/definitions/file_exists"
83 changes: 83 additions & 0 deletions example/custom_backend/my_tavern_plugin/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import logging
import pathlib
from collections.abc import Iterable
from os.path import abspath, dirname, join
from typing import Any, Optional, Union

import box
import yaml

from tavern._core import exceptions
from tavern._core.pytest.config import TestConfig
from tavern.request import BaseRequest
from tavern.response import BaseResponse


class Session:
"""No-op session, but must implement the context manager protocol"""
def __enter__(self):
pass

def __exit__(self, exc_type, exc_val, exc_tb):
pass


class Request(BaseRequest):
"""Touches a file when the 'request' is made"""
def __init__(
self, session: Any, rspec: dict, test_block_config: TestConfig
) -> None:
self.session = session

self._request_vars = rspec

@property
def request_vars(self) -> box.Box:
return self._request_vars

def run(self):
pathlib.Path(self._request_vars["filename"]).touch()


class Response(BaseResponse):
def verify(self, response):
if not pathlib.Path(self.expected["filename"]).exists():
raise exceptions.BadSchemaError(
f"Expected file '{self.expected['filename']}' does not exist"
)

return {}

def __init__(
self,
client,
name: str,
expected: TestConfig,
test_block_config: TestConfig,
) -> None:
super().__init__(name, expected, test_block_config)


logger: logging.Logger = logging.getLogger(__name__)

session_type = Session

request_type = Request
request_block_name = "touch_file"


verifier_type = Response
response_block_name = "file_exists"


def get_expected_from_request(
response_block: Union[dict, Iterable[dict]],
test_block_config: TestConfig,
session: Session,
) -> Optional[dict]:
return response_block


schema_path: str = join(abspath(dirname(__file__)), "jsonschema.yaml")
with open(schema_path, encoding="utf-8") as schema_file:
schema = yaml.load(schema_file, Loader=yaml.SafeLoader)
10 changes: 10 additions & 0 deletions example/custom_backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[project]
name = "my_tavern_plugin"
version = "0.1.0"
description = "A custom 'generic' plugin for tavern that touches files and checks if they are created."
readme = "README.md"
requires-python = ">=3.12"
dependencies = []

[project.entry-points.tavern_file]
my_tavern_plugin = "my_tavern_plugin.plugin"
17 changes: 17 additions & 0 deletions example/custom_backend/run_tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/bin/bash

set -ex

if [ ! -d ".venv" ]; then
uv venv
fi
. .venv/bin/activate

uv sync

if ! command -v bats; then
exit 1
fi

# Run tests using bats
bats --timing --print-output-on-failure "$@" tests.bats
36 changes: 36 additions & 0 deletions example/custom_backend/tests.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bats

setup() {
if [ ! -d ".venv" ]; then
uv venv
fi
. .venv/bin/activate
uv pip install -e . 'tavern @ ../..'
}

@test "run tavern-ci with --tavern-extra-backends=file" {
PYTHONPATH=. run tavern-ci \
--tavern-extra-backends=file \
--debug \
tests

[ "$status" -eq 0 ]
}

@test "run tavern-ci with --tavern-extra-backends=file=my_tavern_plugin" {
PYTHONPATH=. run tavern-ci \
--tavern-extra-backends=file=my_tavern_plugin \
--debug \
tests

[ "$status" -eq 0 ]
}

@test "run tavern-ci with --tavern-extra-backends=file=i_dont_exist should fail" {
PYTHONPATH=. run tavern-ci \
--tavern-extra-backends=file=i_dont_exist \
--debug \
tests

[ "$status" -ne 0 ]
}
46 changes: 46 additions & 0 deletions example/custom_backend/tests/test_file_touched.tavern.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
test_name: Test file touched

stages:
- name: Touch file and check it exists
touch_file:
filename: hello.txt
file_exists:
filename: hello.txt

---
test_name: Test file touched - should fail because file doesn't exist

marks:
- xfail

stages:
- name: Touch file that doesn't exist
touch_file:
filename: some_other_file.txt
file_exists:
filename: nonexistent_file.txt

---
test_name: Test with invalid schema - should fail

_xfail: verify

stages:
- name: Test invalid touch_file schema
touch_file:
nonexistent_field: some_value
file_exists:
filename: hello.txt

---
test_name: Test with invalid response schema - should fail

_xfail: verify

stages:
- name: Test invalid file_exists schema
touch_file:
filename: hello.txt
file_exists:
nonexistent_field: some_value
Loading