Automating NICE Cognigy.AI Dialog Testing with Python

Automating NICE Cognigy.AI Dialog Testing with Python

What You Will Build

A production-grade Python test automation framework that executes parameterized conversational flows against specific Cognigy.AI bot versions, validates action triggers and output messages, generates branch coverage reports, and integrates into CI/CD pipelines. This tutorial uses the Cognigy.AI REST API v1. The implementation covers Python 3.9+ using httpx, pytest, and standard library modules.

Prerequisites

  • Cognigy.AI tenant credentials (username and password) or pre-generated API token
  • Target Bot UUID and version string (e.g., main, v2.1, or a specific commit hash)
  • Python 3.9 or newer
  • Required packages: httpx, pytest, pytest-cov, rich (optional for console output)
  • Required API Permissions: bot:read, dialog:execute, user:read (equivalent to OAuth scopes for Cognigy.AI role-based access)
  • Network access to https://{tenant}.cognigy.ai

Authentication Setup

Cognigy.AI uses a token-based authentication flow via the /api/v1/auth/token endpoint. The following code demonstrates secure token acquisition, caching, and automatic refresh when the token expires.

import httpx
import time
import json
from typing import Optional

class CognigyAuthManager:
    def __init__(self, tenant: str, username: str, password: str):
        self.base_url = f"https://{tenant}.cognigy.ai"
        self.username = username
        self.password = password
        self.token: Optional[str] = None
        self.token_expiry: float = 0.0
        self.client = httpx.Client(timeout=30.0, follow_redirects=True)

    def _request_token(self) -> str:
        url = f"{self.base_url}/api/v1/auth/token"
        payload = {
            "grant_type": "password",
            "username": self.username,
            "password": self.password
        }
        response = self.client.post(url, data=payload)
        response.raise_for_status()
        data = response.json()
        self.token = data["access_token"]
        self.token_expiry = time.time() + data.get("expires_in", 3600)
        return self.token

    def get_token(self) -> str:
        if not self.token or time.time() >= self.token_expiry - 300:
            return self._request_token()
        return self.token

    def get_headers(self) -> dict:
        return {
            "Authorization": f"Bearer {self.get_token()}",
            "Content-Type": "application/json",
            "Accept": "application/json"
        }

HTTP Request Cycle (Authentication)

POST /api/v1/auth/token HTTP/1.1
Host: {tenant}.cognigy.ai
Content-Type: application/x-www-form-urlencoded

grant_type=password&username=tester@cognigy.com&password=SecurePass123

HTTP Response Cycle (Authentication)

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "bot:read dialog:execute user:read"
}

Implementation

Step 1: Construct Parameterized Test Suites and Session Management

Test suites require structured input sequences that map to expected bot behaviors. Each test case defines a sequence of user messages, expected bot responses, and expected action triggers. Session IDs and user IDs must persist across multiple turns to maintain conversational state.

import uuid
from dataclasses import dataclass, field
from typing import List, Dict, Any

@dataclass
class TestStep:
    user_message: str
    expected_messages: List[str] = field(default_factory=list)
    expected_actions: List[str] = field(default_factory=list)
    expected_branch: Optional[str] = None

@dataclass
class TestSuite:
    name: str
    bot_id: str
    version: str
    steps: List[TestStep]
    session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
    user_id: str = field(default_factory=lambda: str(uuid.uuid4()))

def create_welcome_flow_suite(bot_id: str, version: str) -> TestSuite:
    return TestSuite(
        name="Welcome_Greeting_Flow",
        bot_id=bot_id,
        version=version,
        steps=[
            TestStep(
                user_message="hello",
                expected_messages=["Welcome to our support bot"],
                expected_actions=["log_welcome_event", "set_language_en"],
                expected_branch="greeting_main"
            ),
            TestStep(
                user_message="I need billing help",
                expected_messages=["Let me transfer you to billing"],
                expected_actions=["route_to_billing", "log_intent_billing"],
                expected_branch="billing_transfer"
            )
        ]
    )

Step 2: Invoke the Dialog API and Capture Response Payloads

The Dialog API endpoint /api/v1/dialog accepts a JSON payload containing the session identifier, user identifier, message text, and bot version. The response contains the bot output, triggered actions, and updated session metadata. The following class handles request execution, implements retry logic for rate limits, and captures the full payload for validation.

import logging
import time
from typing import Tuple

logger = logging.getLogger("cognigy_test_runner")

class DialogExecutor:
    def __init__(self, auth: CognigyAuthManager):
        self.auth = auth
        self.client = httpx.Client(timeout=30.0, follow_redirects=True)

    def execute_turn(self, suite: TestSuite, message: str, max_retries: int = 3) -> Dict[str, Any]:
        url = f"https://{self.auth.base_url.split('/')[2]}/api/v1/dialog"
        headers = self.auth.get_headers()
        payload = {
            "sessionId": suite.session_id,
            "userId": suite.user_id,
            "message": message,
            "botId": suite.bot_id,
            "version": suite.version
        }

        last_exception: Optional[Exception] = None
        for attempt in range(1, max_retries + 1):
            try:
                response = self.client.post(url, json=payload, headers=headers)
                
                if response.status_code == 429:
                    retry_after = int(response.headers.get("Retry-After", 2))
                    logger.warning("Rate limit hit. Retrying in %d seconds...", retry_after)
                    time.sleep(retry_after)
                    continue
                
                response.raise_for_status()
                return response.json()
                
            except httpx.HTTPStatusError as exc:
                last_exception = exc
                logger.error("HTTP error %d on attempt %d: %s", exc.response.status_code, attempt, exc)
                if exc.response.status_code in (401, 403):
                    raise
                time.sleep(2 ** attempt)
            except httpx.RequestError as exc:
                last_exception = exc
                logger.error("Request error on attempt %d: %s", attempt, exc)
                time.sleep(2 ** attempt)
        
        raise last_exception if last_exception else RuntimeError("Dialog execution failed after retries")

HTTP Request Cycle (Dialog Execution)

POST /api/v1/dialog HTTP/1.1
Host: {tenant}.cognigy.ai
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "userId": "u1v2w3x4-y5z6-7890-abcd-ef1234567890",
  "message": "hello",
  "botId": "bot-abc123def456",
  "version": "main"
}

HTTP Response Cycle (Dialog Execution)

{
  "sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "userId": "u1v2w3x4-y5z6-7890-abcd-ef1234567890",
  "output": [
    {
      "type": "text",
      "content": "Welcome to our support bot"
    }
  ],
  "actions": [
    {"name": "log_welcome_event", "parameters": {}},
    {"name": "set_language_en", "parameters": {}}
  ],
  "branch": "greeting_main",
  "timestamp": "2024-05-15T10:30:00Z"
}

Step 3: Validate Actions, Messages, and Generate Coverage Reports

Test validation compares captured payloads against expected outcomes. The validation engine logs detailed traces when assertions fail, captures the exact mismatch, and tracks branch coverage. Coverage reports identify untested dialog branches by comparing expected branch names against the Cognigy.AI bot definition or a predefined coverage registry.

import pytest
from typing import List, Dict, Any, Set

class TestValidator:
    def __init__(self):
        self.coverage_tracker: Dict[str, Set[str]] = {}
        self.test_results: List[Dict[str, Any]] = []

    def validate_turn(self, suite_name: str, step: TestStep, actual: Dict[str, Any]) -> bool:
        result = {"passed": True, "errors": [], "suite": suite_name, "step_message": step.user_message}
        
        # Validate output messages
        actual_messages = [msg["content"] for msg in actual.get("output", []) if msg.get("type") == "text"]
        for expected_msg in step.expected_messages:
            if expected_msg not in actual_messages:
                error_msg = f"Missing expected message: '{expected_msg}'. Actual: {actual_messages}"
                result["errors"].append(error_msg)
                result["passed"] = False
                logger.error("Assertion failed in %s: %s", suite_name, error_msg)

        # Validate actions
        actual_actions = [act["name"] for act in actual.get("actions", [])]
        for expected_action in step.expected_actions:
            if expected_action not in actual_actions:
                error_msg = f"Missing expected action: '{expected_action}'. Actual: {actual_actions}"
                result["errors"].append(error_msg)
                result["passed"] = False
                logger.error("Assertion failed in %s: %s", suite_name, error_msg)

        # Track coverage
        executed_branch = actual.get("branch", "unknown")
        if suite_name not in self.coverage_tracker:
            self.coverage_tracker[suite_name] = set()
        self.coverage_tracker[suite_name].add(executed_branch)

        self.test_results.append(result)
        return result["passed"]

    def generate_coverage_report(self, known_branches: Dict[str, List[str]]) -> Dict[str, Any]:
        report = {}
        for suite_name, executed_branches in self.coverage_tracker.items():
            expected_branches = set(known_branches.get(suite_name, []))
            missing_branches = expected_branches - executed_branches
            coverage_pct = (len(executed_branches) / len(expected_branches) * 100) if expected_branches else 100.0
            
            report[suite_name] = {
                "executed_branches": list(executed_branches),
                "missing_branches": list(missing_branches),
                "coverage_percentage": round(coverage_pct, 2),
                "status": "PASS" if not missing_branches else "INCOMPLETE"
            }
        return report

Complete Working Example

The following script combines authentication, execution, validation, and coverage reporting into a single pytest-compatible module. Save this as test_cognigy_dialogs.py and execute it with pytest.

import os
import pytest
import logging
from typing import Dict, Any

# Configure logging for trace output
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    handlers=[logging.StreamHandler()]
)
logger = logging.getLogger("cognigy_test_runner")

# Import classes from previous steps (in production, split into modules)
# For this example, assume CognigyAuthManager, DialogExecutor, TestValidator, TestSuite, TestStep are defined above

def run_test_suite(suite: TestSuite, validator: TestValidator, executor: DialogExecutor) -> bool:
    all_passed = True
    logger.info("Starting test suite: %s (Version: %s)", suite.name, suite.version)
    
    for step in suite.steps:
        logger.info("Executing turn: %s", step.user_message)
        try:
            response = executor.execute_turn(suite, step.user_message)
            turn_passed = validator.validate_turn(suite.name, step, response)
            if not turn_passed:
                all_passed = False
                logger.warning("Turn failed: %s", step.user_message)
        except Exception as exc:
            logger.error("Critical failure during turn execution: %s", exc)
            all_passed = False
            
    return all_passed

@pytest.fixture(scope="module")
def cognigy_executor():
    tenant = os.getenv("COGNIGY_TENANT", "mytenant")
    username = os.getenv("COGNIGY_USERNAME", "api_tester@cognigy.com")
    password = os.getenv("COGNIGY_PASSWORD", "SecurePass123")
    
    auth = CognigyAuthManager(tenant, username, password)
    return DialogExecutor(auth)

@pytest.fixture(scope="module")
def validator():
    return TestValidator()

def test_welcome_flow(cognigy_executor: DialogExecutor, validator: TestValidator):
    bot_id = os.getenv("COGNIGY_BOT_ID", "bot-abc123def456")
    version = os.getenv("COGNIGY_BOT_VERSION", "main")
    
    suite = create_welcome_flow_suite(bot_id, version)
    passed = run_test_suite(suite, validator, cognigy_executor)
    
    assert passed, "Welcome flow test suite contained failed assertions"
    
    # Generate coverage report
    known_branches = {
        "Welcome_Greeting_Flow": ["greeting_main", "billing_transfer", "fallback_unknow", "language_switch"]
    }
    report = validator.generate_coverage_report(known_branches)
    logger.info("Coverage Report: %s", report)
    
    # Assert minimum coverage threshold
    for suite_name, metrics in report.items():
        assert metrics["coverage_percentage"] >= 75.0, f"Coverage below threshold for {suite_name}"

CI/CD Pipeline Integration (GitHub Actions / GitLab CI)

name: Cognigy Dialog Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - name: Install dependencies
        run: pip install httpx pytest pytest-cov
      - name: Run dialog tests
        env:
          COGNIGY_TENANT: ${{ secrets.COGNIGY_TENANT }}
          COGNIGY_USERNAME: ${{ secrets.COGNIGY_USERNAME }}
          COGNIGY_PASSWORD: ${{ secrets.COGNIGY_PASSWORD }}
          COGNIGY_BOT_ID: ${{ secrets.COGNIGY_BOT_ID }}
          COGNIGY_BOT_VERSION: ${{ github.ref == 'refs/heads/main' && 'main' || 'develop' }}
        run: pytest test_cognigy_dialogs.py -v --tb=short --log-cli-level=INFO

Common Errors & Debugging

Error: 401 Unauthorized

  • Cause: Expired access token, invalid credentials, or missing API permissions.
  • Fix: Verify username/password match a user with the bot:read and dialog:execute permissions. Ensure the CognigyAuthManager refreshes the token before each request batch.
  • Code Fix: The get_token() method automatically refreshes when time.time() >= self.token_expiry - 300. If the error persists, check tenant spelling and network proxy settings.

Error: 403 Forbidden

  • Cause: The authenticated user lacks permission to execute dialogs or access the specified bot version.
  • Fix: Assign the tester account the Bot Developer or Dialog Tester role in the Cognigy.AI console. Verify the botId parameter matches an existing bot UUID.
  • Code Fix: Validate the bot ID before execution. Return early if the bot does not exist.

Error: 400 Bad Request

  • Cause: Malformed JSON payload, missing required fields (sessionId, userId, message, botId), or invalid version string.
  • Fix: Ensure all fields are present and correctly typed. The version field must match an active deployment or draft version in Cognigy.AI.
  • Code Fix: Add payload validation before client.post(). Log the exact payload structure when the error occurs.

Error: 429 Too Many Requests

  • Cause: Exceeding Cognigy.AI API rate limits (typically 100-300 requests per minute depending on tier).
  • Fix: Implement exponential backoff and respect the Retry-After header. The DialogExecutor already includes retry logic with Retry-After parsing.
  • Code Fix: Increase max_retries or add a global request limiter using aiolimiter or ratelimit for parallel test execution.

Error: 5xx Server Error

  • Cause: Cognigy.AI platform instability, bot compilation failure, or timeout during action execution.
  • Fix: Check the Cognigy.AI status dashboard. Verify the bot version is compiled and active. Increase timeout in httpx.Client if actions take longer than 30 seconds.
  • Code Fix: Wrap the execution in a timeout boundary and log the full response body for platform-side error codes.

Official References