CtrlK
BlogDocsLog inGet started
Tessl Logo

golikovichev/pytest-conversational

Test chat bots, voice assistants, and IVR menus with pytest using a small Conversation object and a callable bot adapter. Use when the user wants to write rule-based assertions over multi-turn dialogue without bringing in an LLM dependency, when they have a chatbot reachable as a Python callable or HTTP webhook, when they need to keep per-conversation state across turns and assert on slot filling, when they want pytest-native fixtures and a printable transcript on failure, or when they mention voice-assistant testing, IVR menu testing, conversational AI testing, LLM bot testing (used as the target under test, not as the matcher), expect matchers for bot replies, or multi-turn dialogue tests.

99

1.56x
Quality

100%

Does it follow best practices?

Impact

97%

1.56x

Average score across 3 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

test_expect.pytests/

"""Tests for the expect matchers module."""

from __future__ import annotations

import re

import pytest

from pytest_conversational import expect


class TestContains:
    def test_substring_present_case_insensitive_default(self) -> None:
        expect.contains("Hello there friend", "HELLO")

    def test_substring_exact_match(self) -> None:
        expect.contains("hello there", "hello")

    def test_substring_missing_raises(self) -> None:
        with pytest.raises(AssertionError, match="expected substring 'foo'"):
            expect.contains("hello there", "foo")

    def test_none_actual_raises(self) -> None:
        with pytest.raises(AssertionError, match="got None"):
            expect.contains(None, "hello")  # type: ignore[arg-type]

    def test_case_sensitive_mode(self) -> None:
        expect.contains("Hello", "Hello", case_sensitive=True)
        with pytest.raises(AssertionError):
            expect.contains("Hello", "hello", case_sensitive=True)

    def test_non_str_substring_raises_typeerror(self) -> None:
        with pytest.raises(TypeError):
            expect.contains("hello", 42)  # type: ignore[arg-type]

    def test_empty_substring_always_passes(self) -> None:
        # Empty substring is in every string, mirrors Python ``in`` semantics.
        expect.contains("anything", "")

    def test_failure_message_shows_actual(self) -> None:
        try:
            expect.contains("got something else", "expected token")
        except AssertionError as e:
            assert "got something else" in str(e)
            assert "expected token" in str(e)
        else:
            pytest.fail("expected AssertionError")


class TestNotContains:
    def test_substring_absent_passes(self) -> None:
        expect.not_contains("hello there", "goodbye")

    def test_substring_absent_case_insensitive_default(self) -> None:
        expect.not_contains("Hello there", "STACKTRACE")

    def test_substring_present_raises(self) -> None:
        with pytest.raises(AssertionError, match="expected substring 'error'"):
            expect.not_contains("internal error: 500", "error")

    def test_present_case_insensitive_default_raises(self) -> None:
        with pytest.raises(AssertionError):
            expect.not_contains("Traceback most recent call", "traceback")

    def test_case_sensitive_mode_allows_different_case(self) -> None:
        # In case-sensitive mode a differently-cased occurrence does not count.
        expect.not_contains("Error", "error", case_sensitive=True)

    def test_case_sensitive_mode_same_case_raises(self) -> None:
        with pytest.raises(AssertionError):
            expect.not_contains("error here", "error", case_sensitive=True)

    def test_none_actual_raises(self) -> None:
        with pytest.raises(AssertionError, match="got None"):
            expect.not_contains(None, "error")  # type: ignore[arg-type]

    def test_non_str_substring_raises_typeerror(self) -> None:
        with pytest.raises(TypeError):
            expect.not_contains("hello", 42)  # type: ignore[arg-type]

    def test_empty_substring_raises(self) -> None:
        # Empty substring is in every string, so the absence assertion fails.
        with pytest.raises(AssertionError):
            expect.not_contains("anything", "")

    def test_failure_message_shows_actual(self) -> None:
        try:
            expect.not_contains("the secret is hunter2", "hunter2")
        except AssertionError as e:
            assert "hunter2" in str(e)
            assert "the secret is hunter2" in str(e)
        else:
            pytest.fail("expected AssertionError")


class TestRegex:
    def test_match_at_start(self) -> None:
        m = expect.regex("hello world", r"^hello")
        assert m.group(0) == "hello"

    def test_match_in_middle(self) -> None:
        m = expect.regex("say hello there", r"hello")
        assert m.group(0) == "hello"

    def test_no_match_raises(self) -> None:
        with pytest.raises(AssertionError, match="expected regex"):
            expect.regex("goodbye", r"^hello")

    def test_none_actual_raises(self) -> None:
        with pytest.raises(AssertionError, match="got None"):
            expect.regex(None, r"hello")  # type: ignore[arg-type]

    def test_flags_are_honoured(self) -> None:
        expect.regex("HELLO world", r"hello", flags=re.IGNORECASE)

    def test_invalid_pattern_raises_re_error(self) -> None:
        with pytest.raises(re.error):
            expect.regex("anything", r"(unclosed")

    def test_returns_match_object_for_capture_inspection(self) -> None:
        m = expect.regex("order 42 placed", r"order (\d+)")
        assert m.group(1) == "42"


class TestOneOf:
    def test_exact_match_first_option(self) -> None:
        expect.one_of("yes", ["yes", "yeah", "yep"])

    def test_exact_match_third_option(self) -> None:
        expect.one_of("yep", ["yes", "yeah", "yep"])

    def test_no_match_raises(self) -> None:
        with pytest.raises(AssertionError, match="expected reply to match one of"):
            expect.one_of("maybe", ["yes", "no"])

    def test_substring_match_does_not_count_in_exact_mode(self) -> None:
        # one_of requires full equality by default (exact mode).
        with pytest.raises(AssertionError):
            expect.one_of("yeshere", ["yes", "no"])

    def test_case_insensitive_default(self) -> None:
        expect.one_of("YES", ["yes", "no"])

    def test_case_sensitive_mode(self) -> None:
        expect.one_of("yes", ["yes", "no"], case_sensitive=True)
        with pytest.raises(AssertionError):
            expect.one_of("YES", ["yes", "no"], case_sensitive=True)

    def test_substring_mode_matches_substring(self) -> None:
        expect.one_of("hello there", ["hello", "bye"], mode="substring")
        expect.one_of("hello there", ["hi", "there"], mode="substring")

    def test_substring_mode_case_sensitive(self) -> None:
        with pytest.raises(AssertionError):
            expect.one_of(
                "HELLO there", ["hello", "bye"], mode="substring", case_sensitive=True
            )

    def test_substring_mode_case_insensitive_by_default(self) -> None:
        expect.one_of("HELLO there", ["hello", "bye"], mode="substring")

    def test_invalid_mode_raises_valueerror(self) -> None:
        with pytest.raises(ValueError, match="invalid mode for one_of"):
            expect.one_of("yes", ["yes"], mode="regex")

    def test_none_actual_raises(self) -> None:
        with pytest.raises(AssertionError, match="got None"):
            expect.one_of(None, ["yes"])  # type: ignore[arg-type]

    def test_empty_options_raises_valueerror(self) -> None:
        with pytest.raises(ValueError, match="at least one option"):
            expect.one_of("yes", [])

    def test_failure_message_shows_actual_and_options(self) -> None:
        try:
            expect.one_of("nope", ["yes", "yeah"])
        except AssertionError as e:
            msg = str(e)
            assert "nope" in msg
            assert "yes" in msg
            assert "yeah" in msg
            assert "mode='exact'" in msg
            assert "case_sensitive=False" in msg
        else:
            pytest.fail("expected AssertionError")

    def test_accepts_any_iterable(self) -> None:
        expect.one_of("yes", iter(["yes", "no"]))


class TestUsageWithConversation:
    """Integration-style: matchers work on real ``convo.last.bot`` values."""

    def test_after_say(self, conversation_factory) -> None:  # type: ignore[no-untyped-def]
        def bot(text: str, convo) -> str:  # noqa: ARG001
            return "hello there"

        convo = conversation_factory(bot=bot)
        convo.say("hi")
        expect.contains(convo.last.bot, "hello")
        expect.regex(convo.last.bot, r"hello\s+\w+")
        expect.one_of(convo.last.bot, ["hello there", "hi there"])

CHANGELOG.md

CONTRIBUTING.md

README.md

REFERENCE.md

SECURITY.md

SKILL.md

tessl.json

tile.json