teachyou.ai academy
← All posts
Prompt EngineeringLLM testingpromptfooDeepEvalCI/CD for AI

Prompt Testing Frameworks for LLM Apps: A Practical Guide

Pramod Dutta · Jun 23, 2026 · 13 min read

Prompt testing is the practice of running a prompt against a fixed set of inputs, checking the outputs against assertions or a grading model, and repeating that check every time the prompt, model, or system instructions change. If you have ever shipped a "small tweak" to a prompt and quietly broken three other features that depended on its output format, you already know why this matters. This guide walks through the actual mechanics of building a prompt testing framework, the tools worth using, and the pitfalls that make people give up on testing prompts altogether.

Most teams treat prompts like disposable strings. They live in a Python file, get edited directly in the IDE, and the only "test" is a developer eyeballing the response in a terminal. That works for a demo. It falls apart the moment a prompt is doing real work: extracting structured data, routing a support ticket, deciding whether to call a tool, or generating copy that a customer will read. At that point a prompt is a piece of application logic, and application logic needs tests.

Why prompt testing is different from unit testing

A traditional unit test is deterministic: given input X, function f always returns Y. LLM calls are not like that. The same prompt against the same model can return different phrasing, different ordering of a list, or occasionally a subtly wrong answer, even at low temperature. This means prompt testing has to answer a different question than "is the output exactly equal to the expected output." It has to answer "is the output good enough, by some measurable definition of good."

That definition of "good enough" is the actual design problem in prompt testing. You are not writing assert output == expected. You are writing a graded rubric: does the output contain the required fields, does it avoid banned phrases, is it factually consistent with the source document, does a second LLM call judging the output score it above a threshold. A prompt testing framework is really an assertion framework plus a way to run it repeatedly and cheaply.

The four pieces of a real prompt testing setup

Every workable prompt testing setup, regardless of tool, has these four pieces:

  • A test dataset: a list of representative inputs, ideally pulled from real usage or support tickets, not just inputs you made up in five minutes.
  • A prompt or prompt variant under test: the actual prompt template, tied to a specific model and parameters (temperature, max tokens, system message).
  • Assertions or graders: rules that decide pass or fail. These range from cheap string checks to LLM-as-judge calls.
  • A runner: something that loops over the dataset, calls the model, applies the graders, and reports results, ideally in CI on every prompt change.

If any one of these four is missing, you don't have a testing framework, you have a demo script. A lot of teams stop at "I wrote a Python loop that prints outputs," which is a good start but has no assertions and no CI integration, so regressions still slip through.

Building a test dataset that's actually useful

Start by pulling real inputs, not synthetic ones. If you're building a support ticket classifier, export fifty real tickets, not fifty tickets you wrote in your head. Real data has messy formatting, typos, mixed languages, and edge cases that synthetic data never has.

Structure each test case with an input and an expected property, not necessarily an expected exact output:

test_cases = [
    {
        "input": "My invoice from last month shows double the amount, can someone look at this?",
        "expected_category": "billing",
        "must_not_contain": ["I don't know", "cannot help"],
    },
    {
        "input": "The app crashes every time I upload a PDF over 10MB",
        "expected_category": "bug_report",
        "must_contain_any": ["file size", "upload", "PDF"],
    },
]

Keep the dataset in version control, right next to the prompt it tests. Treat changes to the dataset the same way you'd treat changes to a test file in a normal codebase: reviewed, diffed, and never silently edited to make a failing test pass.

A practical target is 30 to 100 cases per prompt for day-to-day regression testing, with a smaller "smoke test" subset of 5 to 10 cases that runs on every commit, and the full set running nightly or before a release. Running the full LLM-as-judge suite on every keystroke is slow and expensive; running a small deterministic subset constantly is cheap and catches most regressions.

Assertion types, from cheap to expensive

Order your assertions from cheapest to most expensive, and fail fast on the cheap ones before spending money on the expensive ones.

Deterministic checks cost nothing and run instantly:

  • Exact match or substring match
  • Regex match, useful for checking a structured format like a date or ticket ID
  • JSON schema validation, useful when the prompt is supposed to return structured output
  • Length bounds, useful for catching a prompt that suddenly starts rambling

Semantic checks cost an embedding call but no generation:

  • Cosine similarity between the output and a reference answer, useful when exact wording doesn't matter but meaning does
  • Semantic set membership, checking that the output's meaning matches one of several acceptable reference answers

LLM-as-judge checks cost a full model call and are the most flexible but also the most expensive and least reliable:

  • A grading prompt that asks a model to rate the output on a rubric (correctness, tone, completeness) and return a score
  • A pairwise comparison between two prompt variants, asking the judge which one is better

A minimal LLM-as-judge grader looks like this:

def judge_response(question, answer, criteria):
    judge_prompt = f"""
    You are grading an AI assistant's answer.

    Question: {question}
    Answer: {answer}
    Criteria: {criteria}

    Respond with only PASS or FAIL, then one sentence explaining why.
    """
    result = call_model(judge_prompt, temperature=0)
    return result.startswith("PASS"), result

Use a stronger or different model as the judge than the one you're testing, when budget allows. Grading your own model's output with the same model tends to be lenient, because the judge and the generator share the same blind spots.

Tool options: promptfoo, DeepEval, and hand-rolled pytest

You don't need to pick one tool for everything. Most production setups mix a dedicated prompt-eval tool for the LLM-specific grading with a normal test runner like pytest for wiring it into CI.

promptfoo is a purpose-built CLI and config-driven framework for exactly this job. You define prompts, providers (which model and API), test cases, and assertions in a YAML file, then run promptfoo eval to get a pass/fail matrix and a local web UI to browse diffs across prompt variants.

A minimal promptfooconfig.yaml:

prompts:
  - "Summarize this support ticket in one sentence: {{ticket}}"

providers:
  - openai:gpt-4o-mini
  - anthropic:claude-sonnet

tests:
  - vars:
      ticket: "My invoice from last month shows double the amount"
    assert:
      - type: contains
        value: "invoice"
      - type: llm-rubric
        value: "The summary correctly identifies this as a billing issue"

  - vars:
      ticket: "The app crashes every time I upload a PDF over 10MB"
    assert:
      - type: contains-any
        value: ["crash", "upload", "PDF"]
      - type: not-contains
        value: "I don't know"

Run it with:

npx promptfoo eval
npx promptfoo view

The strength of promptfoo is comparing multiple providers or prompt variants side by side against the same test set, which makes it a good fit for the "should we switch models" or "is variant B actually better than variant A" question.

DeepEval is a Python-native framework built on top of pytest conventions, which makes it a better fit if your team already lives in pytest and wants prompt tests to run alongside normal unit tests in the same CI job. It ships built-in metrics like answer relevancy, faithfulness (does the answer stay grounded in the provided context), and hallucination detection, all implemented as LLM-as-judge graders under the hood.

from deepeval import assert_test
from deepeval.metrics import AnswerRelevancyMetric, FaithfulnessMetric
from deepeval.test_case import LLMTestCase

def test_billing_summary():
    actual_output = summarize_ticket("My invoice from last month shows double the amount")

    test_case = LLMTestCase(
        input="My invoice from last month shows double the amount",
        actual_output=actual_output,
        retrieval_context=["Billing issues include duplicate charges, wrong amounts, and refund requests."],
    )

    relevancy = AnswerRelevancyMetric(threshold=0.7)
    faithfulness = FaithfulnessMetric(threshold=0.7)

    assert_test(test_case, [relevancy, faithfulness])

Run it exactly like any other pytest suite:

pytest test_prompts.py -v

Hand-rolled pytest is worth mentioning because for small teams and simple prompts, it's often the fastest path and avoids pulling in a framework's opinions about scoring. A bare-bones version:

import pytest
from myapp.prompts import classify_ticket

CASES = [
    ("My invoice from last month shows double the amount", "billing"),
    ("The app crashes every time I upload a PDF over 10MB", "bug_report"),
]

@pytest.mark.parametrize("ticket,expected_category", CASES)
def test_ticket_classification(ticket, expected_category):
    result = classify_ticket(ticket)
    assert result["category"] == expected_category
    assert result["confidence"] > 0.5

Start here if you're not sure which tool fits. Add promptfoo when you need to compare model providers side by side. Add DeepEval when you need faithfulness or hallucination scoring against retrieved context, which matters a lot for RAG pipelines.

Handling non-determinism without flaky tests

Non-determinism is the single biggest reason teams abandon prompt testing. A test that passes nine times out of ten and fails randomly on the tenth is worse than no test, because it trains the team to ignore red CI runs.

A few concrete fixes:

  • Set temperature to 0 for any test that checks exact structure or format. Structured output tasks (JSON extraction, classification, routing) rarely need creativity, so there's no reason to accept randomness there.
  • Grade properties, not exact strings, for anything generative. If the prompt writes marketing copy, don't assert on the exact sentence, assert that it mentions the product name, stays under a length limit, and doesn't use banned words.
  • Run judge-based assertions multiple times and take a majority vote when the judge itself is inconsistent. Three calls with a 2-of-3 pass threshold is more stable than one call.
  • Separate flaky-by-design tests from the required gate. Mark generative, subjective tests as non-blocking in CI (report but don't fail the build) while keeping deterministic structural tests as hard gates.
  • Pin model versions in your test config. If your provider silently updates a model behind an alias, your test suite's baseline shifts under you with no code change to point to.

Wiring prompt tests into CI

The whole point of prompt testing is catching regressions before they reach production, which means it has to run automatically, not just on a developer's machine before they remember to check.

A GitHub Actions job that runs the pytest-based suite on every pull request that touches prompts:

name: prompt-tests

on:
  pull_request:
    paths:
      - "prompts/**"
      - "tests/prompts/**"

jobs:
  test-prompts:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: pytest tests/prompts -v --tb=short
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

Scope the trigger with paths so the suite only runs when prompts or their tests actually change, since these tests cost real API calls and shouldn't run on every unrelated commit. For promptfoo, the equivalent job runs npx promptfoo eval --output results.json and then a small script that fails the build if the pass rate drops below a threshold, since promptfoo's default exit code already reflects pass/fail across the test matrix.

Keep the smoke-test subset (5 to 10 deterministic cases) as a hard merge gate. Keep the full LLM-as-judge suite as a scheduled nightly job or a required check only on release branches, so contributors aren't blocked by slow, expensive judge calls on every small pull request.

Versioning prompts like code

Once tests exist, prompts need the same discipline as code: every prompt template gets a version identifier, changes go through a diff and a review, and the test suite runs against the new version before it merges. A simple pattern that works without extra tooling is storing prompts as versioned files:

prompts/
  classify_ticket/
    v1.txt
    v2.txt
  tests/
    test_classify_ticket.py

The test file references the current production version explicitly, so upgrading to v2 is a deliberate, reviewable change rather than something that happens silently because someone edited a string in place. If you're running multiple prompt variants in production for an A/B test, keep both versions under test so a "losing" variant doesn't silently regress while it's still serving live traffic.

Common mistakes that undermine a testing setup

  • Testing only the happy path. Real users send empty inputs, wildly long inputs, inputs in the wrong language, and adversarial inputs trying to get the model to ignore its system prompt. A prompt test suite that only has clean examples will pass right up until the day it meets a real user.
  • Grading on vibes instead of a rubric. An LLM-as-judge assertion with a vague criteria string like "is this a good answer" produces inconsistent scores. Write the rubric the way you'd write a QA checklist: specific, checkable, and phrased so a judge model can't waffle.
  • Never updating the dataset. A test suite frozen at launch stops reflecting how the product is actually used six months later. Pull new failure cases from production logs into the dataset on a regular cadence.
  • Ignoring cost. LLM-as-judge tests are real API calls with real cost and real latency. A CI pipeline that runs 200 judge calls on every commit will get expensive and slow fast. Tier your tests as described above.
  • Skipping the negative cases. It's just as important to test that a prompt refuses to do the wrong thing, like leaking system instructions or answering outside its domain, as it is to test that it does the right thing.

FAQ

What's the difference between prompt testing and LLM evaluation? Prompt testing usually refers to a narrower, engineering-focused practice: running a specific prompt against a fixed dataset with pass/fail assertions, integrated into a development workflow and CI. LLM evaluation is a broader term that also covers things like comparing base models, measuring aggregate quality across a whole product, or benchmarking against public datasets. In practice the two overlap heavily, and the same tools (promptfoo, DeepEval) are used for both.

Do I need an LLM-as-judge for every test? No. Reserve judge-based grading for genuinely subjective outputs like tone, helpfulness, or open-ended summarization quality. Anything with a checkable structure, a classification label, or a required field should use a deterministic assertion instead. It's cheaper, faster, and more reliable.

How many test cases do I actually need? Enough to cover your known edge cases and a representative sample of real traffic, which in practice is often 30 to 100 cases per prompt for ongoing regression testing. Start smaller if that feels like too much: even 10 well-chosen cases catch most regressions from a careless prompt edit, and you can grow the set as you find new failure modes in production.

Can I test prompts without calling a real model, to save cost during development? You can cache responses for deterministic assertions so repeated test runs against unchanged prompts don't re-call the API, which both tools support. But you can't fully mock the model itself, since the whole point of the test is verifying real model behavior. Use a cheaper or smaller model during rapid local iteration and switch to the production model for the CI gate before merging.

How do I test prompts that call tools or functions, not just generate text? Extend your assertions to check the tool call itself: which function was invoked, what arguments were passed, and whether the arguments are valid against the function's schema. Both promptfoo and DeepEval support asserting on structured tool-call output in addition to plain text, and a hand-rolled pytest suite can just inspect the returned function-call object directly.

Should prompt tests block a deploy the same way unit tests do? For the deterministic, structural subset, yes: treat it as a hard gate, the same as any other broken test. For the subjective, LLM-as-judge subset, many teams start with a reporting-only mode (visible in CI, non-blocking) until they trust the judge's consistency, then promote it to a blocking gate with a pass-rate threshold once the false-failure rate is low enough to trust.