<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sara Bezjak</title>
    <description>The latest articles on DEV Community by Sara Bezjak (@sara_bezjak).</description>
    <link>https://dev.to/sara_bezjak</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3841314%2F131c4099-8b3d-474d-9357-5d72e88c9e5d.png</url>
      <title>DEV Community: Sara Bezjak</title>
      <link>https://dev.to/sara_bezjak</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sara_bezjak"/>
    <language>en</language>
    <item>
      <title>Five ways to test an LLM's answer and what each one misses</title>
      <dc:creator>Sara Bezjak</dc:creator>
      <pubDate>Tue, 19 May 2026 09:25:24 +0000</pubDate>
      <link>https://dev.to/sara_bezjak/five-ways-to-test-an-llms-answer-and-what-each-one-misses-5k2</link>
      <guid>https://dev.to/sara_bezjak/five-ways-to-test-an-llms-answer-and-what-each-one-misses-5k2</guid>
      <description>&lt;p&gt;I'm a regular automation engineer. My usual job is checking that an app does the same thing every time. AI testing is the opposite: the same question can give a different answer each run.&lt;/p&gt;

&lt;p&gt;A learning project, written up for anyone trying to get into AI testing.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/sbezjak/llm-eval-harness" rel="noopener noreferrer"&gt;https://github.com/sbezjak/llm-eval-harness&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;A pytest project. 10 questions, a hand-written expected answer for each, a local model (&lt;code&gt;llama3.2&lt;/code&gt;) answering the questions, and the model's responses saved to a file. Then I read every response myself and wrote PASS or FAIL like a human grader. The rest of the project is about getting code to agree with that human verdict.&lt;/p&gt;

&lt;p&gt;I built five scorers and ran all five against the saved responses:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scorer&lt;/th&gt;
&lt;th&gt;What it checks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Exact match&lt;/td&gt;
&lt;td&gt;&lt;code&gt;output == expected&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BLEU&lt;/td&gt;
&lt;td&gt;shared word sequences (from machine translation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ROUGE&lt;/td&gt;
&lt;td&gt;overlap on longest common subsequence (from summarization)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Semantic similarity&lt;/td&gt;
&lt;td&gt;angle between sentence embeddings (does the meaning roughly match)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM-as-judge&lt;/td&gt;
&lt;td&gt;second model call with a correctness + relevance rubric&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of them was right on its own. The interesting bit is &lt;em&gt;how&lt;/em&gt; each one was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The main finding: the judge passes its own hallucinations, deterministically
&lt;/h2&gt;

&lt;p&gt;LLM-as-judge means a second LLM grades the first one's answer against a rubric. It's the only scorer here that can actually read meaning, which is also why it fails in ways the others don't.&lt;/p&gt;

&lt;p&gt;The only wrong answer in my set was on a pytest question. The model invented a command-line flag (&lt;code&gt;--junit-xml-filter&lt;/code&gt;). I had set up the LLM judge specifically to catch this kind of factual error.&lt;/p&gt;

&lt;p&gt;The judge gave it correctness 8/10, relevance 6/10. Combined: &lt;strong&gt;0.700&lt;/strong&gt;. I set the pass threshold: &lt;strong&gt;0.700&lt;/strong&gt;. So it passed, exactly on the line.&lt;/p&gt;

&lt;p&gt;LLM outputs aren't deterministic, so I expected the score to be around 0.7 and the verdict to flip. I ran the judge five times against the same frozen response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;run 1: score=0.700 passed=True
run 2: score=0.700 passed=True
run 3: score=0.700 passed=True
run 4: score=0.700 passed=True
run 5: score=0.700 passed=True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical every run. The judge isn't changing its mind, it's stuck on the threshold. Worse than a flaky test: a flaky test eventually flips red and someone investigates. A deterministic wrong-pass looks green in CI and ships the bug.&lt;/p&gt;

&lt;p&gt;The mechanism is &lt;strong&gt;self-grading bias&lt;/strong&gt;: the judge is the same &lt;code&gt;llama3.2&lt;/code&gt; that wrote the bad answer, so the hallucinated flag doesn't look wrong to either of them. Averaging more runs helps when a score is noisy. It does nothing here. The fix is a different, stronger judge model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other story: 4 of 5 scorers reject a correct answer because of its shape
&lt;/h2&gt;

&lt;p&gt;Question: "How many planets are in our solar system?" Expected: &lt;code&gt;"8"&lt;/code&gt;. The model returned a bulleted list of all eight planets with their names, plus a section about Pluto. A human reads that and says PASS.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scorer&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Exact match&lt;/td&gt;
&lt;td&gt;FAIL&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BLEU&lt;/td&gt;
&lt;td&gt;FAIL&lt;/td&gt;
&lt;td&gt;~0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ROUGE&lt;/td&gt;
&lt;td&gt;FAIL&lt;/td&gt;
&lt;td&gt;~0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Semantic similarity&lt;/td&gt;
&lt;td&gt;FAIL&lt;/td&gt;
&lt;td&gt;0.194&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM-as-judge&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You think you are testing whether the model got the answer right. You are actually testing whether your &lt;em&gt;scorer&lt;/em&gt; can recognize the right answer when the model gives it in a different shape than the reference. Four out of five could not.&lt;/p&gt;

&lt;p&gt;A note on how semantic similarity works: each sentence becomes a vector (a list of numbers encoding meaning), and the score is the angle between two vectors. Close angle, similar meaning. But "similar meaning" isn't "correct". A wrong answer about planets sits in the same semantic neighborhood as a right one.&lt;/p&gt;

&lt;p&gt;The naive fix is to lower the cosine threshold until the planets row passes. It does not work. The lowest right-answer score and the only wrong-answer score in the set sit 0.004 apart. Any threshold that admits the right one also admits the wrong one. Semantic similarity is measuring textual proximity, not correctness.&lt;/p&gt;

&lt;h2&gt;
  
  
  A closer look at BLEU and ROUGE
&lt;/h2&gt;

&lt;p&gt;Two of the four scorers that failed the planets case were BLEU and ROUGE. It's worth slowing down on these, because the usual one-line explanation ("BLEU and ROUGE are bad at prose") turned out to be the wrong story.&lt;/p&gt;

&lt;p&gt;Both metrics measure &lt;strong&gt;word overlap&lt;/strong&gt;. They look at the model's answer, look at your reference answer, and count how many words or word sequences appear in both. More overlap, higher score.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BLEU&lt;/strong&gt; (from machine translation, 2002) counts shared runs of words. If the reference is "the cat sat on the mat" and the model says "the cat sat on a mat," BLEU sees five shared single words and three shared two-word sequences ("the cat", "cat sat", "sat on") and gives a high score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ROUGE&lt;/strong&gt; (from summarization, 2004) counts the longest sequence of words that appears in both texts in the same order, even if other words are sprinkled between them. Same idea, slightly different bookkeeping.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important part is the &lt;strong&gt;denominator&lt;/strong&gt;. Both metrics divide "shared words" by "how long the texts are." That ratio is what breaks on the planets row.&lt;/p&gt;

&lt;p&gt;Reference: &lt;code&gt;"8"&lt;/code&gt;. One token. The model's answer: a paragraph naming all eight planets. The word "8" appears in the paragraph, so the numerator is 1. The denominator is "length of the model's answer," around 30 words. The score is 1/30, basically zero. BLEU also needs the texts to share two-word and three-word sequences, and the reference has none of those (it only has one word), so part of BLEU's math is forced to zero before anything else happens. Final score: zero. Same answer, different reference, completely different result. If the reference had been "There are 8 planets in our solar system," BLEU and ROUGE would both score the same model answer highly, because now there are sequences to overlap with.&lt;/p&gt;

&lt;p&gt;So the rule isn't "BLEU and ROUGE are bad at prose." They were &lt;em&gt;built&lt;/em&gt; for prose. The rule is: &lt;strong&gt;they only work when the reference and the model's answer are similar in shape and length.&lt;/strong&gt; Short reference plus long answer collapses the score. Long reference plus short answer collapses it too.&lt;/p&gt;

&lt;p&gt;This is what the xfail tests surfaced. I had marked the BLEU and ROUGE rows as "expected to fail on prose," and five of them passed unexpectedly. The ones that passed were the rows where the reference happened to be a full sentence, not a single token. The shape matched, the score worked, the test that was "supposed" to fail didn't. That mismatch is what pushed the finding from "BLEU is bad" to "BLEU needs matching reference shape."&lt;/p&gt;

&lt;p&gt;The practical version: if you want a meaningful BLEU or ROUGE score, write reference answers that look roughly like the outputs you expect. A one-word gold answer is fine for exact match but wastes these metrics. For short answers, use exact match or an LLM judge instead. Production setups also support multiple reference answers per question and take the best match, which is another way to cover the shape problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smaller findings, briefly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A bias-swap test caught one drifting pair.&lt;/strong&gt; Same prompt, only the name changes (David vs Priya). Three of four pairs gave similar responses. One question about career advice drifted noticeably. One drifting pair isn't proof of bias, but it's the kind of drift a real bias suite would flag for review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Length bias, null result here.&lt;/strong&gt; LLM judges often score longer answers higher. I expected this and tested three short-vs-long pairs of correct answers. The judge gave both the same score every time. Not proof there's no bias, just no bias on this model and rubric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust the judge's score, not its reasoning.&lt;/strong&gt; The judge sometimes wrote explanations that contradicted the number it gave. The number was closer to right. Treat the prose as a debugging hint, not evidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The thing I'd take back into a Playwright suite tomorrow
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pytest.xfail(strict=True)&lt;/code&gt; with a reason field. The test is &lt;em&gt;supposed&lt;/em&gt; to fail for a written-down reason, and if it ever starts passing, the build breaks on purpose so somebody investigates. I marked every "scorer disagrees with the human" case that way. The test file became the project's spec for what each scorer is known to get wrong.&lt;/p&gt;

&lt;p&gt;It paid for itself twice. I expected the judge-variance test to show noise; stdev came back 0.000, which surfaced the "stuck on the threshold" finding. I expected BLEU and ROUGE to fail on prose like exact match does; five XPASSes forced the reference-shape finding instead. Both times the suite caught me being wrong before I published.&lt;/p&gt;

&lt;p&gt;This is not AI-specific. It works on any flaky integration where the failure mode is understood.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on the numbers
&lt;/h2&gt;

&lt;p&gt;The set is 10 items. That is too small for real applications. The patterns are reproducible. Production calibration uses data in the hundreds with multiple human raters. This project is an introduction to eval harnesses - the same patterns scale up.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to run it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;ollama
ollama serve
ollama pull llama3.2

uv &lt;span class="nb"&gt;sync
&lt;/span&gt;uv run pytest &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"not ollama"&lt;/span&gt;   &lt;span class="c"&gt;# fast tier, mocked, ~10s&lt;/span&gt;
uv run pytest                   &lt;span class="c"&gt;# full suite, ~7 min&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;When the answer is a paragraph and not a value, no single scorer is enough. You run a panel of imperfect scorers, write down where each one is wrong, and let the disagreements be the actual test.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/sbezjak/llm-eval-harness" rel="noopener noreferrer"&gt;https://github.com/sbezjak/llm-eval-harness&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Project 1 of a five-project series on testing AI systems. Project 2 is retrieval-augmented generation.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>python</category>
      <category>qa</category>
    </item>
    <item>
      <title>A QA engineer's first AI testing project - FastAPI + local LLM + pytest</title>
      <dc:creator>Sara Bezjak</dc:creator>
      <pubDate>Fri, 24 Apr 2026 11:15:15 +0000</pubDate>
      <link>https://dev.to/sara_bezjak/a-qa-engineers-first-ai-testing-project-fastapi-local-llm-pytest-5b1c</link>
      <guid>https://dev.to/sara_bezjak/a-qa-engineers-first-ai-testing-project-fastapi-local-llm-pytest-5b1c</guid>
      <description>&lt;p&gt;I'm an automation engineer that writes mostly UI tests with some API sprinkled in. A recruiter wrote to me about an interesting job - AI/LLM testing. I was curious to learn more so I asked the model itself: what skills do I need to learn? The answer was this project.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is it
&lt;/h2&gt;

&lt;p&gt;A FastAPI service with one endpoint (&lt;code&gt;/ask&lt;/code&gt;) that forwards a question to a local LLM (Ollama running llama3.2) and returns the answer. Plus a pytest suite.&lt;/p&gt;

&lt;p&gt;~90 lines of app code, 23 tests, 100% coverage, two-tier test split (fast &amp;lt;1s, full ~90s).&lt;/p&gt;

&lt;p&gt;The point was to learn what AI testing actually looks like compared to UI/API testing.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/sbezjak/llm-api-testing" rel="noopener noreferrer"&gt;https://github.com/sbezjak/llm-api-testing&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One honest thing up front.&lt;/strong&gt; The suite worked first try. That made it harder to learn from, not easier - when nothing breaks, you don't have to understand it. I spent more time reading the code than I would have spent writing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Process timeline
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Read every line before running anything.&lt;/strong&gt; Docs, code, tests, setup. I wanted the big picture - classes, endpoints, test structure in my head before I touched anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Ask questions instead of copy-pasting.&lt;/strong&gt; It's easy to create something that passes. It's harder to understand why it does. I spent 2 hours just discussing the project with the model. Questions like: Why 70% and not 100%? What does &lt;code&gt;ASGITransport&lt;/code&gt; actually do? Why does &lt;code&gt;ConnectError&lt;/code&gt; map to 503 and HTTP errors to 502? Why mock at all with &lt;code&gt;respx&lt;/code&gt;? What's xfail and why is it used like this? What's temperature?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Ran it. All passed.&lt;/strong&gt; But "10 passed in 99s" wasn't enough. I wanted to see which tests hit the model, how long each took, what the model actually answered. So I added structured logging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /ask verdict=allowed status=200 elapsed=0.42s answer='Paris.'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a &lt;code&gt;pytest-html&lt;/code&gt; report with per-test captured logs. Now every test run is a document I can read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Iterate with the model.&lt;/strong&gt; Added logs, reports, comments. Asked about code I didn't understand - why something was there, what a piece did. This is where the differences between UI and AI testing started to click. Probabilistic vs deterministic. The 70% Paris case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Make it production-ish.&lt;/strong&gt; Asked how a real team would harden this. Mocking Ollama and 100% coverage were added in this step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that actually clicked - probabilistic vs deterministic
&lt;/h2&gt;

&lt;p&gt;The consistency test sends "What is the capital of France?" ten times and asserts ≥70% of answers contain "paris".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;answers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;answers&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paris&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In UI testing, same input produces the same output. You assert on exact values. &lt;code&gt;assert button.opens_modal() == True&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;LLMs don't work like that. Same prompt, different valid answers every call - "Paris.", "The capital is Paris.", a paragraph about French geography. The model samples from a distribution. There is no single right string.&lt;/p&gt;

&lt;p&gt;So you assert on properties of the distribution, or on the envelope of acceptable answers. &lt;code&gt;assert ≥70% of answers contain "paris"&lt;/code&gt;. 70% is arbitrary - high enough to catch regressions, low enough to tolerate the model's variance. In a real system you'd tune per prompt. &lt;/p&gt;

&lt;p&gt;Point vs region. Four years of UI-testing instincts took a while to shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three bugs and what they taught me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bug 1 - latency test failing at 35s.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;First thought: my M1 is slow. Then I ran &lt;code&gt;ollama run llama3.2 "say hi"&lt;/code&gt; directly in the terminal - instant. So the model was fine.&lt;/p&gt;

&lt;p&gt;llama3.2 is chatty. Asking "string" produced an essay on null-termination and Unicode. The 35 seconds was generation time, not system latency.&lt;/p&gt;

&lt;p&gt;Fix: &lt;code&gt;"options": {"num_predict": 200}&lt;/code&gt; to cap output tokens. Warm requests dropped to 1-3 seconds.&lt;/p&gt;

&lt;p&gt;Lesson: traditional APIs return what you ask for. LLMs return what they feel like returning. Latency tests measure output length unless you constrain it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 2 - coverage stuck at 85%.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cause: no test exercised Ollama failure paths.&lt;/p&gt;

&lt;p&gt;Fix: three mocked tests with &lt;code&gt;respx&lt;/code&gt; — unreachable → 503, Ollama 5xx → 502, empty response → 502. Coverage hit 100%. New tests run in &amp;lt;50ms each because no real model is involved.&lt;/p&gt;

&lt;p&gt;Lesson: check coverage reports. Gaps usually point at untested failure modes, not untested happy paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 3 - moderation filter false positives.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The moderation filter is a substring blocklist - a Python list of phrases like &lt;code&gt;"how to kill"&lt;/code&gt;, &lt;code&gt;"how to hack"&lt;/code&gt;, etc. Any question containing one gets refused with a 400. Simple: &lt;code&gt;"how to kill a process on linux"&lt;/code&gt; contains &lt;code&gt;"how to kill"&lt;/code&gt;, so a normal dev question gets blocked.&lt;/p&gt;

&lt;p&gt;Fix: added the false positive to the benign dataset with &lt;code&gt;pytest.mark.xfail&lt;/code&gt; and a written reason. The test now runs, fails as expected, and shows as a yellow dot in the report instead of red. Documented in the suite itself.&lt;/p&gt;

&lt;p&gt;It flips to green the day the substring is replaced with a real classifier - a model that understands &lt;em&gt;intent&lt;/em&gt; ("is this user actually trying to cause harm?") instead of just matching strings. That could be a small fine-tuned model, an open-source moderation model like Llama Guard, or a commercial moderation API. The upgrade closes the false-positive gap, the test starts passing, and &lt;code&gt;xfail(strict=False)&lt;/code&gt; signals "unexpectedly passed" - the cue to remove the marker.&lt;/p&gt;

&lt;p&gt;Lesson: xfail makes the suite record what's broken, not just what works. I'd only used xfail for flaky tests before, not as living documentation of known bugs. Much better than hiding a bug in a backlog ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still don't fully understand
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The ASGI internals &lt;code&gt;ASGITransport&lt;/code&gt; relies on. I know what it does, not what's happening inside.&lt;/li&gt;
&lt;li&gt;When &lt;code&gt;respx&lt;/code&gt; is the right call vs building a proper fake.&lt;/li&gt;
&lt;li&gt;Embedding similarity math beyond "cosine measures angle."&lt;/li&gt;
&lt;li&gt;What a real production eval harness looks like.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  From a QA perspective
&lt;/h2&gt;

&lt;p&gt;Most UI-testing instincts didn't transfer. Equality assertions, fixed latency thresholds, asserting a single correct outcome - all had to shift.&lt;/p&gt;

&lt;p&gt;What did transfer: discipline around edge cases, thoughts about what happens when the upstream service dies, care about keeping the feedback loop fast, coverage reports.&lt;/p&gt;

&lt;p&gt;Setting up a local model was new. Using it as a dependency in a test suite was new. Testing something that returns different valid outputs every call was new. If you're a QA engineer looking at this direction - the probability side is the new thing. The rest is still testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to run it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install &amp;amp; start Ollama&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;ollama
ollama serve             &lt;span class="c"&gt;# leave running in its own terminal&lt;/span&gt;
ollama pull llama3.2     &lt;span class="c"&gt;# in another terminal&lt;/span&gt;

&lt;span class="c"&gt;# Python env&lt;/span&gt;
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="c"&gt;# Run the API&lt;/span&gt;
uvicorn app.main:app &lt;span class="nt"&gt;--reload&lt;/span&gt; &lt;span class="nt"&gt;--port&lt;/span&gt; 8000
&lt;span class="c"&gt;# → http://localhost:8000/docs&lt;/span&gt;

&lt;span class="c"&gt;# Tests&lt;/span&gt;
pytest &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"not ollama"&lt;/span&gt;   &lt;span class="c"&gt;# fast tier, no Ollama needed, ~1s&lt;/span&gt;
pytest                   &lt;span class="c"&gt;# full suite with HTML reports&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;When you're testing robustness (did the system stay well-behaved?) instead of correctness (did the right thing happen?), you assert the shape of acceptable failure, not the shape of success. AI systems fail in more ways, so the distinction matters more - a 500 is always a bug; anything else might be correct behavior for an edge case.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/sbezjak/llm-api-testing" rel="noopener noreferrer"&gt;https://github.com/sbezjak/llm-api-testing&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next up - 5 more projects on the list: eval harness, RAG with observability, red-team suite, agent testing, model benchmarking. Writing each one up as I go.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>python</category>
      <category>qa</category>
    </item>
    <item>
      <title>AI Tools for Existing Playwright + Pytest Frameworks: What Actually Works</title>
      <dc:creator>Sara Bezjak</dc:creator>
      <pubDate>Thu, 26 Mar 2026 16:02:24 +0000</pubDate>
      <link>https://dev.to/sara_bezjak/ai-tools-for-existing-playwright-pytest-frameworks-what-actually-works-3jen</link>
      <guid>https://dev.to/sara_bezjak/ai-tools-for-existing-playwright-pytest-frameworks-what-actually-works-3jen</guid>
      <description>&lt;h2&gt;
  
  
  Purpose
&lt;/h2&gt;

&lt;p&gt;Research and evaluate AI-powered tools and workflows to improve test automation efficiency, specifically for test creation speed and reducing maintenance time when UI or business flows change. Focus on tools compatible with an existing Playwright + pytest (Python) stack and IntelliJ IDE.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current Workflow &amp;amp; Pain Points
&lt;/h2&gt;

&lt;p&gt;The two primary pain points in test automation are:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Creating new tests:&lt;/strong&gt; Requires manually assembling context (page objects, fixture patterns, example tests) and writing tests that match existing conventions. The copy-paste workflow works but is slow and repetitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Updating tests when UI or flows change:&lt;/strong&gt; When the product changes, tests break. Diagnosing which tests are affected, understanding what changed, and fixing them to match the new behavior consumes significant time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools Evaluated
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Claude Code (Anthropic) — Recommended
&lt;/h3&gt;

&lt;p&gt;Claude Code is a terminal-based AI coding assistant that works with your entire codebase as context. It integrates with IntelliJ via a plugin (currently in beta) and can read, generate, and modify files directly in the project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key advantages:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Works in IntelliJ via plugin or integrated terminal. No IDE switch required.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Reads the full repository — page objects, fixtures, test files so generated code matches existing patterns and conventions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Supports a CLAUDE.md configuration file in the project root which contains definitions of framework conventions, naming patterns, fixture usage, and domain context. This ensures output is framework-specific and not generic.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Suggests changes via IntelliJ's native diff viewer, making review and approval straightforward.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Shares IDE diagnostics (lint errors, syntax issues) automatically.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Available on Pro plan ($20/month), which is sufficient for regular usage.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Used for:&lt;/strong&gt; Generated a change billing test using Claude Code with full project context. The output followed existing page object patterns, used the correct fixtures, and required minimal manual adjustment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Playwright MCP (Model Context Protocol)
&lt;/h3&gt;

&lt;p&gt;Playwright MCP is a server that gives AI tools live browser access. Instead of manually inspecting the DOM for selectors or using codegen tools, Claude Code can navigate the application, interact with elements, and read the actual page structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Useful for:&lt;/strong&gt; Discovering selectors on new or changed pages without manually opening DevTools / Codegen. Especially valuable when new UI elements are added as part of feature changes. Requires guidance on which flow to walk through (natural language instructions).&lt;/p&gt;

&lt;h3&gt;
  
  
  Playwright Agents (Planner / Generator / Healer) — Not Compatible Yet
&lt;/h3&gt;

&lt;p&gt;Playwright v1.56 introduced three AI agents that can generate test plans, create test code, and automatically fix broken tests. The Healer agent is particularly interesting for maintenance. It replays failing tests, inspects the live UI, and patches selectors or waits.&lt;/p&gt;

&lt;p&gt;However, these agents currently only support TypeScript/JavaScript. There is an open feature request for Python support but no timeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cursor — Viable Alternative
&lt;/h3&gt;

&lt;p&gt;Cursor is an AI-powered IDE (VS Code-based) that provides full codebase context and inline AI editing. Comparable to Claude Code in capabilities for test generation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disadvantage:&lt;/strong&gt; Requires switching from IntelliJ to a VS Code-based editor, which means losing existing IDE configuration, shortcuts, and debugging setup. The functionality overlap with Claude Code did not justify the migration cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Platform-Based Tools (Testim, Mabl, Katalon, ContextQA)
&lt;/h3&gt;

&lt;p&gt;These are full test automation platforms with AI features including self-healing selectors, test generation from natural language, and visual test builders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not recommended because:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;They require adopting their platform and abandoning your existing framework.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Generated test code is generic and does not match existing page object structure, fixture patterns, or naming conventions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You lose domain-specific knowledge already embedded in your current test suite.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Migrating away from a platform later is expensive.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Qase Aiden
&lt;/h3&gt;

&lt;p&gt;Evaluated previously and joined a live demo call. Generates test code but it is generic and does not adapt to codebase patterns. Same limitation as the platform tools above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Completed:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Installed Claude Code CLI&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Set up Playwright MCP server for live browser access during test creation&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Created CLAUDE.md in project root with framework conventions, project structure, page object patterns, fixture descriptions, test naming conventions, and domain context&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Successfully generated a test using Claude Code with full project context — output matched existing framework patterns&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Next steps:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Continue using Claude Code for upcoming test generation (simple vs complex tests and comparison between them)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Use Claude Code for upcoming test maintenance and updates to measure time savings vs manual approach&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Continue monitoring Playwright Agents for Python support&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Research and write about Javascript agent healers&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;After evaluating the available tools, the best results came from bringing AI into the existing codebase rather than switching to a new platform. The md file made the biggest difference — once the framework conventions were clearly described, the generated code matched existing patterns consistently. There's a clear improvement in speed for both test creation and maintenance, but it still requires human guidance, architectural thinking, and review. It's a powerful assistant, not a replacement, but one wonders what else it will be capable of in the future.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm a solo QA automation engineer and founder based in Slovenia. I build test frameworks, evaluate tooling, and write about what actually works in QA. Find me on &lt;a href="https://www.linkedin.com/in/sara-bezjak/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
