Skip to content
Go back

promptfoo: Unit Tests for Your Prompts

By KingPin 13 min read
promptfoo: Unit Tests for Your Prompts
Contents

Your Prompt Is Code, and You Ship It Untested

You have a script that sends a support ticket to a local model and expects JSON back. It works. You tweak one sentence in the prompt, or you swap the model for a newer one, and three days later you notice the downstream parser has been choking on “Hello! I’d be happy to help 😊” instead of {"category": "bug"}. Nothing crashed. Nothing alerted. The output just got a personality.

promptfoo fixes this with the boring tool you already trust for everything else: a test suite. You write the prompt, a handful of inputs, and assertions about what comes back. promptfoo runs every input through every provider you list, prints a pass/fail matrix, and exits non-zero when tests fail. Point it at a local llama.cpp or Ollama server and the whole thing costs you electricity. Put it in GitHub Actions and a prompt change that breaks the contract fails the pull request, which is where you want to find out, not at 2 AM.

This post builds a small ticket classifier suite, runs it against a local model, breaks it on purpose, and wires it into CI. Everything below was run with promptfoo 0.125.1 on Node 24.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/llm/promptfoo-prompt-testing

What You Need

promptfoo is an npm package (MIT license) and version 0.125.1 requires Node 22.22 or newer. You don’t install it. You pin it with npx:

Terminal window
export PROMPTFOO_DISABLE_TELEMETRY=1

PROMPTFOO_DISABLE_TELEMETRY is a real variable in the 0.125.1 source, and I set it for every run in this post. You also need an OpenAI-compatible endpoint. A llama.cpp llama-server exposes /v1/chat/completions, and so does Ollama. My runs used a small llama.cpp server (a 4.6B-parameter Q4_0 model with a 4096-token context). The configs below use http://localhost:8080/v1 and a model called my-model as placeholders. Swap in your own host and model id.

The OpenAI acquisition of Promptfoo was announced in March 2026, and the announcement says the open-source project stays open source under its current license. If you worry about a test tool owned by a model vendor, the answer is cheap: the license is MIT and you pin the version.

The Prompt

The prompt lives in its own file so a diff shows exactly what changed. promptfoo fills {{ticket}} from each test.

prompts/classify.txt
You are a support ticket classifier. Read the ticket and reply with a single JSON object and nothing else.
Keys:
- "category": one of "billing", "bug", "feature_request", "account"
- "priority": one of "low", "high"
Use "high" only if the customer cannot work at all or money is being lost.
Ticket:
{{ticket}}

The Config

Here is the whole suite. Read it top to bottom once, then I’ll walk through the parts.

promptfooconfig.yaml
description: Support ticket classifier
prompts:
- file://prompts/classify.txt
providers:
- id: openai:chat:my-model
label: local-llama
config:
apiBaseUrl: http://localhost:8080/v1
apiKey: not-needed
temperature: 0
max_tokens: 200
defaultTest:
options:
# grader for llm-rubric; without this promptfoo asks an OpenAI model
provider:
id: openai:chat:my-model
config:
apiBaseUrl: http://localhost:8080/v1
apiKey: not-needed
temperature: 0
tests:
- description: refund request is billing
vars:
ticket: "I was charged twice for my March invoice. Please refund the extra payment."
assert:
- type: is-json
- type: javascript
value: JSON.parse(output).category === 'billing'
- description: crash is a bug
vars:
ticket: "The export button crashes the app with a null pointer error every time I click it."
assert:
- type: is-json
value:
type: object
required: [category, priority]
properties:
category: { enum: [billing, bug, feature_request, account] }
priority: { enum: [low, high] }
- type: icontains
value: '"category": "bug"'
- description: wish is a feature request, not urgent
vars:
ticket: "It would be nice if the dashboard had a dark mode option."
assert:
- type: regex
value: '"category"\s*:\s*"feature_request"'
- type: not-contains
value: '"priority": "high"'
- description: output has no prose around the JSON
vars:
ticket: "I forgot my password and the reset email never arrives."
assert:
- type: javascript
value: output.trim().startsWith('{') && output.trim().endsWith('}')
- description: model-graded check on a tricky ticket
vars:
ticket: "Our whole team is locked out since this morning and we are losing sales."
assert:
- type: llm-rubric
value: The reply is a JSON object that marks this ticket as high priority.

The pieces:

  1. prompts takes file paths with a file:// prefix.
  2. providers uses the id openai:chat:<model> with apiBaseUrl pointed at your server. A llama.cpp server ignores the key, but promptfoo’s OpenAI provider insists on one, so apiKey: not-needed keeps it quiet.
  3. temperature: 0 makes the classifier as repeatable as the server allows.
  4. defaultTest applies to every test. Here it carries the grader override, which I’ll get to below.
  5. Each entry under tests has vars that fill the prompt and assert entries that judge the output.

Ollama has two routes. promptfoo ships a native provider with ids like ollama:chat:<model>, which reads the server address from the OLLAMA_BASE_URL environment variable. Ollama also serves an OpenAI-compatible API at /v1, so the openai:chat block above works with apiBaseUrl: http://localhost:11434/v1. I ran the llama.cpp route only.

Run It

Terminal window
npx [email protected] eval -c promptfooconfig.yaml
echo $?

-c selects the config file. promptfoo prints a table with one row per test and a [PASS] or [FAIL] tag in each cell, then a summary. My run:

Results:
✓ 5 passed (100%)
0 failed (0%)
0 errors (0%)
Duration: 11s (concurrency: 4)

Exit code 0. The model returned {"category": "bug", "priority": "high"} for the locked-out team, which the rubric accepted even though I’d have called it an account problem. The suite checks the contract (valid JSON, allowed values, high priority). If the category matters to you on edge cases, add an assertion for it.

promptfoo view opens a browser UI over the same results. It is handy for reading long outputs side by side, but the terminal table is enough for CI.

Deterministic vs Model-Graded Assertions

Assertions come in two families, and the difference is the most useful thing to know about promptfoo.

Deterministic assertions are plain code. This suite uses is-json (with or without a JSON schema), icontains (case-insensitive substring), regex, not-contains, and javascript, which runs an expression where output is the model’s reply. There is also a python type for the same job in Python. These are fast, free, and give the same answer every time. Use them for everything you can.

Model-graded assertions such as llm-rubric and factuality send the output to a second model with a rubric and ask for a verdict. They handle fuzzy questions (“is this summary faithful?”) that no regex can. The price is a grader model, extra tokens, extra seconds, and a judge that can be wrong. In my run the grading step used 249 of the 837 total tokens for a single test out of five.

Now the gotcha. By default llm-rubric calls an OpenAI model. I removed the defaultTest block and ran the suite with no OPENAI_API_KEY set. Four tests passed and the rubric test came back as an error: API key is not set. Set the OPENAI_API_KEY environment variable or add apiKey to the provider config. The exit code was still 100, because an errored test counts as a failure. So an unconfigured grader fails your build for a reason that has nothing to do with your prompt.

The fix is the defaultTest.options.provider block in the config above, which sends grading to the local server. The eval command also has a --grader <provider> flag for the same job, which I saw in eval --help but did not run.

A small local grader is also lenient. Hold that thought for the next section.

Break It on Purpose

A test suite you’ve never seen fail is a decoration. Here is a “friendlier” prompt variant, the kind of edit a well-meaning teammate makes:

prompts/classify-chatty.txt
You are a friendly support assistant. Read the ticket and explain which category it belongs to (billing, bug, feature_request or account) and whether it is high or low priority.
Ticket:
{{ticket}}

The -p flag overrides the prompt list from the config, so you can test the variant without editing the YAML:

Terminal window
npx [email protected] eval -c promptfooconfig.yaml -p file://prompts/classify-chatty.txt
echo $?
Results:
✓ 1 passed (20.00%)
✗ 4 failed (80.00%)
0 errors (0%)
Duration: 23s (concurrency: 4)

The exit code was 100. The failure reasons are readable, for example Expected output to be valid JSON and Expected output to match regex "category"\s*:\s*"feature_request". The javascript assertion that calls JSON.parse on a reply starting with “Hello!” reports the parse error.

The one test that passed was the llm-rubric one. The reply was a paragraph of prose with a bold “high priority” in it, and my small local grader decided that counted as a JSON object that marks the ticket high priority. It did not. The deterministic assertions caught the regression and the model-graded one waved it through. That’s the trade-off in one run: put your hard contract in deterministic assertions and use llm-rubric only for the judgment calls, preferably with a grader at least as strong as the model under test.

Exit Codes, and Making Them Fit CI

promptfoo exits 0 when every test passes and 100 when any test fails or errors. Two environment variables in the source tune that, and I ran both:

A pass-rate threshold is useful when you run flaky model-graded tests and accept, say, 90%. Don’t reach for it before you’ve tried fixing the flaky test.

Caching and --repeat

promptfoo caches provider responses on disk. I ran the good suite again right after the first run:

Terminal window
npx [email protected] eval -c promptfooconfig.yaml

The output reported 5 cache hits (100%) and Duration: 3s, against 11 s for the cold run. The tokens were marked cached and Incurred Tokens: 0. That is great while you edit assertions, because you stop paying for the same model call. It is a trap when you edit the prompt and the model changes, since a stale hit can hide a regression. For CI, use --no-cache. The same run with --no-cache took 8 s here.

Models are not deterministic even at temperature 0 once you swap servers or quantizations, so one pass proves little. --repeat <n> runs each test n times:

Terminal window
npx [email protected] eval -c promptfooconfig.yaml --repeat 3 --no-cache

That produced Running 15 test cases for my five tests, and 15 passed in 23 s. A test that passes 3 of 3 on a temperature-0 local model is a decent sign. A test that passes 2 of 3 means the prompt needs a tighter instruction. Raising the retry count only hides that.

Wire It Into GitHub Actions

Because the exit code is the signal, the CI job is short. This example is github-workflow-example.yml in the kit. Copy it to .github/workflows/promptfoo.yml in your own repo.

.github/workflows/promptfoo.yml
name: prompt-tests
on:
pull_request:
paths:
- "prompts/**"
- "promptfooconfig.yaml"
jobs:
promptfoo:
# A hosted runner cannot reach a model on your LAN.
# Use a self-hosted runner on a machine that can reach the llama.cpp server.
runs-on: [self-hosted, llm]
env:
PROMPTFOO_DISABLE_TELEMETRY: "1"
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- name: Run prompt tests
run: npx [email protected] eval -c promptfooconfig.yaml --no-cache --no-progress-bar -o results.json
- name: Keep the results
if: always()
uses: actions/upload-artifact@v7
with:
name: promptfoo-results
path: results.json

I ran the command in the run step locally, including the -o results.json output (a JSON file with the per-test results and token stats). I did not run the workflow on GitHub, so treat the YAML as a starting point.

The real design question is where the model lives. A GitHub-hosted runner cannot reach a llama.cpp server on your LAN, so you have three options: a self-hosted runner on a machine that can (what the example assumes), a model small enough to run on the runner, or a hosted provider for CI only. For a home lab, the self-hosted runner next to the GPU is the least annoying. The paths filter keeps the job from burning GPU time on commits that don’t touch a prompt.

Swapping Models Without Fear

The reason to keep this suite is the model swap. The providers list takes more than one entry, so you add a second openai:chat:<other-model> entry with its own apiBaseUrl and run the same tests against both. Each provider gets its own column in the results table, so a regression in the new model shows up as a red cell next to a green one. I only had one model reachable for this post, so I did not run a two-provider comparison.

Red-Teaming Exists, Briefly

promptfoo also ships a redteam command that generates adversarial inputs to probe an app for jailbreaks and leaks. It is a separate workflow with its own setup. Get the boring contract tests in place first.

The SumGuy Take

Five tests, one afternoon, and your prompt now has a safety net that costs nothing to run. Put the hard rules in deterministic assertions, keep the model-graded ones few and pointed at a local grader, run with --no-cache in CI, and pin the version. Treat a failing exit code as the feature. Your 2 AM self will thank you.

Common Questions

Does promptfoo work with Ollama?

Yes. promptfoo has a native Ollama provider with ids such as ollama:chat:<model>, configured through the OLLAMA_BASE_URL environment variable. Ollama also exposes an OpenAI-compatible API at /v1, so the openai:chat:<model> provider with apiBaseUrl pointed at Ollama works too. The tested kit uses the OpenAI-compatible route.

Does promptfoo send my prompts to OpenAI?

Only if you point it there. Prompts go to the providers you list. The catch is llm-rubric, which defaults to an OpenAI grader. When I removed the grader override and no key was set, that test errored. Set defaultTest.options.provider to a local provider and grading stays local.

Is promptfoo free?

Yes. The npm package is MIT licensed, and a suite against a local model costs only your own hardware time. Model-graded assertions add tokens, so hosted graders cost money. OpenAI announced in March 2026 that it is acquiring Promptfoo and said the project stays open source. Pin the version if that worries you.

What exit code does promptfoo return when a test fails?

Exit code 100. A passing run exits 0, and an errored test, such as a missing grader key, also exits 100. CI treats any non-zero code as a failed job. Set PROMPTFOO_FAILED_TEST_EXIT_CODE to change the failure code, or PROMPTFOO_PASS_RATE_THRESHOLD to allow a pass rate below 100 percent.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
kube-vip: HA k3s Control Plane

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts