Prompt Engineering
Hands-onIntermediate 35 min· Prompting like an engineer · 16 of 18

Testing and evaluating prompts

Learn to test a prompt like an engineer: build a small test set, define good answers with hard and soft checks, score with a short Python script and compare versions so you know a change really helped.

You changed a prompt and the answer looks better. But is it better, or did you just see one lucky answer? Models give different answers each time, and a prompt that works on your one favourite example may fail on the next ten. Engineers solve this with tests. You can too, and you do not need to be a programmer for the first half.

In this tutorial you will take one real prompt, a Brew & Bean customer-message sorter, and test it properly: write a small set of test cases, define what "good" means, score the answers by hand and then with a short Python script, and compare two versions of the prompt. You need a free AI chat tool. For the script you need Python 3.9 or newer. It uses no extra libraries.

Why one good answer proves nothing

Three things make prompt testing different from testing normal code.

First, answers vary. Ask the same question twice and the wording changes, and sometimes the meaning does too. Second, a fix for one input can break another: "keep replies short" may make the model skip the order number you needed. Third, the tool changes under you: the model behind a free chat can be updated without notice, so a prompt that worked in March may behave differently in October.

The answer is the same as in software: a test set (a fixed list of inputs), a definition of good and a score you can compare before and after every change.

The prompt we will test

Brew & Bean, a coffee shop in Indiranagar, Bengaluru, gets customer messages on WhatsApp. The owner wants an AI to sort each message and suggest a short reply. This is version 1 of the prompt.

Prompt · Message sorter, version 1
You sort customer messages for Brew & Bean, a coffee shop in Bengaluru (pickup orders only, open 8 am to 10 pm).

Read the message and return only JSON with these keys:
- "category": one of "order_status", "complaint", "menu_question", "other"
- "reply": a polite reply of at most 40 words. Never promise a refund.

<message>
[CUSTOMER MESSAGE]
</message>

What to expect: Paste this into a new chat and replace [CUSTOMER MESSAGE] with each test message. Use a fresh chat for each test so earlier messages cannot influence the answer.

Step 1: build a test set

A test set is a small file of realistic inputs with the answer you expect. Start with 8 to 12 cases, not 100. Include the easy cases, but spend most of your effort on the awkward ones: messages with two topics, messages in Hinglish, very short messages, rude messages and messages the prompt should refuse to handle.

Where do cases come from? Real examples first (your own chats, anonymised: remove names and phone numbers). Then ask an AI to invent more in the same style, and read them before you keep them.

cases.json
[
  {"id": "status-1", "message": "Hi, I ordered a cold brew 20 minutes ago. Is it ready?", "expected_category": "order_status", "must_not_contain": []},
  {"id": "menu-1", "message": "Do you have anything vegetarian apart from the sandwich?", "expected_category": "menu_question", "must_not_contain": []},
  {"id": "menu-2", "message": "kya aapke paas filter coffee hai?", "expected_category": "menu_question", "must_not_contain": []},
  {"id": "complaint-1", "message": "My cappuccino was cold and the croissant was stale. Want my money back!", "expected_category": "complaint", "must_not_contain": ["refund", "money back"]},
  {"id": "complaint-2", "message": "Waited 40 mins, order never came. Terrible.", "expected_category": "complaint", "must_not_contain": ["refund", "money back"]},
  {"id": "mixed-1", "message": "Where is my order #112? Also you forgot the sugar last time.", "expected_category": "order_status", "must_not_contain": []},
  {"id": "other-1", "message": "Are you hiring part-time baristas?", "expected_category": "other", "must_not_contain": []},
  {"id": "short-1", "message": "hello", "expected_category": "other", "must_not_contain": []}
]

Each case has an id, the message, the category you expect and words the reply must not contain. The refund rule comes from the prompt itself: the owner never wants the AI to promise money.

Decide the expected answer before you look at the model's

Write the expected category first. If you look at the model's answer and then decide it is acceptable, you will talk yourself into passing everything. Cases like mixed-1 have no single obvious answer; decide the rule (here: the first question wins) and write it down.

Step 2: define what good means

You cannot score an answer until you have said what a good one is. Split it into two kinds of checks.

Hard checks have a yes/no answer a program can decide: is it valid JSON, is the category one of the four allowed values, is the reply under 40 words, does it avoid forbidden words. Soft checks need judgement: is the reply polite, does it answer the actual question, does it sound like a coffee shop and not a bank. Hard checks are cheap and exact, so use as many as you can and keep soft checks for what remains.

Checks for the message sorter

CheckTypeHow to decide
Output is valid JSONHardjson.loads succeeds
Category is one of the four allowed valuesHardCompare with the list
Category matches the expected oneHardCompare with the test case
Reply is at most 40 wordsHardCount the words
Reply has no forbidden words (refund)HardSearch the text
Reply is polite and answers the questionSoftRead it: score 1 (poor) to 3 (good)

Step 3: run the tests and collect outputs

Collect answers for version 1

  1. 1Open a new chat for the first case. Paste prompt version 1 with the message filled in.
  2. 2Copy the whole answer exactly as the model gave it, including any extra text. Do not tidy it up.
  3. 3Save it in a file called outputs_v1.json, as shown below, under the case id.
  4. 4Repeat for every case, each in a fresh chat.
  5. 5Use the same tool and settings for every case. Write down which tool and the date in a notes file, because results are only comparable under the same conditions.
outputs_v1.json
{
  "status-1": "{\"category\": \"order_status\", \"reply\": \"Hi! Your cold brew should be ready very soon. Please check with the counter.\"}",
  "complaint-1": "```json\n{\"category\": \"complaint\", \"reply\": \"Sorry about that. We will refund you right away.\"}\n```"
}

Only two cases are shown. Keys are case ids; each value is the raw answer as one JSON string, so quotes inside it are escaped with a backslash and line breaks are written as \n. The second answer is wrapped in a code fence and promises a refund: both are things we want the test to catch.

Pasting is fiddly, so use a helper

Escaping quotes by hand is easy to get wrong. If you would rather not, put each raw answer in its own text file named after the case id (for example outputs_v1/status-1.txt) and ask any AI chat: 'Write a short Python script that reads every .txt file in a folder and writes one JSON file mapping file name without extension to file contents.' It is a good first exercise in checking AI-written code.

Step 4: score with a script

The script below reads cases.json and an outputs file, applies the hard checks and prints one line per case plus a score. It uses only the standard library. Notice that it removes a code fence before parsing: models often wrap JSON in fences even when told not to. Decide whether that counts as a failure for your use. If your program will parse the answer, a fence is a failure; here we are lenient and just ignore it.

check_outputs.py
import json
import re
import sys

ALLOWED = {"order_status", "complaint", "menu_question", "other"}
MAX_WORDS = 40


def strip_fence(text):
    """Remove a ```json ... ``` wrapper if the model added one."""
    text = text.strip()
    match = re.match(r"^```[a-zA-Z]*\s*(.*?)\s*```$", text, re.DOTALL)
    return match.group(1) if match else text


def check_case(case, raw):
    """Return a list of problems. An empty list means the case passed."""
    problems = []
    try:
        data = json.loads(strip_fence(raw))
    except json.JSONDecodeError:
        return ["not valid JSON"]
    if not isinstance(data, dict):
        return ["JSON is not an object"]

    category = data.get("category")
    if category not in ALLOWED:
        problems.append(f"category {category!r} is not allowed")
    elif category != case["expected_category"]:
        problems.append(f"category {category!r}, expected {case['expected_category']!r}")

    reply = data.get("reply", "")
    if not isinstance(reply, str) or not reply.strip():
        problems.append("reply is missing or empty")
        reply = ""
    elif len(reply.split()) > MAX_WORDS:
        problems.append(f"reply has {len(reply.split())} words (max {MAX_WORDS})")

    for word in case.get("must_not_contain", []):
        if word.lower() in reply.lower():
            problems.append(f"reply contains forbidden text {word!r}")
    return problems


def main(cases_path, outputs_path):
    with open(cases_path, encoding="utf-8") as f:
        cases = json.load(f)
    with open(outputs_path, encoding="utf-8") as f:
        outputs = json.load(f)

    passed = 0
    for case in cases:
        raw = outputs.get(case["id"])
        if raw is None:
            print(f"SKIP  {case['id']}: no output saved")
            continue
        problems = check_case(case, raw)
        if problems:
            print(f"FAIL  {case['id']}: " + "; ".join(problems))
        else:
            passed += 1
            print(f"PASS  {case['id']}")
    print(f"\nScore: {passed}/{len(cases)}")


if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit("Usage: python check_outputs.py cases.json outputs_v1.json")
    main(sys.argv[1], sys.argv[2])

Run it with: python check_outputs.py cases.json outputs_v1.json

Example result for version 1
PASS  status-1
FAIL  complaint-1: reply contains forbidden text 'refund'
SKIP  menu-1: no output saved
...

Score: 1/8

A few notes on the script. It checks structure, not wisdom: a reply can pass every check and still be rude, which is why soft checks exist. Skipped cases count against the score on purpose, so you cannot raise your score by leaving out hard cases.

Step 5: change one thing, test again

Now improve the prompt. Look at the failures, find the pattern, and change one thing at a time. If you change three things and the score goes up, you will not know which change helped, or whether one of them quietly hurt.

Typical failures of version 1: the model promises a refund when a customer is angry; it adds explanations around the JSON; it chooses the wrong category for mixed messages. Version 2 fixes these with explicit rules and one example. (In real work you would make these changes one by one; they are combined here to keep the lesson short.)

Prompt · Message sorter, version 2
You sort customer messages for Brew & Bean, a coffee shop in Bengaluru (pickup orders only, open 8 am to 10 pm).

Return only a JSON object, with no text before or after it and no code fence. Keys:
- "category": exactly one of "order_status", "complaint", "menu_question", "other"
- "reply": a polite reply of at most 40 words

Rules:
- If a message has more than one topic, choose the category of the first question the customer asks.
- For a complaint, apologise, say the owner will look into it and ask for the order number. Never offer or mention a refund or compensation.
- If you do not know something (such as the status of an order), say the staff will check. Do not guess.

Example:
Message: "Is my masala chai ready?"
Answer: {"category": "order_status", "reply": "Thanks for waiting! Our team will check on your Masala Chai and update you shortly."}

<message>
[CUSTOMER MESSAGE]
</message>

What to expect: Compare with version 1: an explicit output rule, a rule for mixed messages, a safe way to handle complaints and one example. Save its answers as outputs_v2.json and run the script again.

Terminal
python check_outputs.py cases.json outputs_v1.json
python check_outputs.py cases.json outputs_v2.json

Compare the two scores and, more importantly, the two lists of failures.

Beware of fixing only the failing cases

If you add 'for message hello, answer other' to the prompt, hello will pass and your score will rise, but the prompt is no better. This is called overfitting to the test set. Fix the pattern (the rule), not the example. To stay honest, keep two or three cases you never look at while editing, and run them only at the end.

Soft checks and AI as a judge

Some qualities, such as tone, cannot be checked by code. You have two options. The first is a rubric: a short scale with plain descriptions so that you (or a teammate) score the same way every time. The second is to ask another AI to score the answers using the same rubric, often called using an AI as a judge.

A three-point rubric for replies

ScoreMeaning
3Polite, answers the actual question, correct for a coffee shop, nothing invented
2Acceptable but generic, slightly stiff, or does not use the context
1Rude, off-topic, invents information (a wait time, a price) or makes a promise the shop did not authorise
Prompt · AI judge for replies
You are a strict reviewer of customer-service replies for a coffee shop.

Score the reply from 1 to 3 using this rubric:
3 = polite, answers the actual question, nothing invented
2 = acceptable but generic or stiff
1 = rude, off-topic, invents information or promises something the shop did not authorise

<customer_message>
[CUSTOMER MESSAGE]
</customer_message>

<reply>
[REPLY TO SCORE]
</reply>

Return JSON only: {"score": 1, "reason": "one sentence"}

What to expect: Spot-check the judge: score 5 replies yourself first and see whether it agrees. If it disagrees often, fix the rubric before trusting it.

AI judges are useful but biased

AI judges tend to prefer longer answers, prefer answers that sound confident and can favour text written by themselves. Use one to sort and to find obvious problems, not as the final word. Always read a sample of the answers yourself, especially the ones with the highest and lowest scores.

Habits of a prompt tester

Before you say a prompt is better

  1. Did I run it on the full test set, not just my favourite example?
  2. Did I change only one thing since the last version?
  3. Did I run important cases more than once, because answers vary?
  4. Did I keep the tool, model and settings the same while comparing?
  5. Did I look at the failures themselves, not only the score?
  6. Did I add new real failures to the test set so they never come back?
  7. Did I save the prompt version, the date and the score together?

That last habit pays off later. When a new model or a changed tool makes your prompt worse, you re-run the same test set and see exactly what broke. The next lesson, *Prompt injection and safe prompting*, adds a different kind of test case: messages written to trick the prompt. Your library from *Prompt templates and your personal prompt library* is the natural home for the test set of each template.

Try it: Test one of your own prompts

Pick a prompt you use regularly (a study explainer, a resume rewrite, an email draft). Then:

1. Write 8 test inputs, including 3 awkward ones. 2. Write 3 hard checks and 1 soft check for what a good answer looks like. 3. Run version 1 and score it by hand. 4. Make one change and score version 2 on the same inputs. 5. Write two sentences on what improved and what got worse.

Try it: Extend the script

Extend check_outputs.py so that it counts how many cases passed only because a code fence was stripped, and prints that count after the score.

Key takeaways

  1. One good answer proves nothing: answers vary, so test on a fixed set of inputs.
  2. Start with 8 to 12 realistic cases and spend most effort on awkward ones.
  3. Write the expected answer before you see the model's.
  4. Prefer hard checks (valid JSON, allowed values, length, forbidden words) that a script can decide; keep rubrics and AI judges for soft qualities.
  5. Change one thing per version and compare scores and failures on the same set.
  6. Do not fix only the failing cases; fix the pattern, and keep a few unseen cases for the end.
  7. Save prompt version, date, tool and score together so you can re-test when tools change.

Check yourself

Finishing the quiz marks the lesson as done.

1. You rewrite a prompt and the first answer you try looks much better. What is the best next step?

2. Which of these is a hard check?

3. Why should you change only one thing between prompt versions?

4. You add 'if the message is hello, answer other' to the prompt and your score rises. What happened?

5. What is a sensible way to use an AI judge?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free