Sitemap

Stop Eyeballing Your Prompts: Test Them Like Real Code with Promptfoo

4 min read3 days ago

--

promptfoo

*Part 1 of a series on testing LLMs and prompts. We start with the simplest thing that works: a deterministic assertion.*

— -

If you’ve shipped anything built on an LLM, you know the sinking feeling. You tweak one line of a prompt to fix a weird edge case, deploy it, and three days later discover you quietly broke four other things. There were no tests, so there was no alarm.

We test regular software constantly — unit tests, CI gates, the works. But prompts? Most teams “test” them by eyeballing a few outputs in a playground and hoping for the best. That doesn’t scale, and it doesn’t survive contact with production.

[promptfoo](https://promptfoo.dev) fixes this. It lets you treat a prompt as a testable unit: you define inputs, you define what a *good* answer looks like, and it runs everything and tells you what passed.

This is the first post in a series. We’ll keep each post to a single idea. Today: the setup, and the simplest possible test.

— -

## The thing we’re testing

Our running example is a support-ticket triage assistant. Give it a customer message, and it returns structured JSON: a category, a priority, and a one-line summary.

Here’s the prompt (`prompts/triage.txt`):

```

You are a customer-support triage assistant.

Classify the ticket below and reply ONLY with JSON in this shape:

{“category”: “billing” | “bug” | “account” | “feature_request” | “other”,

“priority”: “low” | “medium” | “high”,

“summary”: “<one short sentence>”}

Ticket:

{{ticket}}

```

That `{{ticket}}` is a template variable. Each test case fills it with a different customer message. This is the key mental shift: **the prompt is a function, and tests feed it arguments.**

— -

## The config that ties it together

promptfoo is driven by a single config file, `promptfooconfig.yaml`:

```yaml

# yaml-language-server: $schema=https://promptfoo.dev/config-schema.json

description: My first promptfoo setup

# The prompt template. {{ticket}} gets filled from each test’s vars.

prompts:

- file://prompts/triage.txt

# The thing we are testing: a real LLM from OpenAI (key comes from .env)

providers:

- id: openai:gpt-4.1-mini

label: openai-gpt-4.1-mini

# Tests live in the tests/ folder (one file per category).

tests:

- file://tests/*.yaml

```

Get Pranesh’s stories in your inbox

Join Medium for free to get updates from this writer.

Three things to notice:

1. **`prompts`** points at our template file.

2. **`providers`** is *what we’re testing* — here, `gpt-4.1-mini`. The API key lives in a `.env` file (never commit it).

3. **`tests`** is a glob — every file in `tests/` joins the suite.

### Getting set up

```bash

npm install -g promptfoo

# put your key in .env: OPENAI_API_KEY=sk-…

promptfoo eval # run the suite

promptfoo view # open the results in a browser UI

```

That’s the whole loop. Now let’s write one test.

— -

## The simplest test that works

Start with a check that doesn’t need another LLM to judge it — it’s instant, free, and never flaky. Here’s `tests/billing.yaml`:

```yaml

- description: billing — charged twice

vars:

ticket: I was charged twice for my subscription. Please refund ASAP.

assert:

- type: contains # PASS if the answer includes this exact text

value: billing

- type: not-contains

value: bug

```

Read it like a sentence: *given* this ticket, the output **must contain** `billing` and **must not contain** `bug`. If the model ever misclassifies a refund complaint as a bug, this test goes red and you find out immediately.

A test case is just two parts:

- **`vars`** — the input that fills `{{ticket}}`.

- **`assert`** — the checks on the output. You can stack as many as you want.

The two assertion types here are the bread and butter of deterministic testing:

- **`contains`** — a plain substring match. The output must include this text.

- **`not-contains`** — the inverse. Pinning down what the model must *not* do is just as valuable as pinning what it must do.

Run `promptfoo eval` and you get a clean pass/fail — no squinting at JSON required.

> **One gotcha:** `contains` is a literal substring match, not semantic understanding. If you ever renamed the `bug` category to `defect`, every `contains: bug` test would fail even with perfect output. That rigidity is a *feature* — it’s exactly what catches regressions — but it means you maintain these tests as your schema evolves.

— -

## Why this is the right place to start

Deterministic assertions like `contains` and `not-contains` are the unit tests of the prompt world: fast, free, and completely reliable. Before you reach for anything fancier, cover as much behavior as you can with these. They’ll catch the majority of real regressions for almost no cost.

**Next in the series:** the fuzzy stuff. How do you test a free-form `summary` sentence, where two totally different wordings can both be correct? That’s where *model-graded* assertions come in — we’ll tackle `g-eval` in Part 2.

— -

*Following along? Part 2 covers model-graded scoring with `g-eval`.*

--

--

Pranesh
Pranesh

Written by Pranesh

Working as Senior Consultant at Thoughtworks