Skip to main content

pytest plugin for multi-turn dialogue testing with a pluggable bot adapter. Rule-based, no LLM dependency.

Project description

pytest-conversational

CI CodeQL OpenSSF Best Practices codecov PyPI Downloads Python versions License: MIT Last commit

A pytest plugin for testing chat bots, voice assistants, IVR menus. Rule-based assertions, no LLM dependency.

Status: alpha. v1.0.0 target June 2026.

Why

Most chat-bot test setups fall into one of two camps. Either a pile of requests.post calls with hand-rolled assertions, or a heavy framework that pins you to one platform. This plugin sits in the middle: a small Conversation object, a callable bot adapter, and pytest fixtures that wire them together.

You bring the bot. The plugin keeps turn order and per-conversation state, then prints a transcript when an assertion fails.

A multi-turn test catching a bot that drops a slot on the final turn. The failure shows exactly what the bot said versus what the test expected.

Install

pip install pytest-conversational

Python 3.10 and above.

Quick start

def my_bot(text, convo):
    if "hello" in text.lower():
        return "hi"
    return "sorry, did not get that"


def test_greeting(conversation_factory):
    convo = conversation_factory(bot=my_bot)
    convo.say("hello there")
    assert convo.last.bot == "hi"

Multi-turn state

Adapters can read convo.state and convo.turns to keep slots between turns:

def slot_filling_bot(text, convo):
    slots = convo.state.setdefault("slots", {})
    if "name" not in slots:
        slots["name"] = text
        return "got it, what city?"
    if "city" not in slots:
        slots["city"] = text
        return f"hello {slots['name']} from {slots['city']}"
    return "done"


def test_two_slot_flow(conversation_factory):
    convo = conversation_factory(bot=slot_filling_bot)
    convo.say("Mikhail")
    convo.say("Hove")
    assert convo.state["slots"] == {"name": "Mikhail", "city": "Hove"}
    assert convo.last.bot == "hello Mikhail from Hove"

HTTP webhook adapter

If your bot lives behind an HTTP endpoint, use the bundled adapter instead of writing one by hand:

pip install pytest-conversational[http]
from pytest_conversational import Conversation
from pytest_conversational.adapters import http_webhook


def test_remote_bot():
    bot = http_webhook("https://my-bot.example.com/webhook", timeout=3.0)
    convo = Conversation(bot=bot)
    convo.say("hello")
    assert "hi" in convo.last.bot.lower()

The default contract: POST {"user": text, "history": [[u, b], ...]}, expect 200 OK with JSON {"reply": "..."}. If your endpoint speaks a different shape, pass request_builder and response_parser callbacks.

Security note

The webhook URL is passed through to httpx as-is. If your test feeds a URL it pulled from user input, fixture data, or another untrusted source, the adapter will happily hit it. That includes internal addresses like 127.0.0.1, 169.254.169.254 (cloud metadata service), or 10.x.x.x inside a VPC. Pin the URL to a hard-coded value in the test, or gate it through your own allowlist before passing it in.

Matchers

expect is a small module of assertion helpers tuned for bot replies. Each matcher raises AssertionError with the actual reply embedded in the message, so pytest output shows what the bot said versus what the test wanted.

from pytest_conversational import expect

def test_replies(conversation_factory):
    convo = conversation_factory(bot=my_bot)
    convo.say("hi")

    expect.contains(convo.last.bot, "hello")
    expect.not_contains(convo.last.bot, "error")
    expect.regex(convo.last.bot, r"^hello\s+\w+")
    expect.one_of(convo.last.bot, ["hello there", "hi there", "hey"])
  • contains(actual, substring, *, case_sensitive=False): substring search. Case-insensitive by default.
  • not_contains(actual, substring, *, case_sensitive=False): the negative of contains. Guards against leaks, for example a bot echoing an internal error, a stack trace, or a value it was never given.
  • regex(actual, pattern, *, flags=0): re.search semantics. Returns the match object so callers can inspect captured groups.
  • one_of(actual, options, *, case_sensitive=False, mode="exact"): matches actual against a list of alternative options. Supports mode="exact" (full-string match, default) and mode="substring" (checks if any option is a substring of actual).

Use these when bare assert "hello" in convo.last.bot would give noisy failure messages across many tests. For one-off checks, plain assert is still fine.

Fixtures

Fixture Purpose
conversation Empty Conversation, no adapter. Good for user-only flows.
conversation_factory Builder. Pass a bot callable, get a fresh Conversation.

Public API

  • Conversation(bot=None, turns=[], state={})
  • Conversation.say(text): drive a turn through the adapter, return the Turn.
  • Conversation.add_user(text): append a user-only turn.
  • Conversation.last, .turns, .history, .transcript().
  • Turn(user, bot, metadata).
  • BotAdapter = Callable[[str, Conversation], str].
  • expect.contains, expect.not_contains, expect.regex, expect.one_of.

Roadmap

  • v0.4: scenario DSL loaded from YAML or plain text fixtures.
  • v0.5: async adapter support for coroutine-based bots.
  • v1.0: 12.06.2026 release.

Licence

MIT. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pytest_conversational-1.0.0.tar.gz (97.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pytest_conversational-1.0.0-py3-none-any.whl (19.7 kB view details)

Uploaded Python 3

File details

Details for the file pytest_conversational-1.0.0.tar.gz.

File metadata

  • Download URL: pytest_conversational-1.0.0.tar.gz
  • Upload date:
  • Size: 97.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for pytest_conversational-1.0.0.tar.gz
Algorithm Hash digest
SHA256 863c89576c2adba0981e7ddafef59401cfb923840617c10b1c92d85dae8b12df
MD5 5e152ed0d52982352f6aff29b8bdf304
BLAKE2b-256 cd6f05babfb469186a85cefb5a6f31e294bedd2b2eed907f1f0b7083a2e60170

See more details on using hashes here.

Provenance

The following attestation bundles were made for pytest_conversational-1.0.0.tar.gz:

Publisher: publish.yml on golikovichev/pytest-conversational

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pytest_conversational-1.0.0-py3-none-any.whl.

File metadata

File hashes

Hashes for pytest_conversational-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a3fa1e1b7e02e330ee2e4727f431d0d2ff31f849aeb958c9585de2345268d0c5
MD5 c3a7a80258f23abc149a1bb595551a99
BLAKE2b-256 e41363ab9d4cb9110f6a6926bbb51f3feea2ac7d803e6d633c392d34797d5580

See more details on using hashes here.

Provenance

The following attestation bundles were made for pytest_conversational-1.0.0-py3-none-any.whl:

Publisher: publish.yml on golikovichev/pytest-conversational

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page