<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>ProvaLab Blog</title>
    <link>https://provalab.io/blog/</link>
    <description>Testing, security and load testing for teams that write code with AI agents.</description>
    <language>en</language>
    <atom:link href="https://provalab.io/feed.xml" rel="self" type="application/rss+xml"/>
    <lastBuildDate>Fri, 13 Nov 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>k6 in practice: open vs closed models, per-endpoint thresholds, checks that count, and finding the limit</title>
      <link>https://provalab.io/blog/k6-in-practice-scenarios-thresholds-open-model/</link>
      <guid isPermaLink="true">https://provalab.io/blog/k6-in-practice-scenarios-thresholds-open-model/</guid>
      <pubDate>Fri, 13 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>k6</category>
      <category>load-testing</category>
      <category>performance-testing</category>
      <category>tool-guide</category>
      <description>Hands-on: a k6 test passed at 60 users while the API managed only 46 of the 60 orders a second. Open-model scenarios, tagged thresholds, checks that fail a run, and a ramp to the limit.</description>
      <content:encoded><![CDATA[<p><a href="https://provalab.io/blog/first-load-test-k6/">Your first load test with k6</a> covered the basics: virtual users, thresholds, checks, and a stress test that found where an API slows down. This guide is about the mistakes that come next. They are easy to make, because each produces a green run that looks fine.</p>
<p>The task: FoodNow, the food delivery app we use as the example across our posts, is planning a lunch promotion. The business wants <strong>60 orders a second for a minute</strong>, with orders answered in under 300 ms at the 95th percentile. We&#39;ll write the test most teams write first, watch it pass, and then find out why it shouldn&#39;t have.</p>
<p>Every run below is real, on one laptop, with:</p>
<table tabindex="0">
<thead>
<tr>
<th>Tool</th>
<th>Version</th>
</tr>
</thead>
<tbody><tr>
<td>k6</td>
<td>v2.4.0 (Docker image <code>grafana/k6</code>)</td>
</tr>
<tr>
<td>FastAPI</td>
<td>0.162.5</td>
</tr>
<tr>
<td>uvicorn</td>
<td>0.54.0 (one worker)</td>
</tr>
</tbody></table>
<p><img src="https://provalab.io/blog/tools/k6-closed-vs-open.svg" alt="Same API, same target, opposite verdicts. Closed model with 60 users: passed with orders p95 282 ms, but only 46 orders a second. Open model with 60 orders started every second: failed, orders p95 8.5 s and about 400 orders never started. After the fix, open model: passed at 60.0 orders a second with p95 23 ms. A ramp on the fixed API: p95 stays near 23 ms until about 340 orders a second, then rises to 559 ms at about 390." width="880" height="500" loading="lazy"></p>
<h2>The API under test (example code)</h2>
<p>This is a small API we built for the guide, with two problems planted on purpose. Placing an order holds a lock for 20 ms, the way a row lock on the stock table would, so at most about 50 orders a second can get through. And when a dish sells out, it answers <code>200 OK</code> with an error in the body.</p>
<pre tabindex="0"><code class="language-python"># app.py: example API for this guide, not production code
import asyncio

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()
MENU = [{&quot;id&quot;: 1, &quot;name&quot;: &quot;Veg thali&quot;, &quot;price&quot;: 17_900},
        {&quot;id&quot;: 2, &quot;name&quot;: &quot;Butter naan&quot;, &quot;price&quot;: 6_500}]
STOCK = {1: 2_000, 2: 100_000}       # portions left today
order_lock = asyncio.Lock()          # one order at a time, like a row lock on stock


class Order(BaseModel):
    user: str
    dish_id: int
    qty: int = 1


@app.get(&quot;/menu&quot;)
async def menu():
    return MENU


@app.post(&quot;/orders&quot;)
async def place_order(order: Order):
    async with order_lock:
        await asyncio.sleep(0.02)    # stock check + insert: 20 ms, so at most ~50 orders/s
        if STOCK[order.dish_id] &lt; order.qty:
            return {&quot;ok&quot;: False, &quot;error&quot;: &quot;sold out&quot;}      # planted: 200 for a failure
        STOCK[order.dish_id] -= order.qty
        return {&quot;ok&quot;: True, &quot;order_id&quot;: sum(STOCK.values())}


@app.post(&quot;/reset&quot;)                  # test environments only
async def reset(thali: int = 2_000):
    STOCK.update({1: thali, 2: 100_000})
    return {&quot;ok&quot;: True}
</code></pre>
<pre tabindex="0"><code class="language-bash">pip install fastapi uvicorn
uvicorn app:app --port 8020
</code></pre>
<p>k6 runs from Docker. <code>--network host</code> lets it reach the API on localhost:</p>
<pre tabindex="0"><code class="language-bash">docker run --rm --network host -v &quot;$PWD:/w&quot; -w /w grafana/k6 run test.js
</code></pre>
<h2>Version 1: the test most teams write first</h2>
<pre tabindex="0"><code class="language-javascript">// v1.js
import http from &quot;k6/http&quot;;
import { check, sleep } from &quot;k6&quot;;

const BASE = __ENV.BASE || &quot;http://localhost:8020&quot;;
const headers = { &quot;Content-Type&quot;: &quot;application/json&quot; };

export const options = {
  vus: 20,
  duration: &quot;60s&quot;,
  thresholds: {
    http_req_duration: [&quot;p(95)&lt;300&quot;],
    http_req_failed: [&quot;rate&lt;0.01&quot;],
  },
};

export default function () {
  for (let i = 0; i &lt; 3; i++) http.get(`${BASE}/menu`);           // browse
  const res = http.post(`${BASE}/orders`,
    JSON.stringify({ user: `user${__VU}`, dish_id: 1 }), { headers });
  check(res, { &quot;order placed&quot;: (r) =&gt; r.status === 200 });
  sleep(1);                                                         // think time
}
</code></pre>
<pre tabindex="0"><code class="language-text">  █ THRESHOLDS
    http_req_duration
    ✓ &#39;p(95)&lt;300&#39; p(95)=22.68ms
    http_req_failed
    ✓ &#39;rate&lt;0.01&#39; rate=0.00%

    checks_succeeded...: 100.00% 1179 out of 1179
    http_reqs......................: 4716   77.299951/s
    iterations.....................: 1179   19.324988/s
</code></pre>
<p>Green. But look at the last line: 19 orders a second. The business asked for 60. This test never asked the question.</p>
<h2>Step 1: Closed model vs open model (10 minutes)</h2>
<p>The obvious fix is more users: 60 users, each placing about one order a second. Then there is the other way to describe the same load: start 60 orders every second, whatever happens. These two are not the same test, and k6 has a different <strong>executor</strong> for each:</p>
<pre tabindex="0"><code class="language-javascript">// rush.js (imports, BASE and headers as in v1.js)
const closed = {                       // &quot;60 users, each orders about once a second&quot;
  executor: &quot;constant-vus&quot;, vus: 60, duration: &quot;60s&quot;,
};
const open = {                         // &quot;60 orders start every second, whatever happens&quot;
  executor: &quot;constant-arrival-rate&quot;, rate: 60, timeUnit: &quot;1s&quot;, duration: &quot;60s&quot;,
  preAllocatedVUs: 100, maxVUs: 400,
};

export const options = {
  scenarios: { rush: __ENV.MODEL === &quot;open&quot; ? open : closed },
  thresholds: {
    http_req_duration: [&quot;p(95)&lt;300&quot;],
    http_req_failed: [&quot;rate&lt;0.01&quot;],
  },
};

export default function () {
  for (let i = 0; i &lt; 3; i++) http.get(`${BASE}/menu`, { tags: { name: &quot;menu&quot; } });
  const res = http.post(`${BASE}/orders`,
    JSON.stringify({ user: `user${__VU}`, dish_id: 1 }),
    { headers, tags: { name: &quot;orders&quot; } });
  check(res, { &quot;order placed&quot;: (r) =&gt; r.status === 200 });
  if (__ENV.MODEL !== &quot;open&quot;) sleep(1);
}
</code></pre>
<p>The closed model (<code>constant-vus</code>), against the same API:</p>
<pre tabindex="0"><code class="language-text">    ✓ &#39;p(95)&lt;300&#39; p(95)=277.53ms
    ✓ &#39;rate&lt;0.01&#39; rate=0.00%
    checks_succeeded...: 100.00% 2819 out of 2819
    iterations.....................: 2819   46.015457/s
</code></pre>
<p>The open model (<code>constant-arrival-rate</code>, run with <code>-e MODEL=open</code>):</p>
<pre tabindex="0"><code class="language-text">level=warning msg=&quot;Insufficient VUs, reached 400 active VUs and cannot initialize more&quot;
    ✗ &#39;p(95)&lt;300&#39; p(95)=7.75s
    ✓ &#39;rate&lt;0.01&#39; rate=0.00%
    checks_succeeded...: 100.00% 3202 out of 3202
    dropped_iterations.............: 398    5.810434/s
    iterations.....................: 3202   46.746256/s
level=error msg=&quot;thresholds on metrics &#39;http_req_duration&#39; have been crossed&quot;
</code></pre>
<p>Same API, same target, opposite verdicts. Here is why.</p>
<p>In the <strong>closed model</strong>, each virtual user waits for its answer before it starts the next order. When the API slows down, the users slow down with it, so the load drops to whatever the API can take. Here, 60 users only managed 46 orders a second, and the slowness never got the chance to build up. Real customers don&#39;t do that. A thousand people opening the app at 12:30 don&#39;t wait for each other&#39;s orders to finish.</p>
<p>In the <strong>open model</strong>, k6 starts 60 orders every second whatever happens, like real arrivals. The API can only finish about 47 a second, so the queue grows every second, and waiting times climb to 7.75 s. k6 ran out of virtual users to start new orders, and 398 orders never started at all. That is <code>dropped_iterations</code>.</p>
<p>Use the open model (<code>constant-arrival-rate</code> or <code>ramping-arrival-rate</code>) when the question is &quot;can we handle N requests a second?&quot; Use the closed model when you really have a fixed number of users who wait, such as 40 staff using an internal tool.</p>
<h2>Step 2: Thresholds per endpoint, and on the rate itself (5 minutes)</h2>
<p>The test above has one <code>http_req_duration</code> threshold for everything. Each order comes with three menu requests that take about 2 ms, so the slow requests are a small share of the total. Give each endpoint its own threshold by tagging requests with <code>name</code> (already done above) and adding a threshold on the tagged metric. While we&#39;re there, add a threshold on the thing the business actually asked for, the rate:</p>
<pre tabindex="0"><code class="language-javascript">  thresholds: {
    http_req_duration: [&quot;p(95)&lt;300&quot;],
    http_req_failed: [&quot;rate&lt;0.01&quot;],
    &quot;http_req_duration{name:orders}&quot;: [&quot;p(95)&lt;300&quot;],
    &quot;http_req_duration{name:menu}&quot;: [&quot;p(95)&lt;100&quot;],
    iterations: [&quot;rate&gt;=58&quot;],
  },
</code></pre>
<p>The closed model again:</p>
<pre tabindex="0"><code class="language-text">    http_req_duration
    ✓ &#39;p(95)&lt;300&#39; p(95)=277.03ms
      {name:menu}
      ✓ &#39;p(95)&lt;100&#39; p(95)=1.68ms
      {name:orders}
      ✓ &#39;p(95)&lt;300&#39; p(95)=281.71ms
    http_req_failed
    ✓ &#39;rate&lt;0.01&#39; rate=0.00%
    iterations
    ✗ &#39;rate&gt;=58&#39; rate=46.116617/s
level=error msg=&quot;thresholds on metrics &#39;iterations&#39; have been crossed&quot;
</code></pre>
<p>To be fair to the blended number: here the orders on their own were also under 300 ms. Per-endpoint thresholds still earn their place. A 1.7 ms menu and a 282 ms order are different stories, and on an API where reads outnumber writes by more, the fast requests will hide the slow ones. But the threshold that caught this run is the rate. <strong>If the requirement is a rate, put the rate in a threshold.</strong> Otherwise a closed-model test can pass while delivering a fraction of the load.</p>
<h2>Step 3: Checks don&#39;t fail a run (5 minutes)</h2>
<p>Look again at the open-model run: <code>checks_succeeded 100.00%, 3202 out of 3202</code>. But there were only 2,000 thalis in stock. About 1,200 of those &quot;orders&quot; were the API saying &quot;sold out&quot;, with status 200. The check only looked at the status.</p>
<p>Two problems, and both are common:</p>
<ol>
<li><strong>The check looked at the status code, not the answer.</strong> Check the body too.</li>
<li><strong>A failing check doesn&#39;t fail the run.</strong> k6 reports checks, but the exit code only depends on thresholds. Add a threshold on <code>checks</code>.</li>
</ol>
<p>And a third, about the test data: the test assumed there would be enough stock, without making sure. <code>setup()</code> runs once before the load starts; use it to put the data in a known state.</p>
<p>Here is the full version 3, open model, with all of that. It also reads its users from a CSV through <code>SharedArray</code>, so 400 virtual users share one copy instead of loading 400:</p>
<pre tabindex="0"><code class="language-javascript">// rush_v3.js
import http from &quot;k6/http&quot;;
import { check } from &quot;k6&quot;;
import { SharedArray } from &quot;k6/data&quot;;

const BASE = __ENV.BASE || &quot;http://localhost:8020&quot;;
const headers = { &quot;Content-Type&quot;: &quot;application/json&quot; };

// 500 test users, loaded once and shared by every VU.
const users = new SharedArray(&quot;users&quot;, () =&gt;
  open(&quot;./users.csv&quot;).split(&quot;\n&quot;).slice(1).filter(Boolean));

export const options = {
  scenarios: {
    rush: {
      executor: &quot;constant-arrival-rate&quot;, rate: 60, timeUnit: &quot;1s&quot;, duration: &quot;60s&quot;,
      preAllocatedVUs: 100, maxVUs: 400,
    },
  },
  thresholds: {
    &quot;http_req_duration{name:menu}&quot;: [&quot;p(95)&lt;100&quot;],
    &quot;http_req_duration{name:orders}&quot;: [&quot;p(95)&lt;300&quot;],
    http_req_failed: [&quot;rate&lt;0.01&quot;],
    checks: [&quot;rate&gt;0.99&quot;],
    dropped_iterations: [&quot;count&lt;1&quot;],
  },
};

export function setup() {
  // Known data before every run: enough thalis for 60/s for 60 s.
  const res = http.post(`${BASE}/reset?thali=${__ENV.THALI || 5000}`);
  if (res.status !== 200) throw new Error(`reset failed: ${res.status}`);
}

export default function () {
  for (let i = 0; i &lt; 3; i++) http.get(`${BASE}/menu`, { tags: { name: &quot;menu&quot; } });
  const user = users[Math.floor(Math.random() * users.length)];
  const res = http.post(`${BASE}/orders`,
    JSON.stringify({ user, dish_id: 1 }), { headers, tags: { name: &quot;orders&quot; } });
  check(res, {
    &quot;order placed&quot;: (r) =&gt; r.status === 200 &amp;&amp; r.json(&quot;ok&quot;) === true,
  });
}

export function handleSummary(data) {
  const m = data.metrics;
  const short = {
    orders_p95_ms: m[&quot;http_req_duration{name:orders}&quot;].values[&quot;p(95)&quot;],
    menu_p95_ms: m[&quot;http_req_duration{name:menu}&quot;].values[&quot;p(95)&quot;],
    orders_per_s: m.iterations.values.rate,
    dropped: m.dropped_iterations ? m.dropped_iterations.values.count : 0,
    checks_rate: m.checks.values.rate,
  };
  return {
    stdout: JSON.stringify(short, null, 2) + &quot;\n&quot;,
    &quot;summary.json&quot;: JSON.stringify(data, null, 2),
  };
}
</code></pre>
<p><code>handleSummary</code> replaces k6&#39;s end-of-test output with your own. Here that is a short summary on screen and the full data in <code>summary.json</code> for CI to keep. One catch: once you return <code>stdout</code>, k6&#39;s usual summary is gone, so print what you need.</p>
<p>To see the check working, run it with the old stock of 2,000 thalis, <code>-e THALI=2000</code>:</p>
<pre tabindex="0"><code class="language-text">{
  &quot;orders_p95_ms&quot;: 8485.18389665,
  &quot;menu_p95_ms&quot;: 1.63783975,
  &quot;orders_per_s&quot;: 46.75651262857817,
  &quot;dropped&quot;: 398,
  &quot;checks_rate&quot;: 0.6246096189881324
}
level=error msg=&quot;thresholds on metrics &#39;checks, dropped_iterations, http_req_duration{name:orders}&#39; have been crossed&quot;
</code></pre>
<p><code>summary.json</code> has the exact count: <code>&quot;passes&quot;: 2000, &quot;fails&quot;: 1202</code>. Every thali sold, then 1,202 failures that version 1 counted as successes. With the default 5,000 thalis the check passes, and the run still fails on speed and dropped orders. k6 exits with code <strong>99</strong> when a threshold is crossed, which turns a CI job red.</p>
<h2>Step 4: Fix it and run again (5 minutes)</h2>
<p>The fix on the API side, in the example: hold the lock only for the stock update, and do the slow part (here, an 18 ms payment call) outside it. And say &quot;sold out&quot; with a status that means it, <code>409 Conflict</code>:</p>
<pre tabindex="0"><code class="language-python">@app.post(&quot;/orders&quot;)
async def place_order(order: Order):
    async with order_lock:             # hold the lock only for the stock update
        await asyncio.sleep(0.002)     # 2 ms
        if STOCK[order.dish_id] &lt; order.qty:
            raise HTTPException(409, &quot;sold out&quot;)
        STOCK[order.dish_id] -= order.qty
        order_id = sum(STOCK.values())
    await asyncio.sleep(0.018)         # payment call: 18 ms, no lock needed
    return {&quot;ok&quot;: True, &quot;order_id&quot;: order_id}
</code></pre>
<p>Each order still takes about 20 ms, but only 2 ms of it is spent holding the lock. Version 3 again:</p>
<pre tabindex="0"><code class="language-text">{
  &quot;orders_p95_ms&quot;: 22.978024,
  &quot;menu_p95_ms&quot;: 1.6793951,
  &quot;orders_per_s&quot;: 59.98472014837337,
  &quot;dropped&quot;: 0,
  &quot;checks_rate&quot;: 1
}
exit 0
</code></pre>
<p>60 orders a second, orders answered in 23 ms, nothing dropped.</p>
<h2>Step 5: Find the new limit (10 minutes)</h2>
<p>Passing at 60 tells you nothing about 200. To find where the fixed API slows down, raise the arrival rate steadily and stop the test once it goes past the limit, with <code>ramping-arrival-rate</code> and <code>abortOnFail</code>:</p>
<pre tabindex="0"><code class="language-javascript">// limit.js
import http from &quot;k6/http&quot;;
import { check } from &quot;k6&quot;;
import exec from &quot;k6/execution&quot;;

const BASE = __ENV.BASE || &quot;http://localhost:8020&quot;;
const headers = { &quot;Content-Type&quot;: &quot;application/json&quot; };

export const options = {
  scenarios: {
    find_limit: {
      executor: &quot;ramping-arrival-rate&quot;, startRate: 50, timeUnit: &quot;1s&quot;,
      preAllocatedVUs: 200, maxVUs: 1000,
      stages: [{ target: 600, duration: &quot;110s&quot; }],   // +5 orders/s every second
    },
  },
  thresholds: {
    &quot;http_req_duration{name:orders}&quot;: [
      { threshold: &quot;p(95)&lt;300&quot;, abortOnFail: true, delayAbortEval: &quot;10s&quot; },
    ],
  },
};

export function setup() { http.post(`${BASE}/reset?thali=1000000`); }

export default function () {
  const res = http.post(`${BASE}/orders`,
    JSON.stringify({ user: `user${exec.vu.idInTest}`, dish_id: 1 }),
    { headers, tags: { name: &quot;orders&quot; } });
  check(res, { &quot;order placed&quot;: (r) =&gt; r.status === 200 });
}
</code></pre>
<pre tabindex="0"><code class="language-text">    ✗ &#39;p(95)&lt;300&#39; p(95)=449.63ms
find_limit ✗ [  67% ] 0240/0256 VUs  1m14.0s/1m50s  419.85 iters/s
level=error msg=&quot;thresholds on metrics &#39;http_req_duration{name:orders}&#39; were crossed;
  at least one has abortOnFail enabled, stopping test prematurely&quot;
</code></pre>
<p>It stopped at 74 seconds, at about 420 orders a second. <strong>That is not the limit</strong>, and the reason is worth knowing: the threshold is a p95 over the whole run so far, including a minute of fast early requests, so it trips late. To see where it really bent, we ran it again with <code>--out csv=limit.csv</code> and worked out p95 for each 5-second slice:</p>
<table tabindex="0">
<thead>
<tr>
<th>Seconds</th>
<th>Orders started per second</th>
<th>p95</th>
</tr>
</thead>
<tbody><tr>
<td>0–5</td>
<td>~62</td>
<td>23 ms</td>
</tr>
<tr>
<td>25–30</td>
<td>~188</td>
<td>23 ms</td>
</tr>
<tr>
<td>45–50</td>
<td>~288</td>
<td>26 ms</td>
</tr>
<tr>
<td>55–60</td>
<td>~338</td>
<td>31 ms</td>
</tr>
<tr>
<td>60–65</td>
<td>~362</td>
<td>117 ms</td>
</tr>
<tr>
<td>65–70</td>
<td>~388</td>
<td>559 ms</td>
</tr>
</tbody></table>
<p>The fixed API on this laptop holds to about 340 orders a second, then the queue builds quickly. That is more than five times the promotion&#39;s target. That&#39;s the number to write down, and to re-test whenever the order code changes.</p>
<h2>Executors at a glance</h2>
<table tabindex="0">
<thead>
<tr>
<th>Executor</th>
<th>Model</th>
<th>Use it for</th>
</tr>
</thead>
<tbody><tr>
<td><code>constant-vus</code></td>
<td>Closed</td>
<td>A fixed number of users who wait for each answer</td>
</tr>
<tr>
<td><code>ramping-vus</code></td>
<td>Closed</td>
<td>The same, growing and shrinking</td>
</tr>
<tr>
<td><code>constant-arrival-rate</code></td>
<td>Open</td>
<td>&quot;Can we take N requests a second?&quot;</td>
</tr>
<tr>
<td><code>ramping-arrival-rate</code></td>
<td>Open</td>
<td>Finding the limit; traffic that builds up</td>
</tr>
<tr>
<td><code>shared-iterations</code> / <code>per-vu-iterations</code></td>
<td>Closed</td>
<td>A fixed amount of work, such as a data load</td>
</tr>
</tbody></table>
<h2>Habits worth keeping</h2>
<ol>
<li><strong>Requirement says &quot;per second&quot;? Use an arrival-rate executor,</strong> and put the rate in a threshold.</li>
<li><strong>Watch <code>dropped_iterations</code>.</strong> Every dropped iteration is a request your test meant to send and didn&#39;t. Give it a threshold of <code>count&lt;1</code>.</li>
<li><strong>Tag requests by endpoint</strong> and set thresholds per tag.</li>
<li><strong>Checks look at the answer, not just the status, and get a threshold.</strong> On their own they never fail a run.</li>
<li><strong>Put the test data in a known state in <code>setup()</code>.</strong> &quot;Sold out&quot; halfway through is a data problem, not a performance result.</li>
<li><strong>Don&#39;t read the limit off an aborted run.</strong> Look at the time series.</li>
</ol>
<h2>What&#39;s next</h2>
<p>A laptop and one process are fine for finding a bug like this lock. To know what production can take, the load has to come from outside your network, at the right rate, and you need to compare each run with the last one. ProvaLab&#39;s load testing runs your k6 tests and JMeter plans on ProvaLab&#39;s own runners, compares each run with a baseline so a slower build can fail the pipeline, and keeps the trend across runs. See it in the <a href="https://provalab.io/#stage-load">two-minute product tour</a>.</p>
<p>New to k6? Start with <a href="https://provalab.io/blog/first-load-test-k6/">Your first load test with k6</a>. For the same &quot;is my test telling the truth?&quot; questions applied to pytest, read <a href="https://provalab.io/blog/pytest-in-practice-fixtures-parametrize-markers/">pytest in practice</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>ZAP in practice: scanning behind a login with the Automation Framework, failing the build, and what no scanner finds</title>
      <link>https://provalab.io/blog/zap-in-practice-authenticated-scan-automation-framework/</link>
      <guid isPermaLink="true">https://provalab.io/blog/zap-in-practice-authenticated-scan-automation-framework/</guid>
      <pubDate>Fri, 13 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>owasp-zap</category>
      <category>security-testing</category>
      <category>dast</category>
      <category>tool-guide</category>
      <description>Hands-on: a ZAP scan that never signed in missed the XSS behind the login. We fix that with an Automation Framework plan, triage 3 alerts that are 1 bug, fail CI on High, and test what ZAP can't.</description>
      <content:encoded><![CDATA[<p>In <a href="https://provalab.io/blog/first-security-scan-owasp-zap/">your first ZAP scan</a> we scanned an API that used a bearer token, so getting past the login was one header. Most web apps aren&#39;t like that. You sign in through a form, the app sets a session cookie, and everything worth attacking sits behind it. Point a scanner at the front page, and it tests the front page.</p>
<p>This guide scans a small web app the way you&#39;d scan a real one. We start with a scan that looks thorough and misses the main bug, then build a ZAP <strong>Automation Framework</strong> plan that signs in, stays signed in, finds the bug and fails the build. Then we write the tests for the bug no scanner will find. Every run below is real:</p>
<table tabindex="0">
<thead>
<tr>
<th>Tool</th>
<th>Version</th>
</tr>
</thead>
<tbody><tr>
<td>ZAP</td>
<td>2.17.0 (Docker image <code>zaproxy/zap-stable</code>)</td>
</tr>
<tr>
<td>FastAPI</td>
<td>0.162.5</td>
</tr>
<tr>
<td>pytest</td>
<td>9.2.0</td>
</tr>
</tbody></table>
<p><img src="https://provalab.io/blog/tools/zap-what-each-scan-saw.svg" alt="What each scan saw. 1: full scan, not signed in: reached / and /login only, missed all three bugs. 2: plan with form login: found the cookie problem, missed the XSS because the spider never reached /orders. 3: plan with /orders as a start URL: found XSS and cookie, exit 1. pytest access-control tests: found Bob's order being shown to Alice, which no ZAP run found. After the fixes: ZAP exit 0, pytest 3 passed." width="880" height="520" loading="lazy"></p>
<h2>The app: FoodNow&#39;s order pages (example code)</h2>
<p>FoodNow is the food delivery app we use as the example across our posts. This is a tiny version of its website with <strong>three bugs planted on purpose</strong>. Don&#39;t deploy it.</p>
<ol>
<li>The order search page prints the search term back without escaping it: reflected cross-site scripting (XSS).</li>
<li>The session cookie is set without <code>HttpOnly</code> or <code>SameSite</code>.</li>
<li>The order page checks that you&#39;re signed in, but not that the order is yours.</li>
</ol>
<pre tabindex="0"><code class="language-python"># app.py: example app with planted vulnerabilities, never deploy
import html
import secrets

from fastapi import Cookie, FastAPI, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse

app = FastAPI()
USERS = {&quot;alice&quot;: &quot;alice-pass-123&quot;, &quot;bob&quot;: &quot;bob-pass-456&quot;}
ORDERS = {
    101: {&quot;user&quot;: &quot;alice&quot;, &quot;items&quot;: &quot;Veg thali x2&quot;, &quot;address&quot;: &quot;12 MG Road, Pune&quot;},
    102: {&quot;user&quot;: &quot;bob&quot;, &quot;items&quot;: &quot;Butter naan x4&quot;, &quot;address&quot;: &quot;7 Park Street, Kolkata&quot;},
}
SESSIONS: dict[str, str] = {}


def page(title: str, body: str) -&gt; HTMLResponse:
    return HTMLResponse(f&quot;&lt;!doctype html&gt;&lt;html lang=&#39;en&#39;&gt;&lt;head&gt;&lt;title&gt;{title}&lt;/title&gt;&lt;/head&gt;&quot;
                        f&quot;&lt;body&gt;&lt;h1&gt;{title}&lt;/h1&gt;{body}&lt;/body&gt;&lt;/html&gt;&quot;)


def current_user(session: str | None) -&gt; str:
    user = SESSIONS.get(session or &quot;&quot;)
    if not user:
        raise HTTPException(303, headers={&quot;Location&quot;: &quot;/login&quot;})
    return user


@app.get(&quot;/&quot;, response_class=HTMLResponse)
def home():
    return page(&quot;FoodNow&quot;, &quot;&lt;p&gt;Hot food, fast.&lt;/p&gt;&lt;a href=&#39;/login&#39;&gt;Sign in&lt;/a&gt;&quot;)


@app.get(&quot;/login&quot;, response_class=HTMLResponse)
def login_form():
    return page(&quot;Sign in&quot;, &quot;&lt;form method=&#39;post&#39; action=&#39;/login&#39;&gt;&quot;
                &quot;&lt;label&gt;User &lt;input name=&#39;username&#39;&gt;&lt;/label&gt;&quot;
                &quot;&lt;label&gt;Password &lt;input name=&#39;password&#39; type=&#39;password&#39;&gt;&lt;/label&gt;&quot;
                &quot;&lt;button&gt;Sign in&lt;/button&gt;&lt;/form&gt;&quot;)


@app.post(&quot;/login&quot;)
def login(username: str = Form(...), password: str = Form(...)):
    if USERS.get(username) != password:
        return page(&quot;Sign in&quot;, &quot;&lt;p&gt;Wrong user or password.&lt;/p&gt;&lt;a href=&#39;/login&#39;&gt;Try again&lt;/a&gt;&quot;)
    token = secrets.token_hex(16)
    SESSIONS[token] = username
    resp = RedirectResponse(&quot;/orders&quot;, status_code=303)
    resp.set_cookie(&quot;session&quot;, token)                      # planted: no HttpOnly, no SameSite
    return resp


@app.get(&quot;/orders&quot;, response_class=HTMLResponse)
def my_orders(session: str | None = Cookie(None)):
    user = current_user(session)
    rows = &quot;&quot;.join(f&quot;&lt;li&gt;&lt;a href=&#39;/orders/{oid}&#39;&gt;Order {oid}&lt;/a&gt;&lt;/li&gt;&quot;
                   for oid, o in ORDERS.items() if o[&quot;user&quot;] == user)
    return page(&quot;Your orders&quot;, f&quot;&lt;p&gt;Signed in as {html.escape(user)}. &lt;a href=&#39;/logout&#39;&gt;Sign out&lt;/a&gt;&lt;/p&gt;&quot;
                &quot;&lt;form action=&#39;/orders/search&#39;&gt;&lt;input name=&#39;q&#39;&gt;&lt;button&gt;Search&lt;/button&gt;&lt;/form&gt;&quot;
                f&quot;&lt;ul&gt;{rows}&lt;/ul&gt;&quot;)


@app.get(&quot;/orders/search&quot;, response_class=HTMLResponse)
def search(q: str = &quot;&quot;, session: str | None = Cookie(None)):
    user = current_user(session)
    found = [oid for oid, o in ORDERS.items() if o[&quot;user&quot;] == user and q.lower() in o[&quot;items&quot;].lower()]
    return page(&quot;Search&quot;, f&quot;&lt;p&gt;Results for {q}: {len(found)}&lt;/p&gt;&quot;)   # planted: q not escaped


@app.get(&quot;/orders/{oid}&quot;, response_class=HTMLResponse)
def order(oid: int, session: str | None = Cookie(None)):
    current_user(session)                                   # planted: never checks the owner
    o = ORDERS.get(oid)
    if not o:
        raise HTTPException(404)
    return page(f&quot;Order {oid}&quot;, f&quot;&lt;p&gt;{html.escape(o[&#39;items&#39;])}&lt;/p&gt;&lt;p&gt;{html.escape(o[&#39;address&#39;])}&lt;/p&gt;&quot;)


@app.get(&quot;/logout&quot;)
def logout(session: str | None = Cookie(None)):
    SESSIONS.pop(session or &quot;&quot;, None)
    return RedirectResponse(&quot;/&quot;, status_code=303)
</code></pre>
<pre tabindex="0"><code class="language-bash">pip install fastapi uvicorn python-multipart
uvicorn app:app --host 0.0.0.0 --port 8030
docker pull zaproxy/zap-stable        # about 3.7 GB
mkdir wrk &amp;&amp; chmod 777 wrk            # ZAP runs as a non-root user and writes reports here
</code></pre>
<h2>Scan 1: the full scan, without signing in (2 minutes)</h2>
<p>ZAP ships three ready-made scan scripts: <code>zap-baseline.py</code> (spider and passive checks only), <code>zap-api-scan.py</code> (from an OpenAPI file, as in the first guide) and <code>zap-full-scan.py</code> (spider plus active attacks). Full scan sounds thorough:</p>
<pre tabindex="0"><code class="language-bash">docker run --rm --network host -v &quot;$PWD/wrk:/zap/wrk:rw&quot; zaproxy/zap-stable \
  zap-full-scan.py -P 8099 -t http://localhost:8030/ -m 1 -r full-noauth.html -J full-noauth.json
</code></pre>
<p>(<code>-P 8099</code> moves ZAP off port 8080, in case something on your machine already uses it. <code>-m 1</code> gives the spider one minute.)</p>
<pre tabindex="0"><code class="language-text">WARN-NEW: Missing Anti-clickjacking Header [10020] x 3
	http://localhost:8030/ (200 OK)
	http://localhost:8030/login (200 OK)
WARN-NEW: X-Content-Type-Options Header Missing [10021] x 3
WARN-NEW: Content Security Policy (CSP) Header Not Set [10038] x 3
WARN-NEW: Permissions Policy Header Not Set [10063] x 3
WARN-NEW: Absence of Anti-CSRF Tokens [10202] x 1
	http://localhost:8030/login (200 OK)
WARN-NEW: Cross-Origin-Resource-Policy Header Missing or Invalid [90004] x 9
FAIL-NEW: 0	FAIL-INPROG: 0	WARN-NEW: 6	WARN-INPROG: 0	INFO: 0	IGNORE: 0	PASS: 137
</code></pre>
<p>137 checks passed, 6 warnings, no failures, done in 1 minute 37 seconds. It looks like a clean bill of health. Look at the URLs instead: <strong><code>/</code> and <code>/login</code>, nothing else.</strong> ZAP can&#39;t sign in, so it never saw the order pages, never got a session cookie, and never tried an attack on the search box. The XSS and the cookie are both still there.</p>
<p><strong>Lesson one:</strong> before you read a scan&#39;s findings, read its list of URLs. A scan is only as good as the pages it reached.</p>
<h2>Scan 2: an Automation Framework plan that signs in (10 minutes)</h2>
<p>The packaged scripts are fine for a quick look. For anything with a login, use the <strong>Automation Framework</strong>: one YAML file that describes the app, how to sign in, and which jobs to run in which order. It goes in version control next to your code.</p>
<pre tabindex="0"><code class="language-yaml"># wrk/zap.yaml
env:
  contexts:
    - name: foodnow
      urls:
        - http://localhost:8030/
      includePaths:
        - &quot;http://localhost:8030.*&quot;
      excludePaths:
        - &quot;http://localhost:8030/logout&quot;     # or the spider signs itself out
      authentication:
        method: form
        parameters:
          loginPageUrl: http://localhost:8030/login
          loginRequestUrl: http://localhost:8030/login
          loginRequestBody: &quot;username={%username%}&amp;password={%password%}&quot;
        verification:
          method: poll
          pollUrl: http://localhost:8030/orders
          pollFrequency: 60
          pollUnits: requests
          loggedInRegex: &quot;Signed in as&quot;
          loggedOutRegex: &quot;&lt;h1&gt;Sign in&lt;/h1&gt;&quot;
      sessionManagement:
        method: cookie
      users:
        - name: alice
          credentials:
            username: alice
            password: ${ALICE_PASSWORD}     # from the environment, never in the file
  parameters:
    failOnError: true
    progressToStdout: true
jobs:
  - type: spider
    parameters:
      context: foodnow
      user: alice
      maxDuration: 1
  - type: passiveScan-wait
  - type: activeScan
    parameters:
      context: foodnow
      user: alice
      maxScanDurationInMins: 5
  - type: report
    parameters:
      template: traditional-json
      reportDir: /zap/wrk
      reportFile: af-auth
</code></pre>
<p>What the important parts do:</p>
<ul>
<li><strong><code>authentication</code></strong> tells ZAP how to sign in: which URL to post to, with <code>{%username%}</code> and <code>{%password%}</code> filled in from the user.</li>
<li><strong><code>verification</code></strong> is how ZAP knows it&#39;s still signed in. Every 60 requests it fetches <code>/orders</code> and looks for &quot;Signed in as&quot;. Without this, a scan that gets signed out partway through carries on, testing the sign-in page over and over.</li>
<li><strong><code>excludePaths</code></strong> keeps the spider away from <code>/logout</code>. Forget it, and the spider clicks &quot;Sign out&quot; early on.</li>
<li><strong><code>${ALICE_PASSWORD}</code></strong> comes from an environment variable, so the password lives in your CI secrets, not in the repository.</li>
</ul>
<p>Run it:</p>
<pre tabindex="0"><code class="language-bash">docker run --rm --network host -e ALICE_PASSWORD=alice-pass-123 \
  -v &quot;$PWD/wrk:/zap/wrk:rw&quot; zaproxy/zap-stable \
  zap.sh -cmd -port 8099 -autorun /zap/wrk/zap.yaml
</code></pre>
<pre tabindex="0"><code class="language-text">Job spider requesting URL http://localhost:8030/
Job spider found 6 URLs
Job activeScan finished, time taken: 00:01:10
Automation plan succeeded!
</code></pre>
<p>The sign-in worked: the report now has <strong>Cookie No HttpOnly Flag [10010]</strong>, raised on the response to the <code>/login</code> post, which only a signed-in run sees. But there is no XSS. Look at the spider again: 6 URLs, starting from <code>/</code>. The home page links only to <code>/login</code>, and nothing the spider could follow from there led to <code>/orders</code>. ZAP signs in with its own login request; that doesn&#39;t tell the spider where the signed-in pages are. So it never found <code>/orders</code>, the search form, or <code>/orders/search</code>.</p>
<p><strong>Lesson two:</strong> signing in isn&#39;t enough. The spider has to be able to reach the pages behind the login.</p>
<h2>Scan 3: give the spider a way in (2 minutes)</h2>
<p>Two small changes: add <code>/orders</code> to the context&#39;s URLs, and start the spider there.</p>
<pre tabindex="0"><code class="language-yaml">      urls:
        - http://localhost:8030/
        - http://localhost:8030/orders
...
  - type: spider
    parameters:
      context: foodnow
      user: alice
      url: http://localhost:8030/orders
      maxDuration: 1
</code></pre>
<pre tabindex="0"><code class="language-text">Job spider requesting URL http://localhost:8030/
Job spider requesting URL http://localhost:8030/orders
Job spider found 10 URLs
Job activeScan finished, time taken: 00:01:44
</code></pre>
<p>Now the report has what we planted:</p>
<pre tabindex="0"><code class="language-text">[40012] High (Medium)  Cross Site Scripting (Reflected) x1
        GET /orders/search  param=q  attack=&lt;/p&gt;&lt;scrIpt&gt;alert(1);&lt;/scRipt&gt;&lt;p&gt;
[40026] High (High)    Cross Site Scripting (DOM Based) x2
        GET /orders/search?q=&lt;script&gt;alert(5397)&lt;/script&gt;
        GET /orders#jaVasCript:/*-/*`/*\`/*&#39;/*&quot;/**/(/* */oNcliCk=alert(5397) )//...
[10010] Low (Medium)   Cookie No HttpOnly Flag x1
</code></pre>
<p>For a single-page app, you&#39;d also add the <code>spiderAjax</code> job, which drives a real browser to find links that only exist after JavaScript runs. This app has no JavaScript, so the classic spider is enough.</p>
<h2>Triage: three alerts, one bug (5 minutes)</h2>
<p>Three High alerts. Before you file three tickets, read the evidence. The odd one is a DOM-based XSS on <code>/orders</code>, a page that has no JavaScript at all. ZAP&#39;s &quot;Other info&quot; for it lists the steps its browser took:</p>
<pre tabindex="0"><code class="language-text">Access: http://localhost:8030/orders&lt;PAYLOAD_0&gt;
Write to /html/body/form/input the value: &lt;PAYLOAD_0&gt;
Click element: /html/body/form/button
</code></pre>
<p>It typed the attack into the search box and clicked Search, which loaded <code>/orders/search?q=…</code>. That&#39;s the same unescaped <code>q</code> that rule 40012 found. <strong>Three alerts, one bug, one fix.</strong> The DOM XSS rule reports where the browser started, not where the problem is. Scanners report symptoms; grouping them by cause is your job.</p>
<p>The other result worth a decision is <strong>Absence of Anti-CSRF Tokens</strong> on the sign-in form. Login CSRF is a real but small risk, so say we&#39;ve decided to accept it for now and track it. Record that decision in the plan with an <code>alertFilter</code> job, placed before the scans so it is in place when the alert is raised:</p>
<pre tabindex="0"><code class="language-yaml">jobs:
  - type: alertFilter
    alertFilters:
      - ruleId: 10202            # Absence of Anti-CSRF Tokens
        url: &quot;http://localhost:8030/login&quot;
        newRisk: &quot;Info&quot;          # decision: login CSRF accepted for now, see ticket
  - type: spider
  ...
</code></pre>
<p>Use filters for decisions like this, and for confirmed false positives (<code>newRisk: &quot;False Positive&quot;</code>), always with a comment saying why. A filter without a reason becomes a hole nobody remembers making.</p>
<h2>Fail the build on High (2 minutes)</h2>
<p>A scan that only writes a report never blocks anything. Add an <code>exitStatus</code> job at the end of the plan:</p>
<pre tabindex="0"><code class="language-yaml">  - type: exitStatus
    parameters:
      errorLevel: High
      warnLevel: Medium
</code></pre>
<p>Against the buggy app:</p>
<pre tabindex="0"><code class="language-text">Job exitStatus started
An alert has been raised with a risk of at least: Medium
An alert has been raised with a risk of at least: High
Automation plan failures:
	An alert has been raised with a risk of at least: High
Automation plan warnings:
	An alert has been raised with a risk of at least: Medium
exit 1
</code></pre>
<p>Exit code 1 fails the CI job. The login CSRF alert no longer counts towards it, because the filter turned it into Info.</p>
<h2>What no ZAP run found (5 minutes)</h2>
<p>Look back at the first screen: Alice could open <code>/orders/102</code>, Bob&#39;s order, with his address. None of the scans reported it, and none could have. To ZAP, <code>/orders/102</code> returning a page is a success. It doesn&#39;t know which orders are Alice&#39;s. This is broken access control, the <a href="https://provalab.io/blog/security-testing-basics-owasp-top-10/">number one risk in the OWASP Top 10</a>, and it is found by tests that know your business rules:</p>
<pre tabindex="0"><code class="language-python"># tests/test_access.py
import os

import pytest
import requests

BASE = os.environ.get(&quot;BASE&quot;, &quot;http://localhost:8030&quot;)


def signed_in(user, password):
    s = requests.Session()
    r = s.post(f&quot;{BASE}/login&quot;, data={&quot;username&quot;: user, &quot;password&quot;: password})
    assert &quot;Signed in as&quot; in r.text
    return s


@pytest.fixture
def alice():
    return signed_in(&quot;alice&quot;, &quot;alice-pass-123&quot;)


def test_alice_can_read_her_own_order(alice):
    r = alice.get(f&quot;{BASE}/orders/101&quot;)
    assert r.status_code == 200
    assert &quot;12 MG Road&quot; in r.text


def test_alice_cannot_read_bobs_order(alice):
    r = alice.get(f&quot;{BASE}/orders/102&quot;)
    assert r.status_code == 404          # 404, not 403: don&#39;t confirm the order exists
    assert &quot;Park Street&quot; not in r.text


def test_signed_out_user_is_sent_to_login():
    r = requests.get(f&quot;{BASE}/orders/101&quot;, allow_redirects=False)
    assert r.status_code == 303
    assert r.headers[&quot;location&quot;] == &quot;/login&quot;
</code></pre>
<pre tabindex="0"><code class="language-text">$ pytest -q tests
E       assert 200 == 404
E        +  where 200 = &lt;Response [200]&gt;.status_code
1 failed, 2 passed in 0.16s
</code></pre>
<h2>Fix everything and run both again (5 minutes)</h2>
<pre tabindex="0"><code class="language-diff"> app = FastAPI()
+
+
+@app.middleware(&quot;http&quot;)
+async def security_headers(request, call_next):
+    resp = await call_next(request)
+    resp.headers[&quot;Content-Security-Policy&quot;] = &quot;default-src &#39;self&#39;; frame-ancestors &#39;none&#39;; form-action &#39;self&#39;&quot;
+    resp.headers[&quot;X-Frame-Options&quot;] = &quot;DENY&quot;
+    resp.headers[&quot;X-Content-Type-Options&quot;] = &quot;nosniff&quot;
+    return resp

-    resp.set_cookie(&quot;session&quot;, token)
+    resp.set_cookie(&quot;session&quot;, token, httponly=True, samesite=&quot;lax&quot;)  # add secure=True behind HTTPS

-    return page(&quot;Search&quot;, f&quot;&lt;p&gt;Results for {q}: {len(found)}&lt;/p&gt;&quot;)
+    return page(&quot;Search&quot;, f&quot;&lt;p&gt;Results for {html.escape(q)}: {len(found)}&lt;/p&gt;&quot;)

-    current_user(session)
+    user = current_user(session)
     o = ORDERS.get(oid)
-    if not o:
+    if not o or o[&quot;user&quot;] != user:                          # someone else&#39;s order: same as missing
</code></pre>
<pre tabindex="0"><code class="language-text">$ pytest -q tests
3 passed in 0.09s

$ docker run ... zap.sh -cmd -port 8099 -autorun /zap/wrk/zap.yaml
Job spider found 10 URLs
Job exitStatus started
Job exitStatus finished, time taken: 00:00:00
Automation plan succeeded!
exit 0
</code></pre>
<p>What is left in the report is Informational only: the sign-in request and session cookie ZAP identified, and the login CSRF we decided to accept. The spider still found 10 URLs, so the clean result means &quot;tested and clean&quot;, not &quot;never reached&quot;.</p>
<h2>In CI</h2>
<p>The same command runs in any pipeline that has Docker. Start the app, run the plan with the password from your secrets, and keep the report:</p>
<pre tabindex="0"><code class="language-yaml">      - name: ZAP scan (signed in)
        run: |
          docker run --rm --network host \
            -e ALICE_PASSWORD=&quot;${{ secrets.ZAP_TEST_PASSWORD }}&quot; \
            -v &quot;$PWD/wrk:/zap/wrk:rw&quot; zaproxy/zap-stable \
            zap.sh -cmd -port 8099 -autorun /zap/wrk/zap.yaml
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: zap-report, path: wrk/af-auth.* }
</code></pre>
<p>Scan a test environment with test accounts, never production, and never with a real customer&#39;s login. An active scan sends thousands of attack requests, and fills in every form it finds.</p>
<h2>Habits worth keeping</h2>
<ol>
<li><strong>Read the list of URLs before the findings.</strong> A clean scan of two pages proves nothing about the rest.</li>
<li><strong>Use an Automation Framework plan</strong> for anything with a login, kept in the repo, with credentials from the environment.</li>
<li><strong>Verify the session</strong> with a logged-in check, and <strong>exclude logout</strong>.</li>
<li><strong>Give the spider start URLs behind the login.</strong> Logging in doesn&#39;t tell it where the pages are.</li>
<li><strong>Group alerts by cause before filing them.</strong> Three alerts here were one line of code.</li>
<li><strong>Record every filter&#39;s reason</strong> in the plan.</li>
<li><strong>Fail the build with <code>exitStatus</code></strong>, and pair the scan with access-control tests. The scanner finds the generic mistakes; the tests find the ones that depend on who owns what.</li>
</ol>
<h2>What&#39;s next</h2>
<p>A scanner finds the generic mistakes in a running app. To cover the rest, you want code scanning, dependency checks and secrets detection on every change, and the findings kept in one place where someone triages them. ProvaLab AppSec runs code, dependency, secrets and running-app scans on your projects, and keeps each finding&#39;s history so a fixed issue can be re-checked rather than re-filed. See it in the <a href="https://provalab.io/#stage-appsec">two-minute product tour</a>.</p>
<p>New to ZAP? Start with <a href="https://provalab.io/blog/first-security-scan-owasp-zap/">Your first security scan with OWASP ZAP</a>. The other tool guides in this run: <a href="https://provalab.io/blog/pytest-in-practice-fixtures-parametrize-markers/">pytest in practice</a> and <a href="https://provalab.io/blog/k6-in-practice-scenarios-thresholds-open-model/">k6 in practice</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Cross-browser testing (hands-on): Chromium, Firefox and WebKit, and two bugs only Safari users would see</title>
      <link>https://provalab.io/blog/cross-browser-testing-playwright/</link>
      <guid isPermaLink="true">https://provalab.io/blog/cross-browser-testing-playwright/</guid>
      <pubDate>Thu, 12 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>cross-browser-testing</category>
      <category>playwright</category>
      <category>pytest</category>
      <category>webkit</category>
      <description>Run the same tests on three browser engines with Playwright. A real run in the official Docker image: a cut-off button and a broken feature only in WebKit, and an old Safari trap that's gone.</description>
      <content:encoded><![CDATA[<p>Most teams test in Chrome. Most of their users use Chrome-based browsers too, so most bugs are found. The ones that aren&#39;t tend to land on the iPhone, because browsers on an iPhone, including Chrome, use Apple&#39;s <strong>WebKit</strong> engine. (Since iOS 17.4, browsers in the EU may use their own engines, but WebKit is still what almost every iPhone user gets.) A bug that only happens in WebKit is a bug for your iPhone users.</p>
<p><strong>Cross-browser testing</strong> runs the same tests on the different browser <strong>engines</strong>: Chromium (Chrome, Edge, Opera, Samsung Internet), Gecko (Firefox) and WebKit (Safari). Playwright can drive all three from one test.</p>
<p>This post does that for a FoodNow page with two planted bugs. Before planting them, we asked the three engines a set of questions to find out what really differs between them in late 2026, rather than repeating old advice. Every result below is from a real run, in Microsoft&#39;s official Playwright Docker image, version 1.64.0, with Chromium 156, Firefox 157 and WebKit 27.2.</p>
<h2>Why the Docker image</h2>
<p>On a normal Linux laptop, Chromium and Firefox run once Playwright has downloaded them. WebKit didn&#39;t: it needs system libraries that have to be installed with <code>sudo</code>. Playwright&#39;s error said exactly which:</p>
<pre tabindex="0"><code class="language-text">║ Host system is missing dependencies to run browsers. ║
║     sudo playwright install-deps                     ║
║     sudo apt-get install libevent-2.1-7t64\          ║
║         libgstreamer-plugins-bad1.0-0\               ║
║         libflite1\                                   ║
...
</code></pre>
<p>The official image has all three browsers and everything they need, which is also how most teams run them in CI:</p>
<pre tabindex="0"><code class="language-bash">docker pull mcr.microsoft.com/playwright/python:v1.64.0-noble
docker run --rm --ipc=host -v &quot;$PWD&quot;:/work -w /work mcr.microsoft.com/playwright/python:v1.64.0-noble \
  bash -c &quot;pip install -q playwright==1.64.0 pytest==9.2.0 &amp;&amp; python -m pytest -v&quot;
</code></pre>
<p>Two details: the image contains the browsers but not the Python package, so install the same Playwright version inside it. And <code>--ipc=host</code> gives Chromium enough shared memory, which avoids crashes on bigger pages.</p>
<p>WebKit in Playwright is the WebKit engine on Linux, not Safari on a Mac. It catches engine differences, which are most of them, but not everything that&#39;s specific to macOS or iOS. For that, you need real Apple devices, as in <a href="https://provalab.io/blog/mobile-app-testing-appium-pytest/">Part 6</a>.</p>
<h2>Step 1: Ask the engines what differs</h2>
<p>This script asks each engine the same questions, all in the same browser locale (<code>en-IN</code>) and time zone (India):</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;Ask each browser engine the same questions and print the answers side by side.&quot;&quot;&quot;
from playwright.sync_api import sync_playwright

CHECKS = {
    &quot;Date(&#39;2026-11-12 19:30&#39;)&quot;: &quot;String(new Date(&#39;2026-11-12 19:30&#39;))&quot;,
    &quot;Date(&#39;2026-11-12T19:30&#39;)&quot;: &quot;String(new Date(&#39;2026-11-12T19:30&#39;).getHours())&quot;,
    &quot;Date(&#39;12 Nov 2026 19:30&#39;)&quot;: &quot;String(new Date(&#39;12 Nov 2026 19:30&#39;).getHours())&quot;,
    &quot;en-IN time&quot;: &quot;new Date(2026,10,12,19,30).toLocaleTimeString(&#39;en-IN&#39;,{timeStyle:&#39;short&#39;})&quot;,
    &quot;en-IN money&quot;: &quot;new Intl.NumberFormat(&#39;en-IN&#39;,{style:&#39;currency&#39;,currency:&#39;INR&#39;}).format(125000.5)&quot;,
    &quot;button height&quot;: &quot;(()=&gt;{const b=document.createElement(&#39;button&#39;);b.textContent=&#39;Pay&#39;;document.body.append(b);return b.getBoundingClientRect().height})()&quot;,
    &quot;input height&quot;: &quot;(()=&gt;{const b=document.createElement(&#39;input&#39;);document.body.append(b);return b.getBoundingClientRect().height})()&quot;,
    &quot;date input type&quot;: &quot;(()=&gt;{const i=document.createElement(&#39;input&#39;);i.type=&#39;date&#39;;return i.type})()&quot;,
    &quot;scrollbar width&quot;: &quot;(()=&gt;{const d=document.createElement(&#39;div&#39;);d.style.cssText=&#39;width:100px;height:100px;overflow:scroll&#39;;document.body.append(d);return 100-d.clientWidth})()&quot;,
    &quot;user agent&quot;: &quot;navigator.userAgent.slice(0,90)&quot;,
}
with sync_playwright() as p:
    for bt in (p.chromium, p.firefox, p.webkit):
        b = bt.launch()
        page = b.new_page(locale=&quot;en-IN&quot;, timezone_id=&quot;Asia/Kolkata&quot;)
        page.set_content(&quot;&lt;!doctype html&gt;&lt;body&gt;&lt;/body&gt;&quot;)
        print(f&quot;== {bt.name} {b.version}&quot;)
        for name, js in CHECKS.items():
            try:
                print(f&quot;  {name:&lt;28} {page.evaluate(js)!r}&quot;)
            except Exception as e:
                print(f&quot;  {name:&lt;28} ERROR {str(e).splitlines()[0][:80]}&quot;)
        b.close()
</code></pre>
<p>The results, side by side:</p>
<table tabindex="0">
<thead>
<tr>
<th>Question</th>
<th>Chromium 156</th>
<th>Firefox 157</th>
<th>WebKit 27.2</th>
</tr>
</thead>
<tbody><tr>
<td><code>new Date(&quot;2026-11-12 19:30&quot;)</code></td>
<td>19:30 India time</td>
<td>same</td>
<td>same</td>
</tr>
<tr>
<td><code>&quot;2026-11-12T19:30&quot;</code>, <code>&quot;12 Nov 2026 19:30&quot;</code></td>
<td>hour 19</td>
<td>19</td>
<td>19</td>
</tr>
<tr>
<td>Indian time format</td>
<td><code>7:30 pm</code></td>
<td><code>7:30 pm</code></td>
<td><code>7:30 pm</code></td>
</tr>
<tr>
<td>Indian money format</td>
<td><code>₹1,25,000.50</code></td>
<td>same</td>
<td>same</td>
</tr>
<tr>
<td>Default <strong>button</strong> height</td>
<td><strong>21px</strong></td>
<td><strong>23px</strong></td>
<td><strong>30px</strong></td>
</tr>
<tr>
<td>Default <strong>text input</strong> height</td>
<td><strong>21px</strong></td>
<td><strong>23px</strong></td>
<td><strong>27px</strong></td>
</tr>
<tr>
<td>Scrollbar width in a scrolling box</td>
<td>0</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>User agent says</td>
<td>Linux, HeadlessChrome</td>
<td>Linux, Firefox</td>
<td><strong>Macintosh</strong>, Safari-like</td>
</tr>
</tbody></table>
<p>The date row matters because of old advice. For years, <code>new Date(&quot;2026-11-12 19:30&quot;)</code>, with a space instead of a <code>T</code>, returned &quot;Invalid Date&quot; in Safari, and countless bugs came from it. In WebKit 27.2 it parses like the others. The JavaScript standard still leaves non-standard date strings up to each engine, so prefer <code>2026-11-12T19:30</code> or separate numbers. But the specific trap many testers still check for didn&#39;t appear here.</p>
<p>What did differ: the <strong>size of form controls</strong>. A button is 21 pixels tall in Chromium, 23 in Firefox and 30 in WebKit, with the same CSS. And Firefox measured 25 pixels on our laptop but 23 in the Docker image, because the fonts differ. Same browser, different machine, different layout.</p>
<p>We also checked newer features:</p>
<pre tabindex="0"><code class="language-python">CHECKS = {
    &quot;Temporal&quot;: &quot;typeof Temporal&quot;,
    &quot;Intl.DurationFormat&quot;: &quot;typeof Intl.DurationFormat&quot;,
    &quot;CSS field-sizing&quot;: &quot;CSS.supports(&#39;field-sizing&#39;,&#39;content&#39;)&quot;,
    &quot;CSS anchor-name&quot;: &quot;CSS.supports(&#39;anchor-name&#39;,&#39;--a&#39;)&quot;,
    &quot;View transitions&quot;: &quot;typeof document.startViewTransition&quot;,
    &quot;Uint8Array.fromBase64&quot;: &quot;typeof Uint8Array.fromBase64&quot;,
    &quot;RegExp.escape&quot;: &quot;typeof RegExp.escape&quot;,
    &quot;Error.isError&quot;: &quot;typeof Error.isError&quot;,
    &quot;showPicker&quot;: &quot;typeof HTMLInputElement.prototype.showPicker&quot;,
    &quot;Element.moveBefore&quot;: &quot;typeof Element.prototype.moveBefore&quot;,
    &quot;CSS text-wrap: pretty&quot;: &quot;CSS.supports(&#39;text-wrap&#39;,&#39;pretty&#39;)&quot;,
    &quot;Navigation API&quot;: &quot;typeof navigation&quot;,
}
</code></pre>
<p>Ten of the twelve were supported in all three engines. Two weren&#39;t:</p>
<table tabindex="0">
<thead>
<tr>
<th>Feature</th>
<th>Chromium 156</th>
<th>Firefox 157</th>
<th>WebKit 27.2</th>
</tr>
</thead>
<tbody><tr>
<td>CSS <code>text-wrap: pretty</code></td>
<td>yes</td>
<td><strong>no</strong></td>
<td>yes</td>
</tr>
<tr>
<td><code>Element.moveBefore()</code></td>
<td>yes</td>
<td>yes</td>
<td><strong>no</strong></td>
</tr>
</tbody></table>
<p><code>text-wrap: pretty</code> only changes how lines break, so a browser without it just ignores it. <code>moveBefore()</code> is a JavaScript method: calling it where it doesn&#39;t exist throws an error, and whatever it was doing doesn&#39;t happen.</p>
<h2>Step 2: The page</h2>
<p>A FoodNow &quot;order for later&quot; page with three parts: the delivery time, a list of dishes with a &quot;Put my usual first&quot; button, and a promo code row:</p>
<pre tabindex="0"><code class="language-html">&lt;style&gt;
  /* The promo row was sized to fit the button exactly as it looked in Chrome. */
  .promo { display: flex; gap: 8px; height: 21px; overflow: hidden; }
  .promo input { flex: 1; }
&lt;/style&gt;
...
&lt;p&gt;Your lunch arrives at &lt;span id=&quot;slot&quot;&gt;&lt;/span&gt;.&lt;/p&gt;
...
&lt;ul id=&quot;dishes&quot;&gt;&lt;li&gt;Masala chai&lt;/li&gt;&lt;li&gt;Veg biryani&lt;/li&gt;&lt;li id=&quot;usual&quot;&gt;Paneer wrap&lt;/li&gt;&lt;/ul&gt;
&lt;button id=&quot;usual-first&quot;&gt;Put my usual first&lt;/button&gt;
...
&lt;div class=&quot;promo&quot;&gt;&lt;input id=&quot;code&quot; placeholder=&quot;Promo code&quot;&gt;&lt;button id=&quot;apply&quot;&gt;Apply&lt;/button&gt;&lt;/div&gt;
&lt;script&gt;
  // The server sends the slot as &quot;YYYY-MM-DD HH:MM&quot;, in the kitchen&#39;s time.
  const slotFromServer = &quot;2026-11-12 19:30&quot;;
  const when = new Date(slotFromServer);
  document.getElementById(&quot;slot&quot;).textContent =
    when.toLocaleTimeString(&quot;en-IN&quot;, { hour: &quot;numeric&quot;, minute: &quot;2-digit&quot; });

  // Move the customer&#39;s usual dish to the top, keeping its state (moveBefore doesn&#39;t reset it).
  document.getElementById(&quot;usual-first&quot;).onclick = () =&gt; {
    const list = document.getElementById(&quot;dishes&quot;);
    list.moveBefore(document.getElementById(&quot;usual&quot;), list.firstElementChild);
  };
&lt;/script&gt;
</code></pre>
<p>Two planted bugs, both built on assumptions that are true in Chrome: a row exactly as tall as Chrome&#39;s button, and a DOM method that Chrome has. The date string with a space is there on purpose too, to check the old Safari trap in a real page.</p>
<h2>Step 3: One test, three browsers</h2>
<p>A parametrized fixture runs every test once per engine. <code>tests/conftest.py</code>:</p>
<pre tabindex="0"><code class="language-python">import functools
import http.server
import threading
from pathlib import Path

import pytest
from playwright.sync_api import sync_playwright

SITE = Path(__file__).parents[1] / &quot;site&quot;


@pytest.fixture(scope=&quot;session&quot;)
def base_url():
    handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=SITE)
    server = http.server.ThreadingHTTPServer((&quot;127.0.0.1&quot;, 0), handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    yield f&quot;http://127.0.0.1:{server.server_port}&quot;
    server.shutdown()


@pytest.fixture(scope=&quot;session&quot;)
def playwright():
    with sync_playwright() as p:
        yield p


@pytest.fixture(params=[&quot;chromium&quot;, &quot;firefox&quot;, &quot;webkit&quot;])
def page(request, playwright, base_url):
    browser = getattr(playwright, request.param).launch()
    page = browser.new_page(locale=&quot;en-IN&quot;, timezone_id=&quot;Asia/Kolkata&quot;, viewport={&quot;width&quot;: 412, &quot;height&quot;: 500})
    page.goto(base_url + &quot;/index.html&quot;)
    yield page
    page.screenshot(path=f&quot;shots/{request.node.name}.png&quot;)
    browser.close()
</code></pre>
<p>(If you use the <code>pytest-playwright</code> plugin, <code>pytest --browser chromium --browser firefox --browser webkit</code> does the same.)</p>
<p>The tests, in <code>tests/test_xbrowser.py</code>:</p>
<pre tabindex="0"><code class="language-python">def test_slot_time_is_shown(page):
    assert page.text_content(&quot;#slot&quot;) == &quot;7:30 pm&quot;


def test_apply_button_is_not_cut_off(page):
    # The button&#39;s box can be squeezed to fit the row, so check that its *content* fits in the box.
    content, inside = page.evaluate(
        &quot;() =&gt; { const b = document.getElementById(&#39;apply&#39;); return [b.scrollHeight, b.clientHeight]; }&quot;)
    assert content &lt;= inside + 1, f&quot;the button&#39;s text needs {content}px but has {inside}px&quot;


def test_usual_dish_moves_to_the_top(page):
    errors = []
    page.on(&quot;pageerror&quot;, lambda e: errors.append(str(e)))
    page.click(&quot;#usual-first&quot;)
    assert errors == []
    assert page.locator(&quot;#dishes li&quot;).all_inner_texts()[0] == &quot;Paneer wrap&quot;
</code></pre>
<p><code>page.on(&quot;pageerror&quot;, ...)</code> collects JavaScript errors that the page throws. Without it, a broken click often just &quot;does nothing&quot;, and the test fails later with a confusing message, or not at all.</p>
<h2>Step 4: Run it</h2>
<pre tabindex="0"><code class="language-text">tests/test_xbrowser.py::test_slot_time_is_shown[chromium] PASSED         [ 11%]
tests/test_xbrowser.py::test_slot_time_is_shown[firefox] PASSED          [ 22%]
tests/test_xbrowser.py::test_slot_time_is_shown[webkit] PASSED           [ 33%]
tests/test_xbrowser.py::test_apply_button_is_not_cut_off[chromium] PASSED [ 44%]
tests/test_xbrowser.py::test_apply_button_is_not_cut_off[firefox] PASSED [ 55%]
tests/test_xbrowser.py::test_apply_button_is_not_cut_off[webkit] FAILED  [ 66%]
tests/test_xbrowser.py::test_usual_dish_moves_to_the_top[chromium] PASSED [ 77%]
tests/test_xbrowser.py::test_usual_dish_moves_to_the_top[firefox] PASSED [ 88%]
tests/test_xbrowser.py::test_usual_dish_moves_to_the_top[webkit] FAILED  [100%]
E   AssertionError: the button&#39;s text needs 23px but has 17px
E   assert [&#39;list.moveBe...s undefined)&#39;] == []
========================= 2 failed, 7 passed in 11.27s =========================
</code></pre>
<p>Both bugs, only in WebKit, and the date string is fine in all three.</p>
<p><strong>The reorder button does nothing in WebKit.</strong> The full error:</p>
<pre tabindex="0"><code class="language-text">list.moveBefore is not a function. (In &#39;list.moveBefore(document.getElementById(&quot;usual&quot;), list.firstElementChild)&#39;, &#39;list.moveBefore&#39; is undefined)
</code></pre>
<p>An iPhone user taps &quot;Put my usual first&quot;, and nothing happens. No message, nothing in the server logs.</p>
<p><strong>The Apply button is cut off in WebKit.</strong> WebKit&#39;s button needs 23 pixels for its content. The row gives it 21, of which 17 are inside the border.</p>
<p><img src="https://provalab.io/blog/practice/xbrowser-promo-row.png" alt="The promo row zoomed, in Chromium 156, Firefox 157 and WebKit 27.2. Before: in WebKit the bottom of the Apply button and both borders are cut off. After removing the fixed height, the row fits in all three." loading="lazy"></p>
<p>Our first version of this test passed in all three browsers. It compared the button&#39;s box with the row&#39;s box, and both were 21 pixels: in a flex row, the browser squeezes the button&#39;s <strong>box</strong> to fit the row, and the <strong>content</strong> spills out of the box and is cut off. Only the screenshots showed the problem. Firefox&#39;s content is 1 pixel taller than its box (18 against 17), which is rounding you can&#39;t see, so the check allows 1 pixel. Compare what users see, and look at the screenshots of every engine at least once.</p>
<h2>Step 5: Fix it</h2>
<pre tabindex="0"><code class="language-diff">-  .promo { display: flex; gap: 8px; height: 21px; overflow: hidden; }
+  .promo { display: flex; gap: 8px; align-items: center; }
...
-    list.moveBefore(document.getElementById(&quot;usual&quot;), list.firstElementChild);
+    const usual = document.getElementById(&quot;usual&quot;);
+    if (list.moveBefore) list.moveBefore(usual, list.firstElementChild);
+    else list.insertBefore(usual, list.firstElementChild);  // Safari: no moveBefore yet
</code></pre>
<ul>
<li><strong>No fixed heights for rows with form controls.</strong> Let the content decide, and use <code>align-items: center</code> to line things up.</li>
<li><strong>Check before you use a newer API.</strong> If the method exists, use it. If not, fall back to an older one that does the same job. <code>insertBefore</code> has been everywhere for decades. It doesn&#39;t keep some element state that <code>moveBefore</code> keeps, which doesn&#39;t matter for a plain list.</li>
</ul>
<p>Three runs on the fixed page:</p>
<pre tabindex="0"><code class="language-text">9 passed in 11.05s
9 passed in 11.28s
9 passed in 11.05s
</code></pre>
<h2>What to run on which browsers</h2>
<p>Running every test on every engine triples the time. Most teams don&#39;t need to:</p>
<ul>
<li><strong>Run everything on one engine,</strong> usually Chromium, on every commit.</li>
<li><strong>Run a cross-browser set on all three engines,</strong> on every pull request or every night: layout-heavy pages, forms, anything using newer APIs, and the main customer journeys.</li>
<li><strong>Check your own analytics.</strong> If 40% of your customers use iPhones, WebKit isn&#39;t optional.</li>
<li><strong>Visual baselines per engine.</strong> Fonts and controls differ, so a screenshot from Firefox won&#39;t match one from Chromium. Keep one baseline per engine, as in <a href="https://provalab.io/blog/visual-regression-testing-playwright/">Part 3</a>.</li>
<li><strong>Real devices for the last mile.</strong> Playwright&#39;s WebKit catches engine differences. Real iPhones catch the rest.</li>
</ul>
<h2>Common mistakes</h2>
<ul>
<li><strong>Testing only in Chrome.</strong> iPhone browsers almost all run on WebKit. A Chrome-only test suite says nothing about them.</li>
<li><strong>Repeating old browser lore without checking.</strong> The famous Safari date-parsing trap didn&#39;t happen in WebKit 27.2. Ask the engines, as in Step 1.</li>
<li><strong>Sizing layouts to one engine&#39;s controls.</strong> Default buttons were 21, 23 and 30 pixels tall in our run.</li>
<li><strong>Checking boxes instead of content.</strong> Our first check passed while WebKit cut the button off.</li>
<li><strong>Using new APIs without a check.</strong> <code>moveBefore</code> exists in two engines out of three.</li>
<li><strong>Ignoring page errors.</strong> Listen for <code>pageerror</code>, so a broken click fails with the real reason.</li>
<li><strong>Different machines, same baseline.</strong> Fonts differ between a laptop and a container, so layouts do too. Run visual tests in the same image every time.</li>
</ul>
<h2>The end of Testing in Practice, for now</h2>
<p>This is the last part of the series. Eighteen hands-on posts, each built on FoodNow, each run for real:</p>
<ol>
<li><a href="https://provalab.io/blog/accessibility-testing-wcag-axe-playwright/">Accessibility with axe and Playwright</a></li>
<li><a href="https://provalab.io/blog/api-testing-with-postman-and-newman/">API testing with Postman and Newman</a></li>
<li><a href="https://provalab.io/blog/visual-regression-testing-playwright/">Visual regression testing</a></li>
<li><a href="https://provalab.io/blog/testing-database-changes-sql-pytest/">Testing database changes</a></li>
<li><a href="https://provalab.io/blog/test-doubles-mocks-stubs-fakes/">Test doubles: mocks, stubs and fakes</a></li>
<li><a href="https://provalab.io/blog/mobile-app-testing-appium-pytest/">Mobile app testing with Appium</a></li>
<li><a href="https://provalab.io/blog/contract-testing-pact-python/">Contract testing with Pact</a></li>
<li><a href="https://provalab.io/blog/bdd-pytest-bdd-gherkin/">BDD with pytest-bdd</a></li>
<li><a href="https://provalab.io/blog/user-acceptance-testing-uat/">User acceptance testing</a></li>
<li><a href="https://provalab.io/blog/usability-testing-five-users/">Usability testing</a></li>
<li><a href="https://provalab.io/blog/first-selenium-test-python/">Your first Selenium test</a></li>
<li><a href="https://provalab.io/blog/first-cypress-test/">Your first Cypress test</a></li>
<li><a href="https://provalab.io/blog/test-estimation-three-point-pert/">Test estimation</a></li>
<li><a href="https://provalab.io/blog/test-summary-report-metrics/">The test summary report</a></li>
<li><a href="https://provalab.io/blog/localization-testing-playwright/">Localization testing</a></li>
<li><a href="https://provalab.io/blog/flaky-tests-find-and-fix/">Flaky tests</a></li>
<li><a href="https://provalab.io/blog/property-based-testing-hypothesis/">Property-based testing with Hypothesis</a></li>
<li>Cross-browser testing (this post)</li>
</ol>
<p>One topic is still to come: running these suites automatically in CI with GitHub Actions, so a red test blocks a merge. We&#39;ll add it when it&#39;s ready.</p>
<p>ProvaLab&#39;s QA Infrastructure runs Playwright and Selenium tests on browsers in the cloud, with a live view of the browser while each test runs, and ProvaLab&#39;s plugins send each result to the ProvaLab test management system with its screenshots, so a parametrized run like this one shows which test failed on which browser. See it in the <a href="https://provalab.io/#stage-infra">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Flaky tests (hands-on): measure them, reproduce them, fix the cause, and the one that was a real bug</title>
      <link>https://provalab.io/blog/flaky-tests-find-and-fix/</link>
      <guid isPermaLink="true">https://provalab.io/blog/flaky-tests-find-and-fix/</guid>
      <pubDate>Thu, 12 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>flaky-tests</category>
      <category>pytest</category>
      <category>test-automation</category>
      <category>ci-cd</category>
      <description>Find and fix flaky tests with pytest. A real 100-run measurement finds four causes: shared state, timing, the clock and randomness. Retries made it worse, and one flaky test was a real bug.</description>
      <content:encoded><![CDATA[<p>A <strong>flaky test</strong> passes and fails on the same code. Nothing changed, you ran it again, and this time it was green.</p>
<p>One flaky test is an annoyance. Twenty of them change how a team behaves. People stop reading red builds, because &quot;it&#39;s probably flaky&quot;. They press re-run until it&#39;s green. And then a real failure arrives and gets re-run away like all the others.</p>
<p>This series has already met two flaky tests by accident:</p>
<ul>
<li>In <a href="https://provalab.io/blog/first-cypress-test/">Part 12</a>, a Cypress test passed 2 runs in 8 on a page with a real race bug.</li>
<li>In <a href="https://provalab.io/blog/test-summary-report-metrics/">Part 14</a>, a test failed every night from 21:46 to 07:59 because it read the real clock.</li>
</ul>
<p>This post takes flakiness on deliberately. We wrote a small FoodNow module with five tests and four classic causes of flakiness, measured how often each test fails over 100 runs, reproduced the failures, tried the usual shortcut (retries), and fixed each cause. Every number below is from a real run.</p>
<h2>The code and its tests</h2>
<p><code>foodnow/basket.py</code>:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;FoodNow basket and pricing.&quot;&quot;&quot;
import random
import threading
import time
from datetime import datetime

PRICES = {&quot;Paneer wrap&quot;: 180, &quot;Masala chai&quot;: 40, &quot;Veg biryani&quot;: 260}

_basket: list[str] = []  # one basket for the whole process


def add(dish: str) -&gt; None:
    _basket.append(dish)


def items() -&gt; list[str]:
    return list(_basket)


def total() -&gt; int:
    &quot;&quot;&quot;Food total in rupees. Orders of 10 or more dishes get 10% off.&quot;&quot;&quot;
    food = sum(PRICES[d] for d in _basket)
    if len(_basket) &gt; 10:
        food = food * 90 // 100
    return food


def is_kitchen_open(now: datetime | None = None) -&gt; bool:
    now = now or datetime.now()
    return 11 &lt;= now.hour &lt; 23


class EtaService:
    &quot;&quot;&quot;Works out the delivery time in the background, like a call to a maps service.&quot;&quot;&quot;

    def __init__(self):
        self.minutes: int | None = None

    def start(self) -&gt; None:
        def work():
            time.sleep(random.uniform(0.05, 0.15))  # the maps service takes 50-150 ms
            self.minutes = 25
        threading.Thread(target=work, daemon=True).start()
</code></pre>
<p>And <code>tests/test_basket.py</code>. Each test looks reasonable on its own:</p>
<pre tabindex="0"><code class="language-python">import random
import time

from foodnow import basket


def test_new_basket_is_empty():
    assert basket.items() == []
    assert basket.total() == 0


def test_add_a_wrap():
    basket.add(&quot;Paneer wrap&quot;)
    assert basket.total() == 180


def test_kitchen_is_open_now():
    assert basket.is_kitchen_open()


def test_delivery_estimate():
    eta = basket.EtaService()
    eta.start()
    time.sleep(0.1)  # give the maps service time to answer
    assert eta.minutes == 25


def test_big_orders_get_ten_percent_off():
    # Any order of 10 to 20 wraps gets 10% off.
    n = random.randint(10, 20)
    basket._basket.clear()
    for _ in range(n):
        basket.add(&quot;Paneer wrap&quot;)
    assert basket.total() == 180 * n * 90 // 100
</code></pre>
<p>Install pytest and three plugins that help with flaky tests:</p>
<pre tabindex="0"><code class="language-bash">pip install pytest==9.2.0 pytest-randomly==5.1.0 pytest-repeat==0.9.4 pytest-rerunfailures==16.7
</code></pre>
<ul>
<li><strong>pytest-randomly</strong> runs the tests in a different random order every time, and prints the seed so you can repeat an order.</li>
<li><strong>pytest-repeat</strong> runs a test many times (<code>--count=50</code>).</li>
<li><strong>pytest-rerunfailures</strong> re-runs failed tests (<code>--reruns 2</code>). We use it in Step 4 to show why it isn&#39;t the answer.</li>
</ul>
<h2>Step 1: Run it once</h2>
<p>In the order the tests are written, with random ordering turned off:</p>
<pre tabindex="0"><code class="language-text">$ pytest -p no:randomly -q
FAILED tests/test_basket.py::test_delivery_estimate - assert None == 25
1 failed, 4 passed in 0.19s
</code></pre>
<p>One failure, which looks like one problem. A single run tells you almost nothing about flakiness. You have to run the suite many times, and in different orders, and count.</p>
<h2>Step 2: Measure it</h2>
<p>This script runs the whole suite 100 times, each time in a new random order, and counts how often each test fails. Save it as <code>flake_rate.py</code>:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;Run the test suite many times, each in a new random order, and report how often each test fails.&quot;&quot;&quot;
import collections
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path

runs = int(sys.argv[1]) if len(sys.argv) &gt; 1 else 100
extra = sys.argv[2:]
fails, seen = collections.Counter(), collections.Counter()
with tempfile.TemporaryDirectory() as tmp:
    for i in range(runs):
        report = Path(tmp) / f&quot;run{i}.xml&quot;
        subprocess.run([sys.executable, &quot;-m&quot;, &quot;pytest&quot;, &quot;-q&quot;, &quot;-p&quot;, &quot;randomly&quot;, f&quot;--junitxml={report}&quot;, *extra],
                       capture_output=True)
        for tc in ET.parse(report).iter(&quot;testcase&quot;):
            name = tc.get(&quot;name&quot;)
            seen[name] += 1
            if tc.find(&quot;failure&quot;) is not None or tc.find(&quot;error&quot;) is not None:
                fails[name] += 1

print(f&quot;{runs} runs, random order each time\n&quot;)
print(f&quot;{&#39;test&#39;:&lt;40}{&#39;failed&#39;:&gt;8}  rate&quot;)
for name in sorted(seen):
    print(f&quot;{name:&lt;40}{fails[name]:&gt;8}  {fails[name] / seen[name]:5.0%}&quot;)
</code></pre>
<pre tabindex="0"><code class="language-text">$ python flake_rate.py 100
100 runs, random order each time

test                                      failed  rate
test_add_a_wrap                               55    55%
test_big_orders_get_ten_percent_off           10    10%
test_delivery_estimate                        42    42%
test_kitchen_is_open_now                       0     0%
test_new_basket_is_empty                      59    59%
</code></pre>
<p>It took 73 seconds. Four of the five tests fail some of the time, for three different reasons:</p>
<ul>
<li><strong><code>test_new_basket_is_empty</code> (59%) and <code>test_add_a_wrap</code> (55%)</strong> share one basket for the whole test run. Whichever test runs before them leaves dishes in it.</li>
<li><strong><code>test_delivery_estimate</code> (42%)</strong> sleeps for 0.1 seconds and hopes the answer has arrived. The answer takes 50 to 150 milliseconds.</li>
<li><strong><code>test_big_orders_get_ten_percent_off</code> (10%)</strong> picks a random basket size each run. We&#39;ll come back to this one, because it&#39;s not a test problem.</li>
</ul>
<p><code>test_kitchen_is_open_now</code> passed all 100 times. We ran this at 11:28 in the morning, India time.</p>
<h2>Step 3: Run it somewhere else</h2>
<p>Most CI machines run on UTC. At the same moment, in UTC, it was 06:00:</p>
<pre tabindex="0"><code class="language-text">$ TZ=UTC python flake_rate.py 20
20 runs, random order each time

test                                      failed  rate
test_add_a_wrap                               14    70%
test_big_orders_get_ten_percent_off            1     5%
test_delivery_estimate                         5    25%
test_kitchen_is_open_now                      20   100%
test_new_basket_is_empty                      13    65%
</code></pre>
<p>The kitchen test failed <strong>every time</strong>. On a developer&#39;s laptop in India it passes during the day. On the CI server it fails until 11:00 UTC, which is 16:30 in India. Depending on when people look, it&#39;s &quot;always green&quot;, &quot;always red&quot; or &quot;flaky&quot;. It&#39;s the same problem as the Part 14 test: it depends on the real clock.</p>
<p><img src="https://provalab.io/blog/practice/flaky-rates.svg" alt="Five tests and their failure rates before the fixes. Shared basket: 59% and 55% on India time, 65% and 70% on UTC. Delivery time with a fixed sleep: 42% and 25%. Random basket size: 10% and 5%, caused by a real bug. Kitchen open now: 0% on India time and 100% on UTC. After the fixes, 11 tests ran 100 times on UTC and 30 times on India time with no failures." width="880" height="470" loading="lazy"></p>
<h2>Step 4: Reproduce a failure</h2>
<p>A flaky failure you can&#39;t repeat is hard to fix. pytest-randomly prints the seed it used for the order at the start of each run:</p>
<pre tabindex="0"><code class="language-text">$ pytest -p randomly
Using --randomly-seed=2365621843
...
FAILED tests/test_basket.py::test_delivery_estimate - assert None == 25
FAILED tests/test_basket.py::test_new_basket_is_empty - AssertionError: asser...
FAILED tests/test_basket.py::test_add_a_wrap - assert 2430 == 180
========================= 3 failed, 2 passed in 0.17s ==========================
</code></pre>
<p>Run it again with that seed, and you get the same order and the same failures:</p>
<pre tabindex="0"><code class="language-text">$ pytest -p randomly --randomly-seed=2365621843 -v
Using --randomly-seed=2365621843
tests/test_basket.py::test_big_orders_get_ten_percent_off PASSED         [ 20%]
tests/test_basket.py::test_delivery_estimate FAILED                      [ 40%]
tests/test_basket.py::test_new_basket_is_empty FAILED                    [ 60%]
tests/test_basket.py::test_kitchen_is_open_now PASSED                    [ 80%]
tests/test_basket.py::test_add_a_wrap FAILED                             [100%]
...
========================= 3 failed, 2 passed in 0.17s ==========================
</code></pre>
<p>Now the cause is visible: the big-orders test ran first and left 15 wraps in the shared basket, so &quot;add a wrap&quot; saw ₹2,430 instead of ₹180. pytest-randomly also resets Python&#39;s <code>random</code> module with the same seed before each test, so even the delivery-time delay repeated exactly.</p>
<h2>Step 5: The tempting shortcut: retries</h2>
<p>The usual reaction to flaky tests is to retry failures automatically. We tried it, 20 times, with <code>--reruns 2</code>:</p>
<pre tabindex="0"><code class="language-text">$ pytest -p randomly --reruns 2 -q
2 failed, 3 passed, 4 rerun in 0.41s
1 failed, 4 passed, 2 rerun in 0.23s
1 failed, 4 passed, 3 rerun in 0.36s
...
green runs with reruns: 1 / 20
</code></pre>
<p>Retries made <strong>1 run in 20</strong> green. A rerun happens in the same process, so the basket is still full from the earlier test, and the retry fails the same way.</p>
<p>Where retries do turn a suite green, they make things worse in a different way: the failures are still happening, but nobody sees them. The test still fails some of the time, and when it&#39;s a real bug, it&#39;s now hidden. If you use retries at all, record every retried failure and count them, so flakiness stays visible.</p>
<h2>Step 6: Fix each cause</h2>
<p><strong>Shared state.</strong> Each test gets its own basket. That meant changing the code, not just the tests: a module-level basket that lives for the whole process was the real problem. The basket becomes a class, and a fixture gives every test a new one:</p>
<pre tabindex="0"><code class="language-python">class Basket:
    def __init__(self):
        self._items: list[str] = []

    def add(self, dish: str) -&gt; None:
        self._items.append(dish)

    def items(self) -&gt; list[str]:
        return list(self._items)

    def total(self) -&gt; int:
        &quot;&quot;&quot;Food total in rupees. Orders of 10 or more dishes get 10% off.&quot;&quot;&quot;
        food = sum(PRICES[d] for d in self._items)
        if len(self._items) &gt; 10:
            food = food * 90 // 100
        return food
</code></pre>
<pre tabindex="0"><code class="language-python">@pytest.fixture
def basket():
    return Basket()  # a new, empty basket for every test
</code></pre>
<p><strong>Timing.</strong> Don&#39;t sleep and hope. Wait for the thing you need, with a time limit. The service sets an event when the answer arrives:</p>
<pre tabindex="0"><code class="language-python">class EtaService:
    def __init__(self):
        self.minutes: int | None = None
        self.ready = threading.Event()

    def start(self) -&gt; None:
        def work():
            time.sleep(random.uniform(0.05, 0.15))  # the maps service takes 50-150 ms
            self.minutes = 25
            self.ready.set()
        threading.Thread(target=work, daemon=True).start()
</code></pre>
<pre tabindex="0"><code class="language-python">def test_delivery_estimate():
    eta = EtaService()
    eta.start()
    assert eta.ready.wait(timeout=2), &quot;the maps service didn&#39;t answer within 2 seconds&quot;
    assert eta.minutes == 25
</code></pre>
<p>The test now continues the moment the answer arrives, after 50 to 150 ms, which on average is no slower than the old sleep. It only fails if the service really takes more than 2 seconds.</p>
<p><strong>The clock.</strong> Pass the time in. The function already accepted <code>now</code>. The test just didn&#39;t use it:</p>
<pre tabindex="0"><code class="language-python">@pytest.mark.parametrize(&quot;hour, is_open&quot;, [(10, False), (11, True), (22, True), (23, False)])
def test_kitchen_hours(hour, is_open):
    assert is_kitchen_open(datetime(2026, 11, 12, hour, 30)) is is_open
</code></pre>
<p>That&#39;s also a better test: it checks both sides of opening and closing time, instead of whatever time it happens to be.</p>
<p><strong>Randomness.</strong> Replace the random basket size with the sizes that matter: either side of the discount boundary, and a big one:</p>
<pre tabindex="0"><code class="language-python">@pytest.mark.parametrize(&quot;n&quot;, [9, 10, 11, 20])
def test_big_order_discount(basket, n):
    for _ in range(n):
        basket.add(&quot;Paneer wrap&quot;)
    expected = 180 * n * 90 // 100 if n &gt;= 10 else 180 * n
    assert basket.total() == expected
</code></pre>
<h2>Step 7: The flaky test that was a real bug</h2>
<p>With the test fixes in place, the 100-run measurement on UTC:</p>
<pre tabindex="0"><code class="language-text">$ TZ=UTC python flake_rate.py 100
test                                      failed  rate
test_add_a_wrap                                0     0%
test_big_order_discount[10]                  100   100%
test_big_order_discount[11]                    0     0%
test_big_order_discount[20]                    0     0%
test_big_order_discount[9]                     0     0%
test_delivery_estimate                         0     0%
test_kitchen_hours[10-False]                   0     0%
...
</code></pre>
<pre tabindex="0"><code class="language-text">FAILED tests/test_basket.py::test_big_order_discount[10] - assert 1800 == 1620
</code></pre>
<p>The 10% &quot;flaky&quot; test wasn&#39;t flaky at all. The code says <code>if len(self._items) &gt; 10</code>, but the rule, in its own comment, is &quot;10 or more dishes get 10% off&quot;. An order of exactly 10 dishes was charged ₹1,800 instead of ₹1,620. The random basket size picked exactly 10 about one time in eleven, so the bug showed up as a test that &quot;sometimes fails&quot;.</p>
<p>This is why flaky tests deserve attention instead of re-runs: some of them are real bugs that only happen sometimes. Race conditions, time zones, boundary values and rare data all look like flakiness from the outside.</p>
<p>The fix is one character:</p>
<pre tabindex="0"><code class="language-diff">-        if len(self._items) &gt; 10:
+        if len(self._items) &gt;= 10:
</code></pre>
<pre tabindex="0"><code class="language-text">$ TZ=UTC python flake_rate.py 100
test                                      failed  rate
test_add_a_wrap                                0     0%
test_big_order_discount[10]                    0     0%
test_big_order_discount[11]                    0     0%
test_big_order_discount[20]                    0     0%
test_big_order_discount[9]                     0     0%
test_delivery_estimate                         0     0%
test_kitchen_hours[10-False]                   0     0%
test_kitchen_hours[11-True]                    0     0%
test_kitchen_hours[22-True]                    0     0%
test_kitchen_hours[23-False]                   0     0%
test_new_basket_is_empty                       0     0%
</code></pre>
<p>And 30 more runs on India time: 0 failures. Eleven tests, 130 runs, no failures.</p>
<h2>Common causes, and their fixes</h2>
<table tabindex="0">
<thead>
<tr>
<th>Cause</th>
<th>What it looks like</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Shared state</strong></td>
<td>Passes alone, fails in the full suite, or in a different order</td>
<td>Fresh data per test; no module-level state; run in random order to catch it</td>
</tr>
<tr>
<td><strong>Timing</strong></td>
<td><code>sleep()</code> followed by an assertion</td>
<td>Wait for a condition or an event, with a time limit</td>
</tr>
<tr>
<td><strong>The real clock</strong></td>
<td>Fails at night, at month end, or on a CI server in another time zone</td>
<td>Pass the time in, or freeze it in the test</td>
</tr>
<tr>
<td><strong>Unseeded randomness</strong></td>
<td>Fails &quot;sometimes&quot;, with different data each time</td>
<td>Test the boundaries explicitly; if you use random data, log the seed</td>
</tr>
<tr>
<td><strong>External services</strong></td>
<td>Fails when a sandbox or network is slow</td>
<td>Fake it in most tests (<a href="https://provalab.io/blog/test-doubles-mocks-stubs-fakes/">Part 5</a>), check the contract (<a href="https://provalab.io/blog/contract-testing-pact-python/">Part 7</a>)</td>
</tr>
<tr>
<td><strong>Asynchronous UI</strong></td>
<td>Reads the page before it has updated</td>
<td>Retrying assertions or explicit waits (<a href="https://provalab.io/blog/first-selenium-test-python/">Parts 11</a> and <a href="https://provalab.io/blog/first-cypress-test/">12</a>)</td>
</tr>
<tr>
<td><strong>Parallel runs</strong></td>
<td>Fails only with <code>-n auto</code> or several CI jobs</td>
<td>Unique names for files, ports, database rows and users per test</td>
</tr>
<tr>
<td><strong>A real bug</strong></td>
<td>Any of the above</td>
<td>Find out before you fix the test</td>
</tr>
</tbody></table>
<h2>A team policy that works</h2>
<ol>
<li><strong>Measure.</strong> Track each test&#39;s pass rate across runs, not just whether the latest build was green.</li>
<li><strong>Quarantine, with an owner and a date.</strong> Move a known flaky test out of the blocking suite, keep running it, and give it an owner and a deadline. A quarantine without a deadline is a deletion.</li>
<li><strong>Find the cause before fixing the test.</strong> Run it with the failing seed, at the failing time, in the failing environment. One in four of our flaky tests was a real bug.</li>
<li><strong>Don&#39;t let retries hide failures.</strong> If you retry, report every retried failure.</li>
<li><strong>Run in random order</strong> in CI, so shared state is found when it&#39;s added, not months later.</li>
</ol>
<h2>Common mistakes</h2>
<ul>
<li><strong>Re-running until green.</strong> It hides real bugs, and in our run it didn&#39;t even work: 1 green run in 20.</li>
<li><strong>Raising sleeps.</strong> <code>sleep(0.1)</code> becomes <code>sleep(1)</code>. The test gets slower and is still flaky on a bad day.</li>
<li><strong>Testing with the real clock.</strong> The test passes where it was written and fails at night or on UTC.</li>
<li><strong>Random test data without a seed.</strong> You can&#39;t reproduce the failure, and you may be hiding a boundary bug.</li>
<li><strong>Deleting the test.</strong> The flakiness might be the only sign of a real race or boundary bug.</li>
<li><strong>Running in one fixed order.</strong> Shared state stays hidden until someone adds a test in the wrong place.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong>Part 17</strong> of Testing in Practice we look at <strong>property-based testing with Hypothesis</strong>: instead of choosing examples, describe what must always be true, and let the tool search for the inputs that break it. In a quick check on the buggy code, it found the 10-dish bug in all five runs, in under a second each.</p>
<p>ProvaLab&#39;s test management system keeps the history of every test across runs, so a test that passes, fails and passes again on the same code shows up as a pattern, not as a red build someone re-ran. Results arrive from pytest, Cypress, Playwright and other frameworks through ProvaLab&#39;s plugins, and you can re-run just the failed tests of a run from TMS, with the re-run recorded as a run of its own. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Localization testing (hands-on): Hindi, German and Arabic, pseudo-localization, and 16 failures on one page</title>
      <link>https://provalab.io/blog/localization-testing-playwright/</link>
      <guid isPermaLink="true">https://provalab.io/blog/localization-testing-playwright/</guid>
      <pubDate>Thu, 12 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>localization-testing</category>
      <category>i18n</category>
      <category>playwright</category>
      <category>pytest</category>
      <description>Test an app in other languages with Playwright and pytest: pseudo-localization, text that doesn't fit, Indian and German number formats, ambiguous dates, Arabic plurals and right-to-left.</description>
      <content:encoded><![CDATA[<p>Your app works in English. Then it&#39;s translated, and things break that nobody touched:</p>
<ul>
<li>The German for &quot;Change address&quot; doesn&#39;t fit its button.</li>
<li>A price shows as ₹125,000 when an Indian reader expects ₹1,25,000.</li>
<li>&quot;11/12/2026&quot; means 12 November to an American and 11 December to almost everyone else.</li>
<li>Arabic is written right to left, and it has six plural forms.</li>
</ul>
<p><strong>Localization testing</strong> checks that the app works and reads correctly in each language and region it supports. It&#39;s usually split in two:</p>
<ul>
<li><strong>Internationalization (i18n) testing:</strong> is the code <em>ready</em> for other languages? No text built into the code, formats taken from the locale, layouts that grow and flip. Developers and testers can check this, often before any translation exists.</li>
<li><strong>Linguistic testing:</strong> are the translations right, natural and appropriate? That needs native speakers.</li>
</ul>
<p>This post is mostly about the first kind, because it can be automated. We tested a FoodNow order page in Hindi, German and Arabic with Playwright and pytest. Six kinds of bug were planted in it. The tests found them all, and one we didn&#39;t plant. Every result below is from a real run.</p>
<p>The translations in this post were written for this example. Before you ship translations, have native speakers review them. That&#39;s the linguistic testing a script can&#39;t do.</p>
<h2>The page and its translations</h2>
<p>The page shows a FoodNow order: the delivery address with a button to change it, the number of items, the delivery fee, the total, and the delivery date. The language comes from <code>?lang=</code> in the address. The translations live in <code>site/strings.js</code>:</p>
<pre tabindex="0"><code class="language-js">const STRINGS = {
  en: {
    title: &quot;Your order&quot;, fee: &quot;Delivery fee&quot;, total: &quot;Total&quot;, place: &quot;Place order&quot;, address: &quot;Change address&quot;,
    item_one: &quot;{n} item&quot;, item_other: &quot;{n} items&quot;,
    deliver_on: &quot;Delivery on {date}&quot;,
  },
  hi: {
    title: &quot;आपका ऑर्डर&quot;, fee: &quot;डिलीवरी शुल्क&quot;, total: &quot;कुल&quot;, place: &quot;ऑर्डर करें&quot;, address: &quot;पता बदलें&quot;,
    item_one: &quot;{n} आइटम&quot;, item_other: &quot;{n} आइटम&quot;,
    deliver_on: &quot;डिलीवरी {date} को&quot;,
  },
  de: {
    title: &quot;Ihre Bestellung&quot;, fee: &quot;Liefergebühr&quot;, total: &quot;Gesamt&quot;, place: &quot;Bestellung aufgeben&quot;, address: &quot;Lieferadresse ändern&quot;,
    item_one: &quot;{n} Artikel&quot;, item_other: &quot;{n} Artikel&quot;,
    deliver_on: &quot;Lieferung am {date}&quot;,
  },
  ar: {
    title: &quot;طلبك&quot;, fee: &quot;رسوم التوصيل&quot;, total: &quot;المجموع&quot;, place: &quot;تأكيد الطلب&quot;, address: &quot;تغيير العنوان&quot;,
    item_zero: &quot;لا توجد عناصر&quot;, item_one: &quot;عنصر واحد&quot;, item_two: &quot;عنصران&quot;,
    item_few: &quot;{n} عناصر&quot;, item_many: &quot;{n} عنصرًا&quot;, item_other: &quot;{n} عنصر&quot;,
    deliver_on: &quot;التوصيل في {date}&quot;,
  },
};
</code></pre>
<p>Arabic has six plural forms: zero, one, two, few (3–10), many (11–99) and other (for example 100). English has two. Hindi and German also have two, but Hindi counts 0 as &quot;one&quot; and German doesn&#39;t. The Unicode CLDR project publishes these rules for every language, and browsers include them.</p>
<p>The first version of the page&#39;s script, with its bugs:</p>
<pre tabindex="0"><code class="language-js">const money = paise =&gt; &quot;₹&quot; + (paise / 100).toLocaleString(&quot;en-US&quot;);
document.getElementById(&quot;count&quot;).textContent = t(items === 1 ? &quot;item_one&quot; : &quot;item_other&quot;, { n: items });
document.getElementById(&quot;total&quot;).textContent = money(totalPaise);
const when = new Date(2026, 10, 12);  // 12 November 2026
document.getElementById(&quot;when&quot;).textContent = t(&quot;deliver_on&quot;, { date: when.toLocaleDateString(&quot;en-US&quot;) });
</code></pre>
<p>And in the HTML:</p>
<pre tabindex="0"><code class="language-html">&lt;div class=&quot;row&quot;&gt;&lt;span&gt;Delivery fee&lt;/span&gt;&lt;span id=&quot;fee&quot;&gt;&lt;/span&gt;&lt;/div&gt;
</code></pre>
<pre tabindex="0"><code class="language-css">.addr button { width: 130px; white-space: nowrap; overflow: hidden; }
</code></pre>
<p>Six bugs, all common in real apps:</p>
<ol>
<li>&quot;Delivery fee&quot; is typed into the HTML instead of coming from the translations.</li>
<li>Money is always formatted the American way.</li>
<li>Plurals use English&#39;s rule: one, or not one.</li>
<li>The date is always American: month first.</li>
<li>The page never says it&#39;s Arabic, or that Arabic runs right to left.</li>
<li>The address button has a fixed width and hides anything that doesn&#39;t fit.</li>
</ol>
<h2>Step 1: Set up</h2>
<pre tabindex="0"><code class="language-bash">mkdir foodnow-l10n &amp;&amp; cd foodnow-l10n
python -m venv .venv &amp;&amp; source .venv/bin/activate
pip install pytest==9.2.0 playwright==1.64.0
playwright install chromium
mkdir -p site tests shots
</code></pre>
<p>To see Hindi and Arabic text in screenshots, the machine needs fonts for them. We used Noto Sans Devanagari and Noto Sans Arabic. Without them, the browser draws empty boxes, and your screenshots can&#39;t show what users see.</p>
<h2>Step 2: Pseudo-localization</h2>
<p>You don&#39;t have to wait for translations to start testing. <strong>Pseudo-localization</strong> turns every English string into a fake language that&#39;s still readable, so problems stand out before any translator is involved:</p>
<pre tabindex="0"><code class="language-js">// Pseudo-localization: accented, longer, and bracketed, so untranslated or cut-off text stands out.
const ACCENTS = { a: &quot;å&quot;, e: &quot;é&quot;, i: &quot;ï&quot;, o: &quot;ö&quot;, u: &quot;ü&quot;, A: &quot;Å&quot;, E: &quot;É&quot;, I: &quot;Ï&quot;, O: &quot;Ö&quot;, U: &quot;Ü&quot;, c: &quot;ç&quot;, n: &quot;ñ&quot; };
function pseudo(text) {
  const accented = text.replace(/\{[^}]+\}|./g, ch =&gt; ch.startsWith(&quot;{&quot;) ? ch : (ACCENTS[ch] || ch));
  const pad = &quot;~&quot;.repeat(Math.ceil(text.length * 0.4));
  return `[${accented}${pad}]`;
}
STRINGS[&quot;en-XA&quot;] = Object.fromEntries(Object.entries(STRINGS.en).map(([k, v]) =&gt; [k, pseudo(v)]));
</code></pre>
<p>&quot;Change address&quot; becomes <code>[Chåñgé åddréss~~~~~~]</code>. Three things happen at once:</p>
<ul>
<li><strong>Brackets</strong> show where each string starts and ends, so you can see text that&#39;s cut off or joined wrongly.</li>
<li><strong>Accented letters</strong> show text that can&#39;t handle characters beyond plain English.</li>
<li><strong>About 40% extra length</strong> imitates languages like German, which often run 30% longer than English, and more for short strings.</li>
</ul>
<p>Any interface text <strong>without</strong> brackets didn&#39;t come from the translations, so it will stay in English in every language. <code>en-XA</code> is a code commonly used for this, including on Android, which has pseudo-locales built in.</p>
<h2>Step 3: The tests</h2>
<p><code>tests/conftest.py</code> serves the <code>site</code> folder and opens a page in the right browser locale for each language:</p>
<pre tabindex="0"><code class="language-python">import functools
import http.server
import threading
from pathlib import Path

import pytest
from playwright.sync_api import sync_playwright

SITE = Path(__file__).parents[1] / &quot;site&quot;

# The app&#39;s language code, and the browser locale a user of that language would have.
LANGS = {&quot;en&quot;: &quot;en-IN&quot;, &quot;hi&quot;: &quot;hi-IN&quot;, &quot;de&quot;: &quot;de-DE&quot;, &quot;ar&quot;: &quot;ar&quot;, &quot;en-XA&quot;: &quot;en-IN&quot;}


@pytest.fixture(scope=&quot;session&quot;)
def base_url():
    handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=SITE)
    server = http.server.ThreadingHTTPServer((&quot;127.0.0.1&quot;, 0), handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    yield f&quot;http://127.0.0.1:{server.server_port}&quot;
    server.shutdown()


@pytest.fixture(scope=&quot;session&quot;)
def browser():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        yield browser
        browser.close()


@pytest.fixture
def open_page(browser, base_url):
    pages = []

    def _open(lang, items=3, total=12500000, width=412):
        context = browser.new_context(locale=LANGS[lang], viewport={&quot;width&quot;: width, &quot;height&quot;: 800})
        page = context.new_page()
        page.goto(f&quot;{base_url}/index.html?lang={lang}&amp;items={items}&amp;total={total}&quot;)
        pages.append(context)
        return page

    yield _open
    for c in pages:
        c.close()
</code></pre>
<p><code>locale=</code> sets the browser&#39;s language and region, as a real user&#39;s phone would. The total is ₹1,25,000 (12,500,000 paise), a lunch for 400 people, big enough to show the Indian way of grouping digits.</p>
<p>And <code>tests/test_l10n.py</code>, one test for each kind of problem:</p>
<pre tabindex="0"><code class="language-python">import pytest


def clean(text: str) -&gt; str:
    &quot;&quot;&quot;Drop invisible direction marks and turn non-breaking spaces into spaces.&quot;&quot;&quot;
    return text.replace(&quot;‏&quot;, &quot;&quot;).replace(&quot;‎&quot;, &quot;&quot;).replace(&quot;\xa0&quot;, &quot; &quot;).strip()


# 1. Pseudo-localization: every piece of interface text must come from the translations.
def test_no_hardcoded_text(open_page):
    page = open_page(&quot;en-XA&quot;)
    user_data = {&quot;where&quot;}  # the customer&#39;s own address is not interface text
    hardcoded = page.evaluate(&quot;&quot;&quot;userData =&gt; [...document.querySelectorAll(&#39;main *&#39;)]
        .filter(e =&gt; e.children.length === 0 &amp;&amp; /[A-Za-z]/.test(e.textContent) &amp;&amp; !userData.includes(e.id))
        .map(e =&gt; e.textContent.trim())
        .filter(t =&gt; !t.startsWith(&#39;[&#39;))&quot;&quot;&quot;, list(user_data))
    assert hardcoded == []


# 2. Text must fit: nothing cut off, in any language, including pseudo-localized text.
@pytest.mark.parametrize(&quot;lang&quot;, [&quot;en&quot;, &quot;hi&quot;, &quot;de&quot;, &quot;ar&quot;, &quot;en-XA&quot;])
def test_buttons_fit_their_text(open_page, lang):
    page = open_page(lang)
    cut = page.evaluate(&quot;&quot;&quot;() =&gt; [...document.querySelectorAll(&#39;button&#39;)]
        .filter(b =&gt; b.scrollWidth &gt; b.clientWidth)
        .map(b =&gt; `${b.textContent} (${b.scrollWidth}px of text in ${b.clientWidth}px)`)&quot;&quot;&quot;)
    if cut:
        page.screenshot(path=f&quot;shots/cut-{lang}.png&quot;)
    assert cut == []


# 3. Money in each language&#39;s format.
@pytest.mark.parametrize(&quot;lang, total, fee&quot;, [
    (&quot;en&quot;, &quot;₹1,25,000&quot;, &quot;₹40&quot;),
    (&quot;hi&quot;, &quot;₹1,25,000&quot;, &quot;₹40&quot;),
    (&quot;de&quot;, &quot;125.000 ₹&quot;, &quot;40 ₹&quot;),
    (&quot;ar&quot;, &quot;125,000 ₹&quot;, &quot;40 ₹&quot;),
])
def test_money_format(open_page, lang, total, fee):
    page = open_page(lang)
    assert clean(page.text_content(&quot;#total&quot;)) == total
    assert clean(page.text_content(&quot;#fee&quot;)) == fee


# 4. Dates nobody can misread: 12 November 2026.
@pytest.mark.parametrize(&quot;lang, expected&quot;, [
    (&quot;en&quot;, &quot;Delivery on 12 November 2026&quot;),
    (&quot;hi&quot;, &quot;डिलीवरी 12 नवंबर 2026 को&quot;),
    (&quot;de&quot;, &quot;Lieferung am 12. November 2026&quot;),
    (&quot;ar&quot;, &quot;التوصيل في 12 نوفمبر 2026&quot;),
])
def test_delivery_date(open_page, lang, expected):
    assert clean(open_page(lang).text_content(&quot;#when&quot;)) == expected


# 5. Plurals: Arabic has six forms.
@pytest.mark.parametrize(&quot;n, expected&quot;, [
    (1, &quot;عنصر واحد&quot;), (2, &quot;عنصران&quot;), (3, &quot;3 عناصر&quot;), (11, &quot;11 عنصرًا&quot;), (100, &quot;100 عنصر&quot;),
])
def test_arabic_plurals(open_page, n, expected):
    assert clean(open_page(&quot;ar&quot;, items=n).text_content(&quot;#count&quot;)) == expected


# 6. Right-to-left: Arabic pages must be laid out right to left.
def test_arabic_is_right_to_left(open_page):
    page = open_page(&quot;ar&quot;)
    assert page.evaluate(&quot;document.documentElement.dir&quot;) == &quot;rtl&quot;
    assert page.evaluate(&quot;document.documentElement.lang&quot;) == &quot;ar&quot;
    # Where the heading&#39;s text actually is (its box is full width either way).
    text_left = page.evaluate(&quot;&quot;&quot;() =&gt; {
        const range = document.createRange();
        range.selectNodeContents(document.getElementById(&#39;title&#39;));
        return range.getBoundingClientRect().left;
    }&quot;&quot;&quot;)
    assert text_left &gt; 200, f&quot;the heading should start on the right, but its text starts at x={text_left:.0f}&quot;
</code></pre>
<p>A few details that matter:</p>
<ul>
<li><strong>Where the expected formats come from.</strong> We asked Chromium&#39;s built-in <code>Intl</code> formatting what each locale uses, then checked that it was right for our users before writing it into the tests. In German, the currency sign goes after the number, with a dot for thousands. Chromium writes Arabic amounts with Western digits (125,000) by default. Some Arabic-speaking regions prefer Eastern Arabic digits (١٢٥٬٠٠٠). That&#39;s a product decision, so the test records the decision you made.</li>
<li><strong><code>clean()</code>.</strong> Formatted numbers contain invisible characters: a non-breaking space between &quot;125.000&quot; and &quot;₹&quot;, and in Arabic a right-to-left mark. They&#39;re correct, but they make string comparisons fail in confusing ways. Strip them in the comparison, not in the app.</li>
<li><strong>The right-to-left check measures the text, not the box.</strong> Our first version checked the heading element&#39;s position. A heading&#39;s box is the full width of the page in either direction, so that check could never fail. Measuring where the text itself is fixed it: on the buggy page the heading&#39;s text starts at x=36, on the fixed page at x=327.</li>
</ul>
<h2>Step 4: Run it</h2>
<pre tabindex="0"><code class="language-text">$ pytest tests -q --tb=line
E   AssertionError: assert [&#39;Delivery fee&#39;] == []
E   AssertionError: assert [&#39;Change addr...xt in 128px)&#39;] == []
E   AssertionError: assert [&#39;Lieferadres...xt in 128px)&#39;] == []
E   AssertionError: assert [&#39;[Chåñgé ådd...xt in 128px)&#39;] == []
E   AssertionError: assert &#39;₹125,000&#39; == &#39;₹1,25,000&#39;
E   AssertionError: assert &#39;₹125,000&#39; == &#39;₹1,25,000&#39;
E   AssertionError: assert &#39;₹125,000&#39; == &#39;125.000 ₹&#39;
E   AssertionError: assert &#39;₹125,000&#39; == &#39;125,000 ₹&#39;
E   AssertionError: assert &#39;Delivery on 11/12/2026&#39; == &#39;Delivery on 12 November 2026&#39;
E   AssertionError: assert &#39;डिलीवरी 11/12/2026 को&#39; == &#39;डिलीवरी 12 नवंबर 2026 को&#39;
E   AssertionError: assert &#39;Lieferung am 11/12/2026&#39; == &#39;Lieferung am...November 2026&#39;
E   AssertionError: assert &#39;التوصيل في 11/12/2026&#39; == &#39;التوصيل في 12 نوفمبر 2026&#39;
E   AssertionError: assert &#39;2 عنصر&#39; == &#39;عنصران&#39;
E   AssertionError: assert &#39;3 عنصر&#39; == &#39;3 عناصر&#39;
E   AssertionError: assert &#39;11 عنصر&#39; == &#39;11 عنصرًا&#39;
E   AssertionError: assert &#39;&#39; == &#39;rtl&#39;
16 failed, 4 passed in 4.66s
</code></pre>
<p>Sixteen failures on one small page. What they mean for users:</p>
<ul>
<li><strong>&quot;Delivery fee&quot;</strong> stays in English in every language. Pseudo-localization found it with no translator involved.</li>
<li><strong>The address button cuts its text off.</strong> In German, &quot;Lieferadresse ändern&quot; needs 162 pixels and gets 128. Pseudo-localized, it needs 191. And the one we didn&#39;t plant: <strong>the English button is cut off too</strong>, by 2 pixels. &quot;Change address&quot; needs 130 pixels in the font on our machine. It probably fitted in whatever font the button was designed with. A fixed width with hidden overflow fails as soon as the font, the language or the user&#39;s text size changes.</li>
<li><strong>₹125,000</strong> is wrong for Indian readers, who group digits as ₹1,25,000 (one lakh, twenty-five thousand). In German, it reads as if the comma were a decimal point: one hundred twenty-five rupees.</li>
<li><strong>11/12/2026</strong> means 11 December to an Indian, German or Arabic reader. The delivery is on 12 November. A delivery date that&#39;s wrong by a month is a real bug, not a cosmetic one.</li>
<li><strong>Arabic plurals</strong> were only right for 1 and 100, by luck: English&#39;s &quot;one or other&quot; rule happens to match those two of Arabic&#39;s six forms.</li>
<li><strong>No right-to-left layout.</strong> The Arabic text is there, but the page runs left to right: the heading on the left, numbers and labels on the wrong sides.</li>
</ul>
<p>The German page showed four of these at once:</p>
<p><img src="https://provalab.io/blog/practice/l10n-before-after.png" alt="The FoodNow order page before and after the fixes, in pseudo-localized English, German and Arabic. Before: the address button cuts off its text, &quot;Delivery fee&quot; stays in English, the total is ₹125,000, the date is 11/12/2026, and the Arabic page runs left to right with the wrong plural. After: the button fits, every label is translated, totals are ₹1,25,000 and 125.000 ₹, the date is 12 November 2026, and the Arabic page runs right to left with the correct plural." loading="lazy"></p>
<h2>Step 5: Fix it with the platform, not by hand</h2>
<p>Browsers already know every one of these rules, through the <code>Intl</code> APIs. The fix is mostly to stop overriding them:</p>
<pre tabindex="0"><code class="language-diff">-  .addr button { width: 130px; white-space: nowrap; overflow: hidden; ... }
+  .addr button { min-width: 130px; max-width: 60%; ... }
...
-    &lt;div class=&quot;row&quot;&gt;&lt;span&gt;Delivery fee&lt;/span&gt;&lt;span id=&quot;fee&quot;&gt;&lt;/span&gt;&lt;/div&gt;
+    &lt;div class=&quot;row&quot;&gt;&lt;span id=&quot;fee-label&quot;&gt;&lt;/span&gt;&lt;span id=&quot;fee&quot;&gt;&lt;/span&gt;&lt;/div&gt;
...
+  const LOCALES = { en: &quot;en-IN&quot;, hi: &quot;hi-IN&quot;, de: &quot;de-DE&quot;, ar: &quot;ar&quot;, &quot;en-XA&quot;: &quot;en-IN&quot; };
+  const locale = LOCALES[lang] || &quot;en-IN&quot;;
+  document.documentElement.lang = lang;
+  document.documentElement.dir = lang === &quot;ar&quot; ? &quot;rtl&quot; : &quot;ltr&quot;;
+
-  const money = paise =&gt; &quot;₹&quot; + (paise / 100).toLocaleString(&quot;en-US&quot;);
+  const moneyFormat = new Intl.NumberFormat(locale, { style: &quot;currency&quot;, currency: &quot;INR&quot;, maximumFractionDigits: 0 });
+  const money = paise =&gt; moneyFormat.format(paise / 100);
+  const plural = new Intl.PluralRules(locale);
...
-  document.getElementById(&quot;count&quot;).textContent = t(items === 1 ? &quot;item_one&quot; : &quot;item_other&quot;, { n: items });
+  const form = plural.select(items);
+  document.getElementById(&quot;count&quot;).textContent = t(s[`item_${form}`] ? `item_${form}` : &quot;item_other&quot;, { n: items });
+  document.getElementById(&quot;fee-label&quot;).textContent = t(&quot;fee&quot;);
...
-  ... t(&quot;deliver_on&quot;, { date: when.toLocaleDateString(&quot;en-US&quot;) });
+  ... t(&quot;deliver_on&quot;, { date: when.toLocaleDateString(locale, { dateStyle: &quot;long&quot; }) });
</code></pre>
<ul>
<li><strong>Buttons grow and wrap</strong> instead of hiding text. Design for the longest language, not for English.</li>
<li><strong>Every label comes from the translations,</strong> including &quot;Delivery fee&quot;.</li>
<li><strong><code>Intl.NumberFormat</code></strong> formats money for the locale: ₹1,25,000 in India, 125.000 ₹ in Germany.</li>
<li><strong><code>Intl.PluralRules</code></strong> picks the right plural form, so Arabic gets all six.</li>
<li><strong>Dates use the long format,</strong> &quot;12 November 2026&quot;, which nobody can misread. Avoid all-number dates in anything a customer reads.</li>
<li><strong><code>lang</code> and <code>dir</code> on the page.</strong> <code>dir=&quot;rtl&quot;</code> flips the whole layout for Arabic. <code>lang</code> lets screen readers pronounce the text correctly, and lets the browser choose the right fonts.</li>
</ul>
<pre tabindex="0"><code class="language-text">$ pytest tests -q
....................                                                     [100%]
20 passed in 4.34s
</code></pre>
<h2>What automated tests can&#39;t check</h2>
<p>The tests above check that the app is <em>ready</em> for other languages. They can&#39;t tell you whether:</p>
<ul>
<li><strong>The translation is right.</strong> &quot;Bestellung aufgeben&quot; is the usual German for &quot;place order&quot;. A machine translation might produce something technically correct that no German shop would write.</li>
<li><strong>The tone fits.</strong> Formal or informal &quot;you&quot; (German <em>Sie</em> or <em>du</em>, Hindi <em>आप</em> or <em>तुम</em>) is a brand decision.</li>
<li><strong>Images, colours and examples make sense</strong> in the region. A dish, a gesture or a holiday that&#39;s normal in one country can confuse or offend in another.</li>
<li><strong>Legal and regional rules</strong> are met: address formats, tax display, required notices.</li>
</ul>
<p>That&#39;s <strong>linguistic and cultural testing</strong>, done by native speakers reading the real app, ideally on the screens and in the situations real users have. Give them the app, not a spreadsheet of strings: a translation can be right in a spreadsheet and wrong on the button.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Waiting for translations to start testing.</strong> Pseudo-localization finds hardcoded text and cut-off layouts on day one.</li>
<li><strong>Building text from pieces.</strong> <code>&quot;You have &quot; + n + &quot; items&quot;</code> can&#39;t be translated: word order and plurals differ. Translate whole sentences with placeholders.</li>
<li><strong>English plural logic.</strong> <code>n === 1 ? one : many</code> is wrong for Arabic, Russian, Polish and many more. Use <code>Intl.PluralRules</code>.</li>
<li><strong>Formatting numbers and dates by hand.</strong> Use <code>Intl.NumberFormat</code> and <code>Intl.DateTimeFormat</code> with the user&#39;s locale.</li>
<li><strong>Fixed widths with hidden overflow.</strong> They cut off German, pseudo-localized text and, in our run, English too.</li>
<li><strong>Testing right-to-left by checking an element&#39;s box.</strong> Boxes are often full width. Check where the text actually is, and look at the screen.</li>
<li><strong>No fonts on the test machine.</strong> Your screenshots show empty boxes, and you can&#39;t see what users see.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong>Part 16</strong> of Testing in Practice we look at <strong>flaky tests</strong>: why a test passes and fails without a code change, how to find which tests are flaky, and how to fix the causes, with the clock test from Part 14 and the race from Part 12 as real examples.</p>
<p>ProvaLab&#39;s QA Infrastructure runs browser tests in the cloud, with a live view while they run, and ProvaLab&#39;s pytest plugin sends each localized test&#39;s result, one per language, to the ProvaLab test management system. Each language&#39;s history stays separate, so a regression in Arabic shows up as Arabic, not as one failure among hundreds. See it in the <a href="https://provalab.io/#stage-infra">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Property-based testing with Hypothesis (hands-on): seven examples passed, three bugs found, and one property that lied</title>
      <link>https://provalab.io/blog/property-based-testing-hypothesis/</link>
      <guid isPermaLink="true">https://provalab.io/blog/property-based-testing-hypothesis/</guid>
      <pubDate>Thu, 12 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>property-based-testing</category>
      <category>hypothesis</category>
      <category>pytest</category>
      <category>python</category>
      <description>Describe what must always be true and let Hypothesis find the inputs that break it. A real run on FoodNow's money code: three bugs, shrunk to the smallest input, and two honest limits.</description>
      <content:encoded><![CDATA[<p>Every test you&#39;ve written so far in this series checks <strong>examples</strong>: this input, that expected output. Examples are clear and easy to read. Their weakness is that you choose them, and you choose the cases you thought of. The bugs live in the cases you didn&#39;t.</p>
<p><strong>Property-based testing</strong> turns that around. You describe what must be true for <strong>every</strong> input, a <strong>property</strong>, and a tool generates hundreds of inputs to try to break it. When it finds one, it <strong>shrinks</strong> it to the smallest input that still fails, so you get &quot;1 paisa between 2 people&quot; instead of &quot;₹8,471,293.17 between 37 people&quot;.</p>
<p>The best-known tool for Python is <a href="https://hypothesis.readthedocs.io/">Hypothesis</a>. This post uses it on FoodNow&#39;s money code: discounts, splitting a group order&#39;s bill, and showing prices in Indian format. The code has three bugs, and seven example tests that all pass. Every result below is from a real run, with Hypothesis 6.170.15.</p>
<h2>The code</h2>
<p><code>foodnow/money.py</code>. All amounts are in paise (₹1 = 100 paise):</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;FoodNow money helpers. All amounts are in paise (₹1 = 100 paise).&quot;&quot;&quot;


def discounted_food_total(dishes: int, price: int) -&gt; int:
    &quot;&quot;&quot;Orders of 10 or more dishes get 10% off.&quot;&quot;&quot;
    food = dishes * price
    if dishes &gt; 10:
        food = food * 90 // 100
    return food


def split_bill(total: int, people: int) -&gt; list[int]:
    &quot;&quot;&quot;Split a group order&#39;s total between colleagues.&quot;&quot;&quot;
    share = round(total / people)
    return [share] * people


def format_inr(paise: int) -&gt; str:
    &quot;&quot;&quot;₹1,25,000.50 - Indian digit grouping: the last three digits, then pairs.&quot;&quot;&quot;
    rupees, p = divmod(paise, 100)
    digits = str(rupees)
    head, tail = digits[:-3], digits[-3:]
    groups = []
    while len(head) &gt; 2:
        groups.insert(0, head[-2:])
        head = head[:-2]
    if head:
        groups.insert(0, head)
    whole = &quot;,&quot;.join(groups + [tail])
    return f&quot;₹{whole}.{p}&quot; if p else f&quot;₹{whole}&quot;


def parse_inr(text: str) -&gt; int:
    whole, _, frac = text.removeprefix(&quot;₹&quot;).replace(&quot;,&quot;, &quot;&quot;).partition(&quot;.&quot;)
    return int(whole) * 100 + (int(frac) if frac else 0)
</code></pre>
<p><code>split_bill</code> is for group ordering, the feature that <a href="https://provalab.io/blog/test-summary-report-metrics/">Part 14</a> found had no tests. <code>format_inr</code> uses the Indian digit grouping from <a href="https://provalab.io/blog/localization-testing-playwright/">Part 15</a>.</p>
<h2>Step 1: The example tests</h2>
<p>These are the tests the team already had, in <code>tests/test_examples.py</code>:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;The example-based tests the team already had. All of them pass.&quot;&quot;&quot;
from foodnow.money import discounted_food_total, format_inr, parse_inr, split_bill


def test_small_order_no_discount():
    assert discounted_food_total(2, 18000) == 36000


def test_big_order_discount():
    assert discounted_food_total(12, 18000) == 194400


def test_split_evenly():
    assert split_bill(90000, 3) == [30000, 30000, 30000]


def test_split_between_two():
    assert split_bill(41000, 2) == [20500, 20500]


def test_format_lakh():
    assert format_inr(12500000) == &quot;₹1,25,000&quot;


def test_format_with_paise():
    assert format_inr(12500050) == &quot;₹1,25,000.50&quot;


def test_parse():
    assert parse_inr(&quot;₹1,25,000.50&quot;) == 12500050
</code></pre>
<pre tabindex="0"><code class="language-bash">pip install pytest==9.2.0 hypothesis==6.170.15
</code></pre>
<pre tabindex="0"><code class="language-text">$ pytest tests/test_examples.py -q
.......                                                                  [100%]
7 passed in 0.65s
</code></pre>
<p>Seven reasonable examples, all green, three bugs.</p>
<h2>Step 2: Write properties</h2>
<p>A property says what&#39;s always true. For money code, good properties come from the rules and from common sense:</p>
<ul>
<li>10 or more dishes get 10% off; fewer don&#39;t.</li>
<li>When you split a bill, the shares add up to the total. No paisa appears or disappears.</li>
<li>The split is fair: nobody pays more than one paisa more than anyone else.</li>
<li>If you format an amount and parse the text back, you get the same amount.</li>
</ul>
<p><code>tests/test_properties.py</code>:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;Properties: things that must be true for every input, not just the examples we thought of.&quot;&quot;&quot;
from hypothesis import given, strategies as st

from foodnow.money import discounted_food_total, format_inr, parse_inr, split_bill

paise = st.integers(min_value=0, max_value=10**10)          # up to ₹10 crore
people = st.integers(min_value=1, max_value=50)


@given(dishes=st.integers(min_value=1, max_value=100), price=st.integers(min_value=100, max_value=100_000))
def test_ten_or_more_dishes_get_ten_percent_off(dishes, price):
    expected = dishes * price * 90 // 100 if dishes &gt;= 10 else dishes * price
    assert discounted_food_total(dishes, price) == expected


@given(total=paise, n=people)
def test_split_adds_up_to_the_total(total, n):
    assert sum(split_bill(total, n)) == total


@given(total=paise, n=people)
def test_split_is_fair(total, n):
    shares = split_bill(total, n)
    assert len(shares) == n
    assert max(shares) - min(shares) &lt;= 1  # nobody pays more than one paisa extra


@given(amount=paise)
def test_format_then_parse_gives_the_same_amount(amount):
    assert parse_inr(format_inr(amount)) == amount
</code></pre>
<p><code>@given</code> tells Hypothesis to call the test many times (100 by default), each time with values drawn from the <strong>strategies</strong>: here, whole numbers in a range. Hypothesis doesn&#39;t pick them uniformly. It favours values that tend to break code: zero, the ends of the range, small numbers, and numbers it has seen cause trouble.</p>
<h2>Step 3: Run it, and read it carefully</h2>
<pre tabindex="0"><code class="language-text">$ pytest tests/test_properties.py -q
E       assert 0 == 1
E        +  where 0 = sum([0, 0])
E        +    where [0, 0] = split_bill(1, 2)
E       Failing test case: test_split_adds_up_to_the_total(
E           total=1,
E           n=2,
E       )
1 failed, 3 passed in 0.36s
</code></pre>
<p>One real bug, shrunk beautifully: <strong>1 paisa split between 2 people gives [0, 0].</strong> A paisa disappears. <code>round(0.5)</code> is 0 in Python. With bigger numbers it&#39;s the same problem: ₹1,000 split between 3 people becomes three shares of ₹333.33, and a paisa is lost; other amounts round up and charge the group more than the bill. The example tests used amounts that divide exactly.</p>
<p>But look at what <strong>passed</strong>. Three bugs, and only one property failed. That needs explaining before you trust the green ones.</p>
<p><strong>The fairness property passed</strong> because <code>[share] * people</code> is perfectly fair. Every share is the same. It&#39;s just wrong. A property can only check what it says, so you need more than one property per function: here, &quot;adds up&quot; and &quot;is fair&quot; together.</p>
<p><strong>The round-trip property passed</strong> with a real bug in it. One paisa is formatted as <code>₹0.1</code>, which anyone would read as 10 paise. But <code>parse_inr(&quot;₹0.1&quot;)</code> reads the <code>1</code> after the dot as 1 paisa, so the round trip gives back the right number. Formatting and parsing <strong>share the same mistake</strong>, so the round trip can&#39;t see it. Round-trip properties are excellent at finding asymmetry between two functions, and blind when both are wrong in the same way.</p>
<p><strong>The discount property passed</strong> even though 10 dishes don&#39;t get the discount. Hypothesis tries the ends of the range (1 and 100) and many values in between, but 10 is just one value in the middle, and the second input, the price, spreads its effort further. We measured it: 50 runs, each from scratch, with these strategies. Hypothesis found the bug in <strong>20 of the 50</strong>. With a narrower range and a fixed price, 1 to 50 dishes, it found it in <strong>50 of 50</strong>. Boundaries that live inside your rules, rather than at the edges of a range, are worth stating yourself.</p>
<h2>Step 4: Make the properties honest</h2>
<p>Two changes. An explicit example pins the boundary, so it&#39;s checked on every run. And a property that compares the formatter with an independent, obviously correct description of the format, instead of with its own parser:</p>
<pre tabindex="0"><code class="language-python">import re

from hypothesis import example, given, strategies as st


@given(dishes=st.integers(min_value=1, max_value=100), price=st.integers(min_value=100, max_value=100_000))
@example(dishes=9, price=18000)
@example(dishes=10, price=18000)  # the boundary in the rule: always check it
def test_ten_or_more_dishes_get_ten_percent_off(dishes, price):
    expected = dishes * price * 90 // 100 if dishes &gt;= 10 else dishes * price
    assert discounted_food_total(dishes, price) == expected


@given(amount=paise)
def test_format_shows_two_digit_paise_and_indian_grouping(amount):
    text = format_inr(amount)
    rupees, p = divmod(amount, 100)
    assert text.replace(&quot;,&quot;, &quot;&quot;) == f&quot;₹{rupees}&quot; + (f&quot;.{p:02d}&quot; if p else &quot;&quot;)
    assert re.fullmatch(r&quot;₹(\d{1,2},)?(\d{2},)*\d{1,3}(\.\d{2})?&quot;, text), text
</code></pre>
<p>The new formatting property says two things: without the commas, the text is the rupees and two-digit paise. And the commas follow the Indian pattern: a group of three at the end, then pairs.</p>
<p>One more change, in <code>tests/conftest.py</code>:</p>
<pre tabindex="0"><code class="language-python">from hypothesis import settings

# Don&#39;t replay saved failures from earlier runs, so every run shows what Hypothesis finds from scratch.
settings.register_profile(&quot;fresh&quot;, database=None)
settings.load_profile(&quot;fresh&quot;)
</code></pre>
<p>Normally Hypothesis saves every failing input in a <code>.hypothesis</code> folder and tries it first on the next run. That&#39;s a feature: a bug it found once is checked every time after that, until it&#39;s fixed. But it also means &quot;it found the bug again&quot; may only mean &quot;it remembered&quot;. For this post we turned it off, so each run starts from nothing. In your own project, keep the database. Commit it, or share it in CI, so everyone benefits from what Hypothesis has found.</p>
<p>Three runs, each from scratch:</p>
<pre tabindex="0"><code class="language-text">3 failed, 2 passed in 0.24s
3 failed, 2 passed in 0.26s
3 failed, 2 passed in 0.22s
</code></pre>
<pre tabindex="0"><code class="language-text">E       assert 180000 == 162000
E        +  where 180000 = discounted_food_total(10, 18000)
E       Failing explicit example: test_ten_or_more_dishes_get_ten_percent_off(
E           dishes=10,
E           price=18000,
E       )

E       assert 0 == 1
E        +  where 0 = sum([0, 0])
E        +    where [0, 0] = split_bill(1, 2)
E       Failing test case: test_split_adds_up_to_the_total(
E           total=1,
E           n=2,
E       )

E       AssertionError: assert &#39;₹0.1&#39; == &#39;₹0.01&#39;
E       Failing test case: test_format_shows_two_digit_paise_and_indian_grouping(
E           amount=1,
E       )
</code></pre>
<p>All three bugs, every time, each shrunk to the smallest case: 10 dishes at ₹180 charged ₹1,800 instead of ₹1,620; 1 paisa split into nothing; 1 paisa shown as <code>₹0.1</code>.</p>
<p><img src="https://provalab.io/blog/practice/hypothesis-examples-vs-properties.svg" alt="Seven example tests passed. Hypothesis properties found three bugs, each shrunk to the smallest failing input, and the round-trip property passed despite a bug because formatting and parsing shared the same mistake." width="880" height="470" loading="lazy"></p>
<h2>Step 5: Fix the code</h2>
<pre tabindex="0"><code class="language-diff">-    if dishes &gt; 10:
+    if dishes &gt;= 10:
...
-    share = round(total / people)
-    return [share] * people
+    base, extra = divmod(total, people)
+    return [base + 1] * extra + [base] * (people - extra)  # the first `extra` people pay one paisa more
...
-    return f&quot;₹{whole}.{p}&quot; if p else f&quot;₹{whole}&quot;
+    return f&quot;₹{whole}.{p:02d}&quot; if p else f&quot;₹{whole}&quot;
</code></pre>
<p>The split now divides exactly: ₹1,000 between 3 people is ₹333.34, ₹333.33 and ₹333.33, which adds up to ₹1,000 and is fair to within a paisa.</p>
<pre tabindex="0"><code class="language-text">$ pytest tests -q
............                                                             [100%]
12 passed in 0.26s
</code></pre>
<p>Run three times from scratch: 12 passed each time. That&#39;s 7 examples, plus 5 properties that each tried 100 inputs, in about a quarter of a second.</p>
<h2>When to use property-based testing</h2>
<table tabindex="0">
<thead>
<tr>
<th>Good fit</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Money, rounding, units</strong></td>
<td>Easy rules (&quot;adds up&quot;, &quot;never negative&quot;), and bugs in rare values</td>
</tr>
<tr>
<td><strong>Parsers and formatters</strong></td>
<td>Round trips, plus a check against an independent description</td>
</tr>
<tr>
<td><strong>Anything with &quot;for all&quot; in its rule</strong></td>
<td>&quot;Every order...&quot;, &quot;no matter how many people...&quot;</td>
</tr>
<tr>
<td><strong>Code with a simple reference version</strong></td>
<td>Compare a fast or clever version with a slow, obvious one</td>
</tr>
<tr>
<td><strong>Sequences of actions</strong></td>
<td>Hypothesis&#39;s stateful testing generates whole sequences, such as add, remove, check out</td>
</tr>
</tbody></table>
<p>It&#39;s a poorer fit for UI flows and for code whose correct output you can only describe by computing it the same way the code does. If the property repeats the code&#39;s logic, it shares the code&#39;s bugs.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>One property per function.</strong> Our fairness property passed a split that lost money. Combine properties that check different things.</li>
<li><strong>Trusting round trips alone.</strong> Format and parse were wrong together. Add a check against an independent description.</li>
<li><strong>Assuming Hypothesis tries every boundary.</strong> It tries the ends of ranges. Boundaries inside your rules need <code>@example</code>. Ours was found in only 20 runs out of 50 without it.</li>
<li><strong>Mistaking replay for discovery.</strong> The <code>.hypothesis</code> database replays old failures. Great in daily use, but don&#39;t count it as proof that a new test finds the bug.</li>
<li><strong>Repeating the code in the property.</strong> <code>expected = round(total / n) * n</code> would agree with the bug. Describe what must be true, not how to compute it.</li>
<li><strong>Generating huge ranges by habit.</strong> Choose strategies close to real inputs, and add edge cases as examples.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong>Part 18</strong> of Testing in Practice we run the same FoodNow tests on <strong>three browser engines</strong>, Chromium, Firefox and WebKit (the engine behind Safari), and look at what differs between them.</p>
<p>ProvaLab&#39;s pytest plugin reports Hypothesis tests like any other pytest test, so a property that fails shows up in the ProvaLab test management system with its failure message, and in the test&#39;s history across runs. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>pytest in practice: fixtures, parametrize, raises, markers and the plugins worth knowing</title>
      <link>https://provalab.io/blog/pytest-in-practice-fixtures-parametrize-markers/</link>
      <guid isPermaLink="true">https://provalab.io/blog/pytest-in-practice-fixtures-parametrize-markers/</guid>
      <pubDate>Thu, 12 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>pytest</category>
      <category>python</category>
      <category>test-automation</category>
      <category>tool-guide</category>
      <description>Hands-on: a six-test pytest suite with 81% coverage caught none of three bugs. We rebuild it with parametrize, raises with match, fixtures, monkeypatch, strict markers and coverage.</description>
      <content:encoded><![CDATA[<p>Most people learn pytest from a tutorial like <a href="https://provalab.io/blog/first-automated-test-pytest/">our first one</a>: write a function, write <code>assert</code>, run <code>pytest</code>. That gets you a long way. Then the suite grows, and the problems that show up are rarely about <code>assert</code>. They come from how you use the rest of pytest: a <code>pytest.raises</code> that passes for the wrong reason, a setting you patch too late, a marker with a typo, a coverage number that looks fine.</p>
<p>This guide starts with a small suite of the kind that builds up after a busy sprint. It has six tests, 81% line coverage, and catches none of the three bugs in the code. Then we rebuild it, one pytest feature at a time, and look at what each one catches. Every command was run as written, with these versions:</p>
<table tabindex="0">
<thead>
<tr>
<th>Package</th>
<th>Version</th>
</tr>
</thead>
<tbody><tr>
<td>Python</td>
<td>3.12.3</td>
</tr>
<tr>
<td>pytest</td>
<td>9.2.0</td>
</tr>
<tr>
<td>pytest-cov</td>
<td>7.1.0 (coverage 7.17.3)</td>
</tr>
<tr>
<td>pytest-xdist</td>
<td>3.8.0</td>
</tr>
</tbody></table>
<p><img src="https://provalab.io/blog/tools/pytest-v1-vs-v2.svg" alt="Same bill code, two suites: version 1 had 6 tests and 81% coverage and caught 0 of 3 product bugs; version 2 had 15 tests and caught all three, plus two problems in the tests themselves" width="880" height="500" loading="lazy"></p>
<h2>The code: FoodNow&#39;s bill (5 minutes)</h2>
<p>FoodNow is the food delivery app we use as the example across our posts. This is its bill maths. Money is kept in <strong>paise</strong> (₹1 = 100 paise) as integers, so there are no float rounding surprises. The business rules:</p>
<ul>
<li>GST is 5% of the food.</li>
<li>Delivery is ₹40, and free from ₹499.</li>
<li>A coupon like <code>SAVE50</code> takes ₹50 off, but only on orders of ₹199 or more. Below that it is ignored and a warning is logged.</li>
</ul>
<p>The code has <strong>two bugs we planted on purpose</strong>. Try to spot them before the tests do.</p>
<pre tabindex="0"><code class="language-python"># foodnow/bill.py
&quot;&quot;&quot;FoodNow bill maths. All money is in paise (Rs 1 = 100 paise).&quot;&quot;&quot;
import json
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path

log = logging.getLogger(&quot;foodnow.bill&quot;)

GST_RATE = float(os.environ.get(&quot;FOODNOW_GST&quot;, &quot;0.05&quot;))
DELIVERY_FEE = 4_000          # Rs 40
FREE_DELIVERY_FROM = 49_900   # Rs 499 and above: free delivery
COUPON_MIN_ORDER = 19_900     # coupons need Rs 199 of food


@dataclass
class Line:
    name: str
    price: int   # paise, for one
    qty: int


@dataclass
class Basket:
    lines: list[Line] = field(default_factory=list)
    coupon_off: int = 0

    def add(self, name: str, price: int, qty: int = 1) -&gt; None:
        if price &lt;= 0:
            raise ValueError(&quot;price must be above zero&quot;)
        self.lines.append(Line(name, price, qty))

    def apply_coupon(self, code: str) -&gt; None:
        if not code.startswith(&quot;SAVE&quot;):
            raise ValueError(f&quot;unknown coupon {code!r}&quot;)
        self.coupon_off = int(code[4:]) * 100

    def subtotal(self) -&gt; int:
        return sum(line.price * line.qty for line in self.lines)

    def gst(self) -&gt; int:
        return round(self.subtotal() * GST_RATE)

    def delivery(self) -&gt; int:
        return 0 if self.subtotal() &gt; FREE_DELIVERY_FROM else DELIVERY_FEE

    def discount(self) -&gt; int:
        if self.coupon_off and self.subtotal() &lt; COUPON_MIN_ORDER:
            log.warning(&quot;coupon ignored: subtotal %d below %d&quot;,
                        self.subtotal(), COUPON_MIN_ORDER)
            return 0
        return min(self.coupon_off, self.subtotal())

    def total(self) -&gt; int:
        return self.subtotal() + self.gst() + self.delivery() - self.discount()

    def total_rupees(self) -&gt; float:
        return self.total() / 100


def write_receipt(basket: Basket, folder: Path, order_id: str) -&gt; Path:
    path = folder / f&quot;receipt-{order_id}.json&quot;
    path.write_text(json.dumps({
        &quot;order_id&quot;: order_id,
        &quot;lines&quot;: [[l.name, l.price, l.qty] for l in basket.lines],
        &quot;total&quot;: basket.total(),
    }))
    return path
</code></pre>
<p>Set up a project around it:</p>
<pre tabindex="0"><code class="language-bash">mkdir pyguide &amp;&amp; cd pyguide
mkdir foodnow tests &amp;&amp; touch foodnow/__init__.py
# save the code above as foodnow/bill.py
python -m venv .venv &amp;&amp; source .venv/bin/activate
pip install pytest==9.2.0 pytest-cov==7.1.0 pytest-xdist==3.8.0
</code></pre>
<h2>Version 1: the suite after a busy sprint</h2>
<p>Here is a suite like many real ones. Nothing in it is unusual.</p>
<pre tabindex="0"><code class="language-python"># tests/test_bill_v1.py
import pytest

from foodnow.bill import Basket


def test_small_order_pays_delivery():
    basket = Basket()
    basket.add(&quot;Veg thali&quot;, 17_900)
    assert basket.delivery() == 4_000


def test_big_order_free_delivery():
    basket = Basket()
    basket.add(&quot;Veg thali&quot;, 17_900, qty=3)
    assert basket.delivery() == 0


def test_total_with_gst_and_delivery():
    basket = Basket()
    basket.add(&quot;Veg thali&quot;, 17_900)
    basket.add(&quot;Butter naan&quot;, 6_500, qty=2)
    assert basket.total_rupees() == pytest.approx(364.45)


def test_coupon_takes_money_off():
    basket = Basket()
    basket.add(&quot;Veg thali&quot;, 17_900, qty=2)
    basket.apply_coupon(&quot;SAVE50&quot;)
    assert basket.discount() == 5_000


def test_negative_quantity_is_refused():
    basket = Basket()
    with pytest.raises(ValueError):
        basket.apply_coupon(&quot;SAVE&quot;)
        basket.add(&quot;Veg thali&quot;, 17_900, qty=-1)


def test_gst_rate_comes_from_settings(monkeypatch):
    monkeypatch.setenv(&quot;FOODNOW_GST&quot;, &quot;0.12&quot;)
    basket = Basket()
    basket.add(&quot;Veg thali&quot;, 10_000)
    assert basket.gst() == 1_200
</code></pre>
<p>Run it with plain <code>pytest</code>:</p>
<pre tabindex="0"><code class="language-text">$ pytest -q tests/test_bill_v1.py
ImportError while importing test module &#39;.../tests/test_bill_v1.py&#39;.
E   ModuleNotFoundError: No module named &#39;foodnow&#39;
1 error in 0.09s
</code></pre>
<p>That error is the first lesson. <code>python -m pytest</code> adds the current folder to the import path; plain <code>pytest</code> does not. Rather than remembering which command to type, tell pytest where your code is in a <code>pytest.ini</code> at the project root:</p>
<pre tabindex="0"><code class="language-ini"># pytest.ini
[pytest]
testpaths = tests
pythonpath = .
</code></pre>
<p>Now it runs:</p>
<pre tabindex="0"><code class="language-text">$ pytest -q tests/test_bill_v1.py
=================================== FAILURES ===================================
______________________ test_gst_rate_comes_from_settings _______________________
    def test_gst_rate_comes_from_settings(monkeypatch):
        monkeypatch.setenv(&quot;FOODNOW_GST&quot;, &quot;0.12&quot;)
        basket = Basket()
        basket.add(&quot;Veg thali&quot;, 10_000)
&gt;       assert basket.gst() == 1_200
E       AssertionError: assert 500 == 1200
1 failed, 5 passed in 0.03s
</code></pre>
<p>Five pass, one fails, and with coverage turned on (more on that below) the suite reaches 81% of the lines. The team&#39;s verdict would be &quot;one flaky settings test, otherwise fine&quot;. It is not fine. Three things are wrong with this suite.</p>
<p><strong>The <code>raises</code> test passes for the wrong reason.</strong> A line copied from the coupon test, <code>basket.apply_coupon(&quot;SAVE&quot;)</code>, raises <code>ValueError</code> first (the code does <code>int(&quot;&quot;)</code>), so <code>basket.add(..., qty=-1)</code> never runs. pytest sees a <code>ValueError</code> and is happy. The basket in fact accepts a quantity of −1.</p>
<p><strong>The settings test can never pass.</strong> <code>GST_RATE</code> is read once, when <code>foodnow.bill</code> is imported. By the time the test sets the environment variable, the module has already read it. The product works as written; the test is wrong.</p>
<p><strong>The boundary is never tested.</strong> The two delivery tests use ₹179 and ₹537. The rule changes at ₹499, and that is where the bug is.</p>
<h2>Step 1: Parametrize the boundary, with names (5 minutes)</h2>
<p>Boundary tests are where <code>@pytest.mark.parametrize</code> pays for itself: one test body, one row per value either side of the line. Give each row an <code>id</code>, so a failure tells you which row in words:</p>
<pre tabindex="0"><code class="language-python"># tests/test_bill_v2.py
import json
import logging

import pytest

import foodnow.bill as bill

THALI = 17_900   # Rs 179
NAAN = 6_500     # Rs 65


@pytest.mark.parametrize(
    &quot;subtotal, fee&quot;,
    [
        pytest.param(49_800, 4_000, id=&quot;Rs 498 pays&quot;),
        pytest.param(49_900, 0, id=&quot;Rs 499 free&quot;),
        pytest.param(50_000, 0, id=&quot;Rs 500 free&quot;),
    ],
)
def test_delivery_fee_boundary(make_basket, subtotal, fee):
    basket = make_basket((&quot;Party platter&quot;, subtotal, 1))
    assert basket.delivery() == fee
</code></pre>
<p><code>make_basket</code> is a fixture, which we write in step 3. The failure reads like a bug report:</p>
<pre tabindex="0"><code class="language-text">FAILED tests/test_bill_v2.py::test_delivery_fee_boundary[Rs 499 free] - Asser...
&gt;       assert basket.delivery() == fee
E       AssertionError: assert 4000 == 0
E        +  where 4000 = delivery()
</code></pre>
<p>A ₹499 order is charged ₹40 for delivery. The code says <code>&gt;</code> where the rule says &quot;from ₹499&quot;, which is <code>&gt;=</code>. That is planted bug number one.</p>
<h2>Step 2: <code>pytest.raises</code> with <code>match</code>, and nothing else in the block (5 minutes)</h2>
<p>Two rules make <code>pytest.raises</code> trustworthy. Put only the one call you expect to fail inside the <code>with</code> block. And always pass <code>match=</code>, a regular expression the error message must contain, so the test fails if the right type of error is raised for the wrong reason.</p>
<pre tabindex="0"><code class="language-python">def test_negative_quantity_is_refused(make_basket):
    basket = make_basket()
    with pytest.raises(ValueError, match=&quot;quantity&quot;):
        basket.add(&quot;Veg thali&quot;, THALI, qty=-1)


@pytest.mark.parametrize(&quot;code&quot;, [&quot;SAVE&quot;, &quot;SAVEFIFTY&quot;, &quot;FREE50&quot;])
def test_bad_coupon_is_refused(make_basket, code):
    basket = make_basket((&quot;Veg thali&quot;, THALI, 2))
    with pytest.raises(ValueError, match=&quot;coupon&quot;):
        basket.apply_coupon(code)
</code></pre>
<pre tabindex="0"><code class="language-text">&gt;       with pytest.raises(ValueError, match=&quot;quantity&quot;):
E       Failed: DID NOT RAISE ValueError
</code></pre>
<p>Planted bug number two: a negative quantity goes straight into the basket, and the total goes down.</p>
<p>The coupon test found something <strong>we did not plant</strong>:</p>
<pre tabindex="0"><code class="language-text">FAILED tests/test_bill_v2.py::test_bad_coupon_is_refused[SAVE]
E       AssertionError: Regex pattern did not match.
E         Expected regex: &#39;coupon&#39;
E         Actual message: &quot;invalid literal for int() with base 10: &#39;&#39;&quot;
FAILED tests/test_bill_v2.py::test_bad_coupon_is_refused[SAVEFIFTY]
E         Expected regex: &#39;coupon&#39;
E         Actual message: &quot;invalid literal for int() with base 10: &#39;FIFTY&#39;&quot;
</code></pre>
<p><code>FREE50</code> gets our &quot;unknown coupon&quot; message. <code>SAVE</code> and <code>SAVEFIFTY</code> start with <code>SAVE</code>, get past the check, and crash in <code>int()</code>. If the app shows that message to a customer, they see Python internals. A bare <code>pytest.raises(ValueError)</code> passes all three and never tells you. This is the same blind spot that hid the quantity bug in version 1.</p>
<h2>Step 3: Fixtures: a factory, a teardown, and <code>conftest.py</code> (5 minutes)</h2>
<p>Version 1 starts every test with <code>basket = Basket()</code> and a few <code>add</code> calls. A <strong>factory fixture</strong> returns a function, so each test still says what is in its basket, in one line:</p>
<pre tabindex="0"><code class="language-python"># tests/conftest.py
import pytest

from foodnow.bill import Basket


@pytest.fixture
def make_basket():
    &quot;&quot;&quot;Factory: make_basket((&quot;Veg thali&quot;, 17_900, 2), ...) -&gt; Basket.&quot;&quot;&quot;
    def _make(*lines):
        basket = Basket()
        for name, price, qty in lines:
            basket.add(name, price, qty)
        return basket
    return _make


@pytest.fixture
def receipts_dir(tmp_path):
    folder = tmp_path / &quot;receipts&quot;
    folder.mkdir()
    yield folder
    print(f&quot;\nteardown: {len(list(folder.iterdir()))} receipt(s) left in {folder.name}/&quot;)
</code></pre>
<p>Fixtures in <code>conftest.py</code> are available to every test in that folder and below, without an import. Don&#39;t import from <code>conftest.py</code> yourself; if tests share constants, put them in a normal module or keep them in the test file, as we did with <code>THALI</code> and <code>NAAN</code>.</p>
<p><code>receipts_dir</code> shows the other half of fixtures: everything after <code>yield</code> is teardown, and it runs even when the test fails. It builds on <code>tmp_path</code>, a fixture pytest provides: a fresh, empty folder for each test, so no test ever writes into your project or sees another test&#39;s files.</p>
<pre tabindex="0"><code class="language-python">def test_receipt_is_written(make_basket, receipts_dir):
    basket = make_basket((&quot;Veg thali&quot;, THALI, 1))
    path = bill.write_receipt(basket, receipts_dir, &quot;A1001&quot;)
    assert json.loads(path.read_text())[&quot;total&quot;] == basket.total()
</code></pre>
<p>When you&#39;re not sure which fixture runs when, <code>--setup-show</code> prints the order. <code>S</code> is session scope, <code>F</code> is function scope:</p>
<pre tabindex="0"><code class="language-text">$ pytest -q --setup-show -s -k receipt
SETUP    S tmp_path_factory
        SETUP    F make_basket
        SETUP    F tmp_path (fixtures used: tmp_path_factory)
        SETUP    F receipts_dir (fixtures used: tmp_path)
        tests/test_bill_v2.py::test_receipt_is_written (fixtures used: make_basket, receipts_dir, request, tmp_path, tmp_path_factory) .
teardown: 1 receipt(s) left in receipts/

        TEARDOWN F receipts_dir
        TEARDOWN F tmp_path
        TEARDOWN F make_basket
TEARDOWN S tmp_path_factory
</code></pre>
<p>Setup runs in dependency order, and teardown in reverse.</p>
<h2>Step 4: <code>monkeypatch</code> and <code>caplog</code>: patch the right thing, check the log (5 minutes)</h2>
<p>The version 1 settings test set an environment variable the code had already read. <code>monkeypatch</code> can set an attribute instead, and puts the original back after the test:</p>
<pre tabindex="0"><code class="language-python">def test_gst_rate_comes_from_settings(make_basket, monkeypatch):
    monkeypatch.setattr(bill, &quot;GST_RATE&quot;, 0.12)
    basket = make_basket((&quot;Veg thali&quot;, 10_000, 1))
    assert basket.gst() == 1_200
</code></pre>
<p>The general rule: <strong>patch the name where the code looks it up, at the time it looks it up.</strong> Here that is the module attribute <code>bill.GST_RATE</code>, not the environment. (If the rate must change without a restart, that is a product change: read the variable inside <code>gst()</code>. Then <code>monkeypatch.setenv</code> works.)</p>
<p><code>caplog</code> captures log records, so you can test the &quot;ignored, with a warning&quot; rule instead of only the &quot;ignored&quot; half:</p>
<pre tabindex="0"><code class="language-python">def test_small_order_coupon_is_ignored_with_a_warning(make_basket, caplog):
    basket = make_basket((&quot;Butter naan&quot;, NAAN, 1))
    basket.apply_coupon(&quot;SAVE50&quot;)
    with caplog.at_level(logging.WARNING, logger=&quot;foodnow.bill&quot;):
        assert basket.discount() == 0
    assert &quot;coupon ignored&quot; in caplog.text
</code></pre>
<h3>A word on <code>pytest.approx</code> and money</h3>
<p>Version 1 compared rupees as floats with <code>pytest.approx</code>. It worked there, but look at what <code>approx</code> allows by default, a relative tolerance of one in a million:</p>
<pre tabindex="0"><code class="language-text">&gt;&gt;&gt; 0.1 + 0.2 == 0.3
False
&gt;&gt;&gt; 100_000.09 == pytest.approx(100_000.00)
True
&gt;&gt;&gt; 350.01 == pytest.approx(350.00)
False
</code></pre>
<p>On a ₹1,00,000 catering order, <code>approx</code> accepts a total that is 9 paise off. Use <code>approx</code> for real measurements (an average rating, a percentage, a time). For money, keep integers in paise and compare with <code>==</code>, as <code>test_total_in_paise</code> does in the full file.</p>
<h2>Step 5: Markers, and why <code>--strict-markers</code> belongs in every project (3 minutes)</h2>
<p>Markers tag tests so you can choose what to run. We register <code>slow</code> in <code>pytest.ini</code> and mark the one heavy test. It checks that no coupon value makes a total negative, over about 118,000 baskets:</p>
<pre tabindex="0"><code class="language-python">@pytest.mark.slwo
def test_total_never_negative_for_any_coupon(make_basket):
    for rupees in range(1, 2_000):
        for qty in range(1, 60):
            basket = make_basket((&quot;Butter naan&quot;, NAAN, qty))
            basket.coupon_off = rupees * 100
            assert basket.total() &gt;= 0
</code></pre>
<p>Spot the typo: <code>slwo</code>. The fast lane, <code>-m &quot;not slow&quot;</code>, still runs the test, because nothing is marked <code>slow</code>:</p>
<pre tabindex="0"><code class="language-text">$ pytest -q -m &quot;not slow&quot; --durations=2
PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo?
0.57s call     tests/test_bill_v2.py::test_total_never_negative_for_any_coupon
4 failed, 9 passed, 1 warning in 0.62s
</code></pre>
<p>Nothing deselected, and a warning most people scroll past. On a real suite, that is how the &quot;quick&quot; job slowly stops being quick. Make unknown markers an error:</p>
<pre tabindex="0"><code class="language-ini"># pytest.ini
[pytest]
testpaths = tests
pythonpath = .
addopts = --strict-markers
markers =
    slow: takes more than a second; skip with -m &quot;not slow&quot;
</code></pre>
<pre tabindex="0"><code class="language-text">$ pytest -q -m &quot;not slow&quot;
ERROR tests/test_bill_v2.py - Failed: &#39;slwo&#39; not found in `markers` configuration option
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
</code></pre>
<h2>Step 6: Fix the code, and rerun only what failed (3 minutes)</h2>
<p>Three small changes to <code>bill.py</code>:</p>
<pre tabindex="0"><code class="language-diff">     def add(self, name: str, price: int, qty: int = 1) -&gt; None:
         if price &lt;= 0:
             raise ValueError(&quot;price must be above zero&quot;)
+        if qty &lt; 1:
+            raise ValueError(&quot;quantity must be 1 or more&quot;)

     def apply_coupon(self, code: str) -&gt; None:
-        if not code.startswith(&quot;SAVE&quot;):
+        rupees = code.removeprefix(&quot;SAVE&quot;)
+        if rupees == code or not rupees.isdigit():
             raise ValueError(f&quot;unknown coupon {code!r}&quot;)
-        self.coupon_off = int(code[4:]) * 100
+        self.coupon_off = int(rupees) * 100

     def delivery(self) -&gt; int:
-        return 0 if self.subtotal() &gt; FREE_DELIVERY_FROM else DELIVERY_FEE
+        return 0 if self.subtotal() &gt;= FREE_DELIVERY_FROM else DELIVERY_FEE
</code></pre>
<p>With the typo fixed to <code>@pytest.mark.slow</code>, <code>--lf</code> (last failed) runs only the tests that failed last time. On a big suite, that is the difference between seconds and minutes while you fix things:</p>
<pre tabindex="0"><code class="language-text">$ pytest -q --lf          # before the fix
4 failed, 9 deselected in 0.03s
$ pytest -q --lf          # after the fix
4 passed, 9 deselected in 0.01s
$ pytest -q --lf          # nothing failed last time, so it runs everything
13 passed in 0.59s
$ pytest -q -m &quot;not slow&quot;
12 passed, 1 deselected in 0.02s
</code></pre>
<h2>Step 7: Coverage shows what you didn&#39;t run, not what is right (3 minutes)</h2>
<p><code>pytest-cov</code> measures which lines and branches ran. <code>--cov-branch</code> also counts each side of an <code>if</code>:</p>
<pre tabindex="0"><code class="language-text">$ pytest -q --cov=foodnow --cov-branch --cov-report=term-missing tests/test_bill_v1.py   # buggy code
Name                  Stmts   Miss Branch BrPart  Cover   Missing
foodnow/bill.py          46      7      6      3    81%   30, 35, 49-51, 62-68

$ pytest -q --cov=foodnow --cov-branch --cov-report=term-missing tests/test_bill_v2.py   # fixed code
foodnow/bill.py          49      2      8      1    95%   30, 61
</code></pre>
<p>Look at what the version 1 report does <strong>not</strong> list: the <code>delivery()</code> line. It ran, so it counts as covered, and it held a bug the whole time. Coverage only tells you a line ran, not that any test checked its answer.</p>
<p>The version 2 report is still useful: line 30 is the &quot;price must be above zero&quot; check, which no test reaches. One more parametrized test closes it:</p>
<pre tabindex="0"><code class="language-python">@pytest.mark.parametrize(&quot;price&quot;, [0, -100])
def test_zero_or_negative_price_is_refused(make_basket, price):
    with pytest.raises(ValueError, match=&quot;price&quot;):
        make_basket((&quot;Veg thali&quot;, price, 1))
</code></pre>
<pre tabindex="0"><code class="language-text">foodnow/bill.py          49      1      8      0    98%   61
15 passed in 2.85s
</code></pre>
<p>The one line left is <code>total_rupees()</code>, which only version 1 used. Two side notes from the same runs: coverage slowed the slow test from about 0.55 s to about 2.7 s, because tracing every line costs time; and the report reads 95%, not 100%, and that is fine. Chase the lines that matter, not the number.</p>
<h2>Step 8: Parallel runs: measure before you switch them on (2 minutes)</h2>
<p><code>pytest-xdist</code> runs tests across processes with <code>-n</code>. It helps big, slow suites. On this one, it doesn&#39;t:</p>
<pre tabindex="0"><code class="language-text">serial  0.82 s   0.82 s   0.86 s
-n 4    1.39 s   1.45 s   1.45 s
</code></pre>
<p>Starting four worker processes costs more than the whole 15-test run. The <a href="https://provalab.io/blog/playwright-python-page-objects-fixtures-mocking/">Playwright guide</a> shows the other side: browser tests that went from 27.1 s to 11.6 s with <code>-n 4</code>. Measure on your suite. And before you turn on <code>-n</code>, make sure every test passes alone and in any order; <a href="https://provalab.io/blog/flaky-tests-find-and-fix/">the flaky tests post</a> shows what happens when they don&#39;t.</p>
<h2>The commands worth remembering</h2>
<table tabindex="0">
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>pytest -x</code></td>
<td>Stop at the first failure</td>
</tr>
<tr>
<td><code>pytest --lf</code></td>
<td>Rerun only last run&#39;s failures</td>
</tr>
<tr>
<td><code>pytest -k &quot;boundary or coupon&quot;</code></td>
<td>Run tests whose names match</td>
</tr>
<tr>
<td><code>pytest -m &quot;not slow&quot;</code></td>
<td>Skip tests marked <code>slow</code></td>
</tr>
<tr>
<td><code>pytest --setup-show</code></td>
<td>Show fixture setup and teardown order</td>
</tr>
<tr>
<td><code>pytest --durations=5</code></td>
<td>List the five slowest tests</td>
</tr>
<tr>
<td><code>pytest --cov=pkg --cov-branch --cov-report=term-missing</code></td>
<td>Coverage, with the missing lines</td>
</tr>
<tr>
<td><code>pytest -n 4</code></td>
<td>Run on four processes (pytest-xdist)</td>
</tr>
</tbody></table>
<h2>Habits worth keeping</h2>
<ol>
<li><strong>Test both sides of every boundary</strong>, with <code>parametrize</code> and an <code>id</code> per row that reads like the rule.</li>
<li><strong><code>pytest.raises</code> gets one call and a <code>match=</code>.</strong> Without <code>match</code>, any error of that type passes.</li>
<li><strong>Patch where the code looks, when it looks.</strong> If the value is read at import, set the attribute.</li>
<li><strong>Keep money in integers.</strong> <code>approx</code> is for measurements.</li>
<li><strong>Turn on <code>--strict-markers</code></strong> on day one, in <code>pytest.ini</code>, so a typo fails instead of warning.</li>
<li><strong>Treat coverage as a list of untested lines,</strong> not as a score. The buggy line in this guide was covered.</li>
</ol>
<h2>What&#39;s next</h2>
<p>This suite gives one answer on one laptop. On a team, you want every run&#39;s results kept, each test linked to the requirement it checks, and the history of a test across runs, so &quot;it failed once last week&quot; is something you can look up. ProvaLab&#39;s pytest plugin sends each test&#39;s result to a test run in the ProvaLab test management system, and a marker such as <code>@pytest.mark.tms_test_case(id=123)</code> links a test to its test case, so requirement coverage comes from real runs rather than a spreadsheet. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
<p>New to pytest? Start with <a href="https://provalab.io/blog/first-automated-test-pytest/">Your first automated test in 15 minutes</a>. For the same ideas applied to browser tests, read <a href="https://provalab.io/blog/playwright-python-page-objects-fixtures-mocking/">Playwright with Python in practice</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>BDD with pytest-bdd (hands-on): Given/When/Then, and the question that wasn't a bug</title>
      <link>https://provalab.io/blog/bdd-pytest-bdd-gherkin/</link>
      <guid isPermaLink="true">https://provalab.io/blog/bdd-pytest-bdd-gherkin/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>bdd</category>
      <category>gherkin</category>
      <category>pytest</category>
      <category>pytest-bdd</category>
      <description>Behaviour-driven development explained with FoodNow's checkout pricing. A real pytest-bdd run finds two planted bugs and a rule nobody agreed on, plus two traps.</description>
      <content:encoded><![CDATA[<p>Most bugs aren&#39;t typing mistakes. They&#39;re two people who understood one sentence in two different ways. &quot;Delivery is free on orders of ₹500 or more&quot; sounds clear, until someone asks: ₹500 before the discount, or after?</p>
<p><strong>Behaviour-driven development (BDD)</strong> is a way of finding those questions before the code is written. The product owner, a developer and a tester talk through concrete examples of how a feature should behave. They write the examples down in plain language, as <strong>Given / When / Then</strong> scenarios. Then the scenarios are automated, so they run as tests every time the code changes.</p>
<p>This post explains BDD and the Gherkin language. Then it applies them to FoodNow&#39;s checkout pricing with pytest-bdd. The code has two planted bugs. The scenarios also found a rule that nobody had agreed on, and we hit two traps in the tooling. Every command and line of output below was run while writing this post.</p>
<h2>What BDD is, and what it isn&#39;t</h2>
<p>BDD grew out of test-driven development in the mid-2000s. Dan North coined the name, and the Given/When/Then format came with it. The core idea is that you describe behaviour with <strong>examples</strong>, not abstract rules, because examples expose the gaps in a rule.</p>
<p>The usual way to start is a short meeting, often called the <strong>three amigos</strong>: someone who knows what the business wants (the product owner), someone who&#39;ll build it (a developer), and someone who thinks about how it could go wrong (a tester). They take one story and ask &quot;can you give me an example?&quot; until the rules are clear.</p>
<p><img src="https://provalab.io/blog/practice/bdd-conversation-to-test.svg" alt="From a conversation to a test: the tester asks whether ₹520 of food with FIRST50 gets free delivery, the developer and product owner disagree, the answer becomes a scenario, and pytest-bdd runs it against the real pricing code" width="880" height="470" loading="lazy"></p>
<p>BDD is <strong>not</strong> the tool. Writing tests in Gherkin without the conversation gives you slower, wordier tests and none of the benefit. If only developers ever read the feature files, plain pytest is usually the better choice.</p>
<h2>Gherkin in five keywords</h2>
<p>Gherkin is the language the scenarios are written in. Cucumber made it popular, and pytest-bdd, Behave, SpecFlow and many others read it. A feature file looks like this:</p>
<pre tabindex="0"><code class="language-gherkin">Feature: Checkout pricing

  Scenario: Delivery is charged on a small order
    Given my basket has food worth ₹300
    When I check out
    Then delivery costs ₹40
    And I pay ₹340
</code></pre>
<table tabindex="0">
<thead>
<tr>
<th>Keyword</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Feature</strong></td>
<td>The thing being described. One per file.</td>
</tr>
<tr>
<td><strong>Scenario</strong></td>
<td>One concrete example.</td>
</tr>
<tr>
<td><strong>Given</strong></td>
<td>The situation before the action.</td>
</tr>
<tr>
<td><strong>When</strong></td>
<td>The action the user takes.</td>
</tr>
<tr>
<td><strong>Then</strong></td>
<td>What should happen.</td>
</tr>
<tr>
<td><strong>And</strong>, <strong>But</strong></td>
<td>More of the line before.</td>
</tr>
<tr>
<td><strong>Scenario Outline</strong> + <strong>Examples</strong></td>
<td>The same scenario with a table of values, one test per row.</td>
</tr>
</tbody></table>
<p>Each line is a <strong>step</strong>. In the test code, every step is matched to a Python function, its <strong>step definition</strong>.</p>
<h2>Step 1: Set up</h2>
<p>You need Python 3.10 or newer:</p>
<pre tabindex="0"><code class="language-bash">mkdir foodnow-bdd &amp;&amp; cd foodnow-bdd
python -m venv .venv &amp;&amp; source .venv/bin/activate
pip install pytest==9.2.0 pytest-bdd==9.0.1
mkdir -p foodnow tests/features
touch foodnow/__init__.py
printf &#39;[pytest]\npythonpath = .\n&#39; &gt; pytest.ini
</code></pre>
<p>pytest-bdd installs the <code>parse</code> library (1.22.4 here), which we use in Step 4.</p>
<h2>Step 2: The pricing code, with two bugs</h2>
<p>The rules from the product owner: delivery costs ₹40, and it&#39;s free from ₹500. The code <code>FIRST50</code> takes 50% off the food, up to ₹100. Save this as <code>foodnow/pricing.py</code>:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;FoodNow checkout pricing. All money is in paise (₹1 = 100 paise).&quot;&quot;&quot;
from dataclasses import dataclass

DELIVERY_FEE = 4000          # ₹40
FREE_DELIVERY_FROM = 50000   # ₹500
PROMOS = {&quot;FIRST50&quot;: {&quot;percent&quot;: 50, &quot;cap&quot;: 10000}}  # 50% off, up to ₹100


class InvalidPromo(Exception):
    pass


@dataclass
class Bill:
    food: int
    discount: int
    delivery: int

    @property
    def total(self) -&gt; int:
        return self.food - self.discount + self.delivery


def price_order(food: int, promo: str | None = None) -&gt; Bill:
    discount = 0
    if promo is not None:
        if promo not in PROMOS:
            raise InvalidPromo(f&quot;{promo} is not a valid code&quot;)
        rule = PROMOS[promo]
        discount = food * rule[&quot;percent&quot;] // 100
    delivery = 0 if food &gt; FREE_DELIVERY_FROM else DELIVERY_FEE
    return Bill(food, discount, delivery)
</code></pre>
<p>Two bugs are planted: <code>&gt;</code> should be <code>&gt;=</code>, and the ₹100 cap is never applied. Both are the kind of thing that happens when a rule is read quickly.</p>
<h2>Step 3: The scenarios</h2>
<p>These are the examples the three amigos agreed on. Save them as <code>tests/features/checkout_pricing.feature</code>:</p>
<pre tabindex="0"><code class="language-gherkin">Feature: Checkout pricing
  As a hungry customer
  I want to know exactly what I&#39;ll pay before I pay
  So that there are no surprises at the door

  Rules agreed with the product owner:
  - Delivery costs ₹40. It is free when the food comes to ₹500 or more.
  - The code FIRST50 takes 50% off the food, up to ₹100.

  Scenario: Delivery is charged on a small order
    Given my basket has food worth ₹300
    When I check out
    Then delivery costs ₹40
    And I pay ₹340

  Scenario Outline: Free delivery from ₹500
    Given my basket has food worth ₹&lt;food&gt;
    When I check out
    Then delivery costs ₹&lt;delivery&gt;

    Examples:
      | food | delivery |
      | 499  | 40       |
      | 500  | 0        |
      | 501  | 0        |

  Scenario: FIRST50 takes half off a small order
    Given my basket has food worth ₹120
    When I check out with the code &quot;FIRST50&quot;
    Then the discount is ₹60
    And I pay ₹100

  Scenario: FIRST50 is capped at ₹100
    Given my basket has food worth ₹400
    When I check out with the code &quot;FIRST50&quot;
    Then the discount is ₹100
    And I pay ₹340

  Scenario: An unknown code is refused
    Given my basket has food worth ₹300
    When I check out with the code &quot;FREEFOOD&quot;
    Then I see the message &quot;FREEFOOD is not a valid code&quot;
</code></pre>
<p>The text under <code>Feature:</code> is free text. pytest-bdd ignores it, but people read it, so it&#39;s a good place for the rules. The examples table tests both sides of ₹500 and the value itself, the <a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">boundary values</a> where bugs live. A ₹400 basket is where the 50% discount (₹200) is more than the cap, so that&#39;s the example that checks the cap.</p>
<p>Notice what isn&#39;t in the scenarios: buttons, pages and clicks. &quot;When I check out&quot; says <em>what</em> the customer does, not <em>how</em>. That keeps the scenarios readable, and they don&#39;t break when the screen changes.</p>
<h2>Step 4: The step definitions</h2>
<p>Each step line needs a Python function. Save this as <code>tests/test_checkout_pricing.py</code>:</p>
<pre tabindex="0"><code class="language-python">from parse import with_pattern
from pytest_bdd import given, parsers, scenarios, then, when

from foodnow.pricing import InvalidPromo, price_order

scenarios(&quot;features/checkout_pricing.feature&quot;)


@with_pattern(r&quot;\d[\d,]*&quot;)
def rupees(text: str) -&gt; int:
    &quot;&quot;&quot;&#39;₹1,200&#39; in a scenario becomes 120000 paise in the test.&quot;&quot;&quot;
    return int(text.replace(&quot;,&quot;, &quot;&quot;)) * 100


def step(text):
    return parsers.parse(text, extra_types={&quot;Rupees&quot;: rupees})


@given(step(&quot;my basket has food worth ₹{food:Rupees}&quot;), target_fixture=&quot;basket&quot;)
def basket(food):
    return {&quot;food&quot;: food}


@when(&quot;I check out&quot;, target_fixture=&quot;result&quot;)
def check_out(basket):
    return price_order(basket[&quot;food&quot;])


@when(step(&#39;I check out with the code &quot;{code}&quot;&#39;), target_fixture=&quot;result&quot;)
def check_out_with_code(basket, code):
    try:
        return price_order(basket[&quot;food&quot;], promo=code)
    except InvalidPromo as e:
        return e


@then(step(&quot;delivery costs ₹{amount:Rupees}&quot;))
def delivery_costs(result, amount):
    assert result.delivery == amount


@then(step(&quot;the discount is ₹{amount:Rupees}&quot;))
def discount_is(result, amount):
    assert result.discount == amount


@then(step(&quot;I pay ₹{amount:Rupees}&quot;))
def i_pay(result, amount):
    assert result.total == amount


@then(step(&#39;I see the message &quot;{message}&quot;&#39;))
def i_see(result, message):
    assert isinstance(result, InvalidPromo)
    assert str(result) == message
</code></pre>
<p>How it fits together:</p>
<ul>
<li><code>scenarios(...)</code> turns every scenario in the feature file into a pytest test.</li>
<li><code>{food:Rupees}</code> pulls the amount out of the step text and converts it with <code>rupees()</code>. The scenarios talk in rupees, as people do. The code works in paise. Step 6 explains why this is a custom type rather than the usual <code>{food:d}</code>.</li>
<li><code>target_fixture=&quot;basket&quot;</code> makes the step&#39;s return value available as a fixture called <code>basket</code>, which later steps ask for. That&#39;s how a Given passes its data to the When, and the When to the Then.</li>
<li>One step definition serves every scenario that uses that line. &quot;I pay ₹{amount}&quot; is written once and used in four scenarios.</li>
</ul>
<h2>Step 5: Run it</h2>
<p><code>--gherkin-terminal-reporter</code> prints the scenarios instead of test names, and <code>-vv</code> adds the steps:</p>
<pre tabindex="0"><code class="language-text">$ pytest --gherkin-terminal-reporter -vv --tb=no -k &quot;capped or 500-0&quot;
collecting ... collected 7 items / 5 deselected / 2 selected

tests/test_checkout_pricing.py::test_free_delivery_from_500[500-0]
Feature: Checkout pricing
    Scenario Outline: Free delivery from ₹500
        Given my basket has food worth ₹500
        When I check out
        Then delivery costs ₹0
    FAILED


tests/test_checkout_pricing.py::test_first50_is_capped_at_100
Feature: Checkout pricing
    Scenario: FIRST50 is capped at ₹100
        Given my basket has food worth ₹400
        When I check out with the code &quot;FIRST50&quot;
        Then the discount is ₹100
        And I pay ₹340
    FAILED


=========================== short test summary info ============================
FAILED tests/test_checkout_pricing.py::test_free_delivery_from_500[500-0] - assert 4000 == 0
 +  where 4000 = Bill(food=50000, discount=0, delivery=4000).delivery
FAILED tests/test_checkout_pricing.py::test_first50_is_capped_at_100 - assert 20000 == 10000
 +  where 20000 = Bill(food=40000, discount=20000, delivery=4000).discount
======================= 2 failed, 5 deselected in 0.02s ========================
</code></pre>
<p>Without <code>-k</code>, the full run is <code>2 failed, 5 passed</code>. Both planted bugs, and the output is something the product owner can read: &quot;₹500 of food, delivery should cost ₹0, it failed.&quot; The ₹499 and ₹501 rows passed, so only the exact boundary was wrong.</p>
<p>The fix:</p>
<pre tabindex="0"><code class="language-diff">-        discount = food * rule[&quot;percent&quot;] // 100
-    delivery = 0 if food &gt; FREE_DELIVERY_FROM else DELIVERY_FEE
+        discount = min(food * rule[&quot;percent&quot;] // 100, rule[&quot;cap&quot;])
+    delivery = 0 if food &gt;= FREE_DELIVERY_FROM else DELIVERY_FEE
</code></pre>
<pre tabindex="0"><code class="language-text">$ pytest -q
.......                                                                  [100%]
7 passed in 0.03s
</code></pre>
<h2>Step 6: The product owner writes &quot;₹1,200&quot;</h2>
<p>Scenarios are meant to be written and read by people who don&#39;t code. So the product owner adds one:</p>
<pre tabindex="0"><code class="language-gherkin">  Scenario: A big order gets free delivery
    Given my basket has food worth ₹1,200
    When I check out
    Then delivery costs ₹0
    And I pay ₹1,200
</code></pre>
<p>Our first step definitions used the usual <code>{food:d}</code>, which matches digits only. The comma doesn&#39;t match:</p>
<pre tabindex="0"><code class="language-text">E   pytest_bdd.exceptions.StepDefinitionNotFoundError: Step definition is not found: Given &quot;my basket has food worth ₹1,200&quot;. Line 45 in scenario &quot;A big order gets free delivery&quot; in the feature &quot;.../tests/features/checkout_pricing.feature&quot;
1 failed, 7 passed in 0.02s
</code></pre>
<p>This failure is loud, which is good. The wrong fix is to ask the product owner to write &quot;1200&quot;. The right fix is to make the steps accept the way people write money. That&#39;s the <code>Rupees</code> type in Step 4: it allows commas and converts to paise in one place. With it, all 8 pass. When a scenario and a step definition disagree, the scenario is usually the one that&#39;s right, because it&#39;s in the language of the people who own the rule.</p>
<h2>Step 7: The question that wasn&#39;t a bug</h2>
<p>Back to the three amigos. Picture this exchange: the tester asks, &quot;₹520 of food with FIRST50. The discount is ₹100, so the customer pays ₹420 for the food. Is delivery free?&quot;</p>
<p>The developer says yes: the code checks the food total, ₹520, before the discount. The product owner says no: free delivery is for orders where the customer <em>pays</em> ₹500 or more for food. Both had read the same rule. Neither was careless. The rule was ambiguous, and nobody noticed until someone asked for an example.</p>
<p>The product owner&#39;s answer becomes a scenario:</p>
<pre tabindex="0"><code class="language-gherkin">  Scenario: A discount that takes the food below ₹500
    Given my basket has food worth ₹520
    When I check out with the code &quot;FIRST50&quot;
    Then the discount is ₹100
    And delivery costs ₹40
    And I pay ₹460
</code></pre>
<pre tabindex="0"><code class="language-text">$ pytest --gherkin-terminal-reporter -vv --tb=no -k below
collecting ... collected 9 items / 8 deselected / 1 selected

tests/test_checkout_pricing.py::test_a_discount_that_takes_the_food_below_500
Feature: Checkout pricing
    Scenario: A discount that takes the food below ₹500
        Given my basket has food worth ₹520
        When I check out with the code &quot;FIRST50&quot;
        Then the discount is ₹100
        And delivery costs ₹40
        And I pay ₹460
    FAILED


=========================== short test summary info ============================
FAILED tests/test_checkout_pricing.py::test_a_discount_that_takes_the_food_below_500 - assert 0 == 4000
 +  where 0 = Bill(food=52000, discount=10000, delivery=0).delivery
======================= 1 failed, 8 deselected in 0.01s ========================
</code></pre>
<p>The code did what the developer understood. It wasn&#39;t a bug in the code. It was a gap in the rule, and BDD is mainly a tool for finding those. One line changes:</p>
<pre tabindex="0"><code class="language-diff">-    delivery = 0 if food &gt;= FREE_DELIVERY_FROM else DELIVERY_FEE
+    delivery = 0 if food - discount &gt;= FREE_DELIVERY_FROM else DELIVERY_FEE
</code></pre>
<p>And the rule at the top of the feature file is updated, so the next person doesn&#39;t have the same argument:</p>
<pre tabindex="0"><code class="language-gherkin">  - Delivery costs ₹40. It is free when the food, after any discount,
    comes to ₹500 or more.
</code></pre>
<pre tabindex="0"><code class="language-text">$ pytest --gherkin-terminal-reporter -v --cucumberjson=cucumber.json
...
tests/test_checkout_pricing.py::test_a_big_order_gets_free_delivery
Feature: Checkout pricing
    Scenario: A big order gets free delivery PASSED

tests/test_checkout_pricing.py::test_a_discount_that_takes_the_food_below_500
Feature: Checkout pricing
    Scenario: A discount that takes the food below ₹500 PASSED

- generated json file: ~/foodnow-bdd/cucumber.json -
============================== 9 passed in 0.02s ===============================
</code></pre>
<p><code>--cucumberjson</code> writes the results in Cucumber&#39;s JSON format, with each step and its status. Many reporting tools can read it and show the scenarios as a readable report, sometimes called <strong>living documentation</strong>: a description of the system that&#39;s checked against the code on every run, so it can&#39;t quietly go out of date.</p>
<h2>Step 8: The scenario that never ran</h2>
<p>pytest-bdd has two ways to turn scenarios into tests. <code>scenarios(&quot;file.feature&quot;)</code>, used above, binds every scenario in the file. The other way binds them one at a time:</p>
<pre tabindex="0"><code class="language-python">from pytest_bdd import scenario

FEATURE = &quot;features/checkout_pricing.feature&quot;


@scenario(FEATURE, &quot;Delivery is charged on a small order&quot;)
def test_small_order():
    pass


@scenario(FEATURE, &quot;FIRST50 is capped at ₹100&quot;)
def test_first50_cap():
    pass
</code></pre>
<p>Teams sometimes do this to give tests their own names. We copied the feature file, with all seven scenarios, next to a test file bound this way, and ran it on the pricing code from Step 5, <em>before</em> the after-discount fix:</p>
<pre tabindex="0"><code class="language-text">$ pytest trap -v
collecting ... collected 2 items

trap/test_pricing_bound_one_by_one.py::test_small_order PASSED           [ 50%]
trap/test_pricing_bound_one_by_one.py::test_first50_cap PASSED           [100%]

============================== 2 passed in 0.02s ===============================
</code></pre>
<p>Green. The product owner&#39;s new scenario for ₹520 is in the feature file, but nothing binds it, so it never runs, and there&#39;s no warning. The same code with <code>scenarios()</code> gives <code>1 failed, 8 passed</code>. For people who don&#39;t read the Python, this is the worst failure possible: they wrote the rule down, they see a green build, and they believe it&#39;s checked. Use <code>scenarios()</code>, and if you must bind one by one, check that the number of tests matches the number of scenarios.</p>
<h2>When BDD is worth it</h2>
<ul>
<li><strong>Worth it:</strong> rules that business people own and argue about, like pricing, eligibility, discounts, refunds and permissions. When the scenarios are read and changed by people who don&#39;t code.</li>
<li><strong>Not worth it:</strong> technical details nobody outside the team reads, like a retry backoff or a parser. Plain pytest is shorter and easier to change.</li>
<li><strong>Best value:</strong> before the code. Scenarios written afterwards, to describe what the code already does, find the planted kind of bug, but not the Step 7 kind.</li>
</ul>
<h2>Common mistakes</h2>
<ul>
<li><strong>Scenarios that describe clicks.</strong> &quot;When I click the button with id pay-btn&quot; ties the scenario to the screen and nobody outside the team wants to read it. Describe what the user does: &quot;When I check out&quot;.</li>
<li><strong>Gherkin without the conversation.</strong> If only developers write and read the scenarios, you have slower tests, not BDD.</li>
<li><strong>Too many steps per scenario.</strong> If a scenario needs fifteen steps, it&#39;s testing several things. One behaviour per scenario.</li>
<li><strong>Step definitions that are too strict.</strong> A step that only accepts &quot;1200&quot; makes people write for the tool. Accept the way people write, as in Step 6.</li>
<li><strong>Binding scenarios one by one.</strong> New scenarios don&#39;t run, and nothing warns you. Use <code>scenarios()</code>.</li>
<li><strong>Changing the code without updating the rule.</strong> When a scenario settles a question, update the rule text in the feature file too, as in Step 7.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong>Part 9</strong> of Testing in Practice we look at <strong>user acceptance testing (UAT)</strong>: how the people who&#39;ll use the software decide whether it&#39;s ready, and how to plan, run and record it without it turning into a rubber stamp.</p>
<p>ProvaLab&#39;s pytest plugin works with pytest-bdd tests like any other pytest test, and sends each scenario&#39;s result to the ProvaLab test management system with its history across runs. You can link a scenario to the requirement it checks, so a product owner can see which agreed rules are covered and which are passing. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Contract testing with Pact (hands-on): the API change both teams' tests missed</title>
      <link>https://provalab.io/blog/contract-testing-pact-python/</link>
      <guid isPermaLink="true">https://provalab.io/blog/contract-testing-pact-python/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>contract-testing</category>
      <category>pact</category>
      <category>api-testing</category>
      <category>pytest</category>
      <description>Contract testing explained with Pact and pytest on a FoodNow app and its orders API. A real run: an API change passes the API team's tests, and Pact catches it, plus a lost tip.</description>
      <content:encoded><![CDATA[<p>The FoodNow app shows the customer their order total. It gets the total from the orders API, which another team builds. One day the API team tidies up how money is sent: <code>&quot;total_paise&quot;: 41000</code> becomes <code>&quot;total&quot;: {&quot;amount_paise&quot;: 41000, &quot;currency&quot;: &quot;INR&quot;}</code>. They update their tests, and the tests pass. The app team changed nothing, so their tests pass too. The release goes out, and every order screen in the app breaks.</p>
<p>Both teams tested. What nobody tested was the <strong>agreement</strong> between them: the requests the app sends and the answers it needs back. That agreement is a <strong>contract</strong>, and <strong>contract testing</strong> checks it without running the app and the API together.</p>
<p>This post shows how contract testing works with Pact, the most widely used tool for it. Then it runs the story above for real, with FoodNow&#39;s app code and orders API. Every command and line of output below was run while writing this post.</p>
<h2>Why not just test them together?</h2>
<p>You can start the app and the real API in one environment and run tests across both. That&#39;s an <strong>end-to-end</strong> or <strong>integration</strong> test, and you should have a few. But as the only check on the agreement, they&#39;re a poor fit:</p>
<ul>
<li>They&#39;re slow and flaky. Every service, database and network link must be up at once.</li>
<li>They tell you something broke, late. Often after both changes were merged.</li>
<li>They don&#39;t scale. With ten apps and services, you need one environment where the right version of every one runs together.</li>
</ul>
<p>And mocks don&#39;t solve it. In <a href="https://provalab.io/blog/test-doubles-mocks-stubs-fakes/">Part 5</a>, a mock of the payment provider happily accepted calls the real provider would have refused. A mock only knows what you told it.</p>
<h2>How Pact works</h2>
<p>Contract testing splits the check in two. Each half runs in one team&#39;s normal test suite, with nothing else running:</p>
<p><img src="https://provalab.io/blog/practice/pact-how-it-works.svg" alt="How a Pact contract test works: the app's tests run against a Pact mock server and write a pact file; the API's tests replay every request in the pact file against the real API" width="880" height="470" loading="lazy"></p>
<ol>
<li><strong>The consumer test.</strong> The app team writes tests for their API client, against a mock server that Pact runs. Each test says &quot;when the app sends this request, it needs this response&quot;. Pact checks that the app really sends that request, and answers with that response.</li>
<li><strong>The pact file.</strong> When the tests pass, Pact writes every request and response to a JSON file, the contract.</li>
<li><strong>The provider verification.</strong> The API team runs Pact&#39;s verifier against their real API. It replays every request in the pact file and checks that the real responses match.</li>
</ol>
<p>Two words you&#39;ll see: the <strong>consumer</strong> is the side that calls (the app), and the <strong>provider</strong> is the side that answers (the orders API). A <strong>provider state</strong> is the situation a request needs, such as &quot;order 7 exists&quot;. The API team writes a small function for each state, to set it up before the request is replayed.</p>
<p>This is called <strong>consumer-driven</strong> contract testing: the contract holds only what the consumer actually uses. If the API sends 30 fields and the app reads 4, only those 4 are in the contract, and the API team can change the other 26 freely.</p>
<h2>Step 1: Set up</h2>
<p>You need Python 3.10 or newer:</p>
<pre tabindex="0"><code class="language-bash">mkdir foodnow-contract &amp;&amp; cd foodnow-contract
python -m venv .venv &amp;&amp; source .venv/bin/activate
pip install pact-python==4.2.0 pytest==9.2.0 fastapi==0.162.4 uvicorn==0.54.0 requests==2.34.2 httpx==0.28.1
mkdir -p app_client orders_api tests/consumer tests/provider
touch app_client/__init__.py orders_api/__init__.py tests/__init__.py tests/consumer/__init__.py tests/provider/__init__.py
printf &#39;[pytest]\npythonpath = .\n&#39; &gt; pytest.ini
export PACT_DO_NOT_TRACK=true
</code></pre>
<p>pact-python 4 runs Pact&#39;s core, written in Rust, inside Python, so there is nothing else to install. By default, Pact sends anonymous usage statistics (the Pact version and operating system); <code>PACT_DO_NOT_TRACK=true</code> turns that off.</p>
<p>In a real project the two halves live in two repositories, owned by two teams. Here they share one folder so you can run everything.</p>
<h2>Step 2: The app&#39;s API client</h2>
<p>This is the part of the FoodNow app that talks to the orders API. It reads an order, and it places a new one with a tip. Save it as <code>app_client/client.py</code>:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;The FoodNow app&#39;s code for talking to the orders API.&quot;&quot;&quot;
from dataclasses import dataclass

import requests


@dataclass
class Order:
    id: int
    status: str
    total_paise: int
    tip_paise: int


class OrdersClient:
    def __init__(self, base_url: str):
        self.base_url = base_url

    def get_order(self, order_id: int) -&gt; Order | None:
        r = requests.get(f&quot;{self.base_url}/orders/{order_id}&quot;, timeout=5)
        if r.status_code == 404:
            return None
        r.raise_for_status()
        d = r.json()
        return Order(d[&quot;id&quot;], d[&quot;status&quot;], d[&quot;total_paise&quot;], d[&quot;tip_paise&quot;])

    def place_order(self, items: list[dict], tip_paise: int) -&gt; Order:
        r = requests.post(
            f&quot;{self.base_url}/orders&quot;,
            json={&quot;items&quot;: items, &quot;tip_paise&quot;: tip_paise},
            timeout=5,
        )
        r.raise_for_status()
        d = r.json()
        return Order(d[&quot;id&quot;], d[&quot;status&quot;], d[&quot;total_paise&quot;], d[&quot;tip_paise&quot;])
</code></pre>
<p>Money is in paise (1 rupee = 100 paise) as a whole number, so there are no rounding errors.</p>
<h2>Step 3: The consumer test writes the contract</h2>
<p>Save this as <code>tests/consumer/test_orders_contract.py</code>. Each test describes one interaction, starts Pact&#39;s mock server, and calls the real client code against it:</p>
<pre tabindex="0"><code class="language-python">from pathlib import Path

import pytest
from pact import Pact, match

from app_client.client import OrdersClient

PACTS = Path(__file__).parents[2] / &quot;pacts&quot;


@pytest.fixture
def pact():
    pact = Pact(&quot;foodnow-app&quot;, &quot;orders-api&quot;).with_specification(&quot;V4&quot;)
    yield pact
    pact.write_file(PACTS)  # adds this test&#39;s interaction to the file


def test_get_order(pact):
    (
        pact.upon_receiving(&quot;a request for an order&quot;)
        .given(&quot;order 7 exists&quot;)
        .with_request(&quot;GET&quot;, &quot;/orders/7&quot;)
        .will_respond_with(200)
        .with_body(
            {
                &quot;id&quot;: match.int(7),
                &quot;status&quot;: match.str(&quot;preparing&quot;),
                &quot;total_paise&quot;: match.int(40000),
                &quot;tip_paise&quot;: match.int(4000),
            }
        )
    )
    with pact.serve() as srv:
        order = OrdersClient(str(srv.url)).get_order(7)
    assert order.total_paise == 40000
    assert order.tip_paise == 4000


def test_missing_order(pact):
    (
        pact.upon_receiving(&quot;a request for an order that doesn&#39;t exist&quot;)
        .given(&quot;order 999 does not exist&quot;)
        .with_request(&quot;GET&quot;, &quot;/orders/999&quot;)
        .will_respond_with(404)
    )
    with pact.serve() as srv:
        assert OrdersClient(str(srv.url)).get_order(999) is None


def test_place_order(pact):
    (
        pact.upon_receiving(&quot;a new order with a tip&quot;)
        .with_request(&quot;POST&quot;, &quot;/orders&quot;)
        .with_body({&quot;items&quot;: [{&quot;name&quot;: &quot;paneer wrap&quot;, &quot;qty&quot;: 2}], &quot;tip_paise&quot;: 5000})
        .will_respond_with(201)
        .with_body(
            {
                &quot;id&quot;: match.int(1),
                &quot;status&quot;: match.str(&quot;placed&quot;),
                &quot;total_paise&quot;: 41000,
                &quot;tip_paise&quot;: 5000,
            }
        )
    )
    with pact.serve() as srv:
        order = OrdersClient(str(srv.url)).place_order(
            [{&quot;name&quot;: &quot;paneer wrap&quot;, &quot;qty&quot;: 2}], tip_paise=5000
        )
    assert order.total_paise == 41000
</code></pre>
<p>Two kinds of expected value are used on purpose:</p>
<ul>
<li><strong>Matchers</strong>, like <code>match.int(7)</code>, mean &quot;any whole number; 7 is just an example&quot;. Use them where the exact value doesn&#39;t matter. The order id depends on what&#39;s in the API&#39;s database, so the contract shouldn&#39;t fix it.</li>
<li><strong>Exact values</strong>, like <code>&quot;total_paise&quot;: 41000</code>, mean &quot;exactly this&quot;. Two paneer wraps at ₹180 plus a ₹50 tip is ₹410, whatever is in the database. Here the value <em>is</em> the point. Step 7 shows why that choice matters.</li>
</ul>
<p>The fixture is created fresh for each test, because a pact can&#39;t be changed after its mock server has started. Add <code>tests/consumer/conftest.py</code> to start every run with an empty pact folder:</p>
<pre tabindex="0"><code class="language-python">import shutil
from pathlib import Path


def pytest_sessionstart(session):
    shutil.rmtree(Path(__file__).parents[2] / &quot;pacts&quot;, ignore_errors=True)
</code></pre>
<p>Run it:</p>
<pre tabindex="0"><code class="language-text">$ pytest tests/consumer -q
...                                                                      [100%]
3 passed in 0.07s
</code></pre>
<p>The contract is now in <code>pacts/foodnow-app-orders-api.json</code>. This is the part for reading an order:</p>
<pre tabindex="0"><code class="language-json">{
  &quot;description&quot;: &quot;a request for an order&quot;,
  &quot;providerStates&quot;: [{ &quot;name&quot;: &quot;order 7 exists&quot; }],
  &quot;request&quot;: { &quot;method&quot;: &quot;GET&quot;, &quot;path&quot;: &quot;/orders/7&quot; },
  &quot;response&quot;: {
    &quot;status&quot;: 200,
    &quot;body&quot;: {
      &quot;content&quot;: { &quot;id&quot;: 7, &quot;status&quot;: &quot;preparing&quot;, &quot;tip_paise&quot;: 4000, &quot;total_paise&quot;: 40000 }
    },
    &quot;matchingRules&quot;: {
      &quot;body&quot;: {
        &quot;$.id&quot;:          { &quot;combine&quot;: &quot;AND&quot;, &quot;matchers&quot;: [{ &quot;match&quot;: &quot;integer&quot; }] },
        &quot;$.status&quot;:      { &quot;combine&quot;: &quot;AND&quot;, &quot;matchers&quot;: [{ &quot;match&quot;: &quot;type&quot; }] },
        &quot;$.tip_paise&quot;:   { &quot;combine&quot;: &quot;AND&quot;, &quot;matchers&quot;: [{ &quot;match&quot;: &quot;integer&quot; }] },
        &quot;$.total_paise&quot;: { &quot;combine&quot;: &quot;AND&quot;, &quot;matchers&quot;: [{ &quot;match&quot;: &quot;integer&quot; }] }
      }
    }
  }
}
</code></pre>
<p>(We removed the headers and some fields Pact adds, to keep it short.) This file is what the app team hands to the API team.</p>
<h2>Step 4: The provider verifies the contract</h2>
<p>Here&#39;s version 1 of the orders API, in <code>orders_api/app.py</code>. It&#39;s a small FastAPI app that keeps orders in memory:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;FoodNow orders API, version 1.&quot;&quot;&quot;
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

MENU = {&quot;paneer wrap&quot;: 18000, &quot;masala chai&quot;: 4000}
ORDERS: dict[int, dict] = {}


class Item(BaseModel):
    name: str
    qty: int


class NewOrder(BaseModel):
    items: list[Item]
    tip_paise: int = 0


@app.get(&quot;/orders/{order_id}&quot;)
def get_order(order_id: int):
    if order_id not in ORDERS:
        raise HTTPException(404, &quot;order not found&quot;)
    return ORDERS[order_id]


@app.post(&quot;/orders&quot;, status_code=201)
def place_order(body: NewOrder):
    food = sum(MENU[i.name] * i.qty for i in body.items)
    order_id = max(ORDERS, default=0) + 1
    return save_order(order_id, food, body.tip_paise)


def save_order(order_id: int, food_paise: int, tip_paise: int, status: str = &quot;placed&quot;):
    ORDERS[order_id] = {
        &quot;id&quot;: order_id,
        &quot;status&quot;: status,
        &quot;total_paise&quot;: food_paise + tip_paise,
        &quot;tip_paise&quot;: tip_paise,
    }
    return ORDERS[order_id]
</code></pre>
<p>The verification test starts the real API on a local port, tells Pact how to set up each provider state, and runs the verifier. Save it as <code>tests/provider/test_verify_contract.py</code>:</p>
<pre tabindex="0"><code class="language-python">import threading
import time
from pathlib import Path

import pytest
import uvicorn
from pact import Verifier

from orders_api import app as api

PACTS = Path(__file__).parents[2] / &quot;pacts&quot;


@pytest.fixture(scope=&quot;module&quot;)
def server():
    config = uvicorn.Config(api.app, port=8123, log_level=&quot;warning&quot;)
    srv = uvicorn.Server(config)
    threading.Thread(target=srv.run, daemon=True).start()
    while not srv.started:
        time.sleep(0.05)
    yield &quot;http://localhost:8123&quot;
    srv.should_exit = True


def order_7_exists(action, parameters=None):
    if action == &quot;setup&quot;:
        api.ORDERS[7] = {&quot;id&quot;: 7, &quot;status&quot;: &quot;preparing&quot;,
                         &quot;total_paise&quot;: 40000, &quot;tip_paise&quot;: 4000}


def order_999_missing(action, parameters=None):
    api.ORDERS.pop(999, None)


def reset(action, parameters=None):
    if action == &quot;setup&quot;:
        api.ORDERS.clear()


def test_orders_api_keeps_the_contract(server):
    verifier = (
        Verifier(&quot;orders-api&quot;)
        .add_transport(url=server)
        .set_coloured_output(enabled=False)
        .add_source(PACTS)
        .state_handler(
            {
                &quot;&quot;: reset,  # for interactions with no given state
                &quot;order 7 exists&quot;: order_7_exists,
                &quot;order 999 does not exist&quot;: order_999_missing,
            },
            teardown=True,
        )
    )
    verifier.verify()
</code></pre>
<p>Run it with <code>-s</code> to see the verifier&#39;s report:</p>
<pre tabindex="0"><code class="language-text">$ pytest tests/provider/test_verify_contract.py -q -s
Verifying a pact between foodnow-app and orders-api

  a new order with a tip (0s loading, 15ms verification)
    returns a response which
      has status code 201 (OK)
      includes headers
        &quot;Content-Type&quot; with value &quot;application/json&quot; (OK)
      has a matching body (OK)

  a request for an order (0s loading, 8ms verification)
     Given order 7 exists
    returns a response which
      has status code 200 (OK)
      includes headers
        &quot;Content-Type&quot; with value &quot;application/json&quot; (OK)
      has a matching body (OK)

  a request for an order that doesn&#39;t exist (0s loading, 6ms verification)
     Given order 999 does not exist
    returns a response which
      has status code 404 (OK)
      has a matching body (OK)

.
1 passed in 0.79s
</code></pre>
<p>The app and the API agree. Look at <code>order_7_exists</code>, though: it writes an order straight into the API&#39;s storage. Keep that in mind.</p>
<h2>Step 5: The API team changes how money is sent</h2>
<p>Now the API team ships version 2. Money becomes an amount plus a currency, and the request field <code>tip_paise</code> is renamed to <code>tip</code>. These are the lines that change:</p>
<pre tabindex="0"><code class="language-python">class NewOrder(BaseModel):
    items: list[Item]
    tip: int = 0  # paise


def money(paise: int) -&gt; dict:
    return {&quot;amount_paise&quot;: paise, &quot;currency&quot;: &quot;INR&quot;}

# in place_order:
    return save_order(order_id, food, body.tip)

# in save_order:
    ORDERS[order_id] = {
        &quot;id&quot;: order_id,
        &quot;status&quot;: status,
        &quot;total&quot;: money(food_paise + tip_paise),
        &quot;tip&quot;: money(tip_paise),
    }
</code></pre>
<p>They also update their own tests, in <code>tests/provider/test_api.py</code>:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;The orders team&#39;s own tests, updated for version 2.&quot;&quot;&quot;
from fastapi.testclient import TestClient

from orders_api import app as api

client = TestClient(api.app)


def test_place_order_adds_the_tip():
    r = client.post(&quot;/orders&quot;, json={&quot;items&quot;: [{&quot;name&quot;: &quot;paneer wrap&quot;, &quot;qty&quot;: 2}], &quot;tip&quot;: 5000})
    assert r.status_code == 201
    assert r.json()[&quot;total&quot;] == {&quot;amount_paise&quot;: 41000, &quot;currency&quot;: &quot;INR&quot;}


def test_missing_order_is_404():
    assert client.get(&quot;/orders/999&quot;).status_code == 404
</code></pre>
<pre tabindex="0"><code class="language-text">$ pytest tests/provider/test_api.py -q
..                                                                       [100%]
2 passed, 1 warning in 0.25s
</code></pre>
<p>Green. (The warning is a deprecation notice from Starlette&#39;s test client, not from this code.) The app team&#39;s tests are green too, because they haven&#39;t changed anything. Now the contract:</p>
<pre tabindex="0"><code class="language-text">$ pytest tests/provider/test_verify_contract.py -q
...
Failures:

1) Verifying a pact between foodnow-app and orders-api - a new order with a tip
    1.1) has a matching body
           $ -&gt; Actual map is missing the following keys: tip_paise, total_paise
    {
  &quot;id&quot;: 1,
  &quot;status&quot;: &quot;placed&quot;,
    -  &quot;tip_paise&quot;: 5000,
  &quot;total_paise&quot;: 41000
    +  &quot;tip&quot;: {
    &quot;amount_paise&quot;: 0,
    &quot;currency&quot;: &quot;INR&quot;
  },
  &quot;total&quot;: {
    &quot;amount_paise&quot;: 36000,
    &quot;currency&quot;: &quot;INR&quot;
  }
    }

There were 1 pact failures
FAILED tests/provider/test_verify_contract.py::test_orders_api_keeps_the_contract
1 failed in 0.86s
</code></pre>
<p>Pact caught the change before release, and the diff shows a second problem nobody planned for. Read the numbers: the tip is <strong>0</strong> and the total is <strong>₹360</strong>, not ₹410. The app still sends <code>tip_paise</code>. Version 2 only knows <code>tip</code>, and Pydantic ignores fields it doesn&#39;t know, so the tip is silently dropped. No error, no 422. The customer is charged less, and the delivery rider loses the tip.</p>
<p>But only <strong>one</strong> interaction failed. Reading an order passed, although version 2 sends <code>total</code> too. Why?</p>
<h2>Step 6: The state handler that hid a failure</h2>
<p>Back to <code>order_7_exists</code> from Step 4:</p>
<pre tabindex="0"><code class="language-python">api.ORDERS[7] = {&quot;id&quot;: 7, &quot;status&quot;: &quot;preparing&quot;,
                 &quot;total_paise&quot;: 40000, &quot;tip_paise&quot;: 4000}
</code></pre>
<p>It writes the order straight into storage, <strong>in the old shape</strong>. The GET endpoint returns whatever is stored, so the verifier got back exactly the version 1 response the test had written in. The test checked the state handler, not the API.</p>
<p>The fix is to create test data through the provider&#39;s own code, the same way real data is created. Both versions of the API have a <code>save_order</code> function, so:</p>
<pre tabindex="0"><code class="language-python">def order_7_exists(action, parameters=None):
    if action == &quot;setup&quot;:
        api.save_order(7, food_paise=36000, tip_paise=4000, status=&quot;preparing&quot;)
</code></pre>
<p>With the fixed state handler, version 1 still passes. Version 2 now fails both interactions:</p>
<pre tabindex="0"><code class="language-text">2) Verifying a pact between foodnow-app and orders-api Given order 7 exists - a request for an order
    2.1) has a matching body
           $ -&gt; Actual map is missing the following keys: tip_paise, total_paise
    {
  &quot;id&quot;: 7,
  &quot;status&quot;: &quot;preparing&quot;,
    -  &quot;tip_paise&quot;: 4000,
  &quot;total_paise&quot;: 40000
    +  &quot;tip&quot;: {
    &quot;amount_paise&quot;: 4000,
    &quot;currency&quot;: &quot;INR&quot;
  },
  &quot;total&quot;: {
    &quot;amount_paise&quot;: 40000,
    &quot;currency&quot;: &quot;INR&quot;
  }
    }

There were 2 pact failures
</code></pre>
<p>This is the most common way contract tests lie. If a state handler builds the response data by hand, the contract only proves that the handler agrees with the contract.</p>
<h2>Step 7: Change the API without breaking the app</h2>
<p>The API team can still move to the new money format. They just can&#39;t remove the old fields while app versions that read them are still on customers&#39; phones. The usual pattern is <strong>expand, then contract</strong>: add the new fields next to the old ones, wait until no app version uses the old ones, then remove them.</p>
<p>Their first try puts the old response fields back:</p>
<pre tabindex="0"><code class="language-python">    ORDERS[order_id] = {
        &quot;id&quot;: order_id,
        &quot;status&quot;: status,
        &quot;total&quot;: money(food_paise + tip_paise),
        &quot;tip&quot;: money(tip_paise),
        # kept until every app version has moved to &quot;total&quot; and &quot;tip&quot;
        &quot;total_paise&quot;: food_paise + tip_paise,
        &quot;tip_paise&quot;: tip_paise,
    }
</code></pre>
<pre tabindex="0"><code class="language-text">1) Verifying a pact between foodnow-app and orders-api - a new order with a tip
    1.1) has a matching body
           $.tip_paise -&gt; Expected 0 (Integer) to be equal to 5000 (Integer)
           $.total_paise -&gt; Expected 36000 (Integer) to be equal to 41000 (Integer)

There were 1 pact failures
</code></pre>
<p>The fields are back, but the tip is still being dropped: the request side was missed. This failure exists only because the consumer test used <strong>exact values</strong> for the total and tip in Step 3. To check, we changed those two to <code>match.int(41000)</code> and <code>match.int(5000)</code>, regenerated the pact and ran the verification again:</p>
<pre tabindex="0"><code class="language-text">1 passed in 0.79s
</code></pre>
<p>With type matchers, Pact passed an API that drops every tip, because 0 and 36,000 are whole numbers too. Matchers check the shape. They don&#39;t check that the numbers are right. Use exact values where the value comes straight from the request, and add your own functional tests for the rest. Contract tests are not a replacement for those.</p>
<p>We put the exact values back. The second fix accepts the old request field too:</p>
<pre tabindex="0"><code class="language-python">class NewOrder(BaseModel):
    items: list[Item]
    tip: int = 0  # paise
    tip_paise: int | None = None  # old name, still sent by older app versions

# in place_order:
    tip = body.tip if body.tip_paise is None else body.tip_paise
    return save_order(order_id, food, tip)
</code></pre>
<p>The whole suite, consumer and provider:</p>
<pre tabindex="0"><code class="language-text">$ pytest tests -v
platform linux -- Python 3.11.17, pytest-9.2.0, pluggy-1.7.0
rootdir: ~/foodnow-contract
configfile: pytest.ini
plugins: pact-python-4.2.0, anyio-4.16.0
collected 6 items

tests/consumer/test_orders_contract.py::test_get_order PASSED            [ 16%]
tests/consumer/test_orders_contract.py::test_missing_order PASSED        [ 33%]
tests/consumer/test_orders_contract.py::test_place_order PASSED          [ 50%]
tests/provider/test_api.py::test_place_order_adds_the_tip PASSED         [ 66%]
tests/provider/test_api.py::test_missing_order_is_404 PASSED             [ 83%]
tests/provider/test_verify_contract.py::test_orders_api_keeps_the_contract PASSED [100%]

========================= 6 passed, 1 warning in 0.87s =========================
</code></pre>
<p>Old app versions keep working, and new versions can start reading <code>total</code> and <code>tip</code>. When every app version still in use has moved to them, no contract mentions <code>total_paise</code> any more, and the API team can remove it.</p>
<h2>Step 8: The contract checks the app too</h2>
<p>The contract works in both directions. Suppose the app team is the one who renames <code>tip_paise</code> to <code>tip</code>, in <code>place_order</code> in the client. Their contract test fails straight away, in their own suite:</p>
<pre tabindex="0"><code class="language-text">E           requests.exceptions.HTTPError: 500 Server Error: Internal Server Error for url: http://localhost:37013/orders
...
E               pact.error.MismatchesError: Mismatches:
E                 (1) Request mismatch: POST /orders
E                   (1) Body mismatch: Expected a Map with keys [items, tip_paise] but received one with keys [items, tip]
1 failed, 2 passed in 0.11s
</code></pre>
<p>Pact&#39;s mock server answers a request that doesn&#39;t match the contract with a 500, then names the mismatch. So the app can&#39;t quietly start sending something the contract doesn&#39;t describe. If the change is meant, the app team updates the contract, and the API team&#39;s verification shows whether the API can handle it.</p>
<h2>Sharing the contract between teams</h2>
<p>In this post the pact file sits in a folder both halves can read. Real teams need a way to pass it from the app&#39;s CI to the API&#39;s CI. The usual answer is a <strong>Pact Broker</strong>, an open-source server that stores pact files and verification results by version. (We didn&#39;t run a broker for this post.) With a broker:</p>
<ul>
<li>The app&#39;s CI publishes the pact after its tests pass.</li>
<li>The API&#39;s CI fetches the pacts for all the app versions in production and verifies against them.</li>
<li>Before deploying either side, a command called <code>can-i-deploy</code> asks the broker whether this version has been verified against the versions it will talk to. If not, the deploy stops.</li>
</ul>
<p>PactFlow is a hosted broker with extra features, run by SmartBear.</p>
<h2>Where contract tests fit</h2>
<table tabindex="0">
<thead>
<tr>
<th>Test</th>
<th>What it checks</th>
<th>What it misses</th>
</tr>
</thead>
<tbody><tr>
<td>Unit tests with mocks</td>
<td>Each side&#39;s own logic</td>
<td>Whether the mock matches the real other side</td>
</tr>
<tr>
<td><strong>Contract tests</strong></td>
<td>Requests and responses the two sides exchange</td>
<td>Whether the values are right (unless exact), what each side does inside</td>
</tr>
<tr>
<td>API functional tests</td>
<td>The API&#39;s behaviour: totals, rules, errors</td>
<td>What the app needs from it</td>
</tr>
<tr>
<td>End-to-end tests</td>
<td>A full journey with everything running</td>
<td>Little, but they&#39;re slow, flaky and late</td>
</tr>
</tbody></table>
<p>Contract tests replace most of the end-to-end tests that only exist to check the &quot;wiring&quot; between services. Keep a few end-to-end tests for the journeys that matter most.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>State handlers that build the response by hand.</strong> Step 6 passed a broken API this way. Create test data through the provider&#39;s own code or API.</li>
<li><strong>Matchers everywhere.</strong> They check shape, not values. Step 7 shows a contract with only type matchers passing an API that drops every tip.</li>
<li><strong>Exact values everywhere.</strong> The opposite problem: the contract breaks whenever test data changes, people stop trusting it, and they turn it off. Use exact values only where the value follows from the request.</li>
<li><strong>Putting the whole API in the contract.</strong> Include only what the consumer actually uses. A field the app never reads shouldn&#39;t stop the API team from changing it.</li>
<li><strong>Removing old fields as soon as the new ones ship.</strong> Older app versions stay on phones for months. Expand first, contract later.</li>
<li><strong>Treating contract tests as functional tests.</strong> They check the agreement, not the business rules.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong>Part 8</strong> of Testing in Practice we look at <strong>behaviour-driven development (BDD)</strong>: writing tests as Given/When/Then scenarios that product owners can read, and running them with pytest-bdd.</p>
<p>ProvaLab doesn&#39;t replace Pact, but both halves here are ordinary pytest tests. ProvaLab&#39;s pytest plugin sends every result to the ProvaLab test management system, so a failed contract verification shows up in the API team&#39;s test run, with the test&#39;s history across runs, and you can link the test to the requirement it protects. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Your first Cypress test (hands-on): retrying assertions, cy.intercept, and a test that passed too early</title>
      <link>https://provalab.io/blog/first-cypress-test/</link>
      <guid isPermaLink="true">https://provalab.io/blog/first-cypress-test/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>cypress</category>
      <category>ui-testing</category>
      <category>javascript</category>
      <category>e2e-testing</category>
      <description>Write a first Cypress test for a FoodNow order page. A real run finds three bugs, two of them only by faking the network with cy.intercept, and a race test that passed on the buggy page.</description>
      <content:encoded><![CDATA[<p><a href="https://provalab.io/blog/first-selenium-test-python/">Part 11</a> tested the FoodNow order page with Selenium, and two problems got in the way before the test reached the app: a cookie banner over the button, and a total read before the page had worked it out. Cypress was built to make that second kind of problem go away. It runs <strong>inside the browser</strong>, next to your app, and its commands and assertions <strong>retry</strong> by themselves until they pass or time out.</p>
<p>This post writes a first Cypress test for the same page. This time the total comes from a price API, which lets us use Cypress&#39;s best feature, <code>cy.intercept</code>, to make the network fail and answer out of order. The page has three bugs, and two of them are only reachable that way. And one of our own tests passed on the buggy page. Every command and line of output below was run while writing this post, with Cypress 15.21.1.</p>
<h2>How Cypress is different</h2>
<p><img src="https://provalab.io/blog/practice/cypress-how-it-works.svg" alt="Where a Cypress test runs: Selenium drives the browser from outside through a driver; Cypress runs the test inside the browser tab next to the app, and a Cypress proxy sits between the app and the network, where cy.intercept works" width="880" height="470" loading="lazy"></p>
<ul>
<li><strong>The test runs in the browser,</strong> in the same tab as your app. It sees changes the moment they happen, and you can step back through every command in Cypress&#39;s log.</li>
<li><strong>Commands queue and retry.</strong> <code>cy.get(&quot;#total&quot;).should(&quot;have.text&quot;, &quot;₹220&quot;)</code> keeps looking for 4 seconds by default, until the text matches.</li>
<li><strong>Cypress sits on the network.</strong> Its proxy sees every request the app makes, and <code>cy.intercept</code> can watch, delay, change or replace any of them.</li>
<li><strong>The costs:</strong> tests are JavaScript or TypeScript only, each test runs in one browser tab, and Safari is only available through an experimental WebKit option.</li>
</ul>
<h2>Step 1: Set up</h2>
<p>You need Node.js 20 or newer:</p>
<pre tabindex="0"><code class="language-bash">mkdir foodnow-cypress &amp;&amp; cd foodnow-cypress
npm init -y
npm install --save-dev cypress@15.21.1
mkdir -p site cypress/e2e
</code></pre>
<p>The first install downloads the Cypress app (a few hundred MB) into your home folder. <code>npx cypress verify</code> checks that it can run:</p>
<pre tabindex="0"><code class="language-text">$ npx cypress verify
✔  Verified Cypress!       /home/test/.cache/Cypress/15.21.1/Cypress
</code></pre>
<h2>Step 2: The page and a small server</h2>
<p>The order page is the one from Part 11, with one change: the total now comes from a price API. Here&#39;s the part of <code>site/index.html</code> that changed:</p>
<pre tabindex="0"><code class="language-js">  function render() {
    // ...draws the basket lines, each with a Remove button:
    //   row.querySelector(&quot;button&quot;).onclick = () =&gt; { cart.splice(i + 1, 1); render(); };

    // The total comes from the price service (prices, offers, delivery).
    document.getElementById(&quot;total&quot;).textContent = &quot;…&quot;;
    const names = cart.map(x =&gt; x.name).join(&quot;,&quot;);
    fetch(`/api/price?items=${encodeURIComponent(names)}`)
      .then(r =&gt; r.json())
      .then(data =&gt; { document.getElementById(&quot;total&quot;).textContent = `₹${data.total}`; });
  }
</code></pre>
<p>Yes, the Remove bug from Part 11 is still there. We left it in so you can compare how the two tools report it.</p>
<p>And <code>server.js</code>, which serves the page and a price API that answers after 800 ms:</p>
<pre tabindex="0"><code class="language-js">// A tiny FoodNow server: the order page, and a price API that answers after 800 ms.
const http = require(&quot;http&quot;);
const fs = require(&quot;fs&quot;);
const path = require(&quot;path&quot;);

const PRICES = { &quot;Paneer wrap&quot;: 180, &quot;Masala chai&quot;: 40, &quot;Veg biryani&quot;: 260 };
const DELIVERY = 40, FREE_FROM = 500;

http.createServer((req, res) =&gt; {
  const url = new URL(req.url, &quot;http://localhost&quot;);
  if (url.pathname === &quot;/api/price&quot;) {
    const items = (url.searchParams.get(&quot;items&quot;) || &quot;&quot;).split(&quot;,&quot;).filter(Boolean);
    const food = items.reduce((s, name) =&gt; s + PRICES[name], 0);
    const total = food === 0 ? 0 : food + (food &gt;= FREE_FROM ? 0 : DELIVERY);
    setTimeout(() =&gt; {
      res.writeHead(200, { &quot;Content-Type&quot;: &quot;application/json&quot; });
      res.end(JSON.stringify({ food, total }));
    }, 800);
    return;
  }
  if (url.pathname === &quot;/&quot;) {
    res.writeHead(200, { &quot;Content-Type&quot;: &quot;text/html; charset=utf-8&quot; });
    res.end(fs.readFileSync(path.join(__dirname, &quot;site&quot;, &quot;index.html&quot;)));
    return;
  }
  res.writeHead(404);
  res.end();
}).listen(process.env.PORT || 8090, () =&gt; console.log(&quot;FoodNow on http://localhost:&quot; + (process.env.PORT || 8090)));
</code></pre>
<p>Start it in a second terminal with <code>node server.js</code>, and tell Cypress where it is in <code>cypress.config.js</code>:</p>
<pre tabindex="0"><code class="language-js">const { defineConfig } = require(&quot;cypress&quot;);

module.exports = defineConfig({
  e2e: {
    baseUrl: &quot;http://localhost:8090&quot;,
    supportFile: false,
    viewportWidth: 1280,
    viewportHeight: 800,
  },
});
</code></pre>
<h2>Step 3: The first test</h2>
<p>Save this as <code>cypress/e2e/first.cy.js</code>:</p>
<pre tabindex="0"><code class="language-js">describe(&quot;FoodNow order&quot;, () =&gt; {
  it(&quot;adds a wrap&quot;, () =&gt; {
    cy.visit(&quot;/&quot;);
    cy.get(&quot;[data-testid=add-wrap]&quot;).click();
    cy.get(&quot;#total&quot;).should(&quot;have.text&quot;, &quot;₹220&quot;); // ₹180 + ₹40 delivery
  });
});
</code></pre>
<p>Run it headless with <code>npx cypress run</code>, or open the interactive runner with <code>npx cypress open</code> to watch it:</p>
<pre tabindex="0"><code class="language-text">$ npx cypress run --spec cypress/e2e/first.cy.js
  FoodNow order
    1) adds a wrap

  0 passing (5s)
  1 failing

  1) FoodNow order
       adds a wrap:
     CypressError: Timed out retrying after 4050ms: `cy.click()` failed because this element:

`&lt;button data-testid=&quot;add-wrap&quot; data-name=&quot;Paneer wrap&quot; data-price=&quot;180&quot;&gt;Add&lt;/button&gt;`

is being covered by another element:

`&lt;div id=&quot;cookies&quot;&gt;...&lt;/div&gt;`

Fix this problem, or use {force: true} to disable error checking.
</code></pre>
<p>The same cookie banner that stopped Selenium. The difference is that Cypress <strong>retried for 4 seconds first</strong>, in case the banner went away by itself, and then said exactly what was in the way. It also suggests <code>{force: true}</code>. Don&#39;t take that suggestion here: forcing a click through a banner is something no customer can do. Close it as they would:</p>
<pre tabindex="0"><code class="language-js">    cy.visit(&quot;/&quot;);
    cy.get(&quot;#accept&quot;).click(); // close the cookie banner first
    cy.get(&quot;[data-testid=add-wrap]&quot;).click();
    cy.get(&quot;#total&quot;).should(&quot;have.text&quot;, &quot;₹220&quot;); // ₹180 + ₹40 delivery
</code></pre>
<pre tabindex="0"><code class="language-text">  FoodNow order
    ✓ adds a wrap (1020ms)
</code></pre>
<p>And here&#39;s the second difference from Selenium: <strong>no wait code at all.</strong> The total showed &quot;…&quot; for 800 ms, and <code>should(&quot;have.text&quot;, &quot;₹220&quot;)</code> simply kept checking until it changed. The test took 1,020 ms, most of it waiting for the price API.</p>
<h2>Step 4: The mistake every Cypress beginner makes</h2>
<p>Cypress commands look like ordinary function calls, but they don&#39;t run when that line runs. They&#39;re <strong>queued</strong> and run later, one after another. So this, which looks perfectly reasonable, can&#39;t work:</p>
<pre tabindex="0"><code class="language-js">  it(&quot;adds a wrap (the way that doesn&#39;t work)&quot;, () =&gt; {
    cy.visit(&quot;/&quot;);
    cy.get(&quot;#accept&quot;).click();
    cy.get(&quot;[data-testid=add-wrap]&quot;).click();
    const total = cy.get(&quot;#total&quot;).invoke(&quot;text&quot;);
    expect(total).to.equal(&quot;₹220&quot;);
  });
</code></pre>
<pre tabindex="0"><code class="language-text">  1) FoodNow order
       adds a wrap (the way that doesn&#39;t work):
      AssertionError: expected { Object (specWindow, chainerId) } to equal &#39;₹220&#39;
</code></pre>
<p><code>total</code> isn&#39;t the text. It&#39;s a Cypress <strong>chainer</strong>, a handle to a command that hasn&#39;t run yet. The fix that people reach for next is <code>.then()</code>:</p>
<pre tabindex="0"><code class="language-js">    cy.get(&quot;#total&quot;).invoke(&quot;text&quot;).then((text) =&gt; {
      expect(text).to.equal(&quot;₹220&quot;);
    });
</code></pre>
<pre tabindex="0"><code class="language-text">  2) reading a value with .then()
      AssertionError: expected &#39;…&#39; to equal &#39;₹220&#39;
</code></pre>
<p>Now it gets the text, but only once, without retrying: <code>.then()</code> runs as soon as the command before it finishes, and the price hadn&#39;t arrived. <strong>Use <code>.should()</code> for anything you check,</strong> because only <code>.should()</code> retries. Keep <code>.then()</code> for when you need a value to do something with, not to assert on.</p>
<h2>Step 5: The real tests</h2>
<p>Save this as <code>cypress/e2e/order.cy.js</code>:</p>
<pre tabindex="0"><code class="language-js">describe(&quot;FoodNow order&quot;, () =&gt; {
  beforeEach(() =&gt; {
    cy.visit(&quot;/&quot;);
    cy.get(&quot;#accept&quot;).click();
  });

  it(&quot;charges delivery on a small order&quot;, () =&gt; {
    cy.get(&quot;[data-testid=add-wrap]&quot;).click();
    cy.get(&quot;#total&quot;).should(&quot;have.text&quot;, &quot;₹220&quot;);
  });

  it(&quot;gives free delivery from ₹500&quot;, () =&gt; {
    cy.get(&quot;[data-testid=add-biryani]&quot;).click();
    cy.get(&quot;[data-testid=add-biryani]&quot;).click();
    cy.get(&quot;#total&quot;).should(&quot;have.text&quot;, &quot;₹520&quot;);
  });

  it(&quot;removes the item you remove&quot;, () =&gt; {
    cy.get(&quot;[data-testid=add-wrap]&quot;).click();
    cy.get(&quot;[data-testid=add-chai]&quot;).click();
    cy.contains(&quot;[data-testid=cart-line]&quot;, &quot;Paneer wrap&quot;).find(&quot;button&quot;).click();
    cy.get(&quot;[data-testid=cart-line] span&quot;).should(&quot;have.length&quot;, 1).and(&quot;have.text&quot;, &quot;Masala chai&quot;);
    cy.get(&quot;#total&quot;).should(&quot;have.text&quot;, &quot;₹80&quot;);
  });

  it(&quot;won&#39;t place an empty order&quot;, () =&gt; {
    cy.get(&quot;#place&quot;).click();
    cy.get(&quot;#status&quot;).should(&quot;have.text&quot;, &quot;Your basket is empty&quot;);
  });
});
</code></pre>
<p><code>cy.contains(selector, text)</code> finds the basket line that contains &quot;Paneer wrap&quot;, so we click <em>its</em> Remove button, not just the first one on the page.</p>
<h2>Step 6: Make the network misbehave with cy.intercept</h2>
<p>Real price services fail, and answer slowly, and answer in a different order from the one you asked in. Testing that against a real server is hard. With <code>cy.intercept</code>, it&#39;s a few lines. Add this to <code>order.cy.js</code>:</p>
<pre tabindex="0"><code class="language-js">describe(&quot;when the price service misbehaves&quot;, () =&gt; {
  beforeEach(() =&gt; {
    cy.visit(&quot;/&quot;);
    cy.get(&quot;#accept&quot;).click();
  });

  it(&quot;says so when the price service is down&quot;, () =&gt; {
    cy.intercept(&quot;GET&quot;, &quot;/api/price*&quot;, { statusCode: 500, body: { error: &quot;price service down&quot; } }).as(&quot;price&quot;);
    cy.get(&quot;[data-testid=add-wrap]&quot;).click();
    cy.wait(&quot;@price&quot;);
    cy.get(&quot;#total&quot;).should(&quot;contain.text&quot;, &quot;couldn&#39;t work out your total&quot;);
  });

  it(&quot;shows the total for the latest basket when answers arrive out of order&quot;, () =&gt; {
    // Answer 1 (wrap only) arrives late; answer 2 (wrap + chai) arrives first.
    const answers = [
      { body: { food: 180, total: 220 }, delay: 1500 },
      { body: { food: 220, total: 260 }, delay: 100 },
    ];
    cy.intercept(&quot;GET&quot;, &quot;/api/price*&quot;, (req) =&gt; req.reply(answers.shift())).as(&quot;price&quot;);
    cy.get(&quot;[data-testid=add-wrap]&quot;).click();
    cy.get(&quot;[data-testid=add-chai]&quot;).click();
    cy.wait([&quot;@price&quot;, &quot;@price&quot;]); // both answers have been sent to the page
    cy.get(&quot;#total&quot;).should(&quot;have.text&quot;, &quot;₹260&quot;);
  });
});
</code></pre>
<ul>
<li>The first test replaces the price API&#39;s answer with an error, as if the service were down. We expect the page to say it couldn&#39;t work out the total.</li>
<li>The second plays out a real race. The customer adds a wrap, then a chai. Two price requests go out. The answer for &quot;wrap only&quot; is slow and arrives <em>after</em> the answer for &quot;wrap and chai&quot;. The page should still show ₹260, the total for what&#39;s actually in the basket.</li>
<li><code>.as(&quot;price&quot;)</code> names the intercepted request, and <code>cy.wait(&quot;@price&quot;)</code> waits for it.</li>
</ul>
<h2>Step 7: Run it</h2>
<pre tabindex="0"><code class="language-text">$ npx cypress run --spec cypress/e2e/order.cy.js
  FoodNow order
    ✓ charges delivery on a small order (1020ms)
    ✓ gives free delivery from ₹500 (1048ms)
    1) removes the item you remove
    ✓ won&#39;t place an empty order (187ms)

  when the price service misbehaves
    2) says so when the price service is down
    ✓ shows the total for the latest basket when answers arrive out of order (1660ms)

  4 passing (13s)
  2 failing
</code></pre>
<p>Two bugs, and one test we&#39;ll come back to:</p>
<pre tabindex="0"><code class="language-text">  1) FoodNow order
       removes the item you remove:
      Timed out retrying after 4000ms
      + expected - actual
      -&#39;Paneer wrap&#39;
      +&#39;Masala chai&#39;

  2) when the price service misbehaves
       says so when the price service is down:
      Timed out retrying after 4000ms
      + expected - actual
      -&#39;₹undefined&#39;
      +&#39;couldn\&#39;t work out your total&#39;
</code></pre>
<ol>
<li><strong>Remove removes the wrong item,</strong> the same bug Selenium found: <code>cart.splice(i + 1, 1)</code>.</li>
<li><strong>When the price service is down, the customer sees &quot;Total: ₹undefined&quot;.</strong> The page never checks whether the request worked. It reads the error&#39;s JSON as if it were a price, finds no <code>total</code>, and prints <code>undefined</code>. And the Place order button still works.</li>
</ol>
<p>Cypress takes a screenshot of every failure in <code>cypress run</code> mode, with its command log beside the page:</p>
<p><img src="https://provalab.io/blog/practice/cypress-price-down.png" alt="The Cypress runner after the price-service test failed: the command log shows GET 500 /api/price and the failed assertion &quot;expected #total to contain text couldn't work out your total, but the text was ₹undefined&quot;; the page shows Total: ₹undefined" loading="lazy"></p>
<h2>Step 8: The test that passed too early</h2>
<p>Look again at the run: the out-of-order test <strong>passed</strong>, in 1,660 ms. But the page has no protection against out-of-order answers at all: whichever answer arrives last wins. So how did it pass?</p>
<p>We ran exactly the same test on the same buggy page eight times. It failed six times, with <code>-&#39;₹220&#39; +&#39;₹260&#39;</code>, and <strong>passed twice</strong>. A flaky test, on a page with a real bug.</p>
<p>The test does wait for both answers. We measured that: <code>cy.wait(&quot;@price&quot;)</code> on a reply delayed by 2,000 ms finished after 2,058 ms. But <code>cy.wait</code> finishes the moment the reply is <strong>sent</strong> to the page. The page updates the total a few milliseconds later. In that gap, the total still shows ₹260 from the fast answer, and <code>should(&quot;have.text&quot;, &quot;₹260&quot;)</code> sees it and passes at once. Whether the test passes depends on a few milliseconds.</p>
<p>The deeper problem: a retrying assertion checks whether something <strong>becomes</strong> true. This test needs to check that something <strong>stays</strong> true after the late answer lands, and there&#39;s nothing to retry towards.</p>
<p>When there&#39;s no event that means &quot;the page has finished reacting&quot;, a short, explained pause is the honest tool:</p>
<pre tabindex="0"><code class="language-js">    cy.wait([&quot;@price&quot;, &quot;@price&quot;]); // both answers have been sent to the page
    // We&#39;re checking that the total *stays* right after the late answer lands.
    // Retrying assertions can&#39;t wait for &quot;nothing changes&quot;, so give the page a moment.
    cy.wait(300);
    cy.get(&quot;#total&quot;).should(&quot;have.text&quot;, &quot;₹260&quot;);
</code></pre>
<p>With that, the test failed on the buggy page <strong>5 times out of 5</strong>, and passed on the fixed page 5 times out of 5:</p>
<pre tabindex="0"><code class="language-text">  3) when the price service misbehaves
       shows the total for the latest basket when answers arrive out of order:
      Timed out retrying after 4000ms
      + expected - actual
      -&#39;₹220&#39;
      +&#39;₹260&#39;
</code></pre>
<p>The customer has a wrap and a chai in the basket, and the page says ₹220, the price of the wrap alone.</p>
<p>Normally, <code>cy.wait(300)</code> with a number is a mistake, the Cypress version of <code>time.sleep</code>. Here it&#39;s the exception, because there&#39;s nothing else to wait for, and the comment says why. If you can, add something to the app that tests can wait for instead, such as a <code>data-loading</code> attribute that&#39;s removed when the last price has been applied.</p>
<h2>Step 9: Fix the page</h2>
<p>Three changes to <code>render()</code>:</p>
<pre tabindex="0"><code class="language-diff">   const cart = [];
+  let latestRequest = 0;
...
-      row.querySelector(&quot;button&quot;).onclick = () =&gt; { cart.splice(i + 1, 1); render(); };
+      row.querySelector(&quot;button&quot;).onclick = () =&gt; { cart.splice(i, 1); render(); };
...
+    const request = ++latestRequest;
     fetch(`/api/price?items=${encodeURIComponent(names)}`)
-      .then(r =&gt; r.json())
-      .then(data =&gt; { document.getElementById(&quot;total&quot;).textContent = `₹${data.total}`; });
+      .then(r =&gt; { if (!r.ok) throw new Error(`price service: ${r.status}`); return r.json(); })
+      .then(data =&gt; {
+        if (request !== latestRequest) return; // an older answer: the basket has changed since
+        document.getElementById(&quot;total&quot;).textContent = `₹${data.total}`;
+      })
+      .catch(() =&gt; {
+        if (request !== latestRequest) return;
+        document.getElementById(&quot;total&quot;).textContent = &quot;We couldn&#39;t work out your total. Please try again.&quot;;
+      });
</code></pre>
<ul>
<li>Remove the item that was clicked.</li>
<li>Treat a failed request as a failure, and tell the customer.</li>
<li>Number each request, and ignore any answer that isn&#39;t for the latest basket.</li>
</ul>
<pre tabindex="0"><code class="language-text">$ npx cypress run --spec cypress/e2e/order.cy.js
  FoodNow order
    ✓ charges delivery on a small order (1025ms)
    ✓ gives free delivery from ₹500 (1027ms)
    ✓ removes the item you remove (1093ms)
    ✓ won&#39;t place an empty order (186ms)

  when the price service misbehaves
    ✓ says so when the price service is down (187ms)
    ✓ shows the total for the latest basket when answers arrive out of order (1953ms)

  6 passing (6s)
</code></pre>
<p>We ran it five times on the fixed page: 6 passing every time. The two network tests that use stubs are also the fastest, because they don&#39;t wait for the real 800 ms server.</p>
<h2>Selenium, Playwright or Cypress?</h2>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>Selenium</th>
<th>Playwright</th>
<th>Cypress</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Runs</strong></td>
<td>Outside the browser, through a driver</td>
<td>Outside, over its own protocol</td>
<td>Inside the browser</td>
</tr>
<tr>
<td><strong>Waiting</strong></td>
<td>You write explicit waits</td>
<td>Built in</td>
<td>Built in (<code>.should()</code> retries)</td>
</tr>
<tr>
<td><strong>Faking the network</strong></td>
<td>Not built in</td>
<td><code>page.route()</code></td>
<td><code>cy.intercept()</code></td>
</tr>
<tr>
<td><strong>Languages</strong></td>
<td>Java, Python, C#, JavaScript, Ruby</td>
<td>JS/TS, Python, Java, C#</td>
<td>JavaScript, TypeScript</td>
</tr>
<tr>
<td><strong>Browsers</strong></td>
<td>Chrome, Firefox, Safari, Edge</td>
<td>Chromium, Firefox, WebKit</td>
<td>Chrome-family, Firefox, Electron; WebKit experimental</td>
</tr>
<tr>
<td><strong>Multiple tabs / origins</strong></td>
<td>Yes</td>
<td>Yes</td>
<td>One tab; other origins need <code>cy.origin()</code></td>
</tr>
</tbody></table>
<p>Choose <strong>Cypress</strong> when your team writes JavaScript, tests mostly one web app, and wants the friendliest runner to write and debug tests in. Choose <strong>Playwright</strong> for more browsers and languages and multi-tab flows. Keep <strong>Selenium</strong> for existing suites, real Safari, and device clouds built around WebDriver. All three found the Remove bug. What found the other two was the decision to test what happens when the network misbehaves.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Treating commands as values.</strong> <code>const t = cy.get(...)</code> is a chainer, not an element or its text. Chain <code>.should()</code> instead.</li>
<li><strong>Asserting inside <code>.then()</code>.</strong> It doesn&#39;t retry. Use <code>.should()</code>.</li>
<li><strong><code>{force: true}</code> to get past overlays.</strong> It clicks through things users can&#39;t. Close the overlay.</li>
<li><strong>Numeric <code>cy.wait(ms)</code> everywhere.</strong> Wait for an alias (<code>cy.wait(&quot;@price&quot;)</code>) or an assertion. Use a number only when nothing else will do, and say why in a comment.</li>
<li><strong>Testing only the happy network.</strong> <code>cy.intercept</code> makes errors, slow answers and out-of-order answers cheap to test. Two of our three bugs were there.</li>
<li><strong>Trusting a pass you didn&#39;t expect.</strong> Our race test passed on a page with no race protection. When a test passes and you can&#39;t say why the code is right, find out.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong>Part 13</strong> of Testing in Practice we look at <strong>test estimation</strong>: how long will testing take, how to estimate it from the work rather than guess, and how to explain the number to people who wanted it smaller.</p>
<p>ProvaLab&#39;s QA Infrastructure runs browser tests in the cloud with a live view while they run, and ProvaLab&#39;s plugins for Cypress, Playwright and pytest send each result to the ProvaLab test management system, with screenshots and each test&#39;s history across runs. A test that passes one run and fails the next, like the race test in Step 8, shows both results in its history instead of being lost in a CI log. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Your first Selenium test (hands-on): Python, pytest, and the two errors every beginner hits</title>
      <link>https://provalab.io/blog/first-selenium-test-python/</link>
      <guid isPermaLink="true">https://provalab.io/blog/first-selenium-test-python/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>selenium</category>
      <category>ui-testing</category>
      <category>pytest</category>
      <category>python</category>
      <description>Write a first Selenium WebDriver test in Python with pytest. A real run hits a blocked click and a total read too early, then finds a planted bug. Plus how it compares with Playwright.</description>
      <content:encoded><![CDATA[<p>Selenium is the oldest and most widely used tool for automating a web browser. It started in 2004, and its WebDriver protocol is now a W3C standard that every major browser supports. Many test suites in the world still run on it, so whatever tool your team chooses, you&#39;ll meet Selenium.</p>
<p>This post writes a first Selenium test in Python with pytest, against a small FoodNow order page. The first version fails twice for reasons that have nothing to do with the app. Those are the two errors almost every beginner hits, so we show them exactly. Then a proper version, with waits and a page object, finds a bug planted in the page. At the end, the same tests in Playwright, to compare. Every command and line of output below was run while writing this post.</p>
<h2>How Selenium works</h2>
<p><img src="https://provalab.io/blog/practice/selenium-how-it-works.svg" alt="How a Selenium test talks to the browser: the test calls the Selenium library, which sends each command over HTTP to chromedriver, which controls Chrome. Selenium Manager finds the browser and downloads the driver." width="880" height="470" loading="lazy"></p>
<ul>
<li>Your test calls the <strong>Selenium library</strong>: find an element, click it, read its text.</li>
<li>Selenium sends each command as an HTTP request in the <strong>WebDriver protocol</strong> to a <strong>driver</strong>: chromedriver for Chrome, geckodriver for Firefox, safaridriver for Safari.</li>
<li>The driver controls the <strong>real browser</strong>, and sends back the result or an error.</li>
</ul>
<p>You used to have to download the right driver for your browser version yourself, and update it every time the browser updated. Since Selenium 4.6, <strong>Selenium Manager</strong> does it. On our machine it found the installed Google Chrome 148 and downloaded chromedriver 148 into <code>~/.cache/selenium</code>, with no setup.</p>
<p>One thing to remember from the diagram: <strong>every command is one request and one answer</strong>. Selenium doesn&#39;t know that your page is still loading something. If you don&#39;t tell it to wait, it won&#39;t.</p>
<h2>Step 1: Set up</h2>
<p>You need Python 3.10 or newer and Chrome:</p>
<pre tabindex="0"><code class="language-bash">mkdir foodnow-selenium &amp;&amp; cd foodnow-selenium
python -m venv .venv &amp;&amp; source .venv/bin/activate
pip install selenium==4.52.0 pytest==9.2.0
mkdir -p site tests
printf &#39;[pytest]\npythonpath = .\n&#39; &gt; pytest.ini
</code></pre>
<h2>Step 2: The page under test</h2>
<p>The FoodNow order page lets you add dishes, remove them, and see the total. Like many real pages, it gets the total from the server, so it shows &quot;…&quot; for a moment (800 ms here) before the amount appears. And there&#39;s a cookie banner over everything until you press OK. Save it as <code>site/index.html</code>:</p>
<pre tabindex="0"><code class="language-html">&lt;!doctype html&gt;
&lt;html lang=&quot;en&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;utf-8&quot;&gt;
&lt;title&gt;FoodNow: your order&lt;/title&gt;
&lt;style&gt;
  /* ...layout styles... */
  #cookies { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: flex-end; }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;header&gt;FoodNow&lt;/header&gt;
&lt;main&gt;
  &lt;section&gt;
    &lt;h2&gt;Menu&lt;/h2&gt;
    &lt;div class=&quot;item&quot;&gt;&lt;span&gt;Paneer wrap · ₹180&lt;/span&gt;&lt;button data-testid=&quot;add-wrap&quot; data-name=&quot;Paneer wrap&quot; data-price=&quot;180&quot;&gt;Add&lt;/button&gt;&lt;/div&gt;
    &lt;div class=&quot;item&quot;&gt;&lt;span&gt;Masala chai · ₹40&lt;/span&gt;&lt;button data-testid=&quot;add-chai&quot; data-name=&quot;Masala chai&quot; data-price=&quot;40&quot;&gt;Add&lt;/button&gt;&lt;/div&gt;
    &lt;div class=&quot;item&quot;&gt;&lt;span&gt;Veg biryani · ₹260&lt;/span&gt;&lt;button data-testid=&quot;add-biryani&quot; data-name=&quot;Veg biryani&quot; data-price=&quot;260&quot;&gt;Add&lt;/button&gt;&lt;/div&gt;
  &lt;/section&gt;
  &lt;section&gt;
    &lt;h2&gt;Your order&lt;/h2&gt;
    &lt;div id=&quot;cart&quot;&gt;&lt;/div&gt;
    &lt;p&gt;Total: &lt;span id=&quot;total&quot;&gt;₹0&lt;/span&gt;&lt;/p&gt;
    &lt;button id=&quot;place&quot;&gt;Place order&lt;/button&gt;
    &lt;p id=&quot;status&quot; role=&quot;status&quot;&gt;&lt;/p&gt;
  &lt;/section&gt;
&lt;/main&gt;
&lt;div id=&quot;cookies&quot;&gt;&lt;div&gt;&lt;span&gt;We use cookies to remember your basket.&lt;/span&gt;&lt;button id=&quot;accept&quot;&gt;OK&lt;/button&gt;&lt;/div&gt;&lt;/div&gt;
&lt;script&gt;
  const cart = [];
  const DELIVERY = 40, FREE_FROM = 500;

  function render() {
    const el = document.getElementById(&quot;cart&quot;);
    el.innerHTML = &quot;&quot;;
    cart.forEach((item, i) =&gt; {
      const row = document.createElement(&quot;div&quot;);
      row.className = &quot;line&quot;;
      row.dataset.testid = &quot;cart-line&quot;;
      row.innerHTML = `&lt;span&gt;${item.name}&lt;/span&gt;&lt;button&gt;Remove&lt;/button&gt;`;
      row.querySelector(&quot;button&quot;).onclick = () =&gt; { cart.splice(i + 1, 1); render(); };
      el.appendChild(row);
    });
    // The total comes from the server (prices, offers, delivery), so it arrives a little later.
    document.getElementById(&quot;total&quot;).textContent = &quot;…&quot;;
    setTimeout(() =&gt; {
      const food = cart.reduce((s, x) =&gt; s + x.price, 0);
      const total = food === 0 ? 0 : food + (food &gt;= FREE_FROM ? 0 : DELIVERY);
      document.getElementById(&quot;total&quot;).textContent = `₹${total}`;
    }, 800);
  }

  document.querySelectorAll(&quot;[data-testid^=add-]&quot;).forEach(b =&gt;
    b.onclick = () =&gt; { cart.push({ name: b.dataset.name, price: Number(b.dataset.price) }); render(); });
  document.getElementById(&quot;accept&quot;).onclick = () =&gt; document.getElementById(&quot;cookies&quot;).remove();
  document.getElementById(&quot;place&quot;).onclick = () =&gt;
    document.getElementById(&quot;status&quot;).textContent = cart.length ? &quot;Order placed&quot; : &quot;Your basket is empty&quot;;
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>(We left out the layout styles here to keep it short.) One bug is planted. See if you can spot it before the tests do.</p>
<h2>Step 3: Fixtures for the browser and the page</h2>
<p><code>tests/conftest.py</code> serves the <code>site</code> folder on a free local port and opens Chrome for each test:</p>
<pre tabindex="0"><code class="language-python">import functools
import http.server
import threading
from pathlib import Path

import pytest
from selenium import webdriver

SITE = Path(__file__).parents[1] / &quot;site&quot;


@pytest.fixture(scope=&quot;session&quot;)
def base_url():
    handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=SITE)
    server = http.server.ThreadingHTTPServer((&quot;127.0.0.1&quot;, 0), handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    yield f&quot;http://127.0.0.1:{server.server_port}&quot;
    server.shutdown()


@pytest.fixture
def driver():
    options = webdriver.ChromeOptions()
    options.add_argument(&quot;--headless=new&quot;)
    options.add_argument(&quot;--window-size=1280,800&quot;)
    driver = webdriver.Chrome(options=options)
    yield driver
    driver.quit()
</code></pre>
<p><code>webdriver.Chrome()</code> is all it takes: Selenium Manager finds Chrome and the driver. <code>--headless=new</code> runs Chrome without a window, which is what you want in CI. Remove it to watch the test run. A fixed window size makes the layout, and so the test, the same on every machine.</p>
<h2>Step 4: The first test, and the first error</h2>
<p>Here&#39;s the test most people write first. Add a wrap, check the total. Save it as <code>tests/test_first.py</code>:</p>
<pre tabindex="0"><code class="language-python">from selenium.webdriver.common.by import By


def test_add_a_wrap(driver, base_url):
    driver.get(base_url)
    driver.find_element(By.CSS_SELECTOR, &quot;[data-testid=add-wrap]&quot;).click()
    total = driver.find_element(By.ID, &quot;total&quot;).text
    assert total == &quot;₹220&quot;  # ₹180 wrap + ₹40 delivery
</code></pre>
<pre tabindex="0"><code class="language-text">$ pytest tests/test_first.py
...
E       selenium.common.exceptions.ElementClickInterceptedException: Message: element click intercepted: Element &lt;button data-testid=&quot;add-wrap&quot; data-name=&quot;Paneer wrap&quot; data-price=&quot;180&quot;&gt;...&lt;/button&gt; is not clickable at point (335, 163). Other element would receive the click: &lt;div id=&quot;cookies&quot;&gt;...&lt;/div&gt;
E         (Session info: chrome=148.0.7778.215); For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors#elementclickinterceptedexception
...
============================== 1 failed in 2.12s ===============================
</code></pre>
<p><strong><code>ElementClickInterceptedException</code>.</strong> The button exists, but Selenium clicks like a person: at a point on the screen. At that point is the cookie banner, and the message even names it: <code>Other element would receive the click: &lt;div id=&quot;cookies&quot;&gt;</code>. Read the whole message. It usually tells you exactly what&#39;s in the way: a cookie banner, a chat widget, a sticky header, or a loading overlay.</p>
<p>Close the banner first, as a user would:</p>
<pre tabindex="0"><code class="language-python">    driver.get(base_url)
    driver.find_element(By.ID, &quot;accept&quot;).click()  # close the cookie banner first
    driver.find_element(By.CSS_SELECTOR, &quot;[data-testid=add-wrap]&quot;).click()
</code></pre>
<p>Don&#39;t &quot;fix&quot; it with a JavaScript click (<code>driver.execute_script(&quot;arguments[0].click()&quot;, button)</code>). It clicks straight through the banner, which no user can do, so the test stops matching reality.</p>
<h2>Step 5: The second error: reading too early</h2>
<pre tabindex="0"><code class="language-text">$ pytest tests/test_first.py
...
E       AssertionError: assert &#39;…&#39; == &#39;₹220&#39;
E
E         - ₹220
E         + …
============================== 1 failed in 1.00s ===============================
</code></pre>
<p>The click worked. Selenium then read the total <strong>immediately</strong>, while the page was still showing &quot;…&quot;. The amount arrived 800 ms later, after the test had already failed.</p>
<p>The tempting fix is <code>time.sleep(1)</code>. It passes today. It&#39;s also too slow on a fast machine and too short on a slow CI runner or a slow server day. A suite full of sleeps is both slow and flaky.</p>
<p>The right fix is an <strong>explicit wait</strong>: wait until a condition is true, up to a time limit, and continue the moment it is:</p>
<pre tabindex="0"><code class="language-python">from selenium.webdriver.support.ui import WebDriverWait

WebDriverWait(driver, timeout=5).until(
    lambda d: d.find_element(By.ID, &quot;total&quot;).text.startswith(&quot;₹&quot;)
)
</code></pre>
<p>This waits for &quot;a real amount&quot;, not for &quot;₹220&quot;. If the total is wrong, the wait still ends, and the <code>assert</code> afterwards shows the wrong value. If you wait for the exact expected text instead, a wrong total gives you a <code>TimeoutException</code> that doesn&#39;t say what the total was.</p>
<p>Selenium also has an <strong>implicit wait</strong> (<code>driver.implicitly_wait(5)</code>), which makes every <code>find_element</code> retry until the element appears. It wouldn&#39;t have helped here: the total element existed all along, it just had the wrong text. And mixing implicit and explicit waits can make timeouts unpredictable. Use explicit waits.</p>
<h2>Step 6: A page object, and the real tests</h2>
<p>As tests grow, the same locators and waits get copied into every test. A <strong>page object</strong> puts them in one class, so tests read like what a user does, and a change to the page is fixed in one place. Save this as <code>tests/pages.py</code>:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;A page object: one place that knows how the order page is built.&quot;&quot;&quot;
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait


class OrderPage:
    def __init__(self, driver, base_url):
        self.driver = driver
        self.base_url = base_url
        self.wait = WebDriverWait(driver, timeout=5)

    def open(self):
        self.driver.get(self.base_url)
        self.wait.until(EC.element_to_be_clickable((By.ID, &quot;accept&quot;))).click()
        self.wait.until(EC.invisibility_of_element_located((By.ID, &quot;cookies&quot;)))
        return self

    def add(self, dish: str):
        self.driver.find_element(By.CSS_SELECTOR, f&quot;[data-testid=add-{dish}]&quot;).click()
        return self

    def remove(self, name: str):
        for line in self.driver.find_elements(By.CSS_SELECTOR, &quot;[data-testid=cart-line]&quot;):
            if line.find_element(By.TAG_NAME, &quot;span&quot;).text == name:
                line.find_element(By.TAG_NAME, &quot;button&quot;).click()
                return self
        raise AssertionError(f&quot;{name} is not in the basket&quot;)

    def items(self) -&gt; list[str]:
        return [e.text for e in self.driver.find_elements(By.CSS_SELECTOR, &quot;[data-testid=cart-line] span&quot;)]

    def total(self) -&gt; str:
        # The total shows &quot;…&quot; while the price is being checked. Wait until it&#39;s a real amount.
        self.wait.until(lambda d: d.find_element(By.ID, &quot;total&quot;).text.startswith(&quot;₹&quot;))
        return self.driver.find_element(By.ID, &quot;total&quot;).text

    def place_order(self) -&gt; str:
        self.driver.find_element(By.ID, &quot;place&quot;).click()
        self.wait.until(lambda d: d.find_element(By.ID, &quot;status&quot;).text != &quot;&quot;)
        return self.driver.find_element(By.ID, &quot;status&quot;).text
</code></pre>
<p>The locators use <code>data-testid</code> attributes and ids, not long CSS paths or XPath like <code>/html/body/main/section[2]/div[1]/button</code>. Those break the moment someone adds a <code>&lt;div&gt;</code>.</p>
<p>The tests, in <code>tests/test_order.py</code> (delete <code>test_first.py</code> now):</p>
<pre tabindex="0"><code class="language-python">import pytest

from tests.pages import OrderPage


@pytest.fixture
def order(driver, base_url):
    return OrderPage(driver, base_url).open()


def test_one_wrap_pays_delivery(order):
    order.add(&quot;wrap&quot;)
    assert order.total() == &quot;₹220&quot;  # ₹180 + ₹40 delivery


def test_free_delivery_from_500(order):
    order.add(&quot;biryani&quot;).add(&quot;biryani&quot;)
    assert order.total() == &quot;₹520&quot;  # ₹520 of food, delivery free


def test_remove_an_item(order):
    order.add(&quot;wrap&quot;).add(&quot;chai&quot;)
    order.remove(&quot;Paneer wrap&quot;)
    assert order.items() == [&quot;Masala chai&quot;]
    assert order.total() == &quot;₹80&quot;  # ₹40 + ₹40 delivery


def test_cannot_order_an_empty_basket(order):
    assert order.place_order() == &quot;Your basket is empty&quot;
</code></pre>
<h2>Step 7: Take a screenshot when a test fails</h2>
<p>A failing UI test without a screenshot sends you guessing. Add this hook to the end of <code>tests/conftest.py</code>. It saves a screenshot of the browser whenever a test that uses <code>driver</code> fails:</p>
<pre tabindex="0"><code class="language-python">@pytest.hookimpl(wrapper=True)
def pytest_runtest_makereport(item, call):
    report = yield
    if report.when == &quot;call&quot; and report.failed and &quot;driver&quot; in item.fixturenames:
        path = Path(&quot;shots&quot;) / f&quot;{item.name}.png&quot;
        path.parent.mkdir(exist_ok=True)
        item.funcargs[&quot;driver&quot;].save_screenshot(str(path))
        report.sections.append((&quot;screenshot&quot;, str(path)))
    return report
</code></pre>
<h2>Step 8: Run it</h2>
<pre tabindex="0"><code class="language-text">$ pytest tests -v
tests/test_order.py::test_one_wrap_pays_delivery PASSED                  [ 25%]
tests/test_order.py::test_free_delivery_from_500 PASSED                  [ 50%]
tests/test_order.py::test_remove_an_item FAILED                          [ 75%]
tests/test_order.py::test_cannot_order_an_empty_basket PASSED            [100%]

=================================== FAILURES ===================================
...
E       AssertionError: assert [&#39;Paneer wrap&#39;] == [&#39;Masala chai&#39;]
E
E         At index 0 diff: &#39;Paneer wrap&#39; != &#39;Masala chai&#39;
E
E         Full diff: (-: missing in left side, +: extra in left side)
E           [
E         -     &#39;Masala chai&#39;,
E         +     &#39;Paneer wrap&#39;,
E           ]
---------------------------------- screenshot ----------------------------------
shots/test_remove_an_item.png
=========================== short test summary info ============================
FAILED tests/test_order.py::test_remove_an_item - AssertionError: assert [&#39;Pa...
========================= 1 failed, 3 passed in 4.66s ==========================
</code></pre>
<p>We removed the paneer wrap, and the <strong>masala chai</strong> disappeared instead. The screenshot shows it:</p>
<p><img src="https://provalab.io/blog/practice/selenium-remove-bug.png" alt="The FoodNow order page after removing the paneer wrap: the paneer wrap is still in the order and the masala chai is gone. The total shows &quot;…&quot; because it was still being recalculated when the screenshot was taken." loading="lazy"></p>
<p>(The total shows &quot;…&quot; because the screenshot was taken the moment the test failed, while the page was still working out the new total.)</p>
<p>The bug is in the Remove handler: <code>cart.splice(i + 1, 1)</code> removes the item <em>after</em> the one you clicked. It should be <code>cart.splice(i, 1)</code>. With only one item in the basket, Remove does nothing at all. The three other tests passed, because none of them removes anything. That&#39;s why each user action needs at least one test of its own.</p>
<p>After the fix, we ran the suite five times in a row:</p>
<pre tabindex="0"><code class="language-text">4 passed in 5.56s
4 passed in 7.70s
4 passed in 5.66s
4 passed in 5.63s
4 passed in 5.54s
</code></pre>
<p>No flaky failures. The waits are doing their job.</p>
<h2>Selenium or Playwright?</h2>
<p>We wrote two of the same tests with Playwright for Python (<a href="https://provalab.io/blog/first-playwright-test/">our first Playwright post</a> explains the setup):</p>
<pre tabindex="0"><code class="language-python">def test_remove_an_item(page):
    page.get_by_test_id(&quot;add-wrap&quot;).click()
    page.get_by_test_id(&quot;add-chai&quot;).click()
    page.get_by_test_id(&quot;cart-line&quot;).filter(has_text=&quot;Paneer wrap&quot;).get_by_role(&quot;button&quot;, name=&quot;Remove&quot;).click()
    expect(page.get_by_test_id(&quot;cart-line&quot;).locator(&quot;span&quot;)).to_have_text([&quot;Masala chai&quot;])
    expect(page.locator(&quot;#total&quot;)).to_have_text(&quot;₹80&quot;)
</code></pre>
<p>No explicit waits: Playwright waits for an element to be ready before clicking, and <code>expect(...)</code> retries until the text matches or 5 seconds pass. On the buggy page:</p>
<pre tabindex="0"><code class="language-text">E       AssertionError: Locator expected to have text &#39;[&#39;Masala chai&#39;]&#39;
E       Actual value: [&#39;Paneer wrap&#39;]
...
1 failed, 1 passed in 7.68s
</code></pre>
<p>On the fixed page, <code>2 passed</code> in about 3 to 4 seconds. (Our first Playwright attempt also failed on the fixed page: <code>to_have_text</code> on the whole line compared &quot;Masala chaiRemove&quot;, including the button&#39;s text. Every tool has its traps.)</p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>Selenium</th>
<th>Playwright</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Waiting</strong></td>
<td>You add explicit waits</td>
<td>Built in: actions and <code>expect</code> wait automatically</td>
</tr>
<tr>
<td><strong>Browsers</strong></td>
<td>Chrome, Firefox, Safari, Edge, through each vendor&#39;s driver</td>
<td>Chromium, Firefox, WebKit builds that Playwright installs</td>
</tr>
<tr>
<td><strong>Languages</strong></td>
<td>Java, Python, C#, JavaScript, Ruby</td>
<td>JavaScript/TypeScript, Python, Java, C#</td>
</tr>
<tr>
<td><strong>Standard</strong></td>
<td>W3C WebDriver, supported by browser vendors</td>
<td>Its own protocol</td>
</tr>
<tr>
<td><strong>Grid / cloud</strong></td>
<td>Selenium Grid, and almost every device cloud</td>
<td>Its own remote mode, and growing cloud support</td>
</tr>
<tr>
<td><strong>Extras</strong></td>
<td>Small core; add reporting yourself</td>
<td>Trace viewer, video, network mocking built in</td>
</tr>
</tbody></table>
<p>Choose <strong>Selenium</strong> when your team already has a Selenium suite, needs real Safari or the widest set of languages, or runs on a device cloud built around WebDriver. Choose <strong>Playwright</strong> for a new suite where speed of writing and built-in waiting matter most. Both find the same bug, as long as someone writes the test that removes something.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong><code>time.sleep()</code> everywhere.</strong> Slow when the page is fast, flaky when it&#39;s slow. Wait for a condition with <code>WebDriverWait</code>.</li>
<li><strong>Waiting for the expected value.</strong> A wrong value then gives a timeout instead of the actual value. Wait for &quot;ready&quot;, then assert.</li>
<li><strong>JavaScript clicks to get past overlays.</strong> They click through things users can&#39;t. Close the overlay, as a user would.</li>
<li><strong>Absolute XPath and long CSS paths.</strong> Use ids, <code>data-testid</code> attributes, or text and roles users see.</li>
<li><strong>Locators copied into every test.</strong> Use page objects, so a change to the page is fixed in one place.</li>
<li><strong>No screenshot on failure.</strong> Add the hook from Step 7 on day one.</li>
<li><strong>Mixing implicit and explicit waits.</strong> It makes timeouts unpredictable. Pick explicit waits.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong>Part 12</strong> of Testing in Practice we write <strong>your first Cypress test</strong>: the same FoodNow order page, the Cypress way, and what&#39;s different about a tool that runs inside the browser.</p>
<p>ProvaLab&#39;s QA Infrastructure runs Selenium tests like these on browsers in the cloud, with a live view of the browser while the test runs. ProvaLab&#39;s pytest plugin sends each result to the ProvaLab test management system, with the screenshot attached and the test&#39;s history across runs. See it in the <a href="https://provalab.io/#stage-infra">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Mobile app testing with Appium and pytest (hands-on): rotation, crashes and the button you can't reach</title>
      <link>https://provalab.io/blog/mobile-app-testing-appium-pytest/</link>
      <guid isPermaLink="true">https://provalab.io/blog/mobile-app-testing-appium-pytest/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>mobile-testing</category>
      <category>appium</category>
      <category>android</category>
      <category>pytest</category>
      <description>Test an Android app on an emulator with Appium and pytest. A real run finds three bugs a web test never would: a tip lost on rotation, a crash, and a Pay button off screen.</description>
      <content:encoded><![CDATA[<p>A web page lives in one browser window. A phone app lives in a much rougher place. The user turns the phone sideways halfway through checkout. The keyboard covers half the screen. A call comes in. The screen is small, and what fits in portrait doesn&#39;t fit in landscape. Each of these can break an app that works perfectly when you tap through it on your desk.</p>
<p>This post tests a small Android app for FoodNow, our food delivery example, with <strong>Appium</strong> and <strong>pytest</strong> on an Android emulator. Two bugs are planted in it. While running the tests we found a third that we didn&#39;t plant, and two traps in the tooling that make tests lie. Every command and line of output below was run while writing this post.</p>
<h2>What&#39;s different about testing a mobile app</h2>
<table tabindex="0">
<thead>
<tr>
<th>Mobile-only problem</th>
<th>What goes wrong</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Rotation</strong></td>
<td>Android destroys the screen and builds it again. Anything the app didn&#39;t save is gone.</td>
</tr>
<tr>
<td><strong>Small screens, two orientations</strong></td>
<td>A button that fits in portrait can end up below the edge in landscape.</td>
</tr>
<tr>
<td><strong>Crashes</strong></td>
<td>There&#39;s no error page. The app just closes, and the user sees &quot;keeps stopping&quot;.</td>
</tr>
<tr>
<td><strong>Interruptions</strong></td>
<td>Calls, notifications, the app going to the background and coming back.</td>
</tr>
<tr>
<td><strong>Gestures and the keyboard</strong></td>
<td>Swipes, the on-screen keyboard, system gestures that take over the edges of the screen.</td>
</tr>
<tr>
<td><strong>Many devices</strong></td>
<td>Different Android versions, screen sizes and makers&#39; changes.</td>
</tr>
</tbody></table>
<p>This post covers the first four. The tools are the same for the rest.</p>
<h2>How Appium works</h2>
<p>Appium is a server that drives phone apps the way Selenium and WebDriver drive browsers. Your test sends commands over HTTP. Appium&#39;s <strong>UiAutomator2</strong> driver installs your app and a small helper app on the phone, and the helper app taps, types and reads the screen.</p>
<p><img src="https://provalab.io/blog/practice/appium-how-it-works.svg" alt="Your pytest test sends commands over HTTP to the Appium server on port 4723. Its UiAutomator2 driver uses adb to install the FoodNow app and a helper app on the Android emulator or a real phone. The helper finds elements by resource id, taps, types, rotates and reads text." width="880" height="330" loading="lazy"></p>
<p>The same test runs on an emulator or a real phone. Only the device changes.</p>
<h2>Step 1: Set up</h2>
<p>You need an Android emulator, an Appium server with the UiAutomator2 driver, and the Python client.</p>
<p>On Linux with KVM, the quickest way to get the first two is one Docker container that has both. We used this:</p>
<pre tabindex="0"><code class="language-bash">docker run -d --name foodnow-emu --device /dev/kvm \
  -p 4723:4723 -p 6080:6080 -v &quot;$PWD&quot;:/apps:ro \
  -e EMULATOR_DEVICE=&quot;Samsung Galaxy S10&quot; -e WEB_VNC=true -e APPIUM=true \
  budtmo/docker-android:emulator_14.0
</code></pre>
<p>It starts Android 14 with Appium on port 4723, and shows the phone&#39;s screen in your browser at <code>http://localhost:6080</code>. It needs a few minutes to boot the first time. The <code>-v</code> line makes the current folder visible inside the container as <code>/apps</code>, which is where Appium will look for the app.</p>
<p>On a Mac or Windows, use Android Studio&#39;s emulator instead, and install Appium yourself:</p>
<pre tabindex="0"><code class="language-bash">npm install -g appium
appium driver install uiautomator2
appium
</code></pre>
<p>Then the Python side:</p>
<pre tabindex="0"><code class="language-bash">pip install Appium-Python-Client==6.1.5 pytest==9.2.0
</code></pre>
<p>We used Appium 3.9.0 with the UiAutomator2 driver 8.7.3 (both inside the container), an Android 14 emulator (API 34), Python 3.11, pytest 9.2.0 and Appium-Python-Client 6.1.5, on Linux.</p>
<h2>Step 2: The app</h2>
<p>The app is one checkout screen: two paneer wraps for 500, a box for the delivery tip, Add tip and Remove tip buttons, the total, and Pay. Every control has an id in the layout, such as <code>android:id=&quot;@+id/total&quot;</code>, which Appium sees as the resource id <code>com.example.foodnow:id/total</code>. Ids are the most reliable way to find elements, like <code>data-testid</code> on the web.</p>
<p>Here is the code behind the screen, with the two planted bugs in it:</p>
<pre tabindex="0"><code class="language-java">public class CheckoutActivity extends Activity {
    private static final int SUBTOTAL = 500;
    private int tip = 0;
    private TextView total;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.checkout);
        EditText tipField = findViewById(R.id.tip);
        TextView status = findViewById(R.id.status);
        total = findViewById(R.id.total);
        showTotal();

        findViewById(R.id.add_tip).setOnClickListener(v -&gt; {
            String text = tipField.getText().toString();
            int value = text.isEmpty() ? 0 : Integer.parseInt(text);
            if (value &gt; 200) {
                status.setText(&quot;Tip must be 0 to 200&quot;);
                return;
            }
            tip = value;
            status.setText(&quot;&quot;);
            showTotal();
        });
        findViewById(R.id.remove_tip).setOnClickListener(v -&gt; {
            tip = 0;
            tipField.setText(&quot;&quot;);
            showTotal();
        });
        findViewById(R.id.pay).setOnClickListener(v -&gt;
                status.setText(&quot;Order placed: &quot; + (SUBTOTAL + tip)));
    }

    private void showTotal() {
        total.setText(&quot;Total: &quot; + (SUBTOTAL + tip));
    }
}
</code></pre>
<p>We built it into <code>foodnow.apk</code> with the Android SDK&#39;s own tools. In a real project, Android Studio or <code>./gradlew assembleDebug</code> builds the APK for you.</p>
<h2>Step 3: A fixture that starts the app, and collects evidence</h2>
<p><code>tests/conftest.py</code> opens an Appium session for each test, with the app freshly installed and its data cleared. It also does two things most examples leave out. Both turned out to matter.</p>
<pre tabindex="0"><code class="language-python">import os
import pathlib

import pytest
from appium import webdriver
from appium.options.android import UiAutomator2Options

APP = os.environ.get(&quot;APP&quot;, str(pathlib.Path(__file__).parent.parent / &quot;foodnow.apk&quot;))
APPIUM_URL = os.environ.get(&quot;APPIUM_URL&quot;, &quot;http://127.0.0.1:4723&quot;)


@pytest.hookimpl(wrapper=True)
def pytest_runtest_makereport(item, call):
    &quot;&quot;&quot;Remember whether the test failed, so the fixture can collect evidence.&quot;&quot;&quot;
    report = yield
    setattr(item, f&quot;rep_{report.when}&quot;, report)
    return report


@pytest.fixture
def driver(request):
    options = UiAutomator2Options()
    options.app = APP
    options.app_package = &quot;com.example.foodnow&quot;
    options.app_activity = &quot;.CheckoutActivity&quot;
    options.no_reset = False  # fresh app data for every test
    options.set_capability(&quot;appium:enforceAppInstall&quot;, True)  # always install the APK you just built
    driver = webdriver.Remote(APPIUM_URL, options=options)
    driver.implicitly_wait(5)
    driver.get_log(&quot;logcat&quot;)  # skip old log lines: this returns, and clears, what&#39;s there so far
    yield driver
    if getattr(request.node, &quot;rep_call&quot;, None) and request.node.rep_call.failed:
        # A crash on a phone shows up only as &quot;element not found&quot;. Print the real cause.
        crash = [e[&quot;message&quot;] for e in driver.get_log(&quot;logcat&quot;) if &quot; E AndroidRuntime&quot; in e[&quot;message&quot;]]
        print(&quot;\n&quot;.join(crash[:4]) or &quot;no crash in logcat&quot;)
        driver.save_screenshot(f&quot;{request.node.name}.png&quot;)
    driver.quit()
</code></pre>
<p>When a test fails, it prints the crash lines from the phone&#39;s log (<code>logcat</code>) and saves a screenshot. Step 5 shows why.</p>
<h2>Step 4: The tests</h2>
<p>The everyday checks, in <code>tests/test_checkout.py</code>. The last one is a boundary test: a tip far too long to be a real number.</p>
<pre tabindex="0"><code class="language-python">from appium.webdriver.common.appiumby import AppiumBy

PKG = &quot;com.example.foodnow:id/&quot;


def el(driver, name):
    return driver.find_element(AppiumBy.ID, PKG + name)


def add_tip(driver, amount):
    el(driver, &quot;tip&quot;).send_keys(str(amount))
    el(driver, &quot;add_tip&quot;).click()


def test_tip_is_added_to_the_total(driver):
    add_tip(driver, 50)
    assert el(driver, &quot;total&quot;).text == &quot;Total: 550&quot;


def test_remove_tip(driver):
    add_tip(driver, 50)
    el(driver, &quot;remove_tip&quot;).click()
    assert el(driver, &quot;total&quot;).text == &quot;Total: 500&quot;


def test_tip_over_200_is_refused(driver):
    add_tip(driver, 250)
    assert el(driver, &quot;status&quot;).text == &quot;Tip must be 0 to 200&quot;
    assert el(driver, &quot;total&quot;).text == &quot;Total: 500&quot;


def test_a_huge_tip_is_refused_not_a_crash(driver):
    add_tip(driver, 99999999999)
    assert el(driver, &quot;status&quot;).text == &quot;Tip must be 0 to 200&quot;
</code></pre>
<p>And one test that only makes sense on a phone, in <code>tests/test_mobile_only.py</code>: add a tip, turn the phone sideways, then pay.</p>
<pre tabindex="0"><code class="language-python">from appium.webdriver.common.appiumby import AppiumBy
from selenium.common.exceptions import StaleElementReferenceException
from selenium.webdriver.support.ui import WebDriverWait

from test_checkout import PKG, add_tip, el


def text_of(driver, name):
    &quot;&quot;&quot;Read a text, waiting out the moment Android rebuilds the screen.&quot;&quot;&quot;
    return WebDriverWait(driver, 5, ignored_exceptions=[StaleElementReferenceException]).until(
        lambda d: el(d, name).text
    )


def scroll_to(driver, name):
    &quot;&quot;&quot;Scroll until the element is on screen, like a person would. Fails if nothing scrolls.

    The dead zone keeps the swipe away from the screen edges. Without it, the swipe starts
    at the bottom edge, and Android&#39;s gesture navigation takes it as &quot;go to recent apps&quot;.
    &quot;&quot;&quot;
    driver.find_element(
        AppiumBy.ANDROID_UIAUTOMATOR,
        &quot;new UiScrollable(new UiSelector().scrollable(true)).setSwipeDeadZonePercentage(0.25)&quot;
        f&#39;.scrollIntoView(new UiSelector().resourceId(&quot;{PKG}{name}&quot;))&#39;,
    )
    return el(driver, name)


def test_tip_survives_rotating_the_phone(driver):
    add_tip(driver, 50)

    driver.orientation = &quot;LANDSCAPE&quot;

    assert text_of(driver, &quot;tip&quot;) == &quot;50&quot;
    assert text_of(driver, &quot;total&quot;) == &quot;Total: 550&quot;
    scroll_to(driver, &quot;pay&quot;).click()
    assert text_of(driver, &quot;status&quot;) == &quot;Order placed: 550&quot;
</code></pre>
<p><code>text_of</code> is there because rotating rebuilds the screen. Our first version read the text straight away, and failed with <code>StaleElementReferenceException: The element &#39;By.id: com.example.foodnow:id/tip&#39; does not exist in DOM anymore</code>: Appium had found the old screen just before Android threw it away. The two notes in <code>scroll_to</code> come from Step 7.</p>
<h2>Step 5: The run</h2>
<p>The Appium server runs inside the container, so we tell the tests where the app is <em>from the container&#39;s point of view</em>:</p>
<pre tabindex="0"><code class="language-text">$ APP=/apps/foodnow.apk python -m pytest tests --tb=short
============================= test session starts ==============================
platform linux -- Python 3.11.17, pytest-9.2.0, pluggy-1.7.0
rootdir: ~/foodnow-mobile
collected 5 items

tests/test_checkout.py ...F                                              [ 80%]
tests/test_mobile_only.py F                                              [100%]

=================================== FAILURES ===================================
____________________ test_a_huge_tip_is_refused_not_a_crash ____________________
tests/test_checkout.py:34: in test_a_huge_tip_is_refused_not_a_crash
    assert el(driver, &quot;status&quot;).text == &quot;Tip must be 0 to 200&quot;
           ^^^^^^^^^^^^^^^^^^^^
tests/test_checkout.py:7: in el
    return driver.find_element(AppiumBy.ID, PKG + name)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
E   selenium.common.exceptions.NoSuchElementException: Message: An element could not be located on the page using the given search parameters.
...
--------------------------- Captured stdout teardown ---------------------------
11-11 07:31:31.853 24324 24324 E AndroidRuntime: FATAL EXCEPTION: main
11-11 07:31:31.853 24324 24324 E AndroidRuntime: Process: com.example.foodnow, PID: 24324
11-11 07:31:31.853 24324 24324 E AndroidRuntime: java.lang.NumberFormatException: For input string: &quot;99999999999&quot;
11-11 07:31:31.853 24324 24324 E AndroidRuntime: 	at java.lang.Integer.parseInt(Integer.java:797)
_____________________ test_tip_survives_rotating_the_phone _____________________
tests/test_mobile_only.py:35: in test_tip_survives_rotating_the_phone
    assert text_of(driver, &quot;total&quot;) == &quot;Total: 550&quot;
E   AssertionError: assert &#39;Total: 500&#39; == &#39;Total: 550&#39;
E
E     - Total: 550
E     ?        -
E     + Total: 500
E     ?          +
--------------------------- Captured stdout teardown ---------------------------
no crash in logcat
=========================== short test summary info ============================
FAILED tests/test_checkout.py::test_a_huge_tip_is_refused_not_a_crash - selen...
FAILED tests/test_mobile_only.py::test_tip_survives_rotating_the_phone - Asse...
========================= 2 failed, 3 passed in 45.07s =========================
</code></pre>
<p>(We cut the lines from inside Selenium and the Appium server, marked <code>...</code>.)</p>
<h3>Bug 1: the app crashed</h3>
<p>The test&#39;s own error says only &quot;element not found&quot;. On a phone, that&#39;s what a crash looks like from the outside: the app is gone, so every element is gone. <strong>Without the crash lines from <code>logcat</code>, you&#39;d be looking for a wrong locator.</strong> The real cause is in the teardown output: <code>NumberFormatException: For input string: &quot;99999999999&quot;</code>.</p>
<p>The tip box is set to <code>inputType=&quot;number&quot;</code>, which shows a number keyboard but doesn&#39;t limit the length. <code>Integer.parseInt</code> can&#39;t hold a number larger than 2,147,483,647, so it throws, nothing catches it, and Android closes the app. The screenshot the fixture saved:</p>
<p><img src="https://provalab.io/blog/practice/mobile-crash-dialog.png" alt="The Android dialog &quot;FoodNow keeps stopping&quot;, with the options App info and Close app, over the home screen." loading="lazy"></p>
<p>The three everyday tests passed. Only the boundary test, a value at the extreme, found this. That&#39;s the same lesson as boundary value analysis anywhere, with a harsher result on a phone.</p>
<h3>Bug 2: the tip disappeared when the phone turned</h3>
<p>After rotating, the tip box still says 50, but the total says 500. If the customer taps Pay now, they pay 500 and the courier&#39;s tip is gone, while the screen still shows the 50 they typed.</p>
<p><img src="https://provalab.io/blog/practice/mobile-rotation-bug.png" alt="The FoodNow checkout in landscape. The tip box shows 50, but the total shows 500." loading="lazy"></p>
<p>When the phone rotates, Android destroys the screen (the <code>Activity</code>) and creates it again. Android saves the text of an <code>EditText</code> that has an id for you, so the tip box comes back with 50 in it. The <code>tip</code> variable in our code isn&#39;t saved, so it starts again at 0, and <code>showTotal()</code> shows 500. The two halves of the screen now disagree.</p>
<p>You can&#39;t find this by testing in one orientation, and a tester tapping through on a desk rarely turns the phone at exactly the wrong moment.</p>
<h2>Step 6: Fix both</h2>
<p>Save the tip when Android is about to destroy the screen, bring it back when it rebuilds it, and treat a number too long to read as too big:</p>
<pre tabindex="0"><code class="language-diff">     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         setContentView(R.layout.checkout);
+        if (savedInstanceState != null) {
+            tip = savedInstanceState.getInt(&quot;tip&quot;);  // the screen was rebuilt: bring the tip back
+        }
         EditText tipField = findViewById(R.id.tip);
@@
         findViewById(R.id.add_tip).setOnClickListener(v -&gt; {
             String text = tipField.getText().toString();
-            int value = text.isEmpty() ? 0 : Integer.parseInt(text);
+            int value;
+            try {
+                value = text.isEmpty() ? 0 : Integer.parseInt(text);
+            } catch (NumberFormatException e) {
+                value = Integer.MAX_VALUE;  // too long to be a number: treat it as too big
+            }
             if (value &gt; 200) {
@@
+    @Override
+    protected void onSaveInstanceState(Bundle outState) {
+        super.onSaveInstanceState(outState);
+        outState.putInt(&quot;tip&quot;, tip);
+    }
</code></pre>
<h3>Trap 1: the fix that never reached the phone</h3>
<p>We rebuilt the app and ran the tests again. Same two failures. The Appium server&#39;s log had the reason:</p>
<pre tabindex="0"><code class="language-text">[ADB] The version name of the installed &#39;com.example.foodnow&#39; is greater or equal to the application version name (&#39;1.0.0&#39; &gt;= &#39;1.0.0&#39;)
[ADB] There is no need to install/upgrade &#39;/apps/foodnow.apk&#39;
</code></pre>
<p>Appium saw an app with the same version already on the phone and <strong>skipped installing the new build</strong>. We were testing the old code. This is why the fixture in Step 3 sets <code>enforceAppInstall</code>. Without it, bump the version number on every build, or uninstall the app before the run.</p>
<p>With the new build actually installed:</p>
<pre tabindex="0"><code class="language-text">tests/test_checkout.py ....                                              [ 80%]
tests/test_mobile_only.py F                                              [100%]
...
tests/test_mobile_only.py:36: in test_tip_survives_rotating_the_phone
    scroll_to(driver, &quot;pay&quot;).click()
tests/test_mobile_only.py:21: in scroll_to
...
E   selenium.common.exceptions.NoSuchElementException: Message: An element could not be located on the page using the given search parameters.
========================= 1 failed, 4 passed in 50.07s =========================
</code></pre>
<p>The crash is fixed, and the total now survives rotation. But the test can&#39;t get to Pay.</p>
<h2>Step 7: Bug 3, the Pay button you can&#39;t reach</h2>
<p>We didn&#39;t plant this one. In landscape, the screen is 1,440 pixels tall, and the checkout is taller than that. Pay is below the bottom edge, and the layout is a plain <code>LinearLayout</code>, which doesn&#39;t scroll. <strong>In landscape, the customer can&#39;t pay at all.</strong> In portrait it fits, so the three everyday tests never noticed.</p>
<p>The fix is to wrap the layout in a <code>ScrollView</code>:</p>
<pre tabindex="0"><code class="language-xml">&lt;ScrollView xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    android:layout_width=&quot;match_parent&quot; android:layout_height=&quot;match_parent&quot;&gt;
&lt;LinearLayout
    android:layout_width=&quot;match_parent&quot; android:layout_height=&quot;wrap_content&quot;
    android:orientation=&quot;vertical&quot; android:padding=&quot;24dp&quot;&gt;
    ...
&lt;/LinearLayout&gt;
&lt;/ScrollView&gt;
</code></pre>
<h3>Trap 2: the swipe that left the app</h3>
<p>With the <code>ScrollView</code> in, our first version of <code>scroll_to</code>, without the dead zone, <em>still</em> failed. The screenshot showed why: the whole app had shrunk into a card in Android&#39;s recent-apps view.</p>
<p>The emulator uses <strong>gesture navigation</strong>: a swipe up from the bottom edge of the screen means &quot;go home&quot; or &quot;show recent apps&quot;. The <code>ScrollView</code> reaches the bottom edge, so the scroll swipe started right on it, and Android took it as a system gesture, not a scroll. <code>setSwipeDeadZonePercentage(0.25)</code> makes Appium start and end its swipes a quarter of the way in from the edges. Most new Android phones use gesture navigation by default, so this catches you on real devices too.</p>
<p>The full run, after all three fixes:</p>
<pre tabindex="0"><code class="language-text">$ APP=/apps/foodnow.apk python -m pytest tests
============================= test session starts ==============================
platform linux -- Python 3.11.17, pytest-9.2.0, pluggy-1.7.0
rootdir: ~/foodnow-mobile
collected 5 items

tests/test_checkout.py ....                                              [ 80%]
tests/test_mobile_only.py .                                              [100%]

============================== 5 passed in 53.88s ==============================
</code></pre>
<p>We ran it four more times on the final build: 5 passed every time, in 51 to 60 seconds. And to make sure the scroll test catches what it should, we put the old layout without the <code>ScrollView</code> back: the test failed again at <code>scroll_to</code>.</p>
<h2>Emulators or real phones?</h2>
<p><strong>Emulators</strong> are free, quick to reset and easy to run in CI. They&#39;re the right place for most functional tests, like these. <strong>Real phones</strong> find what emulators can&#39;t: a maker&#39;s changes to Android, real performance and battery use, the camera, fingerprint sensor and mobile network, and the odd screen shapes. Most teams run the bulk on emulators and a smaller set on a few real phones that their users actually have.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Testing only in portrait.</strong> Rotate in at least one test for every screen that takes input. Rotation is the cheapest way to find lost state.</li>
<li><strong>Reading &quot;element not found&quot; at face value.</strong> On a phone it often means the app crashed. Collect <code>logcat</code> and a screenshot on every failure, as the fixture in Step 3 does.</li>
<li><strong>Testing an old build without knowing it.</strong> Appium skips installing an app with the same version. Use <code>enforceAppInstall</code>, or change the version on every build.</li>
<li><strong>Fixed sleeps after rotating or navigating.</strong> Wait for the condition instead, ignoring stale elements, as <code>text_of</code> does.</li>
<li><strong>Swipes at the screen edges.</strong> Gesture navigation takes them. Keep swipes away from the edges.</li>
<li><strong>Locating by text or position.</strong> Text changes with language, and position changes with screen size. Use resource ids, or accessibility ids for things like icon buttons.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong>Part 7</strong> of Testing in Practice we look at <strong>contract testing</strong>: how to check that an app and the API it calls still agree, without running both together, using Pact.</p>
<p>ProvaLab&#39;s QA Infrastructure runs Appium tests like these on Android emulators and real phones, with a live view of the device while the test runs, and sends each result to the ProvaLab test management system. See it in the <a href="https://provalab.io/#stage-infra">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Test doubles (hands-on): mocks, stubs, fakes and spies, and how a mock lets a real bug through</title>
      <link>https://provalab.io/blog/test-doubles-mocks-stubs-fakes/</link>
      <guid isPermaLink="true">https://provalab.io/blog/test-doubles-mocks-stubs-fakes/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>test-doubles</category>
      <category>mocking</category>
      <category>pytest</category>
      <category>unit-testing</category>
      <description>Dummy, stub, spy, mock and fake, explained with one payment example. A real run: MagicMock tests pass with two bugs in the code, then fail when the code is fixed.</description>
      <content:encoded><![CDATA[<p>Your checkout code calls a payment provider. You don&#39;t want your tests to charge a real card, wait two seconds for the network, or fail because the provider&#39;s test server is down. So you replace the provider with something that pretends to be it. That stand-in is a <strong>test double</strong>, named after the stunt double who stands in for the actor.</p>
<p>Test doubles make tests fast and reliable. They can also make tests lie. A double only knows what you told it, and you told it the same thing you believed when you wrote the code. If that belief is wrong, the code and the double are wrong together, and the test passes.</p>
<p>This post shows the five kinds of double with one example. Then it runs a real experiment: the same checkout code, tested with four kinds of double, with two bugs planted in it. Every command and line of output below was run while writing this post.</p>
<h2>The five kinds of test double</h2>
<p>The names come from Gerard Meszaros&#39;s book <em>xUnit Test Patterns</em> (2007). People often call all of them &quot;mocks&quot;, but they do different jobs:</p>
<table tabindex="0">
<thead>
<tr>
<th>Double</th>
<th>What it does</th>
<th>FoodNow example</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Dummy</strong></td>
<td>Fills a parameter that the test never uses</td>
<td>A <code>logger=None</code> passed in only because the function needs one</td>
</tr>
<tr>
<td><strong>Stub</strong></td>
<td>Gives fixed answers to the calls the code makes</td>
<td>The payment provider always answers &quot;declined&quot;, so you can test the declined path</td>
</tr>
<tr>
<td><strong>Spy</strong></td>
<td>A stub that also records how it was called, for checks afterwards</td>
<td>Records the amount charged, so the test can check it was 55,000 paise</td>
</tr>
<tr>
<td><strong>Mock</strong></td>
<td>Set up with the calls it <em>expects</em>; the test fails if they don&#39;t happen</td>
<td>&quot;<code>charge</code> must be called exactly once&quot;</td>
</tr>
<tr>
<td><strong>Fake</strong></td>
<td>A working, simpler version of the real thing</td>
<td>An in-memory payment provider that approves, declines and remembers charges</td>
</tr>
</tbody></table>
<p>In Python, <code>unittest.mock.MagicMock</code> can act as a dummy, a stub, a spy or a mock depending on how you use it. A fake is usually a small class you write yourself.</p>
<h2>Step 1: The code under test</h2>
<p>You need Python 3.10 or newer and pytest:</p>
<pre tabindex="0"><code class="language-bash">mkdir foodnow-pay &amp;&amp; cd foodnow-pay
python -m venv .venv &amp;&amp; source .venv/bin/activate
pip install pytest==9.2.0
</code></pre>
<p>We used Python 3.11 and pytest 9.2.0 on Linux. <code>unittest.mock</code> is part of Python, so there&#39;s nothing else to install.</p>
<p><code>gateway.py</code> is the client for the payment provider. It sends one HTTP request and returns a <code>Payment</code> with a <code>status</code> of <code>&quot;succeeded&quot;</code> or <code>&quot;declined&quot;</code>. The amount is in paise, the smallest unit of the rupee, and every charge needs an idempotency key, so the provider can tell a retry from a new charge.</p>
<pre tabindex="0"><code class="language-python">import json
import os
import urllib.request
from dataclasses import dataclass

PAYMENTS_URL = os.environ.get(&quot;PAYMENTS_URL&quot;, &quot;https://api.payments.example&quot;)


@dataclass
class Payment:
    id: str
    status: str  # &quot;succeeded&quot; or &quot;declined&quot;


class PaymentGateway:
    &quot;&quot;&quot;Client for the payment provider&#39;s HTTP API.&quot;&quot;&quot;

    def charge(self, amount_paise: int, idempotency_key: str) -&gt; Payment:
        request = urllib.request.Request(
            f&quot;{PAYMENTS_URL}/v1/charges&quot;,
            data=json.dumps({&quot;amount&quot;: amount_paise, &quot;currency&quot;: &quot;INR&quot;}).encode(),
            headers={&quot;Content-Type&quot;: &quot;application/json&quot;, &quot;Idempotency-Key&quot;: idempotency_key},
            method=&quot;POST&quot;,
        )
        with urllib.request.urlopen(request, timeout=5) as response:
            body = json.load(response)
        return Payment(id=body[&quot;id&quot;], status=body[&quot;status&quot;])
</code></pre>
<p>(<code>.example</code> is a domain reserved for examples, so this URL never reaches anyone.)</p>
<p><code>checkout.py</code> charges the customer and marks the order paid or failed. The developer wrote it from memory of an older version of the client:</p>
<pre tabindex="0"><code class="language-python">from gateway import PaymentGateway


def pay_for_order(order, gateway=None):
    gateway = gateway or PaymentGateway()
    payment = gateway.charge(amount=order[&quot;total&quot;])
    if payment.ok:
        order[&quot;status&quot;] = &quot;paid&quot;
    else:
        order[&quot;status&quot;] = &quot;payment_failed&quot;
    return order
</code></pre>
<p>It has two bugs:</p>
<ul>
<li><strong>Bug A, wrong arguments.</strong> The client takes <code>amount_paise</code> and <code>idempotency_key</code>. This passes <code>amount=</code>, in rupees, and no key.</li>
<li><strong>Bug B, a field that isn&#39;t there.</strong> <code>Payment</code> has a <code>status</code>, not an <code>ok</code>.</li>
</ul>
<p>In production, the first bug makes every checkout crash before charging anyone. Fix only that one, and the second makes every checkout crash <em>after</em> the customer was charged, with the order still unpaid.</p>
<h2>Step 2: MagicMock: both bugs, all green</h2>
<p>The most common way to test this is a <code>MagicMock</code> in place of the gateway:</p>
<pre tabindex="0"><code class="language-python">from unittest.mock import MagicMock

from checkout import pay_for_order


def test_successful_payment_marks_the_order_paid():
    gateway = MagicMock()

    order = pay_for_order({&quot;id&quot;: 7, &quot;total&quot;: 550}, gateway)

    assert order[&quot;status&quot;] == &quot;paid&quot;
    gateway.charge.assert_called_once()


def test_declined_payment_marks_the_order_failed():
    gateway = MagicMock()
    gateway.charge.return_value.ok = False

    order = pay_for_order({&quot;id&quot;: 8, &quot;total&quot;: 550}, gateway)

    assert order[&quot;status&quot;] == &quot;payment_failed&quot;
</code></pre>
<pre tabindex="0"><code class="language-text">$ python -m pytest -q tests/test_with_magicmock.py
..                                                                       [100%]
2 passed in 0.02s
</code></pre>
<p>Both pass, with both bugs in the code. Two reasons:</p>
<ol>
<li><strong>A MagicMock accepts any call.</strong> <code>charge(amount=550)</code> is fine, and so is <code>charge(banana=True)</code>. It has no idea what the real <code>charge</code> looks like.</li>
<li><strong>Any field you ask a MagicMock for is another MagicMock, and a MagicMock counts as true.</strong> <code>payment.ok</code> doesn&#39;t exist on a real <code>Payment</code>, but on the mock it&#39;s there, and it&#39;s truthy, so the order is &quot;paid&quot;.</li>
</ol>
<p>Look at the second test again: <code>gateway.charge.return_value.ok = False</code>. The person who wrote the code believed a payment has an <code>ok</code> field, and they wrote that same belief into the test. <strong>The mock agrees with the code because the same person wrote both.</strong></p>
<h2>Step 3: An autospec mock catches the wrong arguments</h2>
<p><code>create_autospec</code> builds a mock from the real class. It only allows the methods the class has, called the way the class defines them:</p>
<pre tabindex="0"><code class="language-python">from unittest.mock import create_autospec

from checkout import pay_for_order
from gateway import PaymentGateway


def test_successful_payment_marks_the_order_paid():
    gateway = create_autospec(PaymentGateway, instance=True)

    order = pay_for_order({&quot;id&quot;: 7, &quot;total&quot;: 550}, gateway)

    assert order[&quot;status&quot;] == &quot;paid&quot;
</code></pre>
<pre tabindex="0"><code class="language-text">tests/test_with_autospec.py F                                            [100%]

=================================== FAILURES ===================================
_________________ test_successful_payment_marks_the_order_paid _________________
tests/test_with_autospec.py:10: in test_successful_payment_marks_the_order_paid
    order = pay_for_order({&quot;id&quot;: 7, &quot;total&quot;: 550}, gateway)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
checkout.py:6: in pay_for_order
    payment = gateway.charge(amount=order[&quot;total&quot;])
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.../lib/python3.11/unittest/mock.py:1122: in __call__
    self._mock_check_sig(*args, **kwargs)
.../lib/python3.11/unittest/mock.py:131: in checksig
    sig.bind(*args, **kwargs)
.../lib/python3.11/inspect.py:3195: in bind
    return self._bind(args, kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^
.../lib/python3.11/inspect.py:3110: in _bind
    raise TypeError(msg) from None
E   TypeError: missing a required argument: &#39;amount_paise&#39;
</code></pre>
<p>(We shortened the path to Python&#39;s own files to <code>.../lib/python3.11</code>.)</p>
<p>Bug A is caught. The developer fixes the call:</p>
<pre tabindex="0"><code class="language-python">    payment = gateway.charge(
        amount_paise=order[&quot;total&quot;] * 100, idempotency_key=f&quot;order-{order[&#39;id&#39;]}&quot;
    )
</code></pre>
<p>Now the autospec test <strong>passes</strong>, and so do the MagicMock tests:</p>
<pre tabindex="0"><code class="language-text">$ python -m pytest -q tests
...                                                                      [100%]
3 passed in 0.04s
</code></pre>
<p>Bug B is still there. An autospec mock checks how you <em>call</em> a method, but what the method returns is still a plain MagicMock, so <code>payment.ok</code> is still a truthy mock.</p>
<h2>Step 4: A stub with a real answer catches the missing field</h2>
<p>The fix for the test is to make the double answer with a real <code>Payment</code>, the same type of object the real client returns. Now it&#39;s a stub:</p>
<pre tabindex="0"><code class="language-python">from unittest.mock import create_autospec

from checkout import pay_for_order
from gateway import Payment, PaymentGateway


def stub_gateway(status):
    &quot;&quot;&quot;Checks the call matches the real signature, and answers with a real Payment.&quot;&quot;&quot;
    gateway = create_autospec(PaymentGateway, instance=True)
    gateway.charge.return_value = Payment(id=&quot;ch_1&quot;, status=status)
    return gateway


def test_successful_payment_marks_the_order_paid():
    order = pay_for_order({&quot;id&quot;: 7, &quot;total&quot;: 550}, stub_gateway(&quot;succeeded&quot;))
    assert order[&quot;status&quot;] == &quot;paid&quot;


def test_declined_payment_marks_the_order_failed():
    order = pay_for_order({&quot;id&quot;: 8, &quot;total&quot;: 550}, stub_gateway(&quot;declined&quot;))
    assert order[&quot;status&quot;] == &quot;payment_failed&quot;
</code></pre>
<p>Both tests fail with <code>AttributeError: &#39;Payment&#39; object has no attribute &#39;ok&#39;</code>: exactly what would happen in production.</p>
<h2>Step 5: A fake, for the behaviour you can&#39;t stub</h2>
<p>Some things a stub can&#39;t test, because they depend on what happened before. The provider uses the idempotency key so that a double click, or a retry after a timeout, doesn&#39;t charge the customer twice. To test that your code sends the <em>same</em> key for the same order, you need a double that remembers. That&#39;s a fake:</p>
<pre tabindex="0"><code class="language-python">from gateway import Payment


class FakeGateway:
    &quot;&quot;&quot;Works like the provider&#39;s sandbox, in memory: no network, same answers.&quot;&quot;&quot;

    def __init__(self, decline=False):
        self.decline = decline
        self.charges = {}  # idempotency key -&gt; (amount, Payment)

    def charge(self, amount_paise: int, idempotency_key: str) -&gt; Payment:
        if not isinstance(amount_paise, int) or amount_paise &lt;= 0:
            raise ValueError(f&quot;amount must be a positive whole number of paise, got {amount_paise!r}&quot;)
        if idempotency_key in self.charges:  # a retry: same answer, no second charge
            return self.charges[idempotency_key][1]
        status = &quot;declined&quot; if self.decline else &quot;succeeded&quot;
        payment = Payment(id=f&quot;ch_{len(self.charges) + 1}&quot;, status=status)
        self.charges[idempotency_key] = (amount_paise, payment)
        return payment
</code></pre>
<p>The tests read like a description of the business rules. The first also uses the fake as a spy: it checks the amount that was charged.</p>
<pre tabindex="0"><code class="language-python">from checkout import pay_for_order
from fakes import FakeGateway


def test_successful_payment_marks_the_order_paid():
    gateway = FakeGateway()

    order = pay_for_order({&quot;id&quot;: 7, &quot;total&quot;: 550}, gateway)

    assert order[&quot;status&quot;] == &quot;paid&quot;
    assert [amount for amount, _ in gateway.charges.values()] == [55000]


def test_declined_payment_marks_the_order_failed():
    gateway = FakeGateway(decline=True)

    order = pay_for_order({&quot;id&quot;: 8, &quot;total&quot;: 550}, gateway)

    assert order[&quot;status&quot;] == &quot;payment_failed&quot;


def test_paying_twice_charges_the_customer_once():
    gateway = FakeGateway()

    pay_for_order({&quot;id&quot;: 9, &quot;total&quot;: 550}, gateway)
    pay_for_order({&quot;id&quot;: 9, &quot;total&quot;: 550}, gateway)  # double click, or a retry

    assert len(gateway.charges) == 1
</code></pre>
<p>With Bug A fixed and Bug B still in, all three fail at the same line:</p>
<pre tabindex="0"><code class="language-text">tests/test_with_fake.py FFF                                              [ 66%]
...
checkout.py:9: in pay_for_order
    if payment.ok:
       ^^^^^^^^^^
E   AttributeError: &#39;Payment&#39; object has no attribute &#39;ok&#39;
</code></pre>
<h2>Step 6: Fix the code, and watch the mocks fail</h2>
<p>The fix for Bug B is one line:</p>
<pre tabindex="0"><code class="language-python">    if payment.status == &quot;succeeded&quot;:
</code></pre>
<p>The stub and fake tests now pass. And here is the part most people don&#39;t expect. We ran the old MagicMock and autospec tests against the <strong>correct</strong> code:</p>
<pre tabindex="0"><code class="language-text">tests/test_with_autospec.py F                                            [ 16%]
tests/test_with_fake.py ...                                              [ 66%]
tests/test_with_magicmock.py F.                                          [100%]

=================================== FAILURES ===================================
_________________ test_successful_payment_marks_the_order_paid _________________
tests/test_with_autospec.py:12: in test_successful_payment_marks_the_order_paid
    assert order[&quot;status&quot;] == &quot;paid&quot;
E   AssertionError: assert &#39;payment_failed&#39; == &#39;paid&#39;
E
E     - paid
E     + payment_failed
_________________ test_successful_payment_marks_the_order_paid _________________
tests/test_with_magicmock.py:11: in test_successful_payment_marks_the_order_paid
    assert order[&quot;status&quot;] == &quot;paid&quot;
E   AssertionError: assert &#39;payment_failed&#39; == &#39;paid&#39;
E
E     - paid
E     + payment_failed
=========================== short test summary info ============================
FAILED tests/test_with_autospec.py::test_successful_payment_marks_the_order_paid
FAILED tests/test_with_magicmock.py::test_successful_payment_marks_the_order_paid
========================= 2 failed, 4 passed in 0.04s ==========================
</code></pre>
<p><strong>The mock tests passed with the bugs and failed with the fix.</strong> A MagicMock&#39;s <code>status</code> is a MagicMock, not <code>&quot;succeeded&quot;</code>, so the correct code marks the order as failed. These tests weren&#39;t checking the payment logic. They were checking that the code matched the mock.</p>
<p><img src="https://provalab.io/blog/practice/test-doubles-matrix.svg" alt="A table of results. Rows: MagicMock, autospec mock, stub, fake. Columns: bug A wrong arguments, bug B a field that isn't there, and the correct code. MagicMock missed both bugs and failed on the correct code. The autospec mock caught bug A, missed bug B, and failed on the correct code. The stub and the fake both caught both bugs and passed on the correct code." width="880" height="460" loading="lazy"></p>
<h2>Step 7: The patch that missed</h2>
<p>There&#39;s one more common trap. This test doesn&#39;t pass a gateway in. It uses <code>patch</code> to replace the <code>PaymentGateway</code> class while the test runs:</p>
<pre tabindex="0"><code class="language-python">from unittest.mock import patch

from checkout import pay_for_order
from gateway import Payment


@patch(&quot;gateway.PaymentGateway&quot;)
def test_paid_with_patched_gateway(gateway_class):
    gateway_class.return_value.charge.return_value = Payment(id=&quot;ch_1&quot;, status=&quot;succeeded&quot;)

    order = pay_for_order({&quot;id&quot;: 7, &quot;total&quot;: 550})  # no gateway passed: checkout makes its own

    assert order[&quot;status&quot;] == &quot;paid&quot;
</code></pre>
<pre tabindex="0"><code class="language-text">gateway.py:25: in charge
    with urllib.request.urlopen(request, timeout=5) as response:
...
E   urllib.error.URLError: &lt;urlopen error [Errno -2] Name or service not known&gt;
=========================== short test summary info ============================
FAILED tests/test_with_patch.py::test_paid_with_patched_gateway - urllib.erro...
</code></pre>
<p>(We cut the part of the traceback inside Python&#39;s network code.)</p>
<p>The test tried to make a <strong>real network request</strong>. <code>checkout.py</code> did <code>from gateway import PaymentGateway</code> when it was imported, so it has its own name for the class. Patching <code>gateway.PaymentGateway</code> changes the name in <code>gateway</code>, not the one <code>checkout</code> uses. Here the URL goes nowhere, so the test failed. With a real provider URL in your test settings, it would have sent a real charge to the provider.</p>
<p><strong>Patch where the name is used, not where it&#39;s defined:</strong></p>
<pre tabindex="0"><code class="language-python">@patch(&quot;checkout.PaymentGateway&quot;)
</code></pre>
<pre tabindex="0"><code class="language-text">$ python -m pytest tests/test_with_stub.py tests/test_with_fake.py tests/test_with_patch.py
============================= test session starts ==============================
platform linux -- Python 3.11.17, pytest-9.2.0, pluggy-1.7.0
rootdir: ~/foodnow-pay
collected 6 items

tests/test_with_stub.py ..                                               [ 33%]
tests/test_with_fake.py ...                                              [ 83%]
tests/test_with_patch.py .                                               [100%]

============================== 6 passed in 0.03s ===============================
</code></pre>
<p>Passing the gateway in as an argument, as the other tests do, avoids the trap completely. That&#39;s one reason people design code so its dependencies can be passed in.</p>
<h2>Which double should you use?</h2>
<ul>
<li><strong>Answers only (stub):</strong> most of the time. Use <code>create_autospec</code> so calls are checked against the real signature, and return real objects, not mocks.</li>
<li><strong>Behaviour across calls (fake):</strong> retries, idempotency, &quot;add to cart, then check out&quot;. Write it once and share it across tests.</li>
<li><strong>&quot;Was it called?&quot; (mock or spy):</strong> only when the call <em>is</em> the result, such as &quot;an email was sent&quot; or &quot;the card was charged once&quot;. Prefer checking the outcome when you can.</li>
<li><strong>Nothing at all:</strong> your own code, simple data classes, and pure functions. Don&#39;t replace what you&#39;re testing.</li>
</ul>
<h2>How to keep doubles honest</h2>
<p>A double is a copy of your beliefs about the real thing. Keep it close to the real thing:</p>
<ol>
<li><strong>Return real objects.</strong> A <code>Payment</code>, not a MagicMock, so missing fields fail like they would in production.</li>
<li><strong>Use autospec.</strong> <code>create_autospec</code>, or <code>patch(..., autospec=True)</code>, so a changed signature breaks the test, not production.</li>
<li><strong>Test the fake against the real thing.</strong> Run a small set of the same tests against both the fake and the provider&#39;s sandbox, for example nightly. If the sandbox starts answering differently, the fake is out of date. This is the idea behind <strong>contract testing</strong>.</li>
<li><strong>Keep some integration tests.</strong> A test suite where everything external is replaced proves the parts work as you imagine. A few tests against the real database or sandbox prove they work as they are. <a href="https://provalab.io/blog/testing-database-changes-sql-pytest/">Part 4</a> found three bugs that no mock could have.</li>
</ol>
<h2>Common mistakes</h2>
<ul>
<li><strong>MagicMock everywhere.</strong> It accepts every call and every field, so it can&#39;t fail for the reasons production does. Step 2 passed with two bugs.</li>
<li><strong>Mocks that return mocks.</strong> <code>gateway.charge.return_value.ok = False</code> writes your assumption into the test. Return the real type.</li>
<li><strong>Checking calls instead of results.</strong> <code>assert_called_once_with(...)</code> for every step ties the test to how the code works, not what it achieves. Refactor the code and every test breaks, even when nothing is wrong.</li>
<li><strong>Patching in the wrong place.</strong> Patch where the name is looked up, as Step 7 shows.</li>
<li><strong>Faking your own code.</strong> If the test replaces the thing it&#39;s meant to test, it tests the double.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong>Part 6</strong> of Testing in Practice we test <strong>a mobile app</strong>: an Android emulator, Appium and pytest, on the FoodNow app.</p>
<p>ProvaLab&#39;s pytest plugin sends every result from tests like these to the ProvaLab test management system, with the history of each test across runs. So when the tests that were green with the bug go red with the fix, as in Step 6, you see that change in the test&#39;s history next to the code change that caused it. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Test estimation (hands-on): three-point estimates, a 100,000-run simulation, and why adding up 'most likely' fails</title>
      <link>https://provalab.io/blog/test-estimation-three-point-pert/</link>
      <guid isPermaLink="true">https://provalab.io/blog/test-estimation-three-point-pert/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>test-estimation</category>
      <category>test-planning</category>
      <category>python</category>
      <category>qa</category>
      <description>How to estimate testing from the work, not a guess. Three-point estimates, a simulation and past results show why the sum of 'most likely' times is met only 9% of the time.</description>
      <content:encoded><![CDATA[<p>&quot;How long will testing take?&quot; is one of the first questions a tester is asked about a new feature, and one of the hardest. Answer too high and you&#39;re told testing is slow. Answer too low and you&#39;ll be working weekends, or releasing with half the tests not run.</p>
<p>Most test estimates are wrong in the same direction: too low. Not because testers are bad at it, but because of how the numbers are put together. This post estimates the testing for a new FoodNow feature step by step, and uses a short Python script to show where the usual method goes wrong and what to say instead.</p>
<p>The task estimates below are an example of what a test team might give for this feature. They&#39;re judgement, as all estimates are. Every number <strong>calculated</strong> from them, including the 100,000-run simulation, comes from running the scripts in this post.</p>
<h2>Why test estimates go wrong</h2>
<ul>
<li><strong>Adding up the &quot;most likely&quot; times.</strong> Each task&#39;s most likely time is more likely than any other single value, but tasks overrun more than they underrun. Add nine of them up, and the total is very unlikely. Step 3 measures how unlikely.</li>
<li><strong>Forgetting the loop.</strong> Testing isn&#39;t one pass. You find bugs, they&#39;re fixed, you retest, and you run the regression tests again. Teams estimate the first pass and forget the rest.</li>
<li><strong>Testing waits on other work.</strong> A late build, a broken test environment or missing test data stops testing, and the clock keeps running.</li>
<li><strong>Turning hours into days too simply.</strong> Nobody tests for eight hours a day. Meetings, questions, and context switching take their share.</li>
</ul>
<h2>The techniques, briefly</h2>
<table tabindex="0">
<thead>
<tr>
<th>Technique</th>
<th>How it works</th>
<th>Good for</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Work breakdown</strong></td>
<td>List every testing task, estimate each one</td>
<td>Always. It&#39;s the base for the others.</td>
</tr>
<tr>
<td><strong>Three-point (PERT)</strong></td>
<td>Optimistic, most likely and pessimistic time per task</td>
<td>Showing uncertainty, not hiding it</td>
</tr>
<tr>
<td><strong>Historical data</strong></td>
<td>How long similar work took, and how far off past estimates were</td>
<td>Correcting your team&#39;s own bias</td>
</tr>
<tr>
<td><strong>Planning poker / Wideband Delphi</strong></td>
<td>Several people estimate separately, then discuss the differences</td>
<td>Finding tasks someone misunderstood</td>
</tr>
<tr>
<td><strong>Ratio of development effort</strong></td>
<td>&quot;Testing is about 30–40% of development&quot;</td>
<td>A sanity check, never the estimate</td>
</tr>
</tbody></table>
<p>This post uses the first three together.</p>
<h2>Step 1: Break the work down</h2>
<p>The feature is <strong>group ordering</strong>: an office manager starts a group order, colleagues add their own dishes from a link, and the manager pays once. Here are the testing tasks, each with three estimates in hours:</p>
<ul>
<li><strong>O</strong> (optimistic): if everything goes well.</li>
<li><strong>M</strong> (most likely): what usually happens.</li>
<li><strong>P</strong> (pessimistic): if things go badly, but not a disaster.</li>
</ul>
<p>Save them as <code>tasks.csv</code>:</p>
<pre tabindex="0"><code class="language-csv">task,optimistic,likely,pessimistic
Read the stories and ask questions,3,4,8
Write test cases (about 40),10,14,24
Set up test data: offices and 30 test users,3,5,12
Automate the 10 most important cases,12,16,30
First run of all cases,8,10,16
Report bugs and talk them through,3,5,12
Retest fixes and run regression,6,10,28
Exploratory testing (3 sessions),5,6,9
Test summary report,2,3,5
</code></pre>
<p>Look at &quot;Retest fixes and run regression&quot;: 6 hours if the build is clean, 28 if it isn&#39;t. That one line is where most test estimates go wrong, and here it has its own row with an honest range.</p>
<p>Break tasks down until each one is small enough to picture doing, roughly a day or less. &quot;Test group ordering: 70 hours&quot; can&#39;t be checked. &quot;Write about 40 test cases: 14 hours&quot; can: that&#39;s about 20 minutes a case.</p>
<h2>Step 2: Three-point estimates</h2>
<p>The <strong>PERT</strong> formula turns three points into one expected value, with the most likely time counting four times:</p>
<pre tabindex="0"><code class="language-text">expected = (O + 4M + P) / 6
</code></pre>
<p>Save this as <code>estimate.py</code>:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;Three-point (PERT) estimate for a test effort, plus a simulation of the total.&quot;&quot;&quot;
import csv
import random
import statistics
import sys

rows = list(csv.DictReader(open(sys.argv[1], encoding=&quot;utf-8&quot;)))
tasks = [(r[&quot;task&quot;], float(r[&quot;optimistic&quot;]), float(r[&quot;likely&quot;]), float(r[&quot;pessimistic&quot;])) for r in rows]

print(f&quot;{&#39;task&#39;:&lt;42}{&#39;O&#39;:&gt;5}{&#39;M&#39;:&gt;5}{&#39;P&#39;:&gt;5}{&#39;PERT&#39;:&gt;7}&quot;)
for name, o, m, p in tasks:
    print(f&quot;{name:&lt;42}{o:&gt;5.0f}{m:&gt;5.0f}{p:&gt;5.0f}{(o + 4 * m + p) / 6:&gt;7.1f}&quot;)

sum_likely = sum(m for _, _, m, _ in tasks)
sum_pert = sum((o + 4 * m + p) / 6 for _, o, m, p in tasks)
print(f&quot;\nsum of &#39;most likely&#39;:      {sum_likely:6.1f} hours&quot;)
print(f&quot;sum of PERT expected:      {sum_pert:6.1f} hours&quot;)


def pert_sample(o: float, m: float, p: float) -&gt; float:
    &quot;&quot;&quot;One draw from the PERT (beta) distribution with these three points.&quot;&quot;&quot;
    if p == o:
        return m
    a = 1 + 4 * (m - o) / (p - o)
    b = 1 + 4 * (p - m) / (p - o)
    return o + random.betavariate(a, b) * (p - o)


random.seed(13)
RUNS = 100_000
totals = sorted(sum(pert_sample(o, m, p) for _, o, m, p in tasks) for _ in range(RUNS))


def pct(q: float) -&gt; float:
    return totals[int(q * RUNS) - 1]


print(f&quot;\nsimulated {RUNS:,} projects:&quot;)
for q in (0.5, 0.8, 0.95):
    print(f&quot;  {int(q * 100)}% chance of finishing within {pct(q):6.1f} hours&quot;)
within = sum(t &lt;= sum_likely for t in totals) / RUNS
print(f&quot;  chance of finishing within the sum of &#39;most likely&#39; ({sum_likely:.0f} h): {within:.1%}&quot;)
print(f&quot;  mean {statistics.mean(totals):.1f} h, standard deviation {statistics.stdev(totals):.1f} h&quot;)
if len(sys.argv) &gt; 2:
    deadline = float(sys.argv[2])
    print(f&quot;  chance of finishing within {deadline:.0f} hours: {sum(t &lt;= deadline for t in totals) / RUNS:.1%}&quot;)
</code></pre>
<p>It needs only Python, no packages. The first part:</p>
<pre tabindex="0"><code class="language-text">$ python estimate.py tasks.csv
task                                          O    M    P   PERT
Read the stories and ask questions            3    4    8    4.5
Write test cases (about 40)                  10   14   24   15.0
Set up test data: offices and 30 test users    3    5   12    5.8
Automate the 10 most important cases         12   16   30   17.7
First run of all cases                        8   10   16   10.7
Report bugs and talk them through             3    5   12    5.8
Retest fixes and run regression               6   10   28   12.3
Exploratory testing (3 sessions)              5    6    9    6.3
Test summary report                           2    3    5    3.2

sum of &#39;most likely&#39;:        73.0 hours
sum of PERT expected:        81.3 hours
</code></pre>
<p>Every PERT value is higher than the most likely value, because every task can overrun by more than it can underrun. Retesting can take 18 hours longer than likely, but only 4 hours less. Added up, the gap is <strong>8.3 hours</strong>, more than a full working day.</p>
<h2>Step 3: Simulate it 100,000 times</h2>
<p>A single number hides the question that matters: <strong>how likely is it that we finish within X?</strong> To answer it, the script plays out the project 100,000 times. In each run, every task takes a random time drawn from its own three-point range, with times near the most likely value more common. Then it looks at how the totals spread out:</p>
<pre tabindex="0"><code class="language-text">simulated 100,000 projects:
  50% chance of finishing within   81.0 hours
  80% chance of finishing within   86.6 hours
  95% chance of finishing within   92.1 hours
  chance of finishing within the sum of &#39;most likely&#39; (73 h): 8.7%
  mean 81.3 h, standard deviation 6.3 h
</code></pre>
<p><img src="https://provalab.io/blog/practice/estimate-simulation.svg" alt="A histogram of 100,000 simulated test efforts. Most totals fall between 70 and 95 hours, centred near 81. The sum of the most likely estimates, 73 hours, is far to the left: only 8.7% of runs finish by then. 80% finish by 86.6 hours." width="880" height="470" loading="lazy"></p>
<p>The usual estimate, the sum of the most likely times, is <strong>73 hours</strong>. In the simulation, the project finished within 73 hours <strong>8.7% of the time</strong>. That&#39;s roughly one feature in eleven. The number most teams give is a number they&#39;ll almost always miss, even when every single task estimate was reasonable.</p>
<p>The number to give is the <strong>80% point: 86.6 hours.</strong> It&#39;s not padding. It&#39;s the same estimates, added up correctly.</p>
<h2>Step 4: When tasks slip together</h2>
<p>The simulation above assumes each task goes well or badly on its own. In reality, many causes hit several tasks at once. A late, buggy build makes the first run slower, the bug reports longer and the retesting longer, all together. This script looks at the other extreme, where every task goes equally well or badly. Save it as <code>together.py</code>, with <code>pert_sample</code> copied into <code>estimate_lib.py</code>:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;If every task goes well or badly together (one shared cause, like a late build),
the total&#39;s percentiles are the sums of each task&#39;s percentiles.&quot;&quot;&quot;
import csv
import random
import sys

from estimate_lib import pert_sample

rows = list(csv.DictReader(open(sys.argv[1], encoding=&quot;utf-8&quot;)))
tasks = [(float(r[&quot;optimistic&quot;]), float(r[&quot;likely&quot;]), float(r[&quot;pessimistic&quot;])) for r in rows]
random.seed(13)
RUNS = 100_000
per_task = [sorted(pert_sample(o, m, p) for _ in range(RUNS)) for o, m, p in tasks]
for q in (0.5, 0.8, 0.95):
    print(f&quot;  {int(q * 100)}%: {sum(s[int(q * RUNS) - 1] for s in per_task):6.1f} hours&quot;)
</code></pre>
<pre tabindex="0"><code class="language-text">tasks independent:
  50% chance of finishing within   81.0 hours
  80% chance of finishing within   86.6 hours
  95% chance of finishing within   92.1 hours
tasks move together:
  50%:   79.3 hours
  80%:   95.5 hours
  95%:  111.0 hours
</code></pre>
<p>The middle barely moves, but the 80% point rises from 86.6 to 95.5 hours, and the 95% point from 92 to 111. Real projects sit somewhere between these two cases. So when you give the 80% number, also say what would push it up: &quot;86 hours, or up to 95 if the build arrives late or unstable.&quot;</p>
<h2>Step 5: Check against your own history</h2>
<p>The best correction for your estimates is your own past. If your team tracks estimated and actual testing time per feature, compare them. <em>The history below is an example, written for this post. Use your own.</em> Save it as <code>history.csv</code>:</p>
<pre tabindex="0"><code class="language-csv">feature,estimated_hours,actual_hours
Saved addresses,40,52
Promo codes,56,61
Order tracking map,72,104
Rate your rider,24,27
Split the bill,64,90
Order for later,48,58
</code></pre>
<p>And <code>calibrate.py</code>:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;How far off were past test estimates? Use it to adjust the next one.&quot;&quot;&quot;
import csv
import statistics
import sys

rows = list(csv.DictReader(open(sys.argv[1], encoding=&quot;utf-8&quot;)))
ratios = []
for r in rows:
    ratio = float(r[&quot;actual_hours&quot;]) / float(r[&quot;estimated_hours&quot;])
    ratios.append(ratio)
    print(f&quot;{r[&#39;feature&#39;]:&lt;22}{r[&#39;estimated_hours&#39;]:&gt;6} est {r[&#39;actual_hours&#39;]:&gt;6} actual   x{ratio:.2f}&quot;)
print(f&quot;\nmedian actual/estimate: x{statistics.median(ratios):.2f}  (range x{min(ratios):.2f} to x{max(ratios):.2f})&quot;)
if len(sys.argv) &gt; 2:
    est = float(sys.argv[2])
    print(f&quot;a new estimate of {est:.0f} h, adjusted: {est * statistics.median(ratios):.0f} h &quot;
          f&quot;(range {est * min(ratios):.0f} to {est * max(ratios):.0f} h)&quot;)
</code></pre>
<pre tabindex="0"><code class="language-text">$ python calibrate.py history.csv 73
Saved addresses           40 est     52 actual   x1.30
Promo codes               56 est     61 actual   x1.09
Order tracking map        72 est    104 actual   x1.44
Rate your rider           24 est     27 actual   x1.12
Split the bill            64 est     90 actual   x1.41
Order for later           48 est     58 actual   x1.21

median actual/estimate: x1.25  (range x1.09 to x1.44)
a new estimate of 73 h, adjusted: 92 h (range 80 to 105 h)
</code></pre>
<p>In this example, the team&#39;s past estimates were all too low, by 9% to 44%. Applied to the sum of most likely times, 73 hours becomes about 92. That&#39;s close to the simulation&#39;s 80% and 95% points, which is what you&#39;d expect if the past estimates were also sums of most likely times. Two different methods pointing to the same range is a good sign. If they disagree badly, find out why before you give a number.</p>
<p>The biggest overruns in the example are the features with the most integration: a map and split payments. That&#39;s worth knowing for the next estimate too.</p>
<h2>Step 6: &quot;Can you do it in two weeks?&quot;</h2>
<p>The release plan has 10 working days for testing. At about 6 focused testing hours a day, that&#39;s 60 hours:</p>
<pre tabindex="0"><code class="language-text">$ python estimate.py tasks.csv 60
...
  chance of finishing within 60 hours: 0.0%
</code></pre>
<p>None of the 100,000 simulated projects finished in 60 hours. So the honest answer to &quot;can you do it in two weeks?&quot; is no, not with this scope.</p>
<p>The usual next move is to cut scope. Say we automate only the 4 riskiest cases instead of 10, and run one exploratory session instead of three:</p>
<pre tabindex="0"><code class="language-diff">-Automate the 10 most important cases,12,16,30
+Automate the 4 riskiest cases,5,7,13
-Exploratory testing (3 sessions),5,6,9
+Exploratory testing (1 session),2,2,3
</code></pre>
<pre tabindex="0"><code class="language-text">$ python estimate.py tasks-cut.csv 60
...
sum of PERT expected:        67.2 hours

simulated 100,000 projects:
  50% chance of finishing within   66.8 hours
  80% chance of finishing within   71.8 hours
  95% chance of finishing within   76.8 hours
  chance of finishing within the sum of &#39;most likely&#39; (60 h): 9.3%
  mean 67.1 h, standard deviation 5.5 h
  chance of finishing within 60 hours: 9.3%
</code></pre>
<p>Look at that carefully. After the cut, the most likely times add up to <strong>exactly 60 hours</strong>. On a spreadsheet, the plan now fits. In the simulation, it fits <strong>9.3% of the time.</strong> This is how most unrealistic test plans are made: cut until the most likely numbers add up to the deadline, and call it agreed.</p>
<p>What you can honestly offer instead:</p>
<ul>
<li><strong>Time:</strong> &quot;With the reduced scope, 72 hours, about 12 days, gets us to 80% confidence.&quot;</li>
<li><strong>More scope cut:</strong> say which further tests you&#39;d drop, and what risk that leaves untested. That&#39;s a business decision, so let the business make it.</li>
<li><strong>Fewer unknowns:</strong> the widest range is in retesting. An earlier, more stable build narrows it more than any amount of overtime.</li>
<li><strong>More people:</strong> helps the tasks that can be split, like running test cases, but not reading the stories or the final report. Two people don&#39;t make it twice as fast.</li>
</ul>
<h2>How to say the number</h2>
<p>An estimate is a forecast, so say it like one:</p>
<blockquote>
<p>&quot;Testing group ordering: about 87 hours, 15 working days, at 80% confidence. Up to 95 if the build is late or unstable. That includes two rounds of retesting. With 4 automated cases instead of 10 and one exploratory session, it&#39;s 72 hours, 12 days.&quot;</p>
</blockquote>
<ul>
<li><strong>A range or a confidence,</strong> not a single date.</li>
<li><strong>The assumptions:</strong> what&#39;s included, what the build needs to be like.</li>
<li><strong>The options</strong> if it&#39;s too long, with what each one costs.</li>
</ul>
<p>Then <strong>re-estimate as you learn.</strong> After the first test run, you know how buggy the build is, and the retest range narrows. Tell people when your number changes, and why. An updated estimate is information, not failure.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Adding up the most likely times.</strong> In our example, that total was met 8.7% of the time.</li>
<li><strong>One line for &quot;testing&quot;.</strong> Break it down, so each task can be checked and nothing is forgotten.</li>
<li><strong>No line for retesting and regression.</strong> It&#39;s often the biggest and most uncertain task.</li>
<li><strong>Eight-hour days.</strong> Use the hours you actually spend testing, usually 5 to 6.</li>
<li><strong>Cutting scope until the most likely times fit the deadline.</strong> That gave a 9.3% plan. Check the chance, not the sum.</li>
<li><strong>Never comparing estimates with actuals.</strong> Without history, the same mistake repeats on every feature.</li>
<li><strong>Treating the estimate as a promise.</strong> Re-estimate after the first test run, when you know much more.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong>Part 14</strong> of Testing in Practice we look at <strong>the test summary report and test metrics</strong>: what to put in the report at the end of testing, which numbers help people decide, and which ones only look good.</p>
<p>ProvaLab&#39;s test management system records how long every test result took to run and when each test run started and finished, so the history behind Step 5 builds up as you test instead of being reconstructed from memory. Test plans keep the scope and the dates together, so when scope is cut, the plan shows what was dropped. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>The test summary report (hands-on): 96.8% passed, not ready to release, and the metrics that tell you why</title>
      <link>https://provalab.io/blog/test-summary-report-metrics/</link>
      <guid isPermaLink="true">https://provalab.io/blog/test-summary-report-metrics/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>test-reporting</category>
      <category>test-metrics</category>
      <category>pytest</category>
      <category>cypress</category>
      <description>How to write a test summary report people can decide from. A real report built from five suites' results: a 96.8% pass rate, two high-risk gaps, and a test that fails every night.</description>
      <content:encoded><![CDATA[<p>At the end of a test cycle, someone has to decide: release or not? The <strong>test summary report</strong> is what they decide from. ISTQB now calls it a <strong>test completion report</strong>, and the ISO/IEC/IEEE 29119-3 standard has a template for it. Whatever its name, it has one job: give the people who decide an honest picture of what was tested, what was found, what wasn&#39;t tested, and what that means for the release.</p>
<p>Most summary reports fail that job the same way. They lead with a big number, such as &quot;96.8% of tests passed&quot;, and the big number hides the one thing that matters.</p>
<p>This post builds a summary report from real test results. We ran the actual test suites from earlier parts of this series as one FoodNow release candidate, with five tools&#39; results in one place. A short Python script turns the results into a report by requirement and risk. Every result and every line of output below is real. Two things were deliberate:</p>
<ul>
<li>We put one bug from <a href="https://provalab.io/blog/first-cypress-test/">Part 12</a> back into the release candidate.</li>
<li>We wrote the list of requirements and their risks for this example. In a real project, they come from your test plan.</li>
</ul>
<h2>What goes in a test summary report</h2>
<table tabindex="0">
<thead>
<tr>
<th>Section</th>
<th>What it says</th>
</tr>
</thead>
<tbody><tr>
<td><strong>What was tested</strong></td>
<td>The build, the environments, the dates, and the scope from the test plan</td>
</tr>
<tr>
<td><strong>Results by requirement and risk</strong></td>
<td>Which requirements pass, which fail, which have no tests</td>
</tr>
<tr>
<td><strong>Open defects</strong></td>
<td>By severity, with what each one means for a user</td>
</tr>
<tr>
<td><strong>What wasn&#39;t tested, and why</strong></td>
<td>Scope cuts, blocked tests, environments you didn&#39;t have</td>
</tr>
<tr>
<td><strong>Changes from the plan</strong></td>
<td>Dates, scope, approach</td>
</tr>
<tr>
<td><strong>Metrics, with context</strong></td>
<td>A few numbers that help the decision</td>
</tr>
<tr>
<td><strong>Recommendation</strong></td>
<td>Release, release with conditions, or don&#39;t. And why.</td>
</tr>
</tbody></table>
<p>The order matters. Lead with what affects the decision, not with what&#39;s easiest to count.</p>
<h2>Step 1: Collect the results in one format</h2>
<p>Every test tool can write <strong>JUnit XML</strong>, a simple format that most CI systems and reporting tools read. We ran five suites from this series against the release candidate:</p>
<pre tabindex="0"><code class="language-bash"># Parts 8, 11, 7 and 9: pytest suites
pytest tests --junitxml=results/pricing-bdd.xml           # BDD pricing scenarios
pytest tests --junitxml=results/order-page-selenium.xml   # Selenium order page
pytest tests --junitxml=results/orders-contract.xml       # Pact contract
pytest tests --junitxml=results/order-for-later.xml       # order for later

# Part 12: Cypress, with its built-in JUnit reporter
npx cypress run --spec cypress/e2e/order.cy.js \
  --reporter junit --reporter-options &quot;mochaFile=results/order-page-cypress.xml&quot;
</code></pre>
<pre tabindex="0"><code class="language-text">9 passed in 0.26s
4 passed in 14.59s
6 passed, 1 warning in 6.30s
1 failed, 4 passed, 1 warning in 1.88s
 │ Passing: 5 │
 │ Failing: 1 │
</code></pre>
<p>The Cypress failure we expected: it&#39;s the bug we put back. The order-for-later failure we didn&#39;t. We&#39;ll come to it in Step 4.</p>
<h2>Step 2: The number everyone asks for</h2>
<p>Save this as <code>summary.py</code>. It reads every JUnit file in a folder, and a list of requirements:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;Build a test summary from JUnit XML files, by requirement and risk, not just by count.&quot;&quot;&quot;
import csv
import glob
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path

results_dir, req_file = sys.argv[1], sys.argv[2]
triage_file = sys.argv[3] if len(sys.argv) &gt; 3 else None

# 1. Every test result, as &quot;suite::test name&quot;.
tests = {}
for f in sorted(glob.glob(f&quot;{results_dir}/*.xml&quot;)):
    suite = Path(f).stem
    for tc in ET.parse(f).iter(&quot;testcase&quot;):
        problem = tc.find(&quot;failure&quot;)
        if problem is None:
            problem = tc.find(&quot;error&quot;)
        message = &quot;&quot;
        if problem is not None:
            message = (problem.get(&quot;message&quot;) or problem.text or &quot;&quot;).strip().splitlines()[0][:90]
        tests[f&quot;{suite}::{tc.get(&#39;name&#39;)}&quot;] = (&quot;failed&quot; if problem is not None else &quot;passed&quot;, message)

triage = {}
if triage_file:
    for row in csv.DictReader(open(triage_file, encoding=&quot;utf-8&quot;)):
        triage[row[&quot;test&quot;]] = (row[&quot;kind&quot;], row[&quot;note&quot;])

total = len(tests)
passed = sum(1 for s, _ in tests.values() if s == &quot;passed&quot;)
print(f&quot;Tests run: {total}   passed: {passed}   failed: {total - passed}   pass rate: {passed / total:.1%}\n&quot;)

# 2. By requirement.
print(f&quot;{&#39;req&#39;:&lt;4}{&#39;risk&#39;:&lt;8}{&#39;tests&#39;:&gt;6}{&#39;failed&#39;:&gt;8}  status       requirement&quot;)
blockers, mapped = [], set()
for r in csv.DictReader(open(req_file, encoding=&quot;utf-8&quot;)):
    ids = [t for t in tests if r[&quot;tests&quot;] and re.search(r[&quot;tests&quot;], t, flags=re.IGNORECASE)]
    mapped.update(ids)
    failed = [t for t in ids if tests[t][0] == &quot;failed&quot;]
    product_failures = [t for t in failed if triage.get(t, (&quot;product bug&quot;,))[0] != &quot;test bug&quot;]
    if not ids:
        status = &quot;NOT TESTED&quot;
    elif product_failures:
        status = &quot;FAILING&quot;
    elif failed:
        status = &quot;test issue&quot;
    else:
        status = &quot;passing&quot;
    print(f&quot;{r[&#39;id&#39;]:&lt;4}{r[&#39;risk&#39;]:&lt;8}{len(ids):&gt;6}{len(failed):&gt;8}  {status:&lt;12} {r[&#39;requirement&#39;]}&quot;)
    if r[&quot;risk&quot;] == &quot;high&quot; and status in (&quot;NOT TESTED&quot;, &quot;FAILING&quot;):
        blockers.append(f&quot;{r[&#39;id&#39;]} ({r[&#39;risk&#39;]} risk) {status.lower()}: {r[&#39;requirement&#39;]}&quot;)

unmapped = [t for t in tests if t not in mapped]
if unmapped:
    print(f&quot;\n{len(unmapped)} test(s) not linked to any requirement:&quot;)
    for t in unmapped:
        print(&quot;  &quot;, t)

# 3. Failures, with their triage.
print(&quot;\nFailures:&quot;)
for t, (status, msg) in tests.items():
    if status == &quot;failed&quot;:
        kind, note = triage.get(t, (&quot;not triaged&quot;, &quot;&quot;))
        print(f&quot;  [{kind}] {t}\n      {msg}&quot; + (f&quot;\n      triage: {note}&quot; if note else &quot;&quot;))

# 4. Against the exit criterion: every high-risk requirement tested, with no product failures.
print(&quot;\nReady to release?&quot;, &quot;NO&quot; if blockers else &quot;YES&quot;)
for b in blockers:
    print(&quot;  -&quot;, b)
</code></pre>
<p>The first line of its output is the number most reports lead with:</p>
<pre tabindex="0"><code class="language-text">Tests run: 30   passed: 28   failed: 2   pass rate: 93.3%
</code></pre>
<p>93.3% sounds nearly ready. It tells you nothing about <em>what</em> failed, or what was never tested.</p>
<h2>Step 3: Link every test to a requirement and its risk</h2>
<p>A release decision is about requirements, not tests. Which things that customers need are known to work, which are known to be broken, and which nobody checked? This <code>requirements.csv</code> lists FoodNow&#39;s requirements for this release, each with its risk and a pattern that picks out its tests:</p>
<pre tabindex="0"><code class="language-csv">id,requirement,risk,tests
R1,Totals and delivery fee are right,high,pricing-bdd::.*(delivery|big_order)|order-page-.*::.*(delivery|500)
R2,Promo codes work and are capped,medium,pricing-bdd::.*(first50|unknown_code|discount)
R3,The basket can be edited,medium,order-page-.*::.*(remov|empty)
R4,Customers are told when the price can&#39;t be worked out,high,order-page-cypress::.*price service
R5,Order for later offers only future slots in kitchen time,high,order-for-later::.*
R6,The app and the orders API agree,high,orders-contract::.*
R7,Group order: colleagues add dishes and one person pays,high,
</code></pre>
<p>R7 has no pattern, because no tests exist for it yet. Group ordering is in the release, but its tests weren&#39;t written in time.</p>
<p>Our first run of the script printed this:</p>
<pre tabindex="0"><code class="language-text">R1  high         4       0  passing      Totals and delivery fee are right
R2  medium       0       0  NOT TESTED   Promo codes work and are capped
R3  medium       0       0  NOT TESTED   The basket can be edited
R4  high         0       0  NOT TESTED   Customers are told when the price can&#39;t be worked out
...
15 test(s) not linked to any requirement:
   order-page-cypress::FoodNow order charges delivery on a small order
   ...
</code></pre>
<p>Half the tests weren&#39;t linked to anything. That was a bug in our first version of the script: it used <code>re.fullmatch</code>, so a pattern had to match the whole test name. The fixed version above uses <code>re.search</code>. The lesson is the check itself: <strong>always list the tests that aren&#39;t linked to any requirement.</strong> Without that list, a mistake in the linking would quietly turn &quot;tested&quot; into &quot;NOT TESTED&quot;, or the other way round, and the report would be wrong without anyone knowing.</p>
<p>With the fix, every test is linked:</p>
<pre tabindex="0"><code class="language-text">$ python summary.py results requirements.csv
Tests run: 30   passed: 28   failed: 2   pass rate: 93.3%

req risk     tests  failed  status       requirement
R1  high         9       0  passing      Totals and delivery fee are right
R2  medium       4       0  passing      Promo codes work and are capped
R3  medium       4       0  passing      The basket can be edited
R4  high         2       1  FAILING      Customers are told when the price can&#39;t be worked out
R5  high         5       1  FAILING      Order for later offers only future slots in kitchen time
R6  high         6       0  passing      The app and the orders API agree
R7  high         0       0  NOT TESTED   Group order: colleagues add dishes and one person pays

Failures:
  [not triaged] order-for-later::test_page_offers_slots_from_now
      IndexError: list index out of range
  [not triaged] order-page-cypress::when the price service misbehaves says so when the price service is down
      Timed out retrying after 4000ms: expected &#39;&lt;span#total&gt;&#39; to contain text &#39;couldn\&#39;t work o

Ready to release? NO
  - R4 (high risk) failing: Customers are told when the price can&#39;t be worked out
  - R5 (high risk) failing: Order for later offers only future slots in kitchen time
  - R7 (high risk) not tested: Group order: colleagues add dishes and one person pays
</code></pre>
<p>Now the picture is different. Two failures, each in a high-risk requirement, and a high-risk feature that&#39;s in the release with no tests at all.</p>
<h2>Step 4: Triage every failure</h2>
<p>A failed test means something went wrong. It doesn&#39;t yet say whether it&#39;s the product, the test, or the environment. Every failure needs a person to look at it before it goes into a report.</p>
<p><strong>The Cypress failure</strong> is the bug we put back: when the price service fails, the page shows &quot;Total: ₹undefined&quot;. A product bug.</p>
<p><strong>The order-for-later failure</strong> we didn&#39;t plan. The test is from <a href="https://provalab.io/blog/user-acceptance-testing-uat/">Part 9</a>:</p>
<pre tabindex="0"><code class="language-python">def test_page_offers_slots_from_now():
    html = client.get(&quot;/&quot;).text
    first = fn.slots(fn.now())[0]
    assert f&quot;{first:%H:%M} –&quot; in html
</code></pre>
<p>We ran the suites at 23:09. The kitchen closes at 23:00, so there are no slots, and <code>[0]</code> fails with <code>IndexError</code>. The page itself was right: it said the kitchen was closed. We checked exactly when this test fails:</p>
<pre tabindex="0"><code class="language-text">time   slots  first
21:44  1      22:30
21:45  1      22:30
21:46  0      -
07:59  0      -
08:00  1      11:00
</code></pre>
<p>It fails from 21:46 to 07:59, every day: more than ten hours out of every twenty-four. When we wrote and ran it for Part 9, it was evening, and it passed. Anyone running the nightly build would have seen it fail every night. This is a <strong>test bug</strong>: the test depends on the real clock.</p>
<p>The triage decisions go in <code>triage.csv</code>, so the report shows them:</p>
<pre tabindex="0"><code class="language-csv">test,kind,note
order-for-later::test_page_offers_slots_from_now,test bug,&quot;Fails from 21:46 to 07:59 every day: no slots are left, and the test reads slot [0]. The page correctly says the kitchen is closed.&quot;
order-page-cypress::when the price service misbehaves says so when the price service is down,product bug,&quot;Regression of the Part 12 fix: the page shows &#39;Total: ₹undefined&#39; when the price service returns 500.&quot;
</code></pre>
<h2>Step 5: Fix the test, and run the report again</h2>
<p>The fix is to give the test a fixed time instead of the real clock, and to add a test for the closed kitchen, which is real behaviour worth checking:</p>
<pre tabindex="0"><code class="language-python">def test_page_offers_slots_from_now(monkeypatch):
    monkeypatch.setattr(fn, &quot;now&quot;, lambda: datetime(2026, 11, 11, 19, 10))
    html = client.get(&quot;/&quot;).text
    assert &quot;20:00 – 20:30&quot; in html


def test_page_says_when_the_kitchen_is_closed(monkeypatch):
    monkeypatch.setattr(fn, &quot;now&quot;, lambda: datetime(2026, 11, 11, 23, 9))
    assert &quot;The kitchen is closed&quot; in client.get(&quot;/&quot;).text
</code></pre>
<pre tabindex="0"><code class="language-text">6 passed, 1 warning in 0.51s
</code></pre>
<p>And the report, with the triage:</p>
<pre tabindex="0"><code class="language-text">$ python summary.py results requirements.csv triage.csv
Tests run: 31   passed: 30   failed: 1   pass rate: 96.8%

req risk     tests  failed  status       requirement
R1  high         9       0  passing      Totals and delivery fee are right
R2  medium       4       0  passing      Promo codes work and are capped
R3  medium       4       0  passing      The basket can be edited
R4  high         2       1  FAILING      Customers are told when the price can&#39;t be worked out
R5  high         6       0  passing      Order for later offers only future slots in kitchen time
R6  high         6       0  passing      The app and the orders API agree
R7  high         0       0  NOT TESTED   Group order: colleagues add dishes and one person pays

Failures:
  [product bug] order-page-cypress::when the price service misbehaves says so when the price service is down
      Timed out retrying after 4000ms: expected &#39;&lt;span#total&gt;&#39; to contain text &#39;couldn\&#39;t work o
      triage: Regression of the Part 12 fix: the page shows &#39;Total: ₹undefined&#39; when the price service returns 500.

Ready to release? NO
  - R4 (high risk) failing: Customers are told when the price can&#39;t be worked out
  - R7 (high risk) not tested: Group order: colleagues add dishes and one person pays
</code></pre>
<p><img src="https://provalab.io/blog/practice/summary-pass-rate-vs-risk.svg" alt="The same 31 test results counted two ways. By count: 96.8% passed. By requirement and risk: five requirements pass, one high-risk requirement is failing, and one high-risk requirement, group ordering, has no tests. Verdict: not ready to release." width="880" height="470" loading="lazy"></p>
<p>The pass rate went <strong>up</strong>, from 93.3% to 96.8%. The answer is still <strong>no</strong>. One high-risk requirement fails, and one isn&#39;t tested at all. Notice also that R7 doesn&#39;t appear anywhere in the pass rate: a feature with no tests can&#39;t fail a test. That&#39;s why &quot;what wasn&#39;t tested&quot; needs its own section.</p>
<h2>Step 6: Write the report</h2>
<p>The script gives the facts. The report adds what they mean and what you recommend. Here&#39;s the report we&#39;d write for this release candidate. The build name, dates and fix times are made up for the example; the results are the real ones above:</p>
<blockquote>
<p><strong>FoodNow release 26.11, test summary</strong></p>
<p><strong>Tested:</strong> build 26.11-rc2 on the staging environment, 10–11 November. Five suites, 31 automated tests, linked to 7 requirements.</p>
<p><strong>Recommendation: don&#39;t release yet.</strong> Two high-risk problems:</p>
<ol>
<li><strong>When the price service fails, customers see &quot;Total: ₹undefined&quot;</strong> and can still place the order (R4). This is a regression: it was fixed in the previous release. Fix and retest: about half a day.</li>
<li><strong>Group ordering (R7) has no tests.</strong> It&#39;s new in this release, and it handles payment for several people. We don&#39;t know whether it works. Testing it needs about 3 days, or the feature can be switched off for this release.</li>
</ol>
<p><strong>Working:</strong> totals and delivery fees, promo codes, basket editing, order for later, and the app–API contract all pass (29 tests).</p>
<p><strong>Fixed during testing:</strong> one test failed every night between 21:46 and 07:59 because it used the real clock. It&#39;s been fixed, and a test for the closed kitchen was added.</p>
<p><strong>If we release anyway:</strong> customers whose price check fails will see a broken total, and group orders go out untested.</p>
</blockquote>
<p>Short, specific and honest about what&#39;s not known. Someone who reads only the first three lines knows what to decide.</p>
<h2>Metrics: which ones help</h2>
<table tabindex="0">
<thead>
<tr>
<th>Metric</th>
<th>Helps when...</th>
<th>Misleads when...</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Pass rate</strong></td>
<td>Shown per requirement and risk, with the failures named</td>
<td>Shown alone. 96.8% hid a broken price display and an untested feature.</td>
</tr>
<tr>
<td><strong>Requirement coverage by risk</strong></td>
<td>It lists the high-risk requirements with no tests</td>
<td>Tests are linked carelessly, so the list is wrong. Check the unlinked tests.</td>
</tr>
<tr>
<td><strong>Open defects by severity</strong></td>
<td>Each one says what it means for a user</td>
<td>It&#39;s only a count. Ten cosmetic bugs are not worse than one payment bug.</td>
</tr>
<tr>
<td><strong>Flaky tests</strong></td>
<td>You track tests that fail and pass without a code change</td>
<td>They&#39;re quietly re-run until green. Our clock test would have looked flaky.</td>
</tr>
<tr>
<td><strong>Number of tests</strong></td>
<td>Rarely</td>
<td>Used as a target. More tests isn&#39;t better testing.</td>
</tr>
<tr>
<td><strong>Defect detection percentage</strong></td>
<td>Measured after release: how many bugs did testing find, compared with users?</td>
<td>Measured before release, when users haven&#39;t found theirs yet</td>
</tr>
<tr>
<td><strong>Code coverage</strong></td>
<td>It shows code that no test runs</td>
<td>Read as &quot;how well tested&quot;. <a href="https://provalab.io/blog/test-coverage-explained/">It isn&#39;t</a>.</td>
</tr>
</tbody></table>
<p>A warning that applies to every metric: when a measure becomes a target, people improve the measure instead of the thing. This is known as Goodhart&#39;s law. Make a pass rate a target, and tests that fail get deleted or skipped. Use metrics to ask questions, not to grade people.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Leading with the pass rate.</strong> Lead with the recommendation and the high-risk problems.</li>
<li><strong>Not reporting what wasn&#39;t tested.</strong> An untested feature never shows up in a pass rate. Give it its own line.</li>
<li><strong>Reporting failures without triage.</strong> A test bug, an environment problem and a product bug look the same in a raw count.</li>
<li><strong>No link from tests to requirements.</strong> Without it, you can&#39;t say which customer needs are safe, only how many tests passed.</li>
<li><strong>Not checking the linking.</strong> List the unlinked tests. Our first run had 15.</li>
<li><strong>Tests that depend on the real clock.</strong> Ours failed for 10 hours a day. Give tests a fixed time.</li>
<li><strong>Writing for testers.</strong> The reader is deciding whether to release. Say what each problem means for a customer.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong>Part 15</strong> of Testing in Practice we look at <strong>localization testing</strong>: the FoodNow app in Hindi, German and Arabic, with Playwright switching languages and number formats, and the bugs that only appear when the text isn&#39;t in English.</p>
<p>ProvaLab&#39;s test management system builds this kind of report from your test runs without a script. Requirements are linked to the test cases that check them, the requirement coverage view shows which ones pass, fail or have no tests, and issues are linked to the failing results with their severity. Results from pytest, Cypress, Playwright and other frameworks arrive through ProvaLab&#39;s plugins, so five tools end up in one report. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Testing database changes (hands-on): transactions, race conditions and migrations with SQL and pytest</title>
      <link>https://provalab.io/blog/testing-database-changes-sql-pytest/</link>
      <guid isPermaLink="true">https://provalab.io/blog/testing-database-changes-sql-pytest/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>database-testing</category>
      <category>pytest</category>
      <category>sql</category>
      <category>postgresql</category>
      <description>What to check when code writes to a database. A real PostgreSQL run: a refused order that still saves, 20 customers buying 10 wraps, and a migration that fails on real data.</description>
      <content:encoded><![CDATA[<p>Most tests check what the code <em>says</em>. The API returned 409, the function raised <code>OutOfStock</code>, the page showed &quot;Sorry, out of stock&quot;. But what the code <em>says</em> and what it <em>saved</em> can be different things. A test that only checks the response will pass while the database fills up with half-written orders, oversold stock and totals that don&#39;t add up.</p>
<p><strong>Database testing</strong> checks what is actually stored after your code runs. This post builds a small order system for FoodNow, our food delivery example, on a real PostgreSQL database. It then tests it with pytest and plain SQL.</p>
<p>Every command and every line of output below was run while writing this post. Two bugs are planted in the code. The usual tests pass with both of them in. The database tests catch both, and a third bug that lives in a migration.</p>
<h2>What to check when code writes to a database</h2>
<table tabindex="0">
<thead>
<tr>
<th>Check</th>
<th>The question it answers</th>
</tr>
</thead>
<tbody><tr>
<td><strong>The right rows</strong></td>
<td>Did it save what it should, with the right values?</td>
</tr>
<tr>
<td><strong>Nothing else</strong></td>
<td>Did anything change that shouldn&#39;t have? Extra rows, other customers&#39; data, stock of other items?</td>
</tr>
<tr>
<td><strong>All or nothing</strong></td>
<td>When it fails halfway, does it undo the first half?</td>
</tr>
<tr>
<td><strong>Constraints</strong></td>
<td>Does the database itself refuse bad data, even if the code forgets to check?</td>
</tr>
<tr>
<td><strong>Two at once</strong></td>
<td>What happens when two requests change the same row at the same moment?</td>
</tr>
<tr>
<td><strong>Migrations</strong></td>
<td>Does a schema change work on a database that already has data in it?</td>
</tr>
</tbody></table>
<p>The first one is what most people test. The bugs are usually in the other five.</p>
<h2>Step 1: Set up (5 minutes)</h2>
<p>You need Docker and Python 3.10 or newer. Start PostgreSQL and make an empty database for the tests:</p>
<pre tabindex="0"><code class="language-bash">docker run -d --name foodnow-db -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:15
docker exec foodnow-db createdb -U postgres foodnow_test

mkdir foodnow-db &amp;&amp; cd foodnow-db
python -m venv .venv &amp;&amp; source .venv/bin/activate
pip install &quot;psycopg[binary]==3.3.8&quot; pytest==9.2.0
</code></pre>
<p>We used PostgreSQL 15, Python 3.11, psycopg 3.3.8 and pytest 9.2.0 on Linux.</p>
<p>The schema, <code>schema.sql</code>, has three tables: the menu, the orders, and which dishes are in each order. Prices are in whole rupees. The <code>CHECK</code> rules match the business rules: the tip is 0 to 200, and stock can&#39;t go below zero.</p>
<pre tabindex="0"><code class="language-sql">CREATE TABLE menu_items (
    id     serial PRIMARY KEY,
    name   text    NOT NULL,
    price  integer NOT NULL CHECK (price &gt;= 0),
    stock  integer NOT NULL CHECK (stock &gt;= 0)
);

CREATE TABLE orders (
    id         serial PRIMARY KEY,
    customer   text    NOT NULL,
    tip        integer NOT NULL DEFAULT 0 CHECK (tip BETWEEN 0 AND 200),
    total      integer NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
    order_id integer NOT NULL REFERENCES orders(id),
    item_id  integer NOT NULL REFERENCES menu_items(id),
    qty      integer NOT NULL CHECK (qty &gt; 0),
    PRIMARY KEY (order_id, item_id)
);
</code></pre>
<h2>Step 2: The code under test</h2>
<p><code>orders.py</code> places an order: work out the total, save the order, then take each dish out of stock and save it as an order line. If a dish has run out, it raises <code>OutOfStock</code>, and the app shows &quot;Sorry, out of stock&quot;.</p>
<pre tabindex="0"><code class="language-python">class OutOfStock(Exception):
    pass


def place_order(conn, customer, items, tip=0):
    &quot;&quot;&quot;items is {menu_item_id: quantity}. Returns the new order id.&quot;&quot;&quot;
    total = tip
    for item_id, qty in items.items():
        price = conn.execute(
            &quot;SELECT price FROM menu_items WHERE id = %s&quot;, (item_id,)
        ).fetchone()[0]
        total += price * qty

    order_id = conn.execute(
        &quot;INSERT INTO orders (customer, tip, total) VALUES (%s, %s, %s) RETURNING id&quot;,
        (customer, tip, total),
    ).fetchone()[0]

    for item_id, qty in items.items():
        stock = conn.execute(
            &quot;SELECT stock FROM menu_items WHERE id = %s&quot;, (item_id,)
        ).fetchone()[0]
        if stock &lt; qty:
            raise OutOfStock(f&quot;item {item_id}: {stock} left, {qty} wanted&quot;)
        conn.execute(
            &quot;UPDATE menu_items SET stock = %s WHERE id = %s&quot;, (stock - qty, item_id)
        )
        conn.execute(
            &quot;INSERT INTO order_items (order_id, item_id, qty) VALUES (%s, %s, %s)&quot;,
            (order_id, item_id, qty),
        )
    return order_id
</code></pre>
<p>It reads well, and it has two bugs. See if you can spot them before the tests do.</p>
<h2>Step 3: A fresh database for every test</h2>
<p>Tests that share a database start depending on each other: one test leaves a row behind, and another one fails because of it, but only when they run in a certain order. The fix is to give every test a clean start. This fixture, in <code>tests/conftest.py</code>, drops everything, creates the schema again, and puts three dishes on the menu:</p>
<pre tabindex="0"><code class="language-python">import os
import pathlib

import psycopg
import pytest

DB_URL = os.environ.get(&quot;DB_URL&quot;, &quot;postgresql://postgres:postgres@localhost:5432/foodnow_test&quot;)
SCHEMA = (pathlib.Path(__file__).parent.parent / &quot;schema.sql&quot;).read_text()


@pytest.fixture
def db():
    &quot;&quot;&quot;A fresh, empty schema for every test, with three dishes on the menu.&quot;&quot;&quot;
    with psycopg.connect(DB_URL, autocommit=True) as conn:
        conn.execute(&quot;DROP SCHEMA public CASCADE; CREATE SCHEMA public;&quot;)
        conn.execute(SCHEMA)
        conn.execute(
            &quot;INSERT INTO menu_items (name, price, stock) VALUES &quot;
            &quot;(&#39;Paneer wrap&#39;, 250, 10), (&#39;Masala dosa&#39;, 180, 10), (&#39;Mango lassi&#39;, 90, 1)&quot;
        )
        yield conn
</code></pre>
<p>Creating the schema again takes a few milliseconds here. With hundreds of tables, teams often start each test inside a transaction and roll it back at the end instead, which is faster. That doesn&#39;t work for the race test in Step 6, though, because there other connections need to see committed data. We use the simple version for everything.</p>
<p><strong>Use the same database as production.</strong> It&#39;s tempting to test with SQLite because it needs no server. But SQLite behaves differently: for example, it doesn&#39;t enforce foreign keys unless you turn them on with <code>PRAGMA foreign_keys = ON</code>, and its locking is nothing like PostgreSQL&#39;s. The race in Step 6 is about exactly that. A test on a different database tests a different program.</p>
<h2>Step 4: The usual tests pass</h2>
<p>These are the tests most teams write first. The happy path checks the stored order, the stored lines and the stock, which is already better than checking only the return value. The second test checks that an order for too many lassis is refused.</p>
<pre tabindex="0"><code class="language-python">import pytest

from orders import OutOfStock, place_order

WRAP, DOSA, LASSI = 1, 2, 3


def test_order_is_saved_with_the_right_total(db):
    order_id = place_order(db, &quot;asha&quot;, {WRAP: 2}, tip=50)

    row = db.execute(&quot;SELECT customer, tip, total FROM orders WHERE id = %s&quot;, (order_id,)).fetchone()
    assert row == (&quot;asha&quot;, 50, 550)
    items = db.execute(&quot;SELECT item_id, qty FROM order_items WHERE order_id = %s&quot;, (order_id,)).fetchall()
    assert items == [(WRAP, 2)]
    assert db.execute(&quot;SELECT stock FROM menu_items WHERE id = %s&quot;, (WRAP,)).fetchone()[0] == 8


def test_out_of_stock_order_is_refused(db):
    with pytest.raises(OutOfStock):
        place_order(db, &quot;ravi&quot;, {LASSI: 2})
</code></pre>
<pre tabindex="0"><code class="language-text">$ python -m pytest -q tests
..                                                                       [100%]
2 passed in 0.14s
</code></pre>
<p>Green. And the second test is right: the order <em>is</em> refused. It just doesn&#39;t ask what the refusal left behind.</p>
<h2>Step 5: Bug 1, the order that was refused and saved anyway</h2>
<p>Ravi orders a wrap and two lassis. There is only one lassi, so he gets &quot;Sorry, out of stock&quot;. We ran exactly that, after Asha&#39;s order from Step 4, and then looked in the database:</p>
<pre tabindex="0"><code class="language-text"> id | customer | tip | total |          created_at
----+----------+-----+-------+-------------------------------
  1 | asha     |  50 |   550 | 2026-11-10 17:47:06.680316+00
  2 | ravi     |   0 |   430 | 2026-11-10 17:47:06.69123+00

 order_id | item_id | qty
----------+---------+-----
        1 |       1 |   2
        2 |       1 |   1

 id |    name     | price | stock
----+-------------+-------+-------
  1 | Paneer wrap |   250 |     7
  2 | Masala dosa |   180 |    10
  3 | Mango lassi |    90 |     1
</code></pre>
<p>Ravi was told his order failed, but <strong>order 2 is in the database</strong>. It has a total of 430 (one wrap plus two lassis) and only one line, a wrap, and the wrap was taken out of stock. The kitchen may cook it. A report of the day&#39;s sales will count 430 rupees nobody paid.</p>
<p>The cause: psycopg&#39;s <code>autocommit=True</code> (Django&#39;s default mode works the same way) saves each statement the moment it runs. The order row and the wrap were saved before the lassi check failed, and nothing undid them.</p>
<p>The test that catches it checks what the refusal left behind:</p>
<pre tabindex="0"><code class="language-python">def test_refused_order_leaves_no_trace(db):
    with pytest.raises(OutOfStock):
        place_order(db, &quot;ravi&quot;, {WRAP: 1, LASSI: 2})

    assert db.execute(&quot;SELECT count(*) FROM orders&quot;).fetchone()[0] == 0
    assert db.execute(&quot;SELECT count(*) FROM order_items&quot;).fetchone()[0] == 0
    assert db.execute(&quot;SELECT stock FROM menu_items WHERE id = %s&quot;, (WRAP,)).fetchone()[0] == 10
</code></pre>
<h3>The same bug, found with SQL</h3>
<p>Tests catch it before release. But what if it already happened in production? You can look for the damage with a query. This one, <code>checks.sql</code>, finds every order whose total doesn&#39;t match its lines plus the tip:</p>
<pre tabindex="0"><code class="language-sql">SELECT o.id, o.customer, o.total,
       o.tip + coalesce(sum(m.price * oi.qty), 0) AS items_plus_tip
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
LEFT JOIN menu_items m ON m.id = oi.item_id
GROUP BY o.id
HAVING o.total &lt;&gt; o.tip + coalesce(sum(m.price * oi.qty), 0);
</code></pre>
<pre tabindex="0"><code class="language-text"> id | customer | total | items_plus_tip
----+----------+-------+----------------
  2 | ravi     |   430 |            250
(1 row)
</code></pre>
<p>Queries like this are worth keeping. Run them after a data migration, or every night against a copy of production: they find broken data no matter which bug caused it. (This one uses today&#39;s prices, so in a real system you would store the price on each order line.)</p>
<h2>Step 6: Bug 2, twenty customers and ten wraps</h2>
<p>There are 10 paneer wraps. At lunchtime 20 customers press Pay at the same moment, each for one wrap. Ten should get their wrap and ten should get &quot;out of stock&quot;.</p>
<p>A test can do that: 20 threads, each with its own database connection, held at a barrier so they all start together.</p>
<pre tabindex="0"><code class="language-python">import threading

import psycopg

from orders import OutOfStock, place_order
from conftest import DB_URL

WRAP = 1  # 10 in stock


def test_twenty_customers_cannot_buy_ten_wraps(db):
    results = []
    start = threading.Barrier(20)

    def customer(n):
        with psycopg.connect(DB_URL, autocommit=True) as conn:
            start.wait()  # everyone clicks Pay at the same moment
            try:
                place_order(conn, f&quot;customer{n}&quot;, {WRAP: 1})
                results.append(&quot;ok&quot;)
            except OutOfStock:
                results.append(&quot;out of stock&quot;)

    threads = [threading.Thread(target=customer, args=(n,)) for n in range(20)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

    sold = db.execute(&quot;SELECT coalesce(sum(qty), 0) FROM order_items WHERE item_id = %s&quot;, (WRAP,)).fetchone()[0]
    stock = db.execute(&quot;SELECT stock FROM menu_items WHERE id = %s&quot;, (WRAP,)).fetchone()[0]
    # (orders accepted, wraps sold, wraps left)
    assert (results.count(&quot;ok&quot;), sold, stock) == (10, 10, 0)
</code></pre>
<p>Here is the full run, with the tests from Steps 5 and 6 added (the schema and migration tests are in Steps 7 and 8; this run already had the fixed migration from Step 8):</p>
<pre tabindex="0"><code class="language-text">$ python -m pytest tests --tb=short
============================= test session starts ==============================
platform linux -- Python 3.11.17, pytest-9.2.0, pluggy-1.7.0
rootdir: ~/foodnow-db
collected 8 items

tests/test_concurrency.py F                                              [ 12%]
tests/test_migrations.py ..                                              [ 37%]
tests/test_orders.py ..F                                                 [ 75%]
tests/test_schema.py ..                                                  [100%]

=================================== FAILURES ===================================
__________________ test_twenty_customers_cannot_buy_ten_wraps __________________
tests/test_concurrency.py:33: in test_twenty_customers_cannot_buy_ten_wraps
    assert (results.count(&quot;ok&quot;), sold, stock) == (10, 10, 0)
E   assert (20, 20, 9) == (10, 10, 0)
E
E     At index 0 diff: 20 != 10
E     Use -v to get more diff
______________________ test_refused_order_leaves_no_trace ______________________
tests/test_orders.py:27: in test_refused_order_leaves_no_trace
    assert db.execute(&quot;SELECT count(*) FROM orders&quot;).fetchone()[0] == 0
E   assert 1 == 0
=========================== short test summary info ============================
FAILED tests/test_concurrency.py::test_twenty_customers_cannot_buy_ten_wraps
FAILED tests/test_orders.py::test_refused_order_leaves_no_trace - assert 1 == 0
========================= 2 failed, 6 passed in 0.50s ==========================
</code></pre>
<p><strong>All 20 orders were accepted. 20 wraps were sold. And the stock still says 9.</strong> We ran it five more times: 20 accepted every time, with 8 or 9 left in stock. Not one customer got &quot;out of stock&quot;.</p>
<p>The <code>CHECK (stock &gt;= 0)</code> rule didn&#39;t help, because the stock never went below zero. Each customer read the stock (10), worked out the new number in Python (9), and wrote that number back. Most of them read 10 before anyone had written, so they all wrote 9. This is called a <strong>lost update</strong>: every write is based on an old read and overwrites the others.</p>
<p><img src="https://provalab.io/blog/practice/db-lost-update.svg" alt="Two timelines side by side. Left, the bug: customer A reads stock 1, customer B reads stock 1, A writes 0 and its order is accepted, B also writes 0 and its order is accepted. Two wraps sold, one existed. Right, the fix: A runs UPDATE stock = stock - 1 WHERE stock &gt;= 1, which matches one row. B's identical UPDATE waits for A's row lock, re-checks, sees 0, matches no row and gets out of stock. One wrap sold." width="880" height="470" loading="lazy"></p>
<p>None of the single-user tests in Step 4 can find this. A manual tester clicking Pay can&#39;t find it either. It only shows up with many requests at once: at lunchtime, or when a page retries a slow request.</p>
<h2>The fix, and one that doesn&#39;t work</h2>
<p>We tried wrapping the old code in a transaction first. <strong>That alone doesn&#39;t fix the race:</strong> we ran it five times and got 20 orders accepted and 9 left every time. With PostgreSQL&#39;s default isolation level, Read Committed, two transactions can still both read 10 before either writes.</p>
<p>What fixes both bugs is two changes:</p>
<pre tabindex="0"><code class="language-python">def place_order(conn, customer, items, tip=0):
    &quot;&quot;&quot;items is {menu_item_id: quantity}. Returns the new order id.&quot;&quot;&quot;
    with conn.transaction():  # all of it, or none of it
        total = tip
        for item_id, qty in items.items():
            price = conn.execute(
                &quot;SELECT price FROM menu_items WHERE id = %s&quot;, (item_id,)
            ).fetchone()[0]
            total += price * qty

        order_id = conn.execute(
            &quot;INSERT INTO orders (customer, tip, total) VALUES (%s, %s, %s) RETURNING id&quot;,
            (customer, tip, total),
        ).fetchone()[0]

        for item_id, qty in items.items():
            # Check and take the stock in one statement, so no one can sneak in between.
            taken = conn.execute(
                &quot;UPDATE menu_items SET stock = stock - %s &quot;
                &quot;WHERE id = %s AND stock &gt;= %s RETURNING stock&quot;,
                (qty, item_id, qty),
            ).fetchone()
            if taken is None:
                raise OutOfStock(f&quot;item {item_id}: not enough left for {qty}&quot;)
            conn.execute(
                &quot;INSERT INTO order_items (order_id, item_id, qty) VALUES (%s, %s, %s)&quot;,
                (order_id, item_id, qty),
            )
    return order_id
</code></pre>
<ol>
<li><strong><code>with conn.transaction():</code></strong> makes the whole order one unit. If <code>OutOfStock</code> is raised, psycopg rolls everything back: the order row, the lines, the stock. That fixes bug 1.</li>
<li><strong>One <code>UPDATE</code> that checks and takes the stock.</strong> <code>stock = stock - 1 WHERE stock &gt;= 1</code> lets the database do the arithmetic on the current value. When two customers run it at once, PostgreSQL makes the second wait until the first is finished, then checks the <code>WHERE</code> again on the new value. If nothing is left, no row matches, <code>RETURNING</code> returns nothing, and the code raises <code>OutOfStock</code>. That fixes bug 2. (<code>SELECT ... FOR UPDATE</code> before the read also works. It locks the row while you decide.)</li>
</ol>
<pre tabindex="0"><code class="language-text">$ python -m pytest tests
============================= test session starts ==============================
platform linux -- Python 3.11.17, pytest-9.2.0, pluggy-1.7.0
rootdir: ~/foodnow-db
collected 8 items

tests/test_concurrency.py .                                              [ 12%]
tests/test_migrations.py ..                                              [ 37%]
tests/test_orders.py ...                                                 [ 75%]
tests/test_schema.py ..                                                  [100%]

============================== 8 passed in 0.45s ===============================
</code></pre>
<p>We ran the fixed suite 10 times: 8 passed every time. And the 20-customer race, run 5 more times on its own: exactly 10 accepted, 10 sold, 0 left, every time.</p>
<p>A race test that passes once proves little, because races depend on timing. Run it many times, for example <code>for i in $(seq 20); do python -m pytest -q tests/test_concurrency.py; done</code>, and keep it in your suite, where it runs on every change.</p>
<h2>Step 7: Test the constraints too</h2>
<p>The <code>CHECK</code> rules in the schema are the last line of defence: they protect the data even when some other code, a script or a person in a SQL console, skips the rules in your Python. They are worth two lines of test each, because a migration can quietly drop them.</p>
<pre tabindex="0"><code class="language-python">import psycopg
import pytest


def test_database_refuses_a_tip_over_200(db):
    with pytest.raises(psycopg.errors.CheckViolation):
        db.execute(&quot;INSERT INTO orders (customer, tip, total) VALUES (&#39;asha&#39;, 250, 750)&quot;)


def test_database_refuses_negative_stock(db):
    with pytest.raises(psycopg.errors.CheckViolation):
        db.execute(&quot;UPDATE menu_items SET stock = -1 WHERE id = 1&quot;)
</code></pre>
<h2>Step 8: The migration that only fails on real data</h2>
<p>FoodNow adds a delivery fee. The migration, <code>migrations/002_delivery_fee.sql</code>, is one line:</p>
<pre tabindex="0"><code class="language-sql">ALTER TABLE orders ADD COLUMN delivery_fee integer NOT NULL;
</code></pre>
<p>The developer runs it on their test database, which is empty, and it works. Production has a year of orders. Two tests, one for each case:</p>
<pre tabindex="0"><code class="language-python">import pathlib

MIGRATION = (pathlib.Path(__file__).parent.parent / &quot;migrations/002_delivery_fee.sql&quot;).read_text()


def test_migration_on_an_empty_database(db):
    db.execute(MIGRATION)


def test_migration_on_a_database_that_already_has_orders(db):
    db.execute(&quot;INSERT INTO orders (customer, tip, total) VALUES (&#39;asha&#39;, 50, 550)&quot;)

    db.execute(MIGRATION)

    row = db.execute(&quot;SELECT customer, total, delivery_fee FROM orders&quot;).fetchone()
    assert row == (&quot;asha&quot;, 550, 0)
</code></pre>
<pre tabindex="0"><code class="language-text">tests/test_migrations.py .F                                              [100%]

=================================== FAILURES ===================================
_____________ test_migration_on_a_database_that_already_has_orders _____________
tests/test_migrations.py:13: in test_migration_on_a_database_that_already_has_orders
    db.execute(MIGRATION)
.../site-packages/psycopg/connection.py:304: in execute
    raise ex.with_traceback(None)
E   psycopg.errors.NotNullViolation: column &quot;delivery_fee&quot; of relation &quot;orders&quot; contains null values
=========================== short test summary info ============================
FAILED tests/test_migrations.py::test_migration_on_a_database_that_already_has_orders
========================= 1 failed, 1 passed in 0.12s ==========================
</code></pre>
<p>(We shortened the long path to the installed psycopg package to <code>.../site-packages</code>.)</p>
<p>On an empty table it passes. With one existing order it fails, because the old rows would get <code>NULL</code> in a column that doesn&#39;t allow it. In production, that&#39;s a failed release in the middle of a deploy. The fix is to give old rows a value:</p>
<pre tabindex="0"><code class="language-sql">ALTER TABLE orders ADD COLUMN delivery_fee integer NOT NULL DEFAULT 0;
</code></pre>
<p>Both tests pass. Since PostgreSQL 11, adding a column with a fixed default like this doesn&#39;t rewrite the whole table, so it&#39;s quick even on a big one.</p>
<p><strong>Test every migration on data shaped like production:</strong> existing rows, <code>NULL</code>s in optional columns, old values that today&#39;s code would never write. The strongest version is a nightly job that runs the new migrations on a recent anonymised copy of production.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Checking only the return value.</strong> The function&#39;s answer and the database&#39;s contents can disagree, as Step 5 shows. After a write, query the rows. After a failure, query them too.</li>
<li><strong>Mocking the database in these tests.</strong> A mock returns what you told it to. It has no transactions, no locks and no constraints, which is where all three bugs here lived. Mocks have their place, but not in a test whose job is to check the database.</li>
<li><strong>Tests that share data.</strong> Shared rows make tests pass or fail depending on order. Give each test a fresh schema, or a transaction that is rolled back.</li>
<li><strong>Testing concurrency by hand.</strong> Two people can&#39;t click at exactly the same moment. Threads with a barrier, as in Step 6, can, and they do it on every run.</li>
<li><strong>Testing migrations only on an empty database.</strong> Empty tables accept almost any migration. Real tables are where they fail.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong>Part 5</strong> of Testing in Practice we look at <strong>test doubles</strong>: mocks, stubs, fakes and spies, what each one is for, and how a mock can let a real bug through.</p>
<p>ProvaLab&#39;s pytest plugin sends every result from tests like these to the ProvaLab test management system, with the history of each test across runs. So a race test that fails once in fifty runs shows up as a pattern you can look at, not a one-off you forget. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Usability testing (hands-on): a heuristic review, five users, and the form that threw away a correct address</title>
      <link>https://provalab.io/blog/usability-testing-five-users/</link>
      <guid isPermaLink="true">https://provalab.io/blog/usability-testing-five-users/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>usability-testing</category>
      <category>ux</category>
      <category>accessibility</category>
      <category>playwright</category>
      <description>How to find what makes users give up: a heuristic review of a real form, measured tap targets, why five users is enough (and when it isn't), and SUS scoring.</description>
      <content:encoded><![CDATA[<p>Software can pass every functional test and still lose customers. The form works, but nobody can tell which box is the pincode. The error is correct, but it doesn&#39;t say which field. The button is there, but it&#39;s 34 pixels tall and right next to a huge Cancel. <strong>Usability testing</strong> finds these problems: not &quot;does it work?&quot;, but &quot;can people use it, easily, without help?&quot;</p>
<p>The ISO standard for usability, ISO 9241-11, defines it with three measures:</p>
<ul>
<li><strong>Effectiveness:</strong> can people complete the task?</li>
<li><strong>Efficiency:</strong> how much time and effort does it take?</li>
<li><strong>Satisfaction:</strong> how do they feel about it?</li>
</ul>
<p>This post shows the two main ways to find usability problems, on a FoodNow <strong>add a delivery address</strong> form for phones:</p>
<ul>
<li>A <strong>heuristic review</strong>: an expert checks the design against well-known rules. We did this for real, with Playwright filling in the form on a phone-sized screen and measuring what can be measured.</li>
<li>A <strong>usability test with users</strong>: you watch real people try real tasks. That needs real people. We show how to plan and run one. The questionnaire answers in Step 6 are an example, and we say so where they appear.</li>
</ul>
<h2>Usability testing vs UAT</h2>
<p><a href="https://provalab.io/blog/user-acceptance-testing-uat/">Part 9</a> was user acceptance testing. They&#39;re often confused:</p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>Usability testing</th>
<th>User acceptance testing</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Question</strong></td>
<td>Can people use it easily?</td>
<td>Does it fit our work? Do we accept it?</td>
</tr>
<tr>
<td><strong>When</strong></td>
<td>Early and often, even on sketches</td>
<td>Near the end, on the finished build</td>
</tr>
<tr>
<td><strong>Who</strong></td>
<td>Representative users, often new to the product</td>
<td>The users or business who&#39;ll accept it</td>
</tr>
<tr>
<td><strong>Result</strong></td>
<td>A list of problems, by severity</td>
<td>A decision: accept or not</td>
</tr>
</tbody></table>
<p>Usability testing is cheapest early, on a sketch or prototype, when changing the design costs nothing. By UAT, the same finding costs a release.</p>
<h2>The form</h2>
<p>The add-address form, version 1. It&#39;s a small FastAPI app that renders this HTML:</p>
<pre tabindex="0"><code class="language-html">&lt;h1&gt;Add address&lt;/h1&gt;
&lt;form method=&quot;post&quot; action=&quot;/address&quot;&gt;
  &lt;a class=&quot;loc&quot; href=&quot;#&quot;&gt;Use current location&lt;/a&gt;
  &lt;input name=&quot;flat&quot; placeholder=&quot;Flat / house no.&quot;&gt;
  &lt;input name=&quot;street&quot; placeholder=&quot;Street and area&quot;&gt;
  &lt;input name=&quot;pincode&quot; placeholder=&quot;Pincode&quot;&gt;
  &lt;input name=&quot;phone&quot; placeholder=&quot;Phone for the rider&quot;&gt;
  &lt;div class=&quot;row&quot;&gt;
    &lt;button class=&quot;save&quot;&gt;Save&lt;/button&gt;
    &lt;button class=&quot;cancel&quot; type=&quot;button&quot;&gt;Cancel&lt;/button&gt;
  &lt;/div&gt;
&lt;/form&gt;
</code></pre>
<p>And the server-side check:</p>
<pre tabindex="0"><code class="language-python">@app.post(&quot;/address&quot;, response_class=HTMLResponse)
def save(flat: str = Form(&quot;&quot;), street: str = Form(&quot;&quot;), pincode: str = Form(&quot;&quot;), phone: str = Form(&quot;&quot;)):
    if not (flat and street and re.fullmatch(r&quot;\d{6}&quot;, pincode) and re.fullmatch(r&quot;\d{10}&quot;, phone)):
        return form(&quot;Error: invalid input&quot;)
    return page(f&#39;&lt;h1&gt;Address saved&lt;/h1&gt;&lt;p id=&quot;saved&quot;&gt;{flat}, {street}, {pincode}&lt;/p&gt;&#39;)
</code></pre>
<p>Every functional test you&#39;d write for this passes: valid input saves, invalid input is refused. Now look at it as a customer would.</p>
<h2>Step 1: Fill it in like a customer</h2>
<p>This Playwright script opens the form on a phone-sized screen and fills it in the way a person in India would, including the phone number written the usual way, with the country code and a space. Save it as <code>review.py</code> (you need <code>pip install playwright==1.64.0</code> and <code>playwright install chromium</code>):</p>
<pre tabindex="0"><code class="language-python">import sys

from playwright.sync_api import sync_playwright

URL, SHOTS = sys.argv[1], sys.argv[2]

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_context(viewport={&quot;width&quot;: 412, &quot;height&quot;: 915}, device_scale_factor=2,
                               is_mobile=True, has_touch=True, locale=&quot;en-IN&quot;).new_page()
    page.goto(URL)

    # 1. Fill it in as a customer would, including the phone number as people write it.
    fields = page.locator(&quot;input&quot;)
    values = [&quot;Flat 4B, Lotus Apartments&quot;, &quot;14th Cross, Indiranagar&quot;, &quot;560038&quot;, &quot;+91 98765 43210&quot;]
    for f, v in zip(fields.all(), values):
        f.fill(v)
    page.screenshot(path=f&quot;{SHOTS}/2-filled.png&quot;)
    page.get_by_role(&quot;button&quot;, name=&quot;Save&quot;).click()
    page.wait_for_load_state()
    page.screenshot(path=f&quot;{SHOTS}/3-after-save.png&quot;)
    print(&quot;after Save:&quot;, page.locator(&quot;h1&quot;).inner_text(), &quot;|&quot;,
          page.locator(&quot;.err, [role=alert], #saved&quot;).all_inner_texts())
    print(&quot;values kept:&quot;, [f.input_value() for f in page.locator(&quot;input&quot;).all()])

    # 2. Labels and keyboards.
    page.goto(URL)
    for f in page.locator(&quot;input&quot;).all():
        label = f.evaluate(&quot;e =&gt; e.labels.length ? e.labels[0].innerText : null&quot;)
        print(f&quot;field {f.get_attribute(&#39;name&#39;)!r}: label={label!r} placeholder={f.get_attribute(&#39;placeholder&#39;)!r} &quot;
              f&quot;type={f.get_attribute(&#39;type&#39;)!r} inputmode={f.get_attribute(&#39;inputmode&#39;)!r} &quot;
              f&quot;autocomplete={f.get_attribute(&#39;autocomplete&#39;)!r}&quot;)

    # 3. Tap targets: WCAG 2.2 asks for at least 24x24 CSS px; Android and iOS guides say 48 and 44.
    for el in page.locator(&quot;a, button, input&quot;).all():
        box = el.bounding_box()
        name = (el.inner_text() or el.get_attribute(&quot;name&quot;) or &quot;&quot;).strip()
        flag = &quot;  &lt; 24px&quot; if min(box[&quot;width&quot;], box[&quot;height&quot;]) &lt; 24 else (&quot;  &lt; 44px&quot; if min(box[&quot;width&quot;], box[&quot;height&quot;]) &lt; 44 else &quot;&quot;)
        print(f&quot;target {name!r}: {box[&#39;width&#39;]:.0f} x {box[&#39;height&#39;]:.0f}{flag}&quot;)
    browser.close()
</code></pre>
<p>The output, on version 1:</p>
<pre tabindex="0"><code class="language-text">after Save: Add address | [&#39;Error: invalid input&#39;]
values kept: [&#39;&#39;, &#39;&#39;, &#39;&#39;, &#39;&#39;]
field &#39;flat&#39;: label=None placeholder=&#39;Flat / house no.&#39; type=None inputmode=None autocomplete=None
field &#39;street&#39;: label=None placeholder=&#39;Street and area&#39; type=None inputmode=None autocomplete=None
field &#39;pincode&#39;: label=None placeholder=&#39;Pincode&#39; type=None inputmode=None autocomplete=None
field &#39;phone&#39;: label=None placeholder=&#39;Phone for the rider&#39; type=None inputmode=None autocomplete=None
target &#39;Use current location&#39;: 114 x 17  &lt; 24px
target &#39;flat&#39;: 372 x 44
target &#39;street&#39;: 372 x 44
target &#39;pincode&#39;: 372 x 44
target &#39;phone&#39;: 372 x 44
target &#39;Save&#39;: 62 x 34  &lt; 44px
target &#39;Cancel&#39;: 300 x 53
</code></pre>
<p>The address was correct. The phone number was correct too, written the way most people in India write it. The form said &quot;Error: invalid input&quot; and <strong>emptied all four fields</strong>. A customer on a phone has to type the whole address again, without knowing what was wrong. Many won&#39;t. They&#39;ll order somewhere else.</p>
<p>A functional test with <code>9876543210</code> passes. Only a test that uses the input real people type finds this.</p>
<h2>Step 2: The heuristic review</h2>
<p>A <strong>heuristic review</strong> (or heuristic evaluation) checks a design against a short list of rules of thumb. The best known are Jakob Nielsen&#39;s <a href="https://www.nngroup.com/articles/ten-usability-heuristics/">10 usability heuristics</a>, first published in 1994. Two or three reviewers check the design separately, then combine their lists. It&#39;s quick and cheap, and it needs no users.</p>
<p>What we found on version 1, using the run above and the screenshots:</p>
<table tabindex="0">
<thead>
<tr>
<th>#</th>
<th>Problem</th>
<th>Heuristic</th>
<th>Severity</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>A correct phone number, <code>+91 98765 43210</code>, is refused</td>
<td>Flexibility; error prevention</td>
<td>Critical</td>
</tr>
<tr>
<td>2</td>
<td>Every field is wiped after an error</td>
<td>Error prevention; user control</td>
<td>Critical</td>
</tr>
<tr>
<td>3</td>
<td>&quot;Error: invalid input&quot; doesn&#39;t say which field or why</td>
<td>Help users recognise, diagnose and recover from errors</td>
<td>Major</td>
</tr>
<tr>
<td>4</td>
<td>Placeholders are the only labels. Once a field is filled, nothing says what it is (see &quot;filled&quot; below)</td>
<td>Recognition rather than recall</td>
<td>Major</td>
</tr>
<tr>
<td>5</td>
<td>Cancel is the big, bright button, Save is small and pale, side by side</td>
<td>Error prevention; consistency with platform conventions</td>
<td>Major</td>
</tr>
<tr>
<td>6</td>
<td>&quot;Use current location&quot; is a 17-pixel-tall text link</td>
<td>Flexibility; <a href="https://provalab.io/blog/accessibility-testing-wcag-axe-playwright/">WCAG 2.5.8</a> target size</td>
<td>Minor</td>
</tr>
<tr>
<td>7</td>
<td>Pincode and phone open the full letter keyboard, and nothing helps the browser autofill</td>
<td>Efficiency</td>
<td>Minor</td>
</tr>
<tr>
<td>8</td>
<td>Required fields and formats aren&#39;t stated before the customer types</td>
<td>Error prevention</td>
<td>Minor</td>
</tr>
</tbody></table>
<p>We used three severity levels: <strong>critical</strong> stops people from finishing, <strong>major</strong> causes real trouble or errors, <strong>minor</strong> slows them down or annoys them. (Nielsen&#39;s own scale has five, from 0 to 4, but the idea is the same.)</p>
<p>Notice that several of these are also accessibility failures. A placeholder disappears as soon as you type, so anyone who loses track has nothing to go back to, and WCAG asks for visible labels (success criterion 3.3.2). A 17-pixel link is hard to tap for anyone with shaky hands. Good usability and accessibility overlap a lot.</p>
<h2>Step 3: Fix, and run the same checks</h2>
<p>Version 2 changes the form, not the business rules:</p>
<pre tabindex="0"><code class="language-python">FIELDS = [  # name, label, hint, extra attributes
    (&quot;flat&quot;, &quot;Flat or house number&quot;, &quot;&quot;, &#39;autocomplete=&quot;address-line1&quot;&#39;),
    (&quot;street&quot;, &quot;Street and area&quot;, &quot;&quot;, &#39;autocomplete=&quot;address-line2&quot;&#39;),
    (&quot;pincode&quot;, &quot;Pincode&quot;, &quot;6 digits&quot;, &#39;inputmode=&quot;numeric&quot; autocomplete=&quot;postal-code&quot; maxlength=&quot;6&quot;&#39;),
    (&quot;phone&quot;, &quot;Phone for the rider&quot;, &quot;10-digit mobile number&quot;, &#39;type=&quot;tel&quot; autocomplete=&quot;tel&quot;&#39;),
]


def phone_digits(raw: str) -&gt; str:
    d = re.sub(r&quot;\D&quot;, &quot;&quot;, raw)
    if len(d) == 12 and d.startswith(&quot;91&quot;):
        d = d[2:]
    elif len(d) == 11 and d.startswith(&quot;0&quot;):
        d = d[1:]
    return d
</code></pre>
<ul>
<li><strong>Visible labels and hints</strong> above every field, linked with <code>for</code> and <code>aria-describedby</code>.</li>
<li><strong>Accept the formats people use.</strong> Spaces, <code>+91</code> and a leading <code>0</code> are removed before the 10-digit check.</li>
<li><strong>Keep what was typed.</strong> On an error, the form comes back with every value.</li>
<li><strong>Say what&#39;s wrong, next to the field.</strong> &quot;A pincode has 6 digits. This one has 5.&quot; And a summary at the top with <code>role=&quot;alert&quot;</code>, so screen readers announce it.</li>
<li><strong>One clear primary action.</strong> &quot;Save address&quot; is full width and bright. Cancel is a plain link below it.</li>
<li><strong>Right keyboards.</strong> <code>inputmode=&quot;numeric&quot;</code> for the pincode, <code>type=&quot;tel&quot;</code> for the phone, and <code>autocomplete</code> so the browser can fill the address.</li>
<li><strong>Tap targets of at least 48 pixels.</strong></li>
</ul>
<p>The same script, on version 2:</p>
<pre tabindex="0"><code class="language-text">after Save: Address saved | [&#39;Flat 4B, Lotus Apartments, 14th Cross, Indiranagar, 560038. The rider will call 9876543210.&#39;]
values kept: []
field &#39;flat&#39;: label=&#39;Flat or house number&#39; placeholder=None type=None inputmode=None autocomplete=&#39;address-line1&#39;
field &#39;street&#39;: label=&#39;Street and area&#39; placeholder=None type=None inputmode=None autocomplete=&#39;address-line2&#39;
field &#39;pincode&#39;: label=&#39;Pincode&#39; placeholder=None type=None inputmode=&#39;numeric&#39; autocomplete=&#39;postal-code&#39;
field &#39;phone&#39;: label=&#39;Phone for the rider&#39; placeholder=None type=&#39;tel&#39; inputmode=None autocomplete=&#39;tel&#39;
target &#39;Use my current location&#39;: 372 x 48
target &#39;flat&#39;: 372 x 48
target &#39;street&#39;: 372 x 48
target &#39;pincode&#39;: 372 x 48
target &#39;phone&#39;: 372 x 48
target &#39;Save address&#39;: 372 x 52
target &#39;Cancel&#39;: 372 x 48
</code></pre>
<p>The address saves (<code>values kept</code> is empty because the page now shows the confirmation instead of the form). And with a real mistake, a 5-digit pincode:</p>
<pre tabindex="0"><code class="language-text">alert: Please check 1 field below.
pincode error: A pincode has 6 digits. This one has 5.
values kept: [&#39;Flat 4B, Lotus Apartments&#39;, &#39;14th Cross, Indiranagar&#39;, &#39;56003&#39;, &#39;+91 98765 43210&#39;]
</code></pre>
<p><img src="https://provalab.io/blog/practice/usability-before-after.png" alt="What the customer sees after pressing Save. Version 1, with all four fields typed correctly: &quot;Error: invalid input&quot; and every field empty. Version 2, with a 5-digit pincode: the error is next to the pincode field and everything typed is still there." loading="lazy"></p>
<h2>Step 4: Why test with users at all?</h2>
<p>A heuristic review found eight problems in a few minutes. Why go further?</p>
<p>Because reviewers aren&#39;t users. They know what a pincode is, they read every word, and they don&#39;t use the app on a crowded bus with one hand. Users find problems no rule predicts: a word they don&#39;t understand, a step they expected and didn&#39;t get, a field they fill with something nobody imagined. In our form, a user might type their building name in the flat field and leave the street empty, or expect &quot;Use my current location&quot; to fill everything. You only learn that by watching.</p>
<p>So use both. The review clears the obvious problems cheaply, so the users&#39; time is spent on the ones only they can find.</p>
<h2>Step 5: How many users? Usually five, then five more</h2>
<p>The famous answer is <strong>five</strong>. It comes from Jakob Nielsen and Tom Landauer&#39;s 1993 research: if a usability problem affects a share <strong>L</strong> of users, the chance that at least one of <strong>n</strong> test users runs into it is <strong>1 − (1 − L)ⁿ</strong>. In their projects, a typical problem affected about 31% of users. This script works it out:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;Share of usability problems found by n test users: 1 - (1 - L)^n.&quot;&quot;&quot;
for L, label in [(0.31, &quot;a typical problem (L = 31%)&quot;), (0.10, &quot;a problem 1 user in 10 hits&quot;)]:
    print(label)
    for n in (1, 2, 3, 5, 8, 15):
        print(f&quot;  {n:&gt;2} users: {1 - (1 - L) ** n:6.1%}&quot;)
</code></pre>
<pre tabindex="0"><code class="language-text">a typical problem (L = 31%)
   1 users:  31.0%
   2 users:  52.4%
   3 users:  67.1%
   5 users:  84.4%
   8 users:  94.9%
  15 users:  99.6%
a problem 1 user in 10 hits
   1 users:  10.0%
   2 users:  19.0%
   3 users:  27.1%
   5 users:  41.0%
   8 users:  57.0%
  15 users:  79.4%
</code></pre>
<p><img src="https://provalab.io/blog/practice/five-users-curve.svg" alt="The share of problems found against the number of test users: 84% with five users for a typical problem, but only 41% for a problem 1 user in 10 hits" width="880" height="470" loading="lazy"></p>
<p>Five users find about 84% of the common problems. But a problem that only 1 user in 10 hits is found just 41% of the time. That&#39;s why the advice is <strong>five users, fix, then five more</strong>, not &quot;five users and done&quot;. Each round finds the problems that are left. And if you have very different groups of users, such as customers and delivery riders, test about five from each group.</p>
<h2>Step 6: Run the sessions</h2>
<p>A usability session takes 30 to 60 minutes, one user at a time:</p>
<ol>
<li><strong>Welcome them, and say what&#39;s being tested.</strong> &quot;We&#39;re testing the app, not you. If something&#39;s confusing, that&#39;s what we need to know.&quot;</li>
<li><strong>Give a realistic task, not instructions.</strong> &quot;You&#39;ve just moved to a new flat in Indiranagar. Add it as your delivery address.&quot; Not &quot;tap Add address, then type in the first box&quot;.</li>
<li><strong>Ask them to think aloud,</strong> and stay quiet. Don&#39;t help, don&#39;t explain, don&#39;t defend the design. If they ask, say &quot;what would you do if I weren&#39;t here?&quot;</li>
<li><strong>Watch what they do,</strong> not only what they say. Hesitations, wrong taps, re-typing, looking for something.</li>
<li><strong>Measure each task:</strong> completed or not, time taken, and errors.</li>
<li><strong>End with a short questionnaire,</strong> usually the System Usability Scale.</li>
</ol>
<p>The <strong>System Usability Scale (SUS)</strong>, created by John Brooke in 1986, is ten statements that users rate from 1 (strongly disagree) to 5 (strongly agree), such as &quot;I thought the system was easy to use&quot; and &quot;I found the system unnecessarily complex&quot;. Odd-numbered statements are positive and even-numbered ones are negative, and the score runs from 0 to 100. This script scores it:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;Score the System Usability Scale (SUS): ten statements, each answered 1 (strongly disagree) to 5 (strongly agree).&quot;&quot;&quot;
import csv
import sys


def sus(answers: list[int]) -&gt; float:
    assert len(answers) == 10 and all(1 &lt;= a &lt;= 5 for a in answers)
    odd = sum(a - 1 for a in answers[0::2])   # statements 1,3,5,7,9 are positive
    even = sum(5 - a for a in answers[1::2])  # statements 2,4,6,8,10 are negative
    return (odd + even) * 2.5


rows = list(csv.reader(open(sys.argv[1])))[1:]
scores = []
for name, *answers in rows:
    s = sus([int(a) for a in answers])
    scores.append(s)
    print(f&quot;{name:&lt;14} {s:5.1f}&quot;)
print(f&quot;{&#39;average&#39;:&lt;14} {sum(scores) / len(scores):5.1f}  (68 is about average across many studies)&quot;)
</code></pre>
<p><em>The answers below are an example, written for this post to show how the scoring works. They are not from real users.</em> Five people on version 1, and five different people on version 2:</p>
<pre tabindex="0"><code class="language-text">--- v1 (example answers)
P1              55.0
P2              70.0
P3              35.0
P4              60.0
P5              75.0
average         59.0  (68 is about average across many studies)
--- v2 (example answers)
P6              82.5
P7              87.5
P8              72.5
P9              90.0
P10             75.0
average         81.5  (68 is about average across many studies)
</code></pre>
<p>Two things about SUS. First, the score is not a percentage: 68 is roughly average, based on hundreds of published studies, so 59 is below average, not &quot;59% usable&quot;. Second, with five people per version the spread is wide (35 to 75 on version 1), so treat it as a signal to compare rounds, not a precise measurement. The problems you watched people hit matter more than the number.</p>
<h2>Step 7: Report what you found</h2>
<p>A usability report is short and goes to the people who&#39;ll fix things:</p>
<ul>
<li><strong>The tasks,</strong> and how many people completed each one.</li>
<li><strong>Each problem:</strong> what happened, how many users hit it, its severity, and a screenshot or short clip.</li>
<li><strong>What worked well,</strong> so nobody &quot;fixes&quot; it.</li>
<li><strong>Recommendations,</strong> most severe first.</li>
</ul>
<p>Keep it to problems you saw. &quot;Three of five users typed their phone with +91 and lost the whole form&quot; moves a team. &quot;Users may find validation unclear&quot; doesn&#39;t.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Testing with colleagues.</strong> They know the product and the words. Recruit people like your real users.</li>
<li><strong>Helping during the session.</strong> The moment you point, you&#39;ve lost the finding.</li>
<li><strong>Asking &quot;do you like it?&quot;</strong> People are polite. Watch what they do instead.</li>
<li><strong>Testing too late.</strong> A usability finding on a sketch costs an eraser. On a finished release it costs a sprint.</li>
<li><strong>One round of five, then done.</strong> Five users miss many rarer problems. Fix, then test again.</li>
<li><strong>Only reviewing, never watching.</strong> A heuristic review finds the obvious problems. Users find the rest.</li>
<li><strong>Testing on a laptop for an app people use on phones.</strong> Use the real device, the real network, the real situation.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong>Part 11</strong> of Testing in Practice we write <strong>your first Selenium test</strong>: Selenium WebDriver with Python and pytest, against the FoodNow checkout, and how it compares with Playwright.</p>
<p>ProvaLab&#39;s test management system keeps usability findings next to the rest of your testing: write each session task as a test case, record each session&#39;s result, and file what users hit as issues with screenshots attached and a severity, linked to the task that found them. The checks that can be automated, like the label and tap-target checks in Step 1, can run as ordinary pytest tests, and ProvaLab&#39;s pytest plugin reports them on every run. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>User acceptance testing (hands-on): plan, dry run and sign-off, and the bug only the UAT server had</title>
      <link>https://provalab.io/blog/user-acceptance-testing-uat/</link>
      <guid isPermaLink="true">https://provalab.io/blog/user-acceptance-testing-uat/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>uat</category>
      <category>acceptance-testing</category>
      <category>test-planning</category>
      <category>pytest</category>
      <description>How to plan and run UAT so it isn't a rubber stamp. A real dry run on a UAT-like server finds a time-zone bug that passed every system test; a script checks the exit criteria.</description>
      <content:encoded><![CDATA[<p>Your team has tested the new feature. The unit tests pass, the system tests pass, the bugs are fixed. One question is left, and your team can&#39;t answer it: <strong>will this work for the people who&#39;ll use it, for the job they need to do?</strong> Answering that is <strong>user acceptance testing (UAT)</strong>.</p>
<p>UAT is the last test level before release. The people who&#39;ll use the software, or the people who represent them, try it on realistic tasks and decide whether to accept it. It&#39;s often done badly: users are handed a build on a Friday afternoon, click through a script, and sign. That&#39;s a rubber stamp, not a test.</p>
<p>This post shows how to plan and run UAT for a new FoodNow feature, <strong>order for later</strong>. It includes two things that really ran:</p>
<ul>
<li>A <strong>dry run</strong> on a UAT-like server, which found a bug that every system test had passed.</li>
<li>A script that checks the UAT results against the <strong>exit criteria</strong>, which caught a gap in how a retest was recorded.</li>
</ul>
<p>The UAT sessions themselves need real people, so the session results in Step 6 are an example, and we say so where they appear.</p>
<h2>What UAT is, and what it isn&#39;t</h2>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>System testing</th>
<th>User acceptance testing</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Question</strong></td>
<td>Does it meet the specification?</td>
<td>Can users do their job with it?</td>
</tr>
<tr>
<td><strong>Who</strong></td>
<td>Testers and developers</td>
<td>Users, customers, or the business people who represent them</td>
</tr>
<tr>
<td><strong>Checked against</strong></td>
<td>Requirements and acceptance criteria</td>
<td>Real tasks and real expectations</td>
</tr>
<tr>
<td><strong>Finds</strong></td>
<td>Bugs in the code</td>
<td>Misunderstood needs, workflow gaps, confusing wording, and bugs the team&#39;s setup hid</td>
</tr>
<tr>
<td><strong>Ends with</strong></td>
<td>A test report</td>
<td>A decision: accept or not, signed by a named person</td>
</tr>
</tbody></table>
<p>UAT is <strong>not</strong> a second round of system testing by people who aren&#39;t testers. If users are finding crashes and wrong totals, the build wasn&#39;t ready for UAT. Fix the process before that, not the users.</p>
<p>The <a href="https://www.istqb.org/">ISTQB</a> names several kinds of acceptance testing. You&#39;ll hear these:</p>
<ul>
<li><strong>User acceptance testing:</strong> the users check it fits their work. This post.</li>
<li><strong>Operational acceptance testing:</strong> the people who&#39;ll run it check backups, monitoring, restarts and the release steps.</li>
<li><strong>Contract and regulatory acceptance testing:</strong> checks against what a contract or a law requires.</li>
<li><strong>Alpha and beta testing:</strong> for products sold to many customers. Alpha is with users at your site, beta is with users in their own environment.</li>
</ul>
<h2>The feature: order for later</h2>
<p>Office managers asked for a way to order lunch for a meeting in advance. The story:</p>
<blockquote>
<p>As an office manager, I want to choose a delivery slot when I order, so that lunch arrives when the meeting starts, not when I happened to order it.</p>
</blockquote>
<p>The acceptance criteria the team built to:</p>
<ul>
<li>Slots are 30 minutes long, on the hour and half hour.</li>
<li>The first slot is at least 45 minutes from now (cooking and riding time).</li>
<li>Slots go up to 3 hours ahead.</li>
<li>Every slot is within kitchen hours, 11:00 to 23:00.</li>
</ul>
<p>Here&#39;s the code that picks the slots, in <code>foodnow/app.py</code>. The page that shows them is a small FastAPI app with an HTML form:</p>
<pre tabindex="0"><code class="language-python">from datetime import datetime, timedelta

OPENS, CLOSES = 11, 23          # kitchen hours
LEAD = timedelta(minutes=45)    # cooking + riding time
AHEAD = timedelta(hours=3)


def now() -&gt; datetime:
    return datetime.now()


def slots(at: datetime) -&gt; list[datetime]:
    first = (at + LEAD).replace(second=0, microsecond=0)
    first += timedelta(minutes=(30 - first.minute % 30) % 30)
    out, t = [], first
    while t &lt;= at + AHEAD:
        day = t.replace(hour=0, minute=0)
        if day + timedelta(hours=OPENS) &lt;= t &lt;= day + timedelta(hours=CLOSES, minutes=-30):
            out.append(t)
        t += timedelta(minutes=30)
    return out
</code></pre>
<p>And the team&#39;s system tests, which pass:</p>
<pre tabindex="0"><code class="language-python">def test_slots_start_after_the_lead_time_and_are_half_hours():
    at = datetime(2026, 11, 11, 19, 10)
    s = fn.slots(at)
    assert s[0] == datetime(2026, 11, 11, 20, 0)
    assert all(b - a == timedelta(minutes=30) for a, b in zip(s, s[1:]))
    assert s[-1] &lt;= at + fn.AHEAD


def test_no_slots_after_closing():
    assert fn.slots(datetime(2026, 11, 11, 22, 30)) == []


def test_page_offers_slots_from_now():
    html = client.get(&quot;/&quot;).text
    first = fn.slots(fn.now())[0]
    assert f&quot;{first:%H:%M} –&quot; in html


def test_scheduling_confirms_the_slot():
    r = client.post(&quot;/schedule&quot;, data={&quot;slot&quot;: &quot;20:00&quot;})
    assert &quot;between 20:00 and 20:30&quot; in r.text
</code></pre>
<pre tabindex="0"><code class="language-text">$ pytest -q
....                                                                     [100%]
4 passed, 1 deselected, 1 warning in 0.46s

$ TZ=UTC pytest -q
....                                                                     [100%]
4 passed, 1 deselected, 1 warning in 0.40s
</code></pre>
<p>They pass with the machine&#39;s clock in India time and in UTC. (The deselected test is the one we add in Step 4. The warning is a deprecation notice from Starlette&#39;s test client.) Keep <code>datetime.now()</code> in mind.</p>
<p><em>A correction, found later: <code>test_page_offers_slots_from_now</code> also depends on the real clock in a second way. Between 21:46 and 07:59 there are no slots left, and the test fails with <code>IndexError</code>. We ran these tests in the evening, so we missed it. <a href="https://provalab.io/blog/test-summary-report-metrics/">Part 14</a> shows how it turned up in a nightly-style run, and the fix: give the test a fixed time.</em></p>
<p><img src="https://provalab.io/blog/practice/uat-flow.svg" alt="UAT from plan to sign-off: plan, entry check, dry run, sessions, triage, exit and sign-off, with a loop back from triage to sessions for retests" width="880" height="470" loading="lazy"></p>
<h2>Step 1: Plan, and agree the exit criteria first</h2>
<p>A UAT plan is short. It answers:</p>
<ul>
<li><strong>Who tests?</strong> People who&#39;ll really use it. For order for later: two office managers or team assistants who order for meetings, and one night-shift lead, because they order late. Not developers, and not the product owner alone.</li>
<li><strong>What do they do?</strong> Three to six realistic tasks (Step 3).</li>
<li><strong>Where and when?</strong> The UAT environment, on their own phones, in a 45-minute session each, in the week before release.</li>
<li><strong>Who decides?</strong> One named person who signs the acceptance. Here, the head of FoodNow for Business.</li>
<li><strong>What counts as accepted?</strong> The <strong>exit criteria</strong>.</li>
</ul>
<p>Agree the exit criteria <strong>before</strong> UAT starts. Afterwards, there&#39;s pressure to call whatever happened &quot;good enough&quot;. Ours:</p>
<ol>
<li>Every must-have task is completed by at least two testers.</li>
<li>No open critical or major defects.</li>
<li>Minor defects are either fixed or accepted in writing by the product owner.</li>
<li>Change requests are logged, and don&#39;t block acceptance.</li>
</ol>
<h2>Step 2: Check the entry criteria</h2>
<p>UAT wastes everyone&#39;s time if the build isn&#39;t ready. Before inviting anyone, check:</p>
<ul>
<li>The build has passed system testing, and the known defects are listed.</li>
<li>The UAT environment is set up <strong>like production</strong>: same configuration, same kind of data, same integrations.</li>
<li>Testers have accounts, and there&#39;s realistic data (menus, addresses, payment test cards).</li>
<li>Testers know how to report a problem, and who to ask when they&#39;re stuck.</li>
</ul>
<p>&quot;Like production&quot; is the one teams skip, and the next step shows why it matters.</p>
<h2>Step 3: Write the scenarios as tasks, not steps</h2>
<p>A system test script says exactly what to click. A UAT scenario gives the user a <strong>goal</strong>, and you watch how they reach it:</p>
<table tabindex="0">
<thead>
<tr>
<th>Step-by-step script (avoid in UAT)</th>
<th>Task (use in UAT)</th>
</tr>
</thead>
<tbody><tr>
<td>1. Open the app. 2. Tap &quot;Order for later&quot;. 3. Select the first slot. 4. Tap &quot;Schedule delivery&quot;. 5. Check the confirmation shows the slot.</td>
<td><strong>S1.</strong> Your team has a meeting in about two hours. Order lunch so it arrives when the meeting starts.</td>
</tr>
</tbody></table>
<p>The script tells you whether the buttons work, which system testing already did. The task tells you whether a real user can find the feature, choose the right slot and trust the confirmation. If they hesitate, go back, or ask &quot;is this the right time?&quot;, that&#39;s a finding.</p>
<p>Our scenarios:</p>
<ul>
<li><strong>S1</strong> (must have) Order lunch for a meeting that starts in about two hours.</li>
<li><strong>S2</strong> (must have) Choose a slot, then change your mind before paying.</li>
<li><strong>S3</strong> (must have) It&#39;s late evening. Order dinner for the night shift.</li>
<li><strong>S4</strong> (nice to have) Order tomorrow&#39;s lunch today.</li>
<li><strong>S5</strong> (nice to have) Read the confirmation aloud to a colleague: when does the food arrive?</li>
</ul>
<p>S4 is there on purpose. The story only promised the next three hours. If users expect tomorrow, it&#39;s a change request, and that&#39;s worth knowing too.</p>
<h2>Step 4: Dry run on the UAT environment</h2>
<p>Before users arrive, a tester does the scenarios on the UAT environment, as a user would: on a phone, in the users&#39; time zone. This catches the problems that would waste a session, and the ones the team&#39;s own setup hides.</p>
<p>Our UAT server runs in a container, and like most containers, its clock is set to <strong>UTC</strong>. The users are in India, 5 hours 30 minutes ahead. We started the server that way:</p>
<pre tabindex="0"><code class="language-bash">TZ=UTC uvicorn foodnow.app:app --port 8077
</code></pre>
<p>Then ran scenario S1 in a browser set up like the users&#39; phones: a phone-sized screen, India&#39;s time zone and Indian English. This short Playwright script records what the tester saw:</p>
<pre tabindex="0"><code class="language-python">from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    phone = browser.new_context(
        viewport={&quot;width&quot;: 412, &quot;height&quot;: 915}, device_scale_factor=2,
        is_mobile=True, has_touch=True,
        timezone_id=&quot;Asia/Kolkata&quot;, locale=&quot;en-IN&quot;,
    )
    page = phone.new_page()
    page.goto(&quot;http://localhost:8077/&quot;)
    print(&quot;time on the phone:&quot;, page.evaluate(
        &quot;new Date().toLocaleTimeString(&#39;en-IN&#39;, {hour: &#39;2-digit&#39;, minute: &#39;2-digit&#39;, hour12: false})&quot;))
    print(&quot;slots offered:   &quot;, page.locator(&quot;label&quot;).all_inner_texts())
    page.locator(&quot;input[name=slot]&quot;).first.check()
    page.get_by_role(&quot;button&quot;, name=&quot;Schedule delivery&quot;).click()
    print(&quot;confirmation:    &quot;, page.locator(&quot;#confirm&quot;).inner_text())
    browser.close()
</code></pre>
<pre tabindex="0"><code class="language-text">time on the phone: 19:55
slots offered:     [&#39;15:30 – 16:00&#39;, &#39;16:00 – 16:30&#39;, &#39;16:30 – 17:00&#39;, &#39;17:00 – 17:30&#39;]
confirmation:      Your order will arrive between 15:30 and 16:00 today.
</code></pre>
<p>At 19:55, the app offered a delivery at 15:30, four and a half hours ago, and confirmed it.</p>
<p><img src="https://provalab.io/blog/practice/uat-timezone.png" alt="Two phone screens at 19:55 India time. On the UAT server with its clock in UTC, the slots start at 15:30. After the fix, they start at 21:00." loading="lazy"></p>
<p>(We drew the phone frame and its clock around the real screenshots. The time is the one the browser reported in the run.)</p>
<p>Why did every system test pass? <code>datetime.now()</code> returns the <strong>server&#39;s</strong> local time. On the developers&#39; laptops, that&#39;s India time, so everything looked right. The test that checks the page, <code>test_page_offers_slots_from_now</code>, compares the page with <code>slots(fn.now())</code>, the same wrong clock, so it passed on the UTC machine too. A test that computes the expected value the same way as the code shares its mistakes. The other tests used fixed times, so they never asked &quot;what time is it for the customer?&quot;</p>
<p>The fix: slots are in the kitchen&#39;s local time, whatever the server&#39;s clock says:</p>
<pre tabindex="0"><code class="language-diff"> from datetime import datetime, timedelta
+from zoneinfo import ZoneInfo
...
+KITCHEN_TZ = ZoneInfo(&quot;Asia/Kolkata&quot;)  # slots are in the kitchen&#39;s local time
...
 def now() -&gt; datetime:
-    return datetime.now()
+    return datetime.now(KITCHEN_TZ).replace(tzinfo=None)
</code></pre>
<p>And a system test that would have caught it, so it can&#39;t come back:</p>
<pre tabindex="0"><code class="language-python">def test_now_is_kitchen_time_whatever_the_server_clock(monkeypatch):
    monkeypatch.setenv(&quot;TZ&quot;, &quot;UTC&quot;)  # like the UAT server
    time.tzset()
    try:
        kitchen = datetime.now(ZoneInfo(&quot;Asia/Kolkata&quot;)).replace(tzinfo=None)
        assert abs(fn.now() - kitchen) &lt; timedelta(seconds=5)
    finally:
        monkeypatch.undo()
        time.tzset()
</code></pre>
<p>On the old code, it fails by 5 hours 30 minutes:</p>
<pre tabindex="0"><code class="language-text">E       assert datetime.timedelta(seconds=19799, microseconds=999987) &lt; datetime.timedelta(seconds=5)
1 failed, 4 passed, 1 warning in 0.48s
</code></pre>
<p>With the fix, <code>5 passed</code>. The dry run again, on the same UTC server:</p>
<pre tabindex="0"><code class="language-text">time on the phone: 19:55
slots offered:     [&#39;21:00 – 21:30&#39;, &#39;21:30 – 22:00&#39;, &#39;22:00 – 22:30&#39;, &#39;22:30 – 23:00&#39;]
confirmation:      Your order will arrive between 21:00 and 21:30 today.
</code></pre>
<p>19:55 plus 45 minutes is 20:40, so 21:00 is the first slot, and the last ends when the kitchen closes at 23:00. Had we skipped the dry run, the first tester would have hit this in the first minute, and the session would have been spent on a bug instead of on whether the feature fits their work.</p>
<h2>Step 5: Run the sessions</h2>
<p>What makes a UAT session useful:</p>
<ul>
<li><strong>Give the task, then be quiet.</strong> Read the task out, and let the user work. Don&#39;t point. If you help, you&#39;re testing yourself.</li>
<li><strong>Ask them to think aloud.</strong> &quot;I&#39;m looking for... I expected this to say...&quot; is where the findings are.</li>
<li><strong>Write down what they do, not just what they say.</strong> Hesitations, wrong taps, going back. Users often blame themselves for the software&#39;s problems: &quot;Sorry, I&#39;m not good with apps.&quot;</li>
<li><strong>Use their devices, at their pace.</strong> Not a fast office laptop with the developer&#39;s account.</li>
<li><strong>Record each result:</strong> the scenario, the tester, pass or fail, and what they found.</li>
</ul>
<h2>Step 6: Triage, then check the exit criteria</h2>
<p><em>The results below are an example, written for this post to show the process. They are not from real users.</em> Picture this at the end of the sessions:</p>
<pre tabindex="0"><code class="language-csv">scenario,must_have,tester,result,finding,type,severity,status
S1 Schedule lunch for a meeting,yes,Office manager,pass,,,,
S1 Schedule lunch for a meeting,yes,Team assistant,pass,,,,
S2 Change your mind before paying,yes,Office manager,pass,,,,
S2 Change your mind before paying,yes,Team assistant,fail,Back button loses the chosen slot,defect,major,fixed and retested
S3 Late evening order,yes,Night-shift lead,pass,,,,
S3 Late evening order,yes,Team assistant,pass,,,,
S4 Order for tomorrow,no,Office manager,fail,Wants to book tomorrow&#39;s lunch today,change request,,backlog
S5 Read the confirmation aloud,no,Team assistant,pass,&quot;&quot;&quot;Today&quot;&quot; was unclear just after midnight&quot;,defect,minor,accepted by product owner
</code></pre>
<p>Every finding is triaged into one of three kinds:</p>
<ul>
<li><strong>Defect:</strong> the software doesn&#39;t do what was agreed. The back button losing the slot breaks S2. Major, fixed, retested.</li>
<li><strong>Change request:</strong> it does what was agreed, but users want something else. Booking for tomorrow was never in the story. It goes to the backlog, and it doesn&#39;t block acceptance. It&#39;s still valuable: the next story is written.</li>
<li><strong>Wording, training or documentation:</strong> the software is right, but users misread it. &quot;Today&quot; just after midnight is confusing. Minor, and the product owner accepted it for now.</li>
</ul>
<p>Arguing about which kind a finding is happens in every UAT. Decide with the acceptance criteria from the story, not with how strongly someone feels.</p>
<p>This script checks the results against the exit criteria from Step 1. It&#39;s <code>go_no_go.py</code>:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;Check UAT results against the exit criteria agreed before UAT started.&quot;&quot;&quot;
import csv
import sys
from collections import defaultdict

MIN_TESTERS = 2  # each must-have scenario passed by at least two people
BLOCKING = {&quot;critical&quot;, &quot;major&quot;}
CLOSED = {&quot;fixed and retested&quot;, &quot;accepted by product owner&quot;}

rows = list(csv.DictReader(open(sys.argv[1], encoding=&quot;utf-8&quot;)))
problems = []

passes = defaultdict(set)
for r in rows:
    if r[&quot;must_have&quot;] == &quot;yes&quot; and r[&quot;result&quot;] == &quot;pass&quot;:
        passes[r[&quot;scenario&quot;]].add(r[&quot;tester&quot;])
for s in sorted({r[&quot;scenario&quot;] for r in rows if r[&quot;must_have&quot;] == &quot;yes&quot;}):
    if len(passes[s]) &lt; MIN_TESTERS:
        problems.append(f&quot;{s}: passed by {len(passes[s])} tester(s), need {MIN_TESTERS}&quot;)

for r in rows:
    if r[&quot;type&quot;] == &quot;defect&quot; and r[&quot;status&quot;] not in CLOSED:
        if r[&quot;severity&quot;] in BLOCKING:
            problems.append(f&quot;open {r[&#39;severity&#39;]} defect: {r[&#39;finding&#39;]}&quot;)
    if r[&quot;type&quot;] == &quot;defect&quot; and r[&quot;severity&quot;] in BLOCKING and r[&quot;status&quot;] == &quot;accepted by product owner&quot;:
        problems.append(f&quot;{r[&#39;severity&#39;]} defect accepted instead of fixed: {r[&#39;finding&#39;]}&quot;)

defects = [r for r in rows if r[&quot;type&quot;] == &quot;defect&quot;]
changes = [r for r in rows if r[&quot;type&quot;] == &quot;change request&quot;]
print(f&quot;{len(rows)} results, {len(defects)} defects, {len(changes)} change requests&quot;)
for r in changes:
    print(f&quot;  change request (not a blocker): {r[&#39;finding&#39;]} -&gt; {r[&#39;status&#39;]}&quot;)
if problems:
    print(&quot;NO GO&quot;)
    for p in problems:
        print(&quot;  -&quot;, p)
    sys.exit(1)
print(&quot;GO: exit criteria met, ready for sign-off&quot;)
</code></pre>
<pre tabindex="0"><code class="language-text">$ python go_no_go.py uat-results.csv
8 results, 2 defects, 1 change requests
  change request (not a blocker): Wants to book tomorrow&#39;s lunch today -&gt; backlog
NO GO
  - S2 Change your mind before paying: passed by 1 tester(s), need 2
</code></pre>
<p>We expected a GO: the defect&#39;s status says &quot;fixed and retested&quot;. But the retest was never recorded as a result. The sheet only shows the failure. As far as the evidence goes, only one person has completed S2. That&#39;s a real gap that happens all the time: the fix is mentioned in a chat, and the record still says it failed. Recording the retest:</p>
<pre tabindex="0"><code class="language-csv">S2 Change your mind before paying,yes,Team assistant,pass,Retest after the back-button fix,,,
</code></pre>
<pre tabindex="0"><code class="language-text">$ python go_no_go.py uat-results.csv
9 results, 2 defects, 1 change requests
  change request (not a blocker): Wants to book tomorrow&#39;s lunch today -&gt; backlog
GO: exit criteria met, ready for sign-off
</code></pre>
<p>And for comparison, the results as they stood on the first day, with the back-button defect still open:</p>
<pre tabindex="0"><code class="language-text">NO GO
  - S2 Change your mind before paying: passed by 1 tester(s), need 2
  - open major defect: Back button loses the chosen slot
</code></pre>
<p>A script doesn&#39;t make the decision. It makes sure the decision is made against the criteria everyone agreed, with the evidence in front of them, and not from memory at the end of a long week.</p>
<h2>Step 7: Sign-off</h2>
<p>The sign-off is a short record, signed by the named person from Step 1:</p>
<ul>
<li><strong>What was tested:</strong> the build version and the environment.</li>
<li><strong>Results:</strong> the scenarios, testers and outcomes, and the exit criteria result.</li>
<li><strong>Known issues accepted:</strong> the minor &quot;today&quot; wording, with who accepted it.</li>
<li><strong>Change requests raised:</strong> booking for tomorrow, now in the backlog.</li>
<li><strong>Decision:</strong> accepted, accepted with conditions, or not accepted.</li>
</ul>
<p>If the decision is &quot;not accepted&quot;, that&#39;s UAT working, not failing. It&#39;s much cheaper than finding out from customers.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Treating UAT as system testing by other people.</strong> If users find crashes and wrong totals, the build wasn&#39;t ready. Use entry criteria.</li>
<li><strong>Step-by-step scripts.</strong> They tell you the buttons work. Tasks tell you whether people can do their job.</li>
<li><strong>A UAT environment that isn&#39;t like production.</strong> Ours had a UTC clock, and the bug only existed there. Do a dry run on it, as a user, before inviting anyone.</li>
<li><strong>Helping the user.</strong> The moment you point at the right button, you&#39;ve lost the finding.</li>
<li><strong>Deciding the exit criteria afterwards.</strong> Agree them in the plan, then check the results against them.</li>
<li><strong>Treating every finding as a bug.</strong> Change requests are useful, but they&#39;re not defects. Log them, and don&#39;t let them block a release that does what was agreed.</li>
<li><strong>Fixes that are never retested on the record.</strong> Step 6 shows how a &quot;fixed and retested&quot; status can hide a missing result.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong>Part 10</strong> of Testing in Practice we look at <strong>usability testing</strong>: how to watch five people use your app and find most of the problems that make users give up, and how it differs from UAT.</p>
<p>ProvaLab&#39;s test management system keeps UAT in the same place as the rest of your testing. Write the UAT scenarios as test cases, run them as a test run in the UAT build&#39;s version, and record the results, with defects linked to the case that found them. When it&#39;s time to decide, the run shows what passed, what failed and what was retested, next to the system tests for the same release. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Accessibility testing for beginners: WCAG 2.2, an axe scan and keyboard tests (hands-on)</title>
      <link>https://provalab.io/blog/accessibility-testing-wcag-axe-playwright/</link>
      <guid isPermaLink="true">https://provalab.io/blog/accessibility-testing-wcag-axe-playwright/</guid>
      <pubDate>Tue, 10 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>accessibility-testing</category>
      <category>playwright</category>
      <category>pytest</category>
      <description>What accessibility testing checks, what WCAG 2.2 A and AA mean, then a real run: axe-core, a keyboard test and a screen-reader names test catch planted bugs on a checkout page.</description>
      <content:encoded><![CDATA[<p>Close your eyes and try to order a pizza. Or put your mouse in a drawer and pay using only the Tab and Enter keys. For a lot of people that isn&#39;t an experiment, it&#39;s every day: the World Health Organization estimates that <strong>1.3 billion people, about 1 in 6, live with a significant disability</strong>. Many of them use a screen reader, a keyboard, a switch, voice control or 200% zoom to use the same apps you test.</p>
<p>Most of the web still lets them down. The 2026 <a href="https://webaim.org/projects/million/">WebAIM Million</a> scan of the top million home pages found detectable WCAG failures on <strong>95.9%</strong> of them. The most common problem, for the eighth year running, was <strong>low-contrast text</strong>.</p>
<p>This post starts the <strong>Testing in Practice</strong> series. It covers what accessibility testing checks, then a hands-on run: we plant seven accessibility bugs in a small checkout page and see which ones an automated scan finds, which ones our own tests find, and which one only a person notices. Every command and every line of output below was run while writing this post.</p>
<h2>What accessibility testing checks</h2>
<p>Accessibility testing (often shortened to <strong>a11y</strong>: an &quot;a&quot;, 11 letters, a &quot;y&quot;) checks that people with disabilities can perceive, understand and use your app. The yardstick almost everyone uses is <strong>WCAG</strong>, the W3C&#39;s Web Content Accessibility Guidelines. The current version is <strong>WCAG 2.2</strong>, published in October 2023.</p>
<p>WCAG is built on four principles, easy to remember as <strong>POUR</strong>:</p>
<table tabindex="0">
<thead>
<tr>
<th>Principle</th>
<th>Means</th>
<th>Example check</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Perceivable</strong></td>
<td>Everyone can get the information</td>
<td>Images have alt text; text has enough contrast; videos have captions</td>
</tr>
<tr>
<td><strong>Operable</strong></td>
<td>Everyone can use the controls</td>
<td>Everything works with a keyboard; nothing traps focus; no time limits you can&#39;t extend</td>
</tr>
<tr>
<td><strong>Understandable</strong></td>
<td>The content and the UI make sense</td>
<td>Form fields have labels; errors say what went wrong and how to fix it</td>
</tr>
<tr>
<td><strong>Robust</strong></td>
<td>It works with assistive technology</td>
<td>Buttons are real buttons, so a screen reader announces &quot;button&quot; and its name</td>
</tr>
</tbody></table>
<p>Each guideline has <strong>success criteria</strong> at three levels: <strong>A</strong> (the minimum), <strong>AA</strong> (the usual target) and <strong>AAA</strong> (the strictest). When a contract, a law or a client says &quot;accessible&quot;, it almost always means <strong>WCAG 2.1 or 2.2 level AA</strong>. In the EU, the European Accessibility Act has applied since 28 June 2025 to many products and services, online shops included. So this isn&#39;t just good practice: for many teams, it&#39;s a legal requirement.</p>
<h2>Three layers of accessibility testing</h2>
<p>No single check finds everything. Good teams use three layers:</p>
<ol>
<li><strong>An automated scan</strong> (axe-core, Lighthouse, WAVE). It checks the page against rules in about a second: contrast, missing alt text, missing labels, missing page language. Cheap enough to run on every build.</li>
<li><strong>Scripted checks</strong> you write yourself: can a keyboard reach and press every control, and does every control have a name a screen reader can read? These are ordinary automated tests.</li>
<li><strong>A person</strong>: tabbing through the page, zooming to 200%, listening with a screen reader, judging whether a label actually makes sense. No tool can tell you that &quot;Click here&quot; is a bad link text for a blind user scanning a list of links.</li>
</ol>
<p>How much does the first layer catch? Deque, the company behind axe-core, reported that automated tests found <strong>57% of issues</strong> across more than 2,000 of its audits. That&#39;s by volume of issues, and it&#39;s the vendor&#39;s own figure. Read it as &quot;a lot, but nowhere near all&quot;, which is exactly what we&#39;ll see below.</p>
<h2>The page under test</h2>
<p>Our page is a checkout from the food delivery app that this blog uses in every example. It has a delivery tip box (0 to 200, as in <a href="https://provalab.io/blog/how-to-write-test-cases/">How to write test cases</a>), a button to remove the tip and a Pay button. It looks fine. We&#39;ve planted seven accessibility bugs in it:</p>
<pre tabindex="0"><code class="language-html">&lt;!doctype html&gt;
&lt;html&gt;
&lt;head&gt;
  &lt;meta charset=&quot;utf-8&quot;&gt;
  &lt;title&gt;Checkout · FoodNow&lt;/title&gt;
  &lt;style&gt;
    body { font-family: system-ui, sans-serif; max-width: 420px; margin: 40px auto; color: #111; }
    .note { color: #b5b5b5; font-size: 14px; }
    .pay { background: #16a34a; color: #fff; padding: 12px; text-align: center; border-radius: 8px; cursor: pointer; }
    .remove { border: 0; background: none; font-size: 18px; cursor: pointer; }
  &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;img src=&quot;logo.png&quot; width=&quot;120&quot; height=&quot;40&quot;&gt;
  &lt;h1&gt;Your order&lt;/h1&gt;
  &lt;p&gt;Margherita pizza · 500&lt;/p&gt;

  &lt;input id=&quot;tip&quot; type=&quot;number&quot; placeholder=&quot;Delivery tip (0 to 200)&quot;&gt;
  &lt;button class=&quot;remove&quot; onclick=&quot;document.getElementById(&#39;tip&#39;).value=&#39;&#39;&quot;&gt;×&lt;/button&gt;
  &lt;p class=&quot;note&quot;&gt;The tip goes to your rider in full.&lt;/p&gt;

  &lt;div class=&quot;pay&quot; onclick=&quot;pay()&quot;&gt;Pay 500&lt;/div&gt;
  &lt;p id=&quot;status&quot;&gt;&lt;/p&gt;

  &lt;script&gt;
    function pay() { document.getElementById(&quot;status&quot;).textContent = &quot;Order placed&quot;; }
  &lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Can you spot all seven before reading on? Most people find two or three.</p>
<h2>Step 1: Set up (2 minutes)</h2>
<p>You need Python and <a href="https://docs.astral.sh/uv/">uv</a>. We used these versions:</p>
<table tabindex="0">
<thead>
<tr>
<th>Package</th>
<th>Version</th>
</tr>
</thead>
<tbody><tr>
<td>pytest</td>
<td>9.2.0</td>
</tr>
<tr>
<td>pytest-playwright</td>
<td>0.10.0</td>
</tr>
<tr>
<td>playwright</td>
<td>1.64.0</td>
</tr>
<tr>
<td>axe-core</td>
<td>4.14.0</td>
</tr>
</tbody></table>
<p>axe-core is a JavaScript library. We inject it into the page ourselves, so you can see exactly what runs. Download its single file once:</p>
<pre tabindex="0"><code class="language-bash">npm pack axe-core@4.14.0
tar xzf axe-core-4.14.0.tgz package/axe.min.js --strip-components=1
</code></pre>
<p>The project is four files: the page in <code>site/</code>, the axe file, a <code>conftest.py</code> that serves the page, and the tests.</p>
<pre tabindex="0"><code class="language-text">a11y/
├── axe.min.js
├── conftest.py
├── site/
│   ├── checkout.html
│   └── logo.png
└── test_accessibility.py
</code></pre>
<p><code>conftest.py</code> starts a small web server on a free port for the test session, so the tests open a real <code>http://</code> page:</p>
<pre tabindex="0"><code class="language-python">import functools, http.server, pathlib, threading
import pytest

SITE = pathlib.Path(__file__).parent / &quot;site&quot;


class QuietHandler(http.server.SimpleHTTPRequestHandler):
    def log_message(self, *args):  # keep the test output readable
        pass


@pytest.fixture(scope=&quot;session&quot;)
def base_url():
    &quot;&quot;&quot;Serve site/ on a free local port for the whole test session.&quot;&quot;&quot;
    handler = functools.partial(QuietHandler, directory=SITE)
    server = http.server.ThreadingHTTPServer((&quot;127.0.0.1&quot;, 0), handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    yield f&quot;http://127.0.0.1:{server.server_port}&quot;
    server.shutdown()
</code></pre>
<p>If you haven&#39;t used Playwright before, <a href="https://provalab.io/blog/first-playwright-test/">Your first Playwright test</a> covers installing it and its browsers.</p>
<h2>Step 2: Write three accessibility tests</h2>
<p>One test per layer we can automate:</p>
<pre tabindex="0"><code class="language-python">import pathlib
from playwright.sync_api import Page, expect

AXE = pathlib.Path(__file__).parent / &quot;axe.min.js&quot;
WCAG_AA = [&quot;wcag2a&quot;, &quot;wcag2aa&quot;, &quot;wcag21a&quot;, &quot;wcag21aa&quot;, &quot;wcag22aa&quot;]


def axe_violations(page: Page) -&gt; list[dict]:
    &quot;&quot;&quot;Run axe-core on the page and return the WCAG A/AA rules it breaks.&quot;&quot;&quot;
    page.add_script_tag(path=AXE)
    result = page.evaluate(
        &quot;tags =&gt; axe.run(document, { runOnly: { type: &#39;tag&#39;, values: tags } })&quot;, WCAG_AA
    )
    return result[&quot;violations&quot;]


def describe(violations: list[dict]) -&gt; str:
    lines = []
    for v in violations:
        lines.append(f&quot;{v[&#39;id&#39;]} ({v[&#39;impact&#39;]}): {v[&#39;help&#39;]}&quot;)
        for node in v[&quot;nodes&quot;]:
            lines.append(f&quot;    {node[&#39;target&#39;][0]}&quot;)
    return &quot;\n&quot;.join(lines)


def test_checkout_has_no_wcag_violations(page: Page, base_url: str):
    page.goto(f&quot;{base_url}/checkout.html&quot;)
    violations = axe_violations(page)
    assert not violations, &quot;\n&quot; + describe(violations)


def test_checkout_works_with_the_keyboard_alone(page: Page, base_url: str):
    page.goto(f&quot;{base_url}/checkout.html&quot;)
    page.get_by_role(&quot;spinbutton&quot;, name=&quot;Delivery tip&quot;).fill(&quot;50&quot;)
    # From the tip box, Tab until the Pay button has focus (no mouse).
    for _ in range(5):
        page.keyboard.press(&quot;Tab&quot;)
        if page.evaluate(&quot;document.activeElement.textContent.trim()&quot;).startswith(&quot;Pay&quot;):
            break
    page.keyboard.press(&quot;Enter&quot;)
    expect(page.locator(&quot;#status&quot;)).to_have_text(&quot;Order placed&quot;)


def test_controls_have_names_a_screen_reader_can_read(page: Page, base_url: str):
    page.goto(f&quot;{base_url}/checkout.html&quot;)
    expect(page.locator(&quot;body&quot;)).to_match_aria_snapshot(
        &quot;&quot;&quot;
        - spinbutton &quot;Delivery tip (0 to 200)&quot;
        - button &quot;Remove tip&quot;
        - button &quot;Pay 500&quot;
        &quot;&quot;&quot;
    )
</code></pre>
<p>What each one does:</p>
<ul>
<li><strong>The axe scan</strong> limits itself to the WCAG A and AA rules (the <code>wcag2a</code> … <code>wcag22aa</code> tags), so it checks the level you&#39;re actually held to. <code>describe()</code> turns axe&#39;s large JSON answer into one readable line per problem.</li>
<li><strong>The keyboard test</strong> never clicks. It types the tip, presses <strong>Tab</strong> until Pay has focus, and presses <strong>Enter</strong>. That&#39;s how a keyboard or switch user pays.</li>
<li><strong>The names test</strong> checks the <strong>accessibility tree</strong>: the version of the page that a screen reader reads out. Playwright&#39;s <code>to_match_aria_snapshot</code> compares the role and name of each control with what we expect. A button must be announced as a button, with a name that says what it does.</li>
</ul>
<h2>Step 3: Run it and read the failures</h2>
<pre tabindex="0"><code class="language-bash">uv run --no-project --python 3.12 --with pytest==9.2.0 --with pytest-playwright==0.10.0 --with playwright==1.64.0 python -m pytest -q
</code></pre>
<p>All three fail. Here&#39;s the important part of each.</p>
<p><strong>The axe scan</strong> found four problems under three rules:</p>
<pre tabindex="0"><code class="language-text">E       AssertionError:
E         color-contrast (serious): Elements must meet minimum color contrast ratio thresholds
E             .note
E             div
E         html-has-lang (serious): &lt;html&gt; element must have a lang attribute
E             html
E         image-alt (critical): Images must have alternative text
E             img
</code></pre>
<p>axe also explains each one. For the contrast failures it gave the exact numbers:</p>
<pre tabindex="0"><code class="language-text">.note  Element has insufficient color contrast of 2.05 (foreground color: #b5b5b5, background color: #ffffff, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1
div    Element has insufficient color contrast of 3.29 (foreground color: #ffffff, background color: #16a34a, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1
</code></pre>
<p>The grey note was an obvious one. The white-on-green Pay button is a surprise to most people: it looks perfectly readable, and it&#39;s a very common brand green, but at 16px it&#39;s well short of the 4.5:1 that WCAG AA asks for normal-size text.</p>
<p><strong>The keyboard test</strong> typed the tip, pressed Tab, pressed Enter, and nothing happened:</p>
<pre tabindex="0"><code class="language-text">E       AssertionError: Locator expected to have text &#39;Order placed&#39;
E       Actual value:
...
E       Aria snapshot:
E       - img
E       - heading &quot;Your order&quot; [level=1]
E       - paragraph: Margherita pizza · 500
E       - spinbutton &quot;Delivery tip (0 to 200)&quot;: &quot;50&quot;
E       - button &quot;×&quot;
E       - paragraph: The tip goes to your rider in full.
E       - text: Pay 500
E       - paragraph
</code></pre>
<p>Playwright helpfully printed the accessibility tree, and it shows why: <strong>Pay 500 is just <code>text</code></strong>, not a button. A <code>&lt;div&gt;</code> with an <code>onclick</code> works with a mouse, but the keyboard skips it and a screen reader doesn&#39;t announce it as something you can press. A keyboard user simply cannot pay. axe didn&#39;t report this, because a <code>&lt;div&gt;</code> with a click handler is invisible to its rules: nothing in the HTML says it&#39;s meant to be a button.</p>
<p><strong>The names test</strong> failed on two lines:</p>
<pre tabindex="0"><code class="language-text">E       AssertionError: Locator expected to match Aria snapshot &#39;
E               - spinbutton &quot;Delivery tip (0 to 200)&quot;
E               - button &quot;Remove tip&quot;
E               - button &quot;Pay 500&quot;
E               &#39;
...
E       - spinbutton &quot;Delivery tip (0 to 200)&quot;
E       - button &quot;×&quot;
E       - paragraph: The tip goes to your rider in full.
E       - text: Pay 500
</code></pre>
<p>The remove button&#39;s name is &quot;×&quot;. axe was satisfied, because the button does have a name. But a screen reader reads that character out as &quot;times&quot; or &quot;multiplication sign&quot;, which tells a blind user nothing. And Pay is missing as a button, again.</p>
<p>The run took 11 seconds instead of one because both Playwright assertions wait up to five seconds for the page to become right before giving up.</p>
<h2>Step 4: The one only a person finds</h2>
<p>The tests found six of the seven problems. The seventh: <strong>the tip box has no label, only a placeholder</strong>. The placeholder gives the box an accessible name, so axe and our names test both passed it. But the moment you start typing, the placeholder disappears. Someone with a memory or attention difficulty who comes back to the field no longer knows what it was for. And placeholder grey text is usually low-contrast too. Only a person looking at how the form <em>behaves</em> catches that.</p>
<p><img src="https://provalab.io/blog/practice/a11y-who-catches-what.svg" alt="A table of the seven accessibility problems on the checkout page and which check caught each one. The axe scan caught four: low-contrast note text, low-contrast Pay button, no page language and the logo with no alt text. The keyboard test caught that Pay is a div the keyboard cannot reach. The screen-reader names test caught that too, plus the remove button being read out as &quot;times&quot;. Only a person noticed the placeholder being used as the label." width="880" height="516" loading="lazy"></p>
<p>That&#39;s the shape of accessibility testing in one picture. The scan is fast, so run it on every build. It found four of the seven in about a second. Two short tests of your own found two more. A person found the last one, and a person is also the only one who can tell you whether the page is actually <em>pleasant</em> to use with a screen reader, which no test checks.</p>
<h2>Step 5: Fix them and run again</h2>
<p>Every fix is small:</p>
<pre tabindex="0"><code class="language-diff">-&lt;html&gt;
+&lt;html lang=&quot;en&quot;&gt;
-    .note { color: #b5b5b5; font-size: 14px; }
-    .pay { background: #16a34a; color: #fff; padding: 12px; text-align: center; border-radius: 8px; cursor: pointer; }
+    .note { color: #6b7280; font-size: 14px; }
+    .pay { background: #15803d; color: #fff; padding: 12px; width: 100%; border: 0; border-radius: 8px; font: inherit; cursor: pointer; }
-  &lt;img src=&quot;logo.png&quot; width=&quot;120&quot; height=&quot;40&quot;&gt;
+  &lt;img src=&quot;logo.png&quot; width=&quot;120&quot; height=&quot;40&quot; alt=&quot;FoodNow&quot;&gt;
-  &lt;input id=&quot;tip&quot; type=&quot;number&quot; placeholder=&quot;Delivery tip (0 to 200)&quot;&gt;
-  &lt;button class=&quot;remove&quot; onclick=&quot;document.getElementById(&#39;tip&#39;).value=&#39;&#39;&quot;&gt;×&lt;/button&gt;
+  &lt;label for=&quot;tip&quot;&gt;Delivery tip (0 to 200)&lt;/label&gt;
+  &lt;input id=&quot;tip&quot; type=&quot;number&quot; min=&quot;0&quot; max=&quot;200&quot;&gt;
+  &lt;button class=&quot;remove&quot; aria-label=&quot;Remove tip&quot; onclick=&quot;document.getElementById(&#39;tip&#39;).value=&#39;&#39;&quot;&gt;×&lt;/button&gt;
-  &lt;div class=&quot;pay&quot; onclick=&quot;pay()&quot;&gt;Pay 500&lt;/div&gt;
-  &lt;p id=&quot;status&quot;&gt;&lt;/p&gt;
+  &lt;button class=&quot;pay&quot; onclick=&quot;pay()&quot;&gt;Pay 500&lt;/button&gt;
+  &lt;p id=&quot;status&quot; role=&quot;status&quot;&gt;&lt;/p&gt;
</code></pre>
<ul>
<li><strong><code>lang=&quot;en&quot;</code></strong> tells a screen reader which language and pronunciation to use.</li>
<li><strong>Darker colours</strong>: a slightly darker grey and a slightly darker green both pass 4.5:1, and the design looks almost the same.</li>
<li><strong><code>alt=&quot;FoodNow&quot;</code></strong>: the logo now has a text alternative. A purely decorative image would get <code>alt=&quot;&quot;</code> instead, so screen readers skip it.</li>
<li><strong>A real <code>&lt;label&gt;</code></strong> that stays visible while you type.</li>
<li><strong><code>aria-label=&quot;Remove tip&quot;</code></strong>: the button still <em>shows</em> ×, but is <em>announced</em> as &quot;Remove tip&quot;.</li>
<li><strong>A real <code>&lt;button&gt;</code></strong> for Pay. This one change makes it reachable with Tab, pressable with Enter and Space, and announced as a button. Use the native element and you get all of that for free.</li>
<li><strong><code>role=&quot;status&quot;</code></strong> on the message, so a screen reader announces &quot;Order placed&quot; without the user having to go looking for it.</li>
</ul>
<p>Run the same command again:</p>
<pre tabindex="0"><code class="language-text">...                                                                      [100%]
3 passed in 1.24s
</code></pre>
<h2>A 10-minute manual check for every new page</h2>
<p>The tests now guard against these bugs coming back. For anything new, spend ten minutes as a person:</p>
<ol>
<li><strong>Keyboard only.</strong> Unplug the mouse. Tab through the whole page. Can you see where focus is at all times? Can you reach, use and leave every control, including menus and pop-ups?</li>
<li><strong>Zoom to 200%</strong>, then 400%. Does text reflow into one column, or does it overlap, cut off, or make you scroll sideways?</li>
<li><strong>Screen reader for two minutes</strong>: NVDA (free) on Windows, VoiceOver on Mac and iPhone, TalkBack on Android. Listen to the form. Does every field say what it is? Are errors read out?</li>
<li><strong>Headings.</strong> Do they form a sensible outline (one <code>h1</code>, then <code>h2</code>s)? Screen-reader users jump between headings to skim.</li>
<li><strong>Errors.</strong> Submit the form wrong. Is the error in text, not only red colour, and does it say how to fix it?</li>
<li><strong>Moving things.</strong> Anything that moves, blinks or scrolls by itself for more than five seconds needs a way to pause it, and nothing should flash more than three times a second.</li>
</ol>
<h2>Common mistakes</h2>
<ul>
<li><strong>Trusting a green scan.</strong> &quot;axe found nothing&quot; means &quot;axe found nothing it can detect&quot;. As we just saw, it can miss the bug that stops a keyboard user from paying.</li>
<li><strong>Adding ARIA instead of using native elements.</strong> <code>&lt;div role=&quot;button&quot; tabindex=&quot;0&quot;&gt;</code> plus key handlers is more code and more ways to get it wrong than <code>&lt;button&gt;</code>. The first rule of ARIA is: don&#39;t use ARIA if a native element does the job.</li>
<li><strong>Testing once, at the end.</strong> Accessibility fixes are cheap in a component and expensive across 200 finished pages. Put the scan in CI from the first page.</li>
<li><strong>Fixing the tool&#39;s list and calling it done.</strong> Users with disabilities are the real test. If you can, include them in usability sessions.</li>
</ul>
<h2>Put it in your pipeline</h2>
<p>The scan test is an ordinary pytest test, so it runs wherever your other tests run. In a real suite, scan every important page and every important state: the menu open, the form showing errors, the modal on screen, because axe only sees the page as it is at that moment. Many teams fail the build on <code>serious</code> and <code>critical</code> problems, and report <code>moderate</code> and <code>minor</code> ones without failing.</p>
<h2>What&#39;s next</h2>
<p>In <strong>Part 2</strong> of Testing in Practice we test an API with <strong>Postman</strong>, then run the same collection from the command line with Newman, so it can run in CI.</p>
<p>ProvaLab&#39;s QA agent can do the automated part for you: ask it to check a page&#39;s accessibility and it writes an axe-core test like the one above, runs it in a real browser, and reports the WCAG A and AA problems by impact. The keyboard check and the person with a screen reader are still yours. See the agent in the <a href="https://provalab.io/#stage-agent">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>API testing with Postman and Newman (hands-on): tests, variables, a CSV of boundary values, and CI</title>
      <link>https://provalab.io/blog/api-testing-with-postman-and-newman/</link>
      <guid isPermaLink="true">https://provalab.io/blog/api-testing-with-postman-and-newman/</guid>
      <pubDate>Tue, 10 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>api-testing</category>
      <category>postman</category>
      <category>newman</category>
      <description>Build a Postman collection with tests and chained variables, run it from the command line with Newman, then drive it from a CSV of boundary values that catches a real bug.</description>
      <content:encoded><![CDATA[<p>Postman is where most testers meet APIs. You type a URL, press <strong>Send</strong>, and look at the JSON that comes back. That&#39;s exploring, and it&#39;s useful. But a request you only ever send by hand isn&#39;t a test: nobody checks the answer the next time, and nobody runs it when the code changes.</p>
<p>This post turns Postman requests into real tests. We build a small collection for a food delivery API, add checks to every request, pass a login token from one request to the next, and run the whole thing from the command line with <strong>Newman</strong>, Postman&#39;s open-source runner. Then we feed it a CSV of boundary values, and it finds a bug that the &quot;normal&quot; tests walked straight past.</p>
<p>Every command and every line of output below was run while writing this post.</p>
<p>If APIs are new to you, read <a href="https://provalab.io/blog/api-testing-explained/">API testing explained</a> first. It covers methods, status codes and what to check in a response.</p>
<h2>What you&#39;ll build</h2>
<ul>
<li>An API to test: a tiny FoodNow orders API, running on your own machine.</li>
<li>A Postman <strong>collection</strong>: 7 requests in two folders, with 13 checks.</li>
<li>An <strong>environment</strong>: the base URL and login, kept out of the requests so you can point the same tests at local, staging or production.</li>
<li>A <strong>data-driven run</strong>: one request, six tip values from a CSV file.</li>
<li>A run you can put in CI: one command, an exit code, and a JUnit report.</li>
</ul>
<p>We used these versions:</p>
<table tabindex="0">
<thead>
<tr>
<th>Tool</th>
<th>Version</th>
</tr>
</thead>
<tbody><tr>
<td>Newman</td>
<td>6.2.3</td>
</tr>
<tr>
<td>Node.js</td>
<td>20.19.5</td>
</tr>
<tr>
<td>Python (for the API)</td>
<td>3.12</td>
</tr>
<tr>
<td>FastAPI / uvicorn</td>
<td>0.162.2 / 0.54.0</td>
</tr>
</tbody></table>
<h2>Step 1: Start the API under test (2 minutes)</h2>
<p>The API has four endpoints: log in, read the menu, place an order, read an order. Orders take a delivery tip, and the requirement is the one from <a href="https://provalab.io/blog/how-to-write-test-cases/">How to write test cases</a>: <strong>a tip from 0 to 200</strong>.</p>
<p>Save this as <code>api.py</code>:</p>
<pre tabindex="0"><code class="language-python">&quot;&quot;&quot;FoodNow orders API: just enough to test with Postman and Newman.&quot;&quot;&quot;
import itertools
from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel, Field

app = FastAPI(title=&quot;FoodNow&quot;)

USERS = {&quot;asha@example.com&quot;: &quot;pizza123&quot;}
TOKENS: dict[str, str] = {}
MENU = {1: {&quot;id&quot;: 1, &quot;name&quot;: &quot;Margherita pizza&quot;, &quot;price&quot;: 500},
        2: {&quot;id&quot;: 2, &quot;name&quot;: &quot;Paneer wrap&quot;, &quot;price&quot;: 250}}
ORDERS: dict[int, dict] = {}
next_id = itertools.count(1001)


class Login(BaseModel):
    email: str
    password: str


class NewOrder(BaseModel):
    item_id: int
    tip: int = Field(0, ge=0, lt=200)  # the requirement says 0 to 200


def user(authorization: str = Header(None)) -&gt; str:
    token = (authorization or &quot;&quot;).removeprefix(&quot;Bearer &quot;)
    if token not in TOKENS:
        raise HTTPException(401, &quot;Log in first&quot;)
    return TOKENS[token]


@app.post(&quot;/login&quot;)
def login(body: Login):
    if USERS.get(body.email) != body.password:
        raise HTTPException(401, &quot;Wrong email or password&quot;)
    token = f&quot;tok-{len(TOKENS) + 1}&quot;
    TOKENS[token] = body.email
    return {&quot;token&quot;: token}


@app.get(&quot;/menu&quot;)
def menu():
    return list(MENU.values())


@app.post(&quot;/orders&quot;, status_code=201)
def create_order(body: NewOrder, email: str = Depends(user)):
    item = MENU.get(body.item_id)
    if not item:
        raise HTTPException(404, &quot;No such item&quot;)
    order = {&quot;id&quot;: next(next_id), &quot;item&quot;: item[&quot;name&quot;], &quot;subtotal&quot;: item[&quot;price&quot;],
             &quot;tip&quot;: body.tip, &quot;total&quot;: item[&quot;price&quot;] + body.tip, &quot;customer&quot;: email}
    ORDERS[order[&quot;id&quot;]] = order
    return order


@app.get(&quot;/orders/{order_id}&quot;)
def get_order(order_id: int, email: str = Depends(user)):
    order = ORDERS.get(order_id)
    if not order or order[&quot;customer&quot;] != email:
        raise HTTPException(404, &quot;No such order&quot;)
    return order
</code></pre>
<p>There&#39;s a bug in it, the kind that ships every week. Look at the <code>tip</code> line if you want a head start. Otherwise, let&#39;s see whether our tests find it.</p>
<p>Start it with <a href="https://docs.astral.sh/uv/">uv</a>:</p>
<pre tabindex="0"><code class="language-bash">uv run --with fastapi==0.162.2 --with uvicorn==0.54.0 uvicorn api:app --port 8077
</code></pre>
<h2>Step 2: The pieces of a Postman collection</h2>
<p>A <strong>collection</strong> is a saved set of requests, in folders, with everything Postman needs to send them again. Four features turn it from a bookmark list into a test suite:</p>
<table tabindex="0">
<thead>
<tr>
<th>Feature</th>
<th>What it does</th>
<th>In our collection</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Variables</strong> <code>{{like_this}}</code></td>
<td>Fill in a value when the request is sent</td>
<td><code>{{baseUrl}}</code>, <code>{{token}}</code>, <code>{{orderId}}</code></td>
</tr>
<tr>
<td><strong>Environment</strong></td>
<td>A named set of variables, swapped in one click</td>
<td><code>Local</code> with the base URL, email and password</td>
</tr>
<tr>
<td><strong>Test script</strong></td>
<td>JavaScript that runs after the response arrives</td>
<td><code>pm.test(&quot;status is 201&quot;, ...)</code></td>
</tr>
<tr>
<td><strong>Pre-request script</strong></td>
<td>JavaScript that runs before the request is sent</td>
<td>Log in once, before the first order</td>
</tr>
</tbody></table>
<p>In the app, you&#39;ll find these on each request&#39;s <strong>Scripts</strong> tab (Pre-request and Post-response), and the variables on the collection&#39;s <strong>Variables</strong> tab. Postman saves the whole collection as a JSON file, and that file is what Newman runs. It also goes into git next to your code, so a pull request can show what changed in the tests.</p>
<p>We wrote our collection as that JSON file directly. To see it in the app, use <strong>Import</strong> and choose the file: you get the same folders, requests and scripts.</p>
<h2>Step 3: Write the requests and their tests</h2>
<p>Our collection has two folders.</p>
<p><strong>Happy path</strong>, in this order, because each request uses what the one before it saved:</p>
<ol>
<li><p><strong>Log in</strong>: <code>POST {{baseUrl}}/login</code> with the email and password from the environment. The test saves the token:</p>
<pre tabindex="0"><code class="language-javascript">pm.test(&quot;status is 200&quot;, () =&gt; pm.response.to.have.status(200));
const token = pm.response.json().token;
pm.test(&quot;returns a token&quot;, () =&gt; pm.expect(token).to.be.a(&quot;string&quot;).and.not.empty);
pm.collectionVariables.set(&quot;token&quot;, token);
</code></pre>
</li>
<li><p><strong>Get the menu</strong>: <code>GET {{baseUrl}}/menu</code>. Checks the data, not just the status, and puts a ceiling on the response time:</p>
<pre tabindex="0"><code class="language-javascript">pm.test(&quot;status is 200&quot;, () =&gt; pm.response.to.have.status(200));
const menu = pm.response.json();
pm.test(&quot;pizza costs 500&quot;, () =&gt; {
    const pizza = menu.find(i =&gt; i.name === &quot;Margherita pizza&quot;);
    pm.expect(pizza.price).to.eql(500);
});
pm.test(&quot;answers in under 500 ms&quot;, () =&gt; pm.expect(pm.response.responseTime).to.be.below(500));
</code></pre>
</li>
<li><p><strong>Order with a tip of 50</strong>: <code>POST {{baseUrl}}/orders</code> with <code>{&quot;item_id&quot;: 1, &quot;tip&quot;: 50}</code>. It uses the collection&#39;s auth setting, <strong>Bearer token</strong> <code>{{token}}</code>, so every request sends the token from step 1 without you copying it anywhere. The test checks the arithmetic and saves the new order&#39;s id:</p>
<pre tabindex="0"><code class="language-javascript">pm.test(&quot;status is 201&quot;, () =&gt; pm.response.to.have.status(201));
const o = pm.response.json();
pm.test(&quot;total is subtotal + tip&quot;, () =&gt; pm.expect(o.total).to.eql(o.subtotal + 50));
pm.collectionVariables.set(&quot;orderId&quot;, o.id);
</code></pre>
</li>
<li><p><strong>Get that order</strong>: <code>GET {{baseUrl}}/orders/{{orderId}}</code>, and check it&#39;s the same order:</p>
<pre tabindex="0"><code class="language-javascript">pm.test(&quot;status is 200&quot;, () =&gt; pm.response.to.have.status(200));
pm.test(&quot;it is the order we just placed&quot;, () =&gt; {
    const o = pm.response.json();
    pm.expect(o.id).to.eql(Number(pm.collectionVariables.get(&quot;orderId&quot;)));
    pm.expect(o.tip).to.eql(50);
});
</code></pre>
</li>
</ol>
<p>Passing values from one request to the next like this is called <strong>chaining</strong>. It&#39;s what makes a collection test a whole flow (log in, order, check the order) instead of single requests in isolation.</p>
<p><strong>Errors</strong>, the negative tests:</p>
<ol start="5">
<li><p><strong>Order without logging in</strong>: the same order with auth set to <strong>No Auth</strong>. Expect <code>401</code>.</p>
</li>
<li><p><strong>Order with a tip of 250</strong>: expect <code>422</code>, and check the error points at the right field:</p>
<pre tabindex="0"><code class="language-javascript">pm.test(&quot;status is 422&quot;, () =&gt; pm.response.to.have.status(422));
pm.test(&quot;the error names the tip field&quot;, () =&gt;
    pm.expect(pm.response.json().detail[0].loc).to.include(&quot;tip&quot;));
</code></pre>
</li>
<li><p><strong>Order an item that does not exist</strong>: <code>item_id</code> 99. Expect <code>404</code>.</p>
</li>
</ol>
<p>The environment file, <code>local.postman_environment.json</code>, holds the three values that change from one place to another:</p>
<pre tabindex="0"><code class="language-json">{
  &quot;name&quot;: &quot;Local&quot;,
  &quot;values&quot;: [
    { &quot;key&quot;: &quot;baseUrl&quot;, &quot;value&quot;: &quot;http://localhost:8077&quot;, &quot;enabled&quot;: true },
    { &quot;key&quot;: &quot;email&quot;, &quot;value&quot;: &quot;asha@example.com&quot;, &quot;enabled&quot;: true },
    { &quot;key&quot;: &quot;password&quot;, &quot;value&quot;: &quot;pizza123&quot;, &quot;enabled&quot;: true, &quot;type&quot;: &quot;secret&quot; }
  ]
}
</code></pre>
<p>For staging you&#39;d have a second file with a different <code>baseUrl</code> and a test account. The requests and tests don&#39;t change at all.</p>
<h2>Step 4: Run it with Newman</h2>
<p>Newman runs a collection from the terminal, with no app and no account. With Node.js installed:</p>
<pre tabindex="0"><code class="language-bash">npx newman@6.2.3 run foodnow.postman_collection.json -e local.postman_environment.json
</code></pre>
<pre tabindex="0"><code class="language-text">FoodNow API

❏ Happy path
↳ Log in
  POST http://localhost:8077/login [200 OK, 142B, 23ms]
  ✓  status is 200
  ✓  returns a token

↳ Get the menu
  GET http://localhost:8077/menu [200 OK, 215B, 4ms]
  ✓  status is 200
  ✓  pizza costs 500
  ✓  answers in under 500 ms

↳ Order with a tip of 50
  POST http://localhost:8077/orders [201 Created, 234B, 3ms]
  ✓  status is 201
  ✓  total is subtotal + tip

↳ Get that order
  GET http://localhost:8077/orders/1001 [200 OK, 229B, 3ms]
  ✓  status is 200
  ✓  it is the order we just placed

❏ Errors
↳ Order without logging in
  POST http://localhost:8077/orders [401 Unauthorized, 160B, 3ms]
  ✓  status is 401

↳ Order with a tip of 250
  POST http://localhost:8077/orders [422 Unprocessable Entity, 265B, 3ms]
  ✓  status is 422
  ✓  the error names the tip field

↳ Order an item that does not exist
  POST http://localhost:8077/orders [404 Not Found, 157B, 3ms]
  ✓  status is 404

┌─────────────────────────┬─────────────────┬─────────────────┐
│                         │        executed │          failed │
├─────────────────────────┼─────────────────┼─────────────────┤
│              iterations │               1 │               0 │
├─────────────────────────┼─────────────────┼─────────────────┤
│                requests │               7 │               0 │
├─────────────────────────┼─────────────────┼─────────────────┤
│            test-scripts │               7 │               0 │
├─────────────────────────┼─────────────────┼─────────────────┤
│      prerequest-scripts │               0 │               0 │
├─────────────────────────┼─────────────────┼─────────────────┤
│              assertions │              13 │               0 │
├─────────────────────────┴─────────────────┴─────────────────┤
│ total run duration: 160ms                                   │
├─────────────────────────────────────────────────────────────┤
│ total data received: 484B (approx)                          │
├─────────────────────────────────────────────────────────────┤
│ average response time: 6ms [min: 3ms, max: 23ms, s.d.: 6ms] │
└─────────────────────────────────────────────────────────────┘
</code></pre>
<p>Thirteen checks, all green, in 160 milliseconds. You can see the chaining working: the order request got <code>201</code>, and the next request fetched <code>/orders/1001</code>, the id it saved.</p>
<p>And the API still has a bug in it. Every one of these tests is correct. They just never asked the question that matters.</p>
<h2>Step 5: Drive it from a CSV of boundary values</h2>
<p>In <a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">Test design techniques</a> we said bugs love edges: test <strong>at and around each boundary</strong>. For a tip from 0 to 200 that&#39;s six values: −1, 0, 1, 199, 200 and 201. Writing six requests would be copy-paste. Instead, write <strong>one</strong> request and give Newman a data file. It runs the collection once per row, an <strong>iteration</strong>, and the test reads that row&#39;s values.</p>
<p><code>tips.csv</code>:</p>
<pre tabindex="0"><code class="language-text">tip,expected_status
-1,422
0,201
1,201
199,201
200,201
201,422
</code></pre>
<p>A second, small collection has one request, <strong>Order with the tip from tips.csv</strong>, with the body <code>{&quot;item_id&quot;: 1, &quot;tip&quot;: {{tip}}}</code>. Here <code>{{tip}}</code> comes from the CSV row. Its <strong>pre-request script</strong> logs in on the first iteration only, using <code>pm.sendRequest</code>:</p>
<pre tabindex="0"><code class="language-javascript">// Log in once, before the first request, and keep the token for the others.
if (!pm.collectionVariables.get(&quot;token&quot;)) {
    pm.sendRequest({
        url: pm.environment.get(&quot;baseUrl&quot;) + &quot;/login&quot;,
        method: &quot;POST&quot;,
        header: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: { mode: &quot;raw&quot;, raw: JSON.stringify({
            email: pm.environment.get(&quot;email&quot;),
            password: pm.environment.get(&quot;password&quot;),
        }) },
    }, (err, res) =&gt; pm.collectionVariables.set(&quot;token&quot;, res.json().token));
}
</code></pre>
<p>And its test compares the status with the row&#39;s <code>expected_status</code>:</p>
<pre tabindex="0"><code class="language-javascript">const tip = pm.iterationData.get(&quot;tip&quot;);
const expected = Number(pm.iterationData.get(&quot;expected_status&quot;));
pm.test(`tip ${tip} → ${expected}`, () =&gt; pm.response.to.have.status(expected));
</code></pre>
<p>Run it with <code>-d</code> for the data file:</p>
<pre tabindex="0"><code class="language-bash">npx newman@6.2.3 run tip-boundaries.postman_collection.json -e local.postman_environment.json -d tips.csv
</code></pre>
<pre tabindex="0"><code class="language-text">Iteration 1/6
→ Order with the tip from tips.csv
  POST http://localhost:8077/login [200 OK, 142B, 25ms]
  POST http://localhost:8077/orders [422 Unprocessable Entity, 284B, 4ms]
  ✓  tip -1 → 422
...
Iteration 4/6
→ Order with the tip from tips.csv
  POST http://localhost:8077/orders [201 Created, 235B, 4ms]
  ✓  tip 199 → 201

Iteration 5/6
→ Order with the tip from tips.csv
  POST http://localhost:8077/orders [422 Unprocessable Entity, 265B, 2ms]
  1. tip 200 → 201

Iteration 6/6
→ Order with the tip from tips.csv
  POST http://localhost:8077/orders [422 Unprocessable Entity, 265B, 3ms]
  ✓  tip 201 → 422

  #  failure         detail
 1.  AssertionError  tip 200 → 201
     iteration: 5    expected response to have status code 201 but got 422
                     at assertion:0 in test-script
                     inside &quot;Order with the tip from tips.csv&quot;
</code></pre>
<p><strong>A tip of exactly 200 is refused.</strong> The requirement says 0 to 200, so 200 must be accepted. The cause is the line we pointed at: <code>lt=200</code> means &quot;less than 200&quot;. It should be <code>le=200</code>, &quot;less than or equal&quot;. It&#39;s the classic off-by-one at a boundary. The happy-path test (tip 50) and the obvious negative test (tip 250) both passed, because neither of them is anywhere near the edge.</p>
<p>Notice the login request appears only in iteration 1. The pre-request script saw the token on later iterations and skipped it.</p>
<p><img src="https://provalab.io/blog/practice/postman-run-flow.svg" alt="How Newman runs a collection: it reads the collection, the environment and an optional CSV. For each CSV row it runs every request: pre-request script, send, test script. Variables come from the CSV row first, then the environment, then the collection. Exit code 0 when every test passes, 1 when any fails." width="880" height="470" loading="lazy"></p>
<h2>Step 6: Fix it, and make the run CI-ready</h2>
<p>The fix is two letters:</p>
<pre tabindex="0"><code class="language-diff">-    tip: int = Field(0, ge=0, lt=200)  # the requirement says 0 to 200
+    tip: int = Field(0, ge=0, le=200)  # the requirement says 0 to 200
</code></pre>
<p>Restart the API and run again, this time adding a JUnit report, the XML format almost every CI system can read and display:</p>
<pre tabindex="0"><code class="language-bash">npx newman@6.2.3 run tip-boundaries.postman_collection.json -e local.postman_environment.json -d tips.csv \
  --reporters cli,junit --reporter-junit-export results/tips.xml
</code></pre>
<pre tabindex="0"><code class="language-text">  ✓  tip -1 → 422
  ✓  tip 0 → 201
  ✓  tip 1 → 201
  ✓  tip 199 → 201
  ✓  tip 200 → 201
  ✓  tip 201 → 422
│              assertions │               6 │               0 │
</code></pre>
<pre tabindex="0"><code class="language-xml">&lt;testsuites name=&quot;FoodNow tip boundaries&quot; tests=&quot;6&quot; time=&quot;0.019&quot;&gt;
  &lt;testsuite name=&quot;Order with the tip from tips.csv&quot; tests=&quot;1&quot; failures=&quot;0&quot; errors=&quot;0&quot; time=&quot;0.003&quot;&gt;
    &lt;testcase name=&quot;tip -1 → 422&quot; time=&quot;0.003&quot; classname=&quot;OrderWithTheTipFromTipsCsv&quot;/&gt;
    &lt;testcase name=&quot;tip 0 → 201&quot; time=&quot;0.003&quot; classname=&quot;OrderWithTheTipFromTipsCsv&quot;/&gt;
</code></pre>
<p>The first lines of the report (we&#39;ve left out the generated ids and timestamps) show one test case per CSV row. The main collection still passes all 13 checks too. Two things make this ready for a pipeline:</p>
<ul>
<li><strong>The exit code.</strong> Newman exits with <code>1</code> when any test fails (our first boundary run did) and <code>0</code> when all pass. That&#39;s all a CI job needs to go red or green.</li>
<li><strong>The JUnit file.</strong> CI tools turn it into a list of passed and failed tests on the build page, so nobody has to scroll through a log.</li>
</ul>
<p>Run it in your CI system and you have an API test stage. A full CI pipeline gets its own post later in this series.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Tests that only check the status.</strong> <code>200 OK</code> with the wrong total is still a bug. Check the values that matter: the price, the total, the id you just created.</li>
<li><strong>Hard-coded URLs and tokens.</strong> <code>http://localhost:8077</code> typed into 40 requests means 40 edits for staging. Put it in the environment once. Same for tokens: log in in the collection, never paste one in.</li>
<li><strong>Requests that depend on each other by accident.</strong> Chaining on purpose is fine: log in, order, read the order. But if test 12 only passes because test 7 happened to create some data, a reordered or filtered run breaks for no visible reason. Keep each folder able to run on its own.</li>
<li><strong>Secrets in the exported files.</strong> Collections and environments end up in git. Mark passwords as secret, use test accounts, and pass real secrets from the CI system with <code>--env-var &quot;password=...&quot;</code>.</li>
<li><strong>Only happy-path values.</strong> As the run above shows, a correct-looking suite can miss a boundary bug completely. Test the edges.</li>
</ul>
<h2>Postman, Newman, or code?</h2>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>Postman + Newman</th>
<th>Code (pytest + requests)</th>
</tr>
</thead>
<tbody><tr>
<td>Getting started</td>
<td>Fastest: click, send, see</td>
<td>Needs a little Python</td>
</tr>
<tr>
<td>Exploring a new API</td>
<td>Excellent</td>
<td>Clumsy</td>
</tr>
<tr>
<td>Sharing with non-coders</td>
<td>Easy</td>
<td>Harder</td>
</tr>
<tr>
<td>Complex logic, fixtures, reuse</td>
<td>Possible, gets messy in scripts</td>
<td>Natural</td>
</tr>
<tr>
<td>Reviewing changes in git</td>
<td>One large JSON file</td>
<td>Readable diffs</td>
</tr>
</tbody></table>
<p>Many teams use both: Postman to explore and to share examples, code for the regression suite that grows over years. <a href="https://provalab.io/blog/first-api-test-pytest/">Your first API tests</a> builds the code version of what we did here.</p>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/visual-regression-testing-playwright/">Part 3</a></strong> of Testing in Practice we catch the bugs functional tests can&#39;t see: <strong>visual regression testing</strong> with Playwright screenshots, baselines, masks and thresholds.</p>
<p>ProvaLab&#39;s QA AI Agent runs API test cases on its own and records every request and response against its test case, and AppSec&#39;s API fuzzing sends thousands of malformed and unauthorised requests to find what a hand-written collection misses. See it in the <a href="https://provalab.io/#stage-agent">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Visual regression testing with Playwright (hands-on): screenshots, baselines, masks and thresholds</title>
      <link>https://provalab.io/blog/visual-regression-testing-playwright/</link>
      <guid isPermaLink="true">https://provalab.io/blog/visual-regression-testing-playwright/</guid>
      <pubDate>Tue, 10 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-in-practice</category>
      <category>visual-testing</category>
      <category>playwright</category>
      <category>ui-testing</category>
      <description>Catch the layout bugs functional tests can't see. A real run: a one-line CSS change breaks a checkout page, every functional test passes, and a Playwright screenshot test catches it.</description>
      <content:encoded><![CDATA[<p>A functional test asks &quot;does it work?&quot;. Click Remove tip, and the total goes from 550 to 500: pass. It doesn&#39;t ask &quot;does it still look right?&quot;. So when someone changes a shared stylesheet for a different page, and the tip label on your checkout suddenly wraps onto two lines and the layout shifts, every functional test stays green. A customer sees it in a minute.</p>
<p><strong>Visual regression testing</strong> closes that gap. It takes a screenshot of the page, compares it with an approved one from before, and fails when the pixels change. This post builds one with Playwright, then shows the three things that make or break it in real life: the first run, the content that changes every time, and how much difference counts as &quot;different&quot;.</p>
<p>Every command and every line of output below was run while writing this post. Along the way we found that the default settings let a real colour change through. The numbers are in Step 5.</p>
<h2>What visual regression testing catches</h2>
<table tabindex="0">
<thead>
<tr>
<th>Catches</th>
<th>Functional tests usually miss it because...</th>
</tr>
</thead>
<tbody><tr>
<td>Overlapping or wrapping text, shifted layouts</td>
<td>The element is still there and still clickable</td>
</tr>
<tr>
<td>Wrong colours, missing icons, broken images</td>
<td>Nobody asserts on colour or pixels</td>
</tr>
<tr>
<td>A shared CSS change leaking into other pages</td>
<td>The test for the <em>other</em> page never runs a check on layout</td>
</tr>
<tr>
<td>Things off-screen or hidden behind other things</td>
<td><code>toBeVisible()</code> checks for display, size and visibility, not whether something covers it</td>
</tr>
</tbody></table>
<p>It doesn&#39;t replace functional tests. A screenshot of a checkout that charges the wrong amount can look perfect. You want both.</p>
<h2>How it works</h2>
<p>The loop is always the same, whatever the tool:</p>
<p><img src="https://provalab.io/blog/practice/visual-regression-loop.svg" alt="The visual regression loop: take a screenshot with animations off, compare it with the baseline pixel by pixel within a threshold. Same: pass. Different: fail, and save expected, actual and diff images. A person looks at the diff: if it's a bug, fix the code and the old baseline stays; if it was intended, run with --update-snapshots and commit the new image. On the very first run there is no baseline, so the test saves one and fails once." width="880" height="400" loading="lazy"></p>
<p>The <strong>baseline</strong> is the approved screenshot, stored next to the tests and committed to git. The comparison isn&#39;t &quot;are the files identical&quot;. It&#39;s &quot;how many pixels differ, by how much colour&quot;, with two dials you control. We&#39;ll turn both.</p>
<h2>Step 1: Set up (3 minutes)</h2>
<p>Playwright&#39;s screenshot assertion, <code>toHaveScreenshot</code>, lives in its JavaScript/TypeScript test runner. The Python package we used in earlier posts doesn&#39;t have it built in, so this post uses TypeScript. You don&#39;t need to know much of it: the tests are a few lines each.</p>
<pre tabindex="0"><code class="language-bash">mkdir visual &amp;&amp; cd visual
npm init -y
npm install -D @playwright/test@1.64.0
npx playwright install chromium
</code></pre>
<p>We used Node.js 20.19.5 and Playwright 1.64.0 on Linux.</p>
<p>The page under test is the FoodNow checkout from <a href="https://provalab.io/blog/accessibility-testing-wcag-axe-playwright/">the accessibility post</a>, with a little more design. It uses a shared stylesheet, <code>site/base.css</code>, which every FoodNow page loads:</p>
<pre tabindex="0"><code class="language-css">/* Shared styles for every FoodNow page. */
* { box-sizing: border-box; }
body { font-family: &quot;DejaVu Sans&quot;, Verdana, sans-serif; margin: 0; background: #f1f5f9; color: #0f172a; }
button { font: inherit; cursor: pointer; }
</code></pre>
<p>The page shows the order, a tip box with a × button to remove the tip, the total, a line with the time of the order, and a Pay button. A short Node server, <code>server.mjs</code>, serves the <code>site/</code> folder on port 4173. The config starts it before the tests:</p>
<pre tabindex="0"><code class="language-typescript">// playwright.config.ts
import { defineConfig, devices } from &quot;@playwright/test&quot;;

export default defineConfig({
  testDir: &quot;./tests&quot;,
  use: { baseURL: &quot;http://localhost:4173&quot; },
  webServer: { command: &quot;node server.mjs&quot;, url: &quot;http://localhost:4173/checkout.html&quot;, reuseExistingServer: true },
  projects: [{ name: &quot;chromium&quot;, use: { ...devices[&quot;Desktop Chrome&quot;] } }],
});
</code></pre>
<h2>Step 2: One functional test, one visual test</h2>
<pre tabindex="0"><code class="language-typescript">// tests/checkout.spec.ts
import { test, expect } from &quot;@playwright/test&quot;;

test(&quot;removing the tip updates the total&quot;, async ({ page }) =&gt; {
  await page.goto(&quot;/checkout.html&quot;);
  await page.getByRole(&quot;button&quot;, { name: &quot;Remove tip&quot; }).click();
  await expect(page.locator(&quot;#total&quot;)).toHaveText(&quot;500&quot;);
  await expect(page.getByRole(&quot;button&quot;, { name: &quot;Pay 500&quot; })).toBeVisible();
});

test(&quot;checkout looks right&quot;, async ({ page }) =&gt; {
  await page.goto(&quot;/checkout.html&quot;);
  await expect(page).toHaveScreenshot(&quot;checkout.png&quot;);
});
</code></pre>
<p>The first test is the kind you already write. The second is the whole of a visual test: one line.</p>
<h2>Step 3: The first run fails, on purpose</h2>
<pre tabindex="0"><code class="language-bash">npx playwright test
</code></pre>
<pre tabindex="0"><code class="language-text">  ✘  2 [chromium] › tests/checkout.spec.ts:10:5 › checkout looks right (341ms)

    Error: A snapshot doesn&#39;t exist at tests/checkout.spec.ts-snapshots/checkout-chromium-linux.png, writing actual.

  1 failed
  1 passed (1.3s)
</code></pre>
<p>That&#39;s expected. There&#39;s nothing to compare with yet, so Playwright saves this screenshot as the baseline and fails, to make sure a person looks at it before it&#39;s trusted. Open the PNG and check it&#39;s actually right. Everything after this is measured against it.</p>
<p>Note the file name: <code>checkout-chromium-linux.png</code>. The browser and the operating system are part of it, because fonts and anti-aliasing render slightly differently on Linux, macOS and Windows. More on that in the mistakes section.</p>
<h2>Step 4: The second run fails too. Why?</h2>
<p>Run it again, change nothing:</p>
<pre tabindex="0"><code class="language-text">  ✓  1 [chromium] › tests/checkout.spec.ts:3:5 › removing the tip updates the total (149ms)
  ✘  2 [chromium] › tests/checkout.spec.ts:10:5 › checkout looks right (568ms)

    Error: expect(page).toHaveScreenshot(expected) failed

      15 pixels (ratio 0.01 of all image pixels) are different.

    Call log:
      - taking page screenshot
        - disabled all CSS animations
      - waiting for fonts to load...
      - fonts loaded
      - 15 pixels (ratio 0.01 of all image pixels) are different.
      - waiting 100ms before taking screenshot
      - taking page screenshot
      - captured a stable screenshot
      - 15 pixels (ratio 0.01 of all image pixels) are different.
</code></pre>
<p>Fifteen pixels. The diff image Playwright saved in <code>test-results/</code> shows them in red, on one character: the seconds in <strong>&quot;Ordered at 21:37:40&quot;</strong>. The page prints the time of the order, and the second ticked over between the two runs.</p>
<p>This is the number one cause of flaky visual tests: <strong>content that changes on every run</strong>. Times, dates, random ids, ads, live prices, a &quot;3 people are looking at this&quot; counter. The call log also shows Playwright handling the other usual suspects for you: it <strong>disables CSS animations</strong>, <strong>waits for web fonts</strong>, and takes screenshots until two in a row match before comparing.</p>
<p>The fix is to <strong>mask</strong> the changing part. Playwright paints a solid box over it in both screenshots, so it&#39;s never compared:</p>
<pre tabindex="0"><code class="language-typescript">test(&quot;checkout looks right&quot;, async ({ page }) =&gt; {
  await page.goto(&quot;/checkout.html&quot;);
  await expect(page).toHaveScreenshot(&quot;checkout.png&quot;, {
    mask: [page.getByTestId(&quot;eta&quot;)], // the order time changes every run
  });
});
</code></pre>
<p>The old baseline has the unmasked time in it, so replace it once, deliberately:</p>
<pre tabindex="0"><code class="language-bash">npx playwright test --update-snapshots
</code></pre>
<pre tabindex="0"><code class="language-text">tests/checkout.spec.ts-snapshots/checkout-chromium-linux.png is re-generated, writing actual.
  2 passed (1.4s)
</code></pre>
<p>Three more runs, a few seconds apart: <code>2 passed (947ms)</code>, <code>2 passed (966ms)</code>, <code>2 passed (949ms)</code>. Stable.</p>
<p>Masking isn&#39;t the only option. You can also freeze the input, with a fixed clock (<code>page.clock</code>) or fixed test data, so the page always shows the same thing. That&#39;s better when the changing part matters, for example a price you want to see. Mask what you don&#39;t care about. Freeze what you do.</p>
<h2>Step 5: The regression</h2>
<p>Now the realistic part. A developer working on a new menu page wants bigger buttons there, so they&#39;re easy to tap. They make a one-line change to the shared stylesheet:</p>
<pre tabindex="0"><code class="language-diff">-button { font: inherit; cursor: pointer; }
+button { font: inherit; cursor: pointer; min-width: 120px; } /* big enough to tap on the new menu page */
</code></pre>
<p>Run the tests:</p>
<pre tabindex="0"><code class="language-text">  ✓  1 [chromium] › tests/checkout.spec.ts:3:5 › removing the tip updates the total (147ms)
  ✘  2 [chromium] › tests/checkout.spec.ts:10:5 › checkout looks right (563ms)

    Error: expect(page).toHaveScreenshot(expected) failed

      3439 pixels (ratio 0.01 of all image pixels) are different.

  1 failed
  1 passed (1.3s)
</code></pre>
<p>The functional test <strong>passed</strong>: the × button still exists, still has its name, and still removes the tip. The visual test <strong>failed</strong>, and here are the three images it saved, cropped to the card:</p>
<p><img src="https://provalab.io/blog/practice/visual-regression-diff.png" alt="Three screenshots of the checkout card side by side. Left, the baseline: the tip label, the tip box and the × button fit on one line. Middle, after the CSS change: the × button is now 120 pixels wide, so the label &quot;Delivery tip (0 to 200)&quot; wraps onto two lines and the rows below move down. Right, Playwright's diff: the changed pixels in red over a faded copy of the page. The pink bar in the first two is the masked order time." loading="lazy"></p>
<p><em>Left: the baseline. Middle: after the change. Right: the diff, with changed pixels in red. The pink bar is the masked order time.</em></p>
<p>The × button grew to 120 pixels, the row ran out of room, and the tip label wrapped onto two lines, pushing everything under it down. Nothing is &quot;broken&quot; in the functional sense. It just looks broken. This is exactly the bug visual tests exist for.</p>
<p>The fix is to keep the rule where it belongs:</p>
<pre tabindex="0"><code class="language-css">/* Only the menu page wants big buttons. */
.menu button { min-width: 120px; }
</code></pre>
<p><code>2 passed</code>. The baseline didn&#39;t change, because the page is back to how it was approved.</p>
<h2>Step 6: How different is &quot;different&quot;? We tested it</h2>
<p>Two settings decide what counts as a change:</p>
<ul>
<li><strong><code>threshold</code></strong>: how different a single pixel&#39;s colour must be to count as changed, from 0 (any difference) to 1 (anything goes). <strong>The default is 0.2.</strong></li>
<li><strong><code>maxDiffPixels</code></strong> or <strong><code>maxDiffPixelRatio</code></strong>: how many changed pixels you&#39;ll tolerate before failing. The default is zero: one changed pixel fails the test.</li>
</ul>
<p>We changed the Pay button from <code>#15803d</code> to <code>#166534</code>, a darker green. Anyone looking at the two side by side would see it. Here&#39;s what each threshold made of it:</p>
<table tabindex="0">
<thead>
<tr>
<th><code>threshold</code></th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>0.2 (default)</td>
<td><strong>passed</strong>: the change was ignored</td>
</tr>
<tr>
<td>0.1</td>
<td><strong>passed</strong></td>
</tr>
<tr>
<td>0.05</td>
<td>failed: 13,694 pixels are different</td>
</tr>
</tbody></table>
<p>So <strong>the default lets a real colour change through</strong>. That&#39;s on purpose: a slightly loose threshold stops tests failing on tiny anti-aliasing differences between machines. But if colour matters to you (brand colours, or contrast you fixed for accessibility), lower it for that test, or test colour directly with <code>toHaveCSS(&quot;background-color&quot;, ...)</code>.</p>
<h2>Step 7: When the change is intended</h2>
<p>Designs change on purpose too. The designer asks for a pill-shaped Pay button:</p>
<pre tabindex="0"><code class="language-diff">-.pay { ... border-radius: 10px; }
+.pay { ... border-radius: 999px; }
</code></pre>
<pre tabindex="0"><code class="language-text">      212 pixels (ratio 0.01 of all image pixels) are different.
  1 failed
  1 passed (1.3s)
</code></pre>
<p>The test fails, correctly: something changed. Look at the diff, confirm it&#39;s the change you wanted and nothing else, then accept it:</p>
<pre tabindex="0"><code class="language-bash">npx playwright test --update-snapshots
</code></pre>
<pre tabindex="0"><code class="language-text">tests/checkout.spec.ts-snapshots/checkout-chromium-linux.png is re-generated, writing actual.
  2 passed (1.3s)
</code></pre>
<p><strong>Commit the new PNG with the CSS change.</strong> The reviewer then sees the before and after images in the pull request, next to the code that caused them. GitHub and GitLab both show image diffs. A visual change that nobody looked at is the one thing this whole process exists to prevent.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Updating baselines without looking.</strong> <code>--update-snapshots</code> accepts <em>every</em> difference, including the bug you were meant to catch. Run it only after checking each diff.</li>
<li><strong>Making baselines on a laptop and comparing in CI.</strong> Fonts and rendering differ between operating systems, so a Mac baseline fails on a Linux CI runner. Generate and compare baselines in the same environment, for example Playwright&#39;s official Docker image for your version, both locally and in CI.</li>
<li><strong>Screenshotting whole pages full of moving parts.</strong> Mask what changes, freeze the clock, use fixed test data, or screenshot one element: <code>await expect(page.locator(&quot;.card&quot;)).toHaveScreenshot()</code>. Smaller screenshots fail for clearer reasons.</li>
<li><strong>Raising the threshold until it passes.</strong> If a test is flaky, find the moving part. A threshold loose enough to hide flakiness also hides real changes, as Step 6 shows.</li>
<li><strong>Hundreds of screenshots on day one.</strong> Start with your most important pages and states: checkout, sign-up, the error state of the main form. Every baseline is something a person has to review when the design changes.</li>
</ul>
<h2>Built-in, or a service?</h2>
<p>Playwright&#39;s built-in screenshots, as here, are free and live in your repo. Hosted services such as Percy, Chromatic and Applitools add a web page for reviewing and approving diffs, history, many browsers at once, and smarter comparisons that ignore some rendering noise. Teams often start built-in and move to a service when reviewing PNGs in pull requests stops scaling.</p>
<h2>What&#39;s next</h2>
<p>In <strong>Part 4</strong> of Testing in Practice we look at <strong>testing database changes</strong>: what to check when the code writes to a database, and a hands-on run with SQL and pytest.</p>
<p>ProvaLab&#39;s QA Infrastructure runs Playwright tests on real browsers in the cloud, the same environment on every run, which is what visual baselines need, and records every session so a failed screenshot comes with a video of what happened. See it in the <a href="https://provalab.io/#stage-infra">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Your first load test with k6 (hands-on): find where your API slows down</title>
      <link>https://provalab.io/blog/first-load-test-k6/</link>
      <guid isPermaLink="true">https://provalab.io/blog/first-load-test-k6/</guid>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>hands-on-testing</category>
      <category>load-testing</category>
      <category>k6</category>
      <category>performance-testing</category>
      <description>Write and run a k6 load test against the API from Part 4, set pass/fail thresholds, read the summary, then run a stress test to 1,000 users and find where it slows, with the real output.</description>
      <content:encoded><![CDATA[<p><a href="https://provalab.io/blog/performance-testing-types/">Performance testing types</a> explained load, stress, spike and soak tests. This final post of the Hands-On series runs two of them for real, with <strong>k6</strong>, against the discount API from <a href="https://provalab.io/blog/first-api-test-pytest/">Part 4</a>:</p>
<ol>
<li>A <strong>load test</strong> at the expected peak: does it hold up?</li>
<li>A <strong>stress test</strong> past the peak: where does it slow down, and does it break?</li>
</ol>
<p>Every command and every line of output below was run while writing this post.</p>
<p><strong>Before you start:</strong> a load test is, by definition, a flood of traffic. <strong>Only ever point it at something you own</strong>, and never at production without a plan. Our target is the API from Part 4, running on our own machine.</p>
<h2>Step 1: Get k6 (1 minute)</h2>
<p>k6 is a single program. Install it from <a href="https://k6.io">k6.io</a> for your operating system, or run it from its container image, which is what we do here:</p>
<pre tabindex="0"><code class="language-bash">docker pull grafana/k6:latest
docker run --rm grafana/k6:latest version
</code></pre>
<pre tabindex="0"><code class="language-text">k6 v2.3.0 (commit/e088784614, go1.27.1, linux/amd64)
</code></pre>
<h2>Step 2: Start the target (1 minute)</h2>
<p>In the Part 4 folder, with the fixed version of the API from <a href="https://provalab.io/blog/first-security-scan-owasp-zap/">Part 6</a>:</p>
<pre tabindex="0"><code class="language-bash">uvicorn app:app
</code></pre>
<p>One request by hand, to know what &quot;normal&quot; looks like before we add load:</p>
<pre tabindex="0"><code class="language-bash">curl -s -o /dev/null -w &quot;%{http_code} in %{time_total}s\n&quot; -X POST http://127.0.0.1:8000/discounts/apply \
  -H &quot;Authorization: Bearer token-alice&quot; -H &quot;Content-Type: application/json&quot; \
  -d &#39;{&quot;order_total&quot;: 500, &quot;code&quot;: &quot;SAVE10&quot;}&#39;
</code></pre>
<pre tabindex="0"><code class="language-text">200 in 0.039147s
</code></pre>
<p>Forty milliseconds for one request, on an idle machine. The question load testing answers is: what happens to that number when it&#39;s fifty requests at once?</p>
<h2>Step 3: Write the load test (4 minutes)</h2>
<p>Create a folder called <code>k6</code> and in it <code>load.js</code>:</p>
<pre tabindex="0"><code class="language-javascript">import http from &quot;k6/http&quot;;
import { check, sleep } from &quot;k6&quot;;

const BASE = __ENV.API_URL || &quot;http://127.0.0.1:8000&quot;;
const HEADERS = { Authorization: &quot;Bearer token-alice&quot;, &quot;Content-Type&quot;: &quot;application/json&quot; };

export const options = {
  stages: [
    { duration: &quot;30s&quot;, target: 50 }, // ramp up to 50 users
    { duration: &quot;60s&quot;, target: 50 }, // hold the expected peak
    { duration: &quot;15s&quot;, target: 0 },  // ramp down
  ],
  thresholds: {
    http_req_duration: [&quot;p(95)&lt;200&quot;], // 95% of requests under 200 ms
    http_req_failed: [&quot;rate&lt;0.01&quot;],   // less than 1% errors
  },
};

export default function () {
  // 7 in 10 users apply a discount, 3 in 10 look at an order
  if (Math.random() &lt; 0.7) {
    const res = http.post(`${BASE}/discounts/apply`, JSON.stringify({ order_total: 500, code: &quot;SAVE10&quot; }), { headers: HEADERS });
    check(res, { &quot;discount applied&quot;: (r) =&gt; r.status === 200 &amp;&amp; r.json(&quot;total&quot;) === 450 });
  } else {
    const res = http.get(`${BASE}/orders/42`, { headers: HEADERS });
    check(res, { &quot;order returned&quot;: (r) =&gt; r.status === 200 });
  }
  sleep(1); // think time: a real user pauses between actions
}
</code></pre>
<p>The four ideas in this file are the four ideas in any load test:</p>
<ul>
<li><strong>Stages</strong> describe the shape of the load over time: ramp up, hold, ramp down. The ramp matters; starting 50 users in the same millisecond tests something different (that&#39;s a spike test).</li>
<li><strong>Thresholds</strong> are the pass/fail rules, decided <em>before</em> running: p95 under 200 ms, under 1% errors. Without them you get numbers but no verdict.</li>
<li><strong>The default function</strong> is what one <strong>virtual user (VU)</strong> does, over and over: here, a realistic mix of two actions.</li>
<li><strong>Think time</strong> (<code>sleep(1)</code>) is the pause a real person takes between actions. Without it, 50 virtual users behave like thousands of real ones.</li>
</ul>
<p>The <code>check</code> lines are the load test&#39;s assertions: a <code>200</code> with the right total. A load test that only counts responses would happily report success while every response was an error page.</p>
<h2>Step 4: Run it (2 minutes)</h2>
<pre tabindex="0"><code class="language-bash">docker run --rm --network host -e API_URL=http://127.0.0.1:8000 -v &quot;$PWD/k6:/scripts&quot; \
  grafana/k6:latest run /scripts/load.js
</code></pre>
<p>(As in Part 6, on macOS and Windows use <code>http://host.docker.internal:8000</code> and drop <code>--network host</code>.)</p>
<p>k6 prints a progress line every second, then the summary:</p>
<pre tabindex="0"><code class="language-text">  █ TOTAL RESULTS

    checks_total.......: 4145    39.338342/s
    checks_succeeded...: 100.00% 4145 out of 4145
    checks_failed......: 0.00%   0 out of 4145

    ✓ discount applied
    ✓ order returned

    HTTP
    http_req_duration..............: avg=1.91ms min=975.39µs med=1.68ms max=16.09ms p(90)=2.7ms p(95)=3.15ms
    http_req_failed................: 0.00%  0 out of 4145
    http_reqs......................: 4145   39.338342/s

    EXECUTION
    iteration_duration.............: avg=1s     min=1s       med=1s     max=1.01s   p(90)=1s    p(95)=1s
    iterations.....................: 4145   39.338342/s
    vus............................: 1      min=1         max=50
    vus_max........................: 50     min=50        max=50

default ✓ [ 100% ] 00/50 VUs  1m45s
</code></pre>
<p><strong>Passed.</strong> 4,145 requests over 1 minute 45, every check green, no failures, and a p95 of <strong>3.15 ms</strong> against a target of 200 ms. At its expected peak this API isn&#39;t remotely stressed. (The &quot;iteration_duration&quot; of 1 s is just the think time.)</p>
<p>Note the tick next to <code>default</code> and the absence of any threshold error: that&#39;s k6&#39;s verdict. In CI, a crossed threshold makes k6 exit with a non-zero code, which fails the job.</p>
<h2>Step 5: Stress it (5 minutes)</h2>
<p>Passing at 50 users tells you it&#39;s fine today. It doesn&#39;t tell you how much headroom you have, or what failure looks like. For that, keep increasing the load past the expected peak until something gives. Create <code>stress.js</code>:</p>
<pre tabindex="0"><code class="language-javascript">import http from &quot;k6/http&quot;;
import { check } from &quot;k6&quot;;

const BASE = __ENV.API_URL || &quot;http://127.0.0.1:8000&quot;;
const HEADERS = { Authorization: &quot;Bearer token-alice&quot;, &quot;Content-Type&quot;: &quot;application/json&quot; };

export const options = {
  stages: [
    { duration: &quot;20s&quot;, target: 100 },
    { duration: &quot;20s&quot;, target: 300 },
    { duration: &quot;20s&quot;, target: 600 },
    { duration: &quot;20s&quot;, target: 1000 }, // keep going past the expected peak
    { duration: &quot;10s&quot;, target: 0 },
  ],
  thresholds: {
    // we expect these to fail: the point is to see where
    http_req_duration: [&quot;p(95)&lt;500&quot;],
    http_req_failed: [&quot;rate&lt;0.05&quot;],
  },
};

export default function () {
  const res = http.post(`${BASE}/discounts/apply`, JSON.stringify({ order_total: 500, code: &quot;SAVE10&quot; }), { headers: HEADERS });
  check(res, { &quot;status 200&quot;: (r) =&gt; r.status === 200 });
  // no think time: every virtual user hammers the API as fast as it can
}
</code></pre>
<p>Two deliberate changes: the load climbs in steps to 1,000 users, and there&#39;s <strong>no think time</strong>, so each virtual user sends requests as fast as the API answers. Run it the same way, with <code>stress.js</code>:</p>
<pre tabindex="0"><code class="language-text">  █ TOTAL RESULTS

    checks_total.......: 70982   788.671898/s
    checks_succeeded...: 100.00% 70982 out of 70982
    checks_failed......: 0.00%   0 out of 70982

    ✓ status 200

    HTTP
    http_req_duration..............: avg=498.54ms min=731.6µs med=430.55ms max=1.79s p(90)=1.1s p(95)=1.51s
    http_req_failed................: 0.00% 0 out of 70982
    http_reqs......................: 70982 788.671898/s

    EXECUTION
    vus............................: 13    min=5          max=997
    vus_max........................: 1000  min=1000       max=1000

default ✓ [ 100% ] 0000/1000 VUs  1m30s
time=&quot;2026-10-29T18:31:28Z&quot; level=error msg=&quot;thresholds on metrics &#39;http_req_duration&#39; have been crossed&quot;
</code></pre>
<p><img src="https://provalab.io/blog/handson/read-k6-summary.svg" alt="Reading a k6 summary. checks_succeeded 100%: every response had the right status. http_req_duration: read p(95), not avg; here 1.51 s, meaning 1 in 20 waited that long or more. http_req_failed 0%: nothing errored outright. http_reqs 70,982 at 789 per second: the throughput the API sustained. vus_max 1000: how many users were hitting it. The threshold line at the bottom is the verdict: failed on speed, not on errors." width="880" height="440" loading="lazy"></p>
<p>Read it line by line:</p>
<ul>
<li><strong>70,982 requests in 90 seconds, 789 per second.</strong> Twenty times the load test&#39;s rate.</li>
<li><strong>Still 0% failed, every check green.</strong> The API never fell over. Every one of 70,982 requests got a correct <code>200</code>.</li>
<li><strong>But p95 is 1.51 s</strong>, and the maximum 1.79 s. One in twenty customers would wait a second and a half for a discount to apply. That&#39;s the threshold k6 crossed, and the reason the run is marked failed.</li>
</ul>
<p>So the honest answer from this stress test is: <strong>this API doesn&#39;t break under 1,000 hammering users; it slows down</strong>, from 3 ms to 1.5 s at the 95th percentile. That&#39;s the &quot;breaking point&quot; for a single-process server: the point where the queue of waiting requests grows faster than it drains. Whether that&#39;s acceptable depends on your product, which is exactly why thresholds are written down before the run.</p>
<p><img src="https://provalab.io/blog/handson/load-vs-stress-results.svg" alt="Two runs side by side. Load test: 50 users with think time, ramp 30 s, hold 60 s, ramp down 15 s; 4,145 requests, 0 errors, p95 3.15 ms, passed. Stress test: steps to 100, 300, 600 and 1,000 users with no think time; 70,982 requests at 789 per second, still 0 errors, p95 1.51 s, threshold crossed: it slowed, it didn't break." width="880" height="470" loading="lazy"></p>
<h2>What we&#39;d do next</h2>
<ul>
<li><strong>Find the knee.</strong> Re-run with finer steps (200, 250, 300…) to find the user count where p95 first crosses 500 ms. That number is your capacity, and it belongs in the <a href="https://provalab.io/blog/how-to-write-a-test-plan/">test plan</a>.</li>
<li><strong>Watch the server, not just k6.</strong> CPU, memory and, for a real app, the database. Here the limit is one Python process on one core; <code>uvicorn --workers 4</code> would roughly quadruple it. Load testing tells you <em>that</em> it slows; server metrics tell you <em>why</em>.</li>
<li><strong>Run it from a separate machine.</strong> We ran k6 on the same laptop as the API, so they competed for CPU. For real numbers the load generator must not share resources with the target, and for big loads it must be several machines.</li>
<li><strong>Add a soak run:</strong> 50 users for two hours, to catch memory leaks a 90-second test never will.</li>
</ul>
<h2>Putting it in the pipeline</h2>
<p>Nightly, not on every commit (a load test takes minutes and needs a quiet environment):</p>
<pre tabindex="0"><code class="language-yaml">      - run: uvicorn app:app &amp;
      - run: &gt;
          docker run --rm --network host -e API_URL=http://127.0.0.1:8000
          -v &quot;$PWD/k6:/scripts&quot; grafana/k6:latest run /scripts/load.js
</code></pre>
<p>If the thresholds are crossed, k6 exits non-zero and the job goes red. Over months, that catches the commit that made things slower.</p>
<h2>Habits worth keeping</h2>
<ol>
<li><strong>Decide the thresholds before you run.</strong> Numbers without a target are trivia.</li>
<li><strong>Read p95 and p99, never the average.</strong> The average in the stress run (498 ms) hid a p95 three times worse.</li>
<li><strong>Assert on the response</strong>, not just the status. A fast wrong answer is still wrong.</li>
<li><strong>Model real behaviour:</strong> a mix of actions, with think time. Drop the think time only when you mean to stress.</li>
<li><strong>Never load-test what you don&#39;t own.</strong> And never production without warning the people who run it.</li>
</ol>
<h2>Hands-On Testing: the complete series</h2>
<p>That closes the series. Over seven parts you&#39;ve built and run:</p>
<ol>
<li><a href="https://provalab.io/blog/ui-testing-explained/">UI testing explained</a> · 2. <a href="https://provalab.io/blog/first-playwright-test/">Your first Playwright test</a></li>
<li><a href="https://provalab.io/blog/api-testing-explained/">API testing explained</a> · 4. <a href="https://provalab.io/blog/first-api-test-pytest/">Your first API tests</a></li>
<li><a href="https://provalab.io/blog/security-testing-basics-owasp-top-10/">Security testing basics: the OWASP Top 10</a> · 6. <a href="https://provalab.io/blog/first-security-scan-owasp-zap/">Your first security scan with OWASP ZAP</a></li>
<li>Your first load test with k6 (this post)</li>
</ol>
<p>Together with the 23-part <a href="https://provalab.io/blog/what-is-software-testing/">Testing Basics</a> series, that&#39;s a complete foundation: the ideas, and the tools in your hands.</p>
<p>ProvaLab&#39;s load testing runs k6 tests like these from the cloud, on machines that don&#39;t compete with your app, shows p95 and throughput live, and can find the user count where your API first slows down, step by step. Your coding agent can write the script from your code and explain the slow spots afterwards. See it in the <a href="https://provalab.io/#stage-load">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Playwright with Python in practice: fixtures, page objects, saved state, mocked APIs and parallel runs</title>
      <link>https://provalab.io/blog/playwright-python-page-objects-fixtures-mocking/</link>
      <guid isPermaLink="true">https://provalab.io/blog/playwright-python-page-objects-fixtures-mocking/</guid>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>playwright</category>
      <category>pytest</category>
      <category>test-automation</category>
      <category>tool-guide</category>
      <description>Hands-on: grow three Playwright tests into a real suite with page objects, pytest fixtures, saved browser state, an API mocked with page.route, and a parallel run that more than halves the time.</description>
      <content:encoded><![CDATA[<p>In <a href="https://provalab.io/blog/first-playwright-test/">Hands-On Part 2</a> we wrote three Playwright tests in one file, ran them against this site and read a failure. That is where most tutorials stop, and where most real suites start to hurt: the fourth file copies the first, every test dismisses the same cookie banner, one test submits a real form and creates a real record in the real database, and the whole thing takes a minute because nothing runs in parallel.</p>
<p>This guide takes the three tests from Part 2 and grows them into a suite with ten tests that is cheap to change. Every command below was run as written, against provalab.io, with these versions:</p>
<table tabindex="0">
<thead>
<tr>
<th>Package</th>
<th>Version</th>
</tr>
</thead>
<tbody><tr>
<td>playwright</td>
<td>1.64.0</td>
</tr>
<tr>
<td>pytest-playwright</td>
<td>0.10.0</td>
</tr>
<tr>
<td>pytest</td>
<td>9.2.0</td>
</tr>
<tr>
<td>pytest-xdist</td>
<td>3.8.0</td>
</tr>
</tbody></table>
<h2>What we&#39;re building</h2>
<pre tabindex="0"><code>playwright-in-practice/
├── pytest.ini
├── conftest.py          # fixtures: saved state, viewport, mocked API
├── pages/
│   ├── __init__.py
│   ├── blog.py          # BlogPage, PostPage
│   └── guide.py         # GuidePage
├── test_blog.py
├── test_consent.py
└── test_guides.py
</code></pre>
<p>Four ideas, one each for the four problems above:</p>
<ol>
<li><strong>Page objects</strong> so a locator lives in one place.</li>
<li><strong>Fixtures</strong> so a test asks for what it needs instead of building it.</li>
<li><strong>Saved browser state</strong> so the cookie banner is dismissed once, not ten times.</li>
<li><strong>A mocked API</strong> so the form test never creates a real lead.</li>
</ol>
<p>Then we run the whole thing on four workers.</p>
<p><img src="https://provalab.io/blog/handson/test-layers.svg" alt="Five layers. Tests say what should happen. Fixtures provide what every test needs. Page objects know where things are. Playwright drives the browser. The browser loads the site. When a button moves, only the page objects change." width="880" height="500" loading="lazy"></p>
<h2>Setup (2 minutes)</h2>
<p>Same folder and virtual environment routine as <a href="https://provalab.io/blog/first-playwright-test/">Part 2</a>, plus the parallel runner:</p>
<pre tabindex="0"><code class="language-bash">mkdir playwright-in-practice &amp;&amp; cd playwright-in-practice
python3 -m venv .venv &amp;&amp; source .venv/bin/activate
pip install pytest-playwright pytest-xdist
playwright install chromium
</code></pre>
<p><code>pytest.ini</code> points at the site and silences one warning:</p>
<pre tabindex="0"><code class="language-ini">[pytest]
addopts = --base-url https://provalab.io
filterwarnings = ignore::pytest.PytestRemovedIn10Warning
</code></pre>
<p>The second line is worth a sentence. pytest 9 deprecated a pattern that pytest-playwright 0.10 still uses for its <code>base_url</code> fixture, and prints a warning on every run. It is harmless, and the plugin will catch up; until then this keeps the output clean so that a warning you <em>should</em> read doesn&#39;t hide among ones you shouldn&#39;t.</p>
<h2>Step 1: Page objects (10 minutes)</h2>
<p>A page object is a small class that knows where things are on one page and what a user can do there. Tests use it instead of locators. Here is the blog index and a post:</p>
<pre tabindex="0"><code class="language-python"># pages/blog.py
import re

from playwright.sync_api import Page


class PostPage:
    def __init__(self, page: Page):
        self.page = page
        self.title = page.get_by_role(&quot;heading&quot;, level=1)
        self.all_posts = page.get_by_role(&quot;link&quot;, name=&quot;All posts&quot;)

    URL = re.compile(r&quot;/blog/[a-z0-9-]+/$&quot;)


class BlogPage:
    def __init__(self, page: Page):
        self.page = page
        self.heading = page.get_by_role(&quot;heading&quot;, level=1)
        self.cards = page.get_by_test_id(&quot;blog-card&quot;)
        self.pdf_banner = page.get_by_test_id(&quot;blog-pdf-banner&quot;)

    def open(self):
        self.page.goto(&quot;/blog/&quot;)
        return self

    def open_first_post(self) -&gt; PostPage:
        self.cards.first.get_by_role(&quot;link&quot;).first.click()
        return PostPage(self.page)

    def pdf_link(self, series: str):
        return self.pdf_banner.get_by_role(&quot;link&quot;, name=f&quot;{series} PDF&quot;)
</code></pre>
<p>Two things to notice. The attributes are <strong>locators, not elements</strong>: <code>self.cards</code> is a description of how to find the cards, evaluated fresh every time a test uses it, so it never goes stale after navigation. And an action that leads somewhere <strong>returns the page object for where you end up</strong>: <code>open_first_post()</code> hands back a <code>PostPage</code>.</p>
<p>The second page object covers the free-PDF pages, which have a form:</p>
<pre tabindex="0"><code class="language-python"># pages/guide.py
from playwright.sync_api import Page


class GuidePage:
    &quot;&quot;&quot;The free-PDF page for one series, e.g. /testing-basics/.&quot;&quot;&quot;

    def __init__(self, page: Page, slug: str):
        self.page = page
        self.slug = slug
        self.parts = page.get_by_test_id(&quot;pdf-part&quot;)
        self.download_link = page.get_by_test_id(&quot;download-link&quot;)
        self.error = page.get_by_test_id(&quot;lead-error&quot;)

    def open(self):
        self.page.goto(f&quot;/{self.slug}/&quot;)
        return self

    def request_pdf(self, name: str, email: str):
        self.page.get_by_test_id(&quot;lead-name&quot;).fill(name)
        self.page.get_by_test_id(&quot;lead-email&quot;).fill(email)
        self.page.get_by_test_id(&quot;lead-submit&quot;).click()
</code></pre>
<p><code>request_pdf</code> is the kind of method page objects exist for: three low-level steps that every test would otherwise repeat, named after what the user is doing.</p>
<p>Now the three tests from Part 2, rewritten. They read like sentences, and there is not a single selector in them:</p>
<pre tabindex="0"><code class="language-python"># test_blog.py
from playwright.sync_api import expect

from pages.blog import PostPage


def test_blog_lists_posts(blog):
    expect(blog.heading).to_have_text(&quot;ProvaLab Blog&quot;)
    expect(blog.cards.first).to_be_visible()


def test_a_post_opens_from_the_list(blog):
    post = blog.open_first_post()
    expect(post.page).to_have_url(PostPage.URL)
    expect(post.title).to_be_visible()
    expect(post.all_posts).to_be_visible()


def test_pdf_banner_links_to_both_guides(blog):
    expect(blog.pdf_link(&quot;Testing Basics&quot;)).to_have_attribute(&quot;href&quot;, &quot;/testing-basics/&quot;)
    expect(blog.pdf_link(&quot;Hands-On Testing&quot;)).to_have_attribute(&quot;href&quot;, &quot;/hands-on-testing/&quot;)
</code></pre>
<p>Where does <code>blog</code> come from? That is the next step.</p>
<h2>Step 2: Fixtures (5 minutes)</h2>
<p>A pytest <strong>fixture</strong> is a function that builds something a test needs; a test asks for it by naming it as a parameter. You have been using them since Part 2: <code>page</code> is a fixture that pytest-playwright provides. Yours go in <code>conftest.py</code>, which pytest loads automatically:</p>
<pre tabindex="0"><code class="language-python"># conftest.py (first part)
import pytest

from pages.blog import BlogPage


@pytest.fixture
def blog(page) -&gt; BlogPage:
    return BlogPage(page).open()
</code></pre>
<p>A fixture can ask for other fixtures. <code>blog</code> asks for <code>page</code>, wraps it and opens the blog. Every test that names <code>blog</code> gets a fresh page, already on <code>/blog/</code>, with no setup lines of its own.</p>
<p>pytest-playwright also lets you <strong>override</strong> its own fixtures. The most useful one is <code>browser_context_args</code>, the options every new browser context is created with. A fixed viewport and locale make screenshots and date formats the same on every machine:</p>
<pre tabindex="0"><code class="language-python">@pytest.fixture(scope=&quot;session&quot;)
def browser_context_args(browser_context_args):
    return {
        **browser_context_args,
        &quot;viewport&quot;: {&quot;width&quot;: 1280, &quot;height&quot;: 800},
        &quot;locale&quot;: &quot;en-GB&quot;,
    }
</code></pre>
<p><code>scope=&quot;session&quot;</code> means it runs once per test session, not once per test. Keep that word in mind for the next step.</p>
<h2>Step 3: Dismiss the cookie banner once (10 minutes)</h2>
<p>Every page on this site shows a small cookie notice until you close it. In Part 2 we ignored it, because none of the three tests clicked anything under it. In a bigger suite it will eventually sit on top of something you need, and the lazy fix, a click at the top of every test, is slow and gets copied everywhere.</p>
<p>Playwright has a better answer. A browser context can <strong>save its state</strong> (cookies and local storage) to a file, and a new context can <strong>start from that file</strong>. So: dismiss the banner once, save, and have every test&#39;s context begin with it already gone.</p>
<pre tabindex="0"><code class="language-python"># conftest.py (second part)
import os
from pathlib import Path

# One state file per pytest-xdist worker, so parallel runs never write the same file.
STATE = Path(__file__).parent / &quot;.state&quot; / f&quot;consent-{os.environ.get(&#39;PYTEST_XDIST_WORKER&#39;, &#39;main&#39;)}.json&quot;


@pytest.fixture(scope=&quot;session&quot;)
def consent_state(browser, base_url) -&gt; Path:
    &quot;&quot;&quot;Dismiss the cookie banner once per session and save the browser state.&quot;&quot;&quot;
    if not STATE.exists():
        context = browser.new_context(base_url=base_url)
        page = context.new_page()
        page.goto(&quot;/&quot;)
        page.get_by_role(&quot;button&quot;, name=&quot;Dismiss&quot;).click()
        STATE.parent.mkdir(exist_ok=True)
        context.storage_state(path=STATE)
        context.close()
    return STATE


@pytest.fixture(scope=&quot;session&quot;)
def browser_context_args(browser_context_args, consent_state):
    &quot;&quot;&quot;Every test&#39;s browser context starts with the banner already dismissed.&quot;&quot;&quot;
    return {
        **browser_context_args,
        &quot;storage_state&quot;: str(consent_state),
        &quot;viewport&quot;: {&quot;width&quot;: 1280, &quot;height&quot;: 800},
        &quot;locale&quot;: &quot;en-GB&quot;,
    }
</code></pre>
<p><code>consent_state</code> is session-scoped, so the banner is dismissed once per run. <code>browser_context_args</code> now asks for it and passes the file as <code>storage_state</code>. Nothing else in the suite mentions the banner at all. The saved file is small and readable:</p>
<pre tabindex="0"><code class="language-json">{&quot;cookies&quot;: [], &quot;origins&quot;: [{&quot;origin&quot;: &quot;https://provalab.io&quot;, &quot;localStorage&quot;: [{&quot;name&quot;: &quot;cookie-consent&quot;, &quot;value&quot;: &quot;dismissed&quot;}]}]}
</code></pre>
<h3>What went wrong the first time</h3>
<p>Our first version clicked the banner&#39;s <strong>OK</strong> button instead of <strong>Dismiss</strong>. The run was green, the state file was <code>{&quot;cookies&quot;: [], &quot;origins&quot;: []}</code>, and the banner was still there in every test. The home page has two buttons called &quot;OK&quot;, and the click had landed on the other one. The lesson is the one from <a href="https://provalab.io/blog/ui-testing-explained/">Part 1</a> again: a locator should describe one thing. &quot;Dismiss&quot; is the banner&#39;s only button with that accessible name, so that is the one the fixture clicks.</p>
<p>Two tests prove the state works, and show the difference between a context built from the fixture and a bare one:</p>
<pre tabindex="0"><code class="language-python"># test_consent.py
from playwright.sync_api import expect

BANNER = &quot;We use cookies for analytics&quot;


def test_banner_is_gone_with_saved_state(page):
    page.goto(&quot;/&quot;)
    expect(page.get_by_text(BANNER)).to_have_count(0)


def test_banner_shows_for_a_fresh_visitor(browser, base_url):
    context = browser.new_context(base_url=base_url)   # no storage_state
    page = context.new_page()
    page.goto(&quot;/&quot;)
    expect(page.get_by_text(BANNER)).to_be_visible()
    context.close()
</code></pre>
<p>The same trick is how you handle <strong>login</strong>. Sign in once in a session fixture, save the state, and every test starts signed in. Put the state file in <code>.gitignore</code>: it holds whatever cookies the site set.</p>
<h2>Step 4: Mock the API so no real lead is created (10 minutes)</h2>
<p>The free-PDF pages have a form: name and email, then a download link appears. Behind it the page POSTs to a lead API. A test that fills the form for real creates a real lead every run, and someone has to delete &quot;Test Reader&quot; from the inbox each morning.</p>
<p><code>page.route</code> intercepts requests <strong>inside the browser</strong>, before they leave it. The test decides what the server says.</p>
<p><img src="https://provalab.io/blog/handson/mock-route.svg" alt="The page sends a POST to the leads API. page.route intercepts it inside the browser, records the body and replies with whatever the test says: 201 and the PDF link appears, or 422 and an error appears. The real API never receives the request." width="880" height="460" loading="lazy"></p>
<p>As a fixture, so any test can ask for it:</p>
<pre tabindex="0"><code class="language-python"># conftest.py (third part)
@pytest.fixture
def mock_leads(page):
    &quot;&quot;&quot;Answer the lead API inside the browser, so tests never create a real lead.
    Returns the list of request bodies the page sent.&quot;&quot;&quot;
    sent = []

    def handle(route):
        sent.append(route.request.post_data_json)
        route.fulfill(status=201, json={&quot;id&quot;: 1})

    page.route(&quot;**/api/v1/public/leads&quot;, handle)
    return sent
</code></pre>
<p>The fixture returns the list it appends to, so a test can check <strong>what the page sent</strong>, not just what it showed afterwards. Here are the guide tests, parametrised over both PDF pages:</p>
<pre tabindex="0"><code class="language-python"># test_guides.py
import pytest
from playwright.sync_api import expect

from pages.guide import GuidePage

GUIDES = [(&quot;testing-basics&quot;, 23), (&quot;hands-on-testing&quot;, 7)]


@pytest.mark.parametrize(&quot;slug,parts&quot;, GUIDES)
def test_guide_lists_every_part(page, slug, parts):
    guide = GuidePage(page, slug).open()
    expect(guide.parts).to_have_count(parts)


@pytest.mark.parametrize(&quot;slug,parts&quot;, GUIDES)
def test_pdf_appears_after_the_form(page, mock_leads, slug, parts):
    guide = GuidePage(page, slug).open()
    guide.request_pdf(&quot;Test Reader&quot;, &quot;reader@example.com&quot;)
    expect(guide.download_link).to_have_attribute(&quot;href&quot;, f&quot;/downloads/{slug}.pdf&quot;)
    assert len(mock_leads) == 1
    assert mock_leads[0][&quot;email&quot;] == &quot;reader@example.com&quot;
    assert mock_leads[0][&quot;source_path&quot;] == f&quot;/{slug}/&quot;


def test_rejected_submission_shows_an_error(page):
    page.route(&quot;**/api/v1/public/leads&quot;, lambda route: route.fulfill(status=422, json={&quot;detail&quot;: &quot;invalid&quot;}))
    guide = GuidePage(page, &quot;testing-basics&quot;).open()
    guide.request_pdf(&quot;Test Reader&quot;, &quot;reader@example.com&quot;)
    expect(guide.error).to_be_visible()
    expect(guide.download_link).to_have_count(0)
</code></pre>
<p><code>@pytest.mark.parametrize</code> runs a test once per row of <code>GUIDES</code>, so two tests become four, each with its own name in the output. The last test skips the fixture and routes inline, because it wants a different answer: a 422. That is the real value of mocking. A real server gives you the happy path all day; the sad paths, a rejection, a 500, a timeout, you get on demand only when you are the server.</p>
<p>One rule: mock the things <strong>around</strong> what you are testing, never the thing itself. Here the subject is the page&#39;s behaviour after an answer, so faking the answer is fair. A test of the lead API would have to call the lead API.</p>
<h2>Step 5: Run it, then run it in parallel (3 minutes)</h2>
<p>The whole suite, ten tests, one browser:</p>
<pre tabindex="0"><code>$ pytest -v
test_blog.py::test_blog_lists_posts[chromium] PASSED                     [ 10%]
test_blog.py::test_a_post_opens_from_the_list[chromium] PASSED           [ 20%]
test_blog.py::test_pdf_banner_links_to_both_guides[chromium] PASSED      [ 30%]
test_consent.py::test_banner_is_gone_with_saved_state[chromium] PASSED   [ 40%]
test_consent.py::test_banner_shows_for_a_fresh_visitor[chromium] PASSED  [ 50%]
test_guides.py::test_guide_lists_every_part[chromium-testing-basics-23] PASSED [ 60%]
test_guides.py::test_guide_lists_every_part[chromium-hands-on-testing-7] PASSED [ 70%]
test_guides.py::test_pdf_appears_after_the_form[chromium-testing-basics-23] PASSED [ 80%]
test_guides.py::test_pdf_appears_after_the_form[chromium-hands-on-testing-7] PASSED [ 90%]
test_guides.py::test_rejected_submission_shows_an_error[chromium] PASSED [100%]
============================= 10 passed in 27.14s ==============================
</code></pre>
<p>Now the same suite on four workers. pytest-xdist starts four pytest processes and deals the tests out between them; each process gets its own browser and its own session fixtures, which is why the state file is named after the worker:</p>
<pre tabindex="0"><code>$ pytest -q -n 4
..........                                                               [100%]
10 passed in 11.56s
</code></pre>
<table tabindex="0">
<thead>
<tr>
<th>Run</th>
<th>Time</th>
</tr>
</thead>
<tbody><tr>
<td><code>pytest</code> (one worker)</td>
<td>27.1 s</td>
</tr>
<tr>
<td><code>pytest -n 4</code></td>
<td>11.6 s</td>
</tr>
</tbody></table>
<p>Ten tests don&#39;t need four workers. A hundred do, and parallel only works if the tests were written as if they would run in any order, each starting from its own <code>goto()</code> and sharing nothing but read-only state. That is why Part 2&#39;s habit, &quot;no test depends on another&quot;, matters before the suite is big, not after.</p>
<p>Everything from Part 2 still applies on top: <code>--headed</code> to watch, <code>--tracing retain-on-failure</code> to keep a trace of anything red, <code>--browser firefox</code> to run the same ten tests in a second engine.</p>
<h2>Habits worth keeping</h2>
<ol>
<li><strong>Page objects hold locators and actions, never assertions.</strong> The test decides what should be true; the page object only knows where to look.</li>
<li><strong>A fixture per recurring need.</strong> If two tests share three setup lines, that is a fixture.</li>
<li><strong>Session scope for anything expensive and read-only</strong>: saved state, a signed-in session, test data that doesn&#39;t change.</li>
<li><strong>Mock what creates real records, and the sad paths.</strong> Everything else, hit for real.</li>
<li><strong>Write every test to run alone and in any order.</strong> Then <code>-n 4</code> is free when you need it.</li>
</ol>
<h2>What&#39;s next</h2>
<p>This suite runs on one laptop and one browser. The next step is to run it on the browsers and devices your users actually have, keep every trace somewhere the whole team can see it, and have failures land with the agent that wrote the code. That is what ProvaLab&#39;s QA Infrastructure does: the same Playwright tests connect to real browsers and devices in the cloud, every session is recorded, and a failing test&#39;s trace goes straight back to your coding agent through the ProvaLab plugin or MCP. See it in the <a href="https://provalab.io/#stage-infra">two-minute product tour</a>.</p>
<p>If you came here without doing Part 2 first, start at <a href="https://provalab.io/blog/first-playwright-test/">Your first Playwright test</a>. The whole Hands-On series, seven parts, is also <a href="https://provalab.io/hands-on-testing/">one free PDF</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>50 software testing interview questions, with short answers and where to read more</title>
      <link>https://provalab.io/blog/qa-interview-questions/</link>
      <guid isPermaLink="true">https://provalab.io/blog/qa-interview-questions/</guid>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>qa-interview</category>
      <category>software-testing</category>
      <category>careers</category>
      <description>The questions QA and test automation interviews actually ask, grouped by topic, each with a two-sentence answer and a link to the full explanation in our Testing Basics and Hands-On series.</description>
      <content:encoded><![CDATA[<p>Every question below is one we&#39;d expect in an interview for a QA, test or automation role, from junior to mid-level. Each gets a short answer you could say out loud, and a link to the post that explains it properly. Read the short answers to revise; follow the links for anything you couldn&#39;t explain to a colleague.</p>
<p><img src="https://provalab.io/blog/handson/interview-topic-map.svg" alt="Ten topic cards: fundamentals (7 questions, Testing Basics 1, 2, 19), process and planning (6, parts 4, 8, 23), test design (6, parts 6, 7), bugs and reporting (5, parts 9, 10), test types (7, parts 12 to 16), runs, coverage and traceability (5, parts 11, 17, 18), automation and tools (5, Basics 3 and 5, Hands-On 1 and 2), API and security (5, Hands-On 3 to 7), agile, CI/CD and behavioural (4, part 23)." width="880" height="470" loading="lazy"></p>
<h2>Fundamentals</h2>
<p><strong>1. What is software testing?</strong>
Checking that software does what it should, and doesn&#39;t do what it shouldn&#39;t. A test has a starting point, an action and an expected result; a mismatch is a bug. (<a href="https://provalab.io/blog/what-is-software-testing/">Part 1</a>)</p>
<p><strong>2. Can testing prove software is bug-free?</strong>
No. Testing shows the presence of defects, never their absence. Its job is to find the important bugs before users do, and to report the risk that remains. (<a href="https://provalab.io/blog/seven-principles-of-testing/">The 7 principles</a>)</p>
<p><strong>3. Why is exhaustive testing impossible?</strong>
The number of possible inputs and paths is astronomically large; a ten-character field alone has quadrillions of values. So we choose tests by risk and by technique. (<a href="https://provalab.io/blog/seven-principles-of-testing/">The 7 principles</a>)</p>
<p><strong>4. What are the levels of testing?</strong>
Unit (one piece of code), integration (pieces together), system (the whole application, end to end) and acceptance (does it meet the real need). Each catches what the others miss. (<a href="https://provalab.io/blog/types-of-software-testing/">Part 2</a>)</p>
<p><strong>5. Explain the testing pyramid.</strong>
Many fast unit tests at the base, fewer integration tests, and a few slow end-to-end tests at the top. The inverted version, mostly end-to-end tests, is slow and flaky. (<a href="https://provalab.io/blog/types-of-software-testing/">Part 2</a>)</p>
<p><strong>6. What is the difference between QA, QC and testing?</strong>
QA improves the process to prevent defects; QC checks the product to find them; testing is the main QC activity. Job titles blur this, but the definitions don&#39;t. (<a href="https://provalab.io/blog/qa-vs-qc-verification-vs-validation/">Part 20</a>)</p>
<p><strong>7. Verification vs validation?</strong>
Verification: are we building the product right, against its specification? Validation: are we building the right product, for its users? A product can pass one and fail the other. (<a href="https://provalab.io/blog/qa-vs-qc-verification-vs-validation/">Part 20</a>)</p>
<h2>Process and planning</h2>
<p><strong>8. What is the STLC?</strong>
The software testing life cycle: requirement analysis, test planning, test case design, environment setup, execution and closure, each with entry and exit criteria. It runs alongside development, not after it. (<a href="https://provalab.io/blog/software-testing-life-cycle-stlc/">Part 4</a>)</p>
<p><strong>9. What are entry and exit criteria?</strong>
Entry: what must be true before a phase starts, such as &quot;build deployed, smoke test passed&quot;. Exit: what must be true to finish, such as &quot;all high-priority tests passed, no open critical bugs&quot;. They stop testing starting too early or never ending. (<a href="https://provalab.io/blog/software-testing-life-cycle-stlc/">Part 4</a>)</p>
<p><strong>10. What goes in a test plan?</strong>
Objective, scope in and out, approach, environment, entry and exit criteria, people, schedule, deliverables and risks. The most underrated line is &quot;out of scope&quot;. (<a href="https://provalab.io/blog/how-to-write-a-test-plan/">Part 8</a>)</p>
<p><strong>11. Test plan vs test strategy?</strong>
A strategy is the organisation&#39;s long-term approach; a plan applies it to one release or project. (<a href="https://provalab.io/blog/how-to-write-a-test-plan/">Part 8</a>)</p>
<p><strong>12. What is risk-based testing?</strong>
Spending the most testing effort where failure is both likely and harmful. Score each area on likelihood and impact, and test the top-right of that grid first and deepest. (<a href="https://provalab.io/blog/how-to-write-a-test-plan/">Part 8</a>)</p>
<p><strong>13. What does &quot;shift left&quot; mean?</strong>
Moving testing earlier: reviewing requirements, writing tests before code, running checks on every commit. Bugs found earlier are cheaper to fix. (<a href="https://provalab.io/blog/testing-in-agile-and-ci-cd/">Part 23</a>)</p>
<h2>Test design</h2>
<p><strong>14. What is a test case, and what does a good one contain?</strong>
A set of steps with specific data that checks one thing and states the expected result. It has an ID, a title, preconditions, test data, steps, an expected result and a link to its requirement. (<a href="https://provalab.io/blog/how-to-write-test-cases/">Part 6</a>)</p>
<p><strong>15. Test scenario vs test case?</strong>
A scenario is what to test, in one line; a test case is exactly how to test one part of it. One scenario becomes several cases. (<a href="https://provalab.io/blog/how-to-write-test-cases/">Part 6</a>)</p>
<p><strong>16. What is boundary value analysis?</strong>
Testing at and around the edges of a valid range, because bugs like <code>&lt;</code> instead of <code>&lt;=</code> only show at the boundary. For a 0 to 100 field: -1, 0, 1, 99, 100, 101. (<a href="https://provalab.io/blog/how-to-write-test-cases/">Part 6</a>)</p>
<p><strong>17. What is equivalence partitioning?</strong>
Grouping inputs the software should treat the same, and testing one value from each group instead of all of them. (<a href="https://provalab.io/blog/how-to-write-test-cases/">Part 6</a>)</p>
<p><strong>18. Black-box vs white-box vs grey-box testing?</strong>
Black-box tests from the outside, against requirements. White-box designs tests from the code&#39;s structure. Grey-box tests from outside with some knowledge of the inside, such as the database or the API. (<a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">Part 7</a>)</p>
<p><strong>19. Statement coverage vs branch coverage?</strong>
Statement: did every line run? Branch: did every decision go both true and false? One test can give 100% statement and only 50% branch coverage. (<a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">Part 7</a>)</p>
<h2>Bugs and reporting</h2>
<p><strong>20. What makes a good bug report?</strong>
A title that says where, what and when; the environment; the fewest steps to reproduce; expected vs actual result; severity and priority; and evidence. Test: could a stranger reproduce it without asking you anything? (<a href="https://provalab.io/blog/how-to-write-a-bug-report/">Part 9</a>)</p>
<p><strong>21. Severity vs priority?</strong>
Severity is how badly the bug breaks the software; priority is how soon it must be fixed. A misspelled brand name on the home page is low severity, high priority. (<a href="https://provalab.io/blog/how-to-write-a-bug-report/">Part 9</a>)</p>
<p><strong>22. Describe the bug life cycle.</strong>
New, assigned, open, fixed, ready to retest, retest, verified, closed; or reopened if the retest fails. Side exits: rejected, duplicate, deferred, can&#39;t reproduce. (<a href="https://provalab.io/blog/bug-life-cycle/">Part 10</a>)</p>
<p><strong>23. Who should verify a fix?</strong>
Ideally the person who found the bug. They know exactly what was wrong, and they retest on every platform the bug affected. (<a href="https://provalab.io/blog/bug-life-cycle/">Part 10</a>)</p>
<p><strong>24. What do you do when a developer says &quot;works on my machine&quot;?</strong>
Add what the report was missing: exact data, environment, a recording. &quot;Can&#39;t reproduce&quot; usually means the report lacked something, not that the bug isn&#39;t real. (<a href="https://provalab.io/blog/how-to-write-a-bug-report/">Part 9</a>)</p>
<h2>Test types</h2>
<p><strong>25. Smoke vs sanity testing?</strong>
Smoke: a wide, shallow check of every main feature on each new build, to decide whether it&#39;s worth testing at all. Sanity: a narrow, deeper check of one area after a change. (<a href="https://provalab.io/blog/smoke-vs-sanity-testing/">Part 14</a>)</p>
<p><strong>26. Regression testing vs re-testing?</strong>
Re-testing confirms a specific bug is fixed by running the failed test again. Regression testing runs previously passing tests to check the change broke nothing else. Every fix needs both. (<a href="https://provalab.io/blog/regression-testing-vs-retesting/">Part 15</a>)</p>
<p><strong>27. What is integration testing, and what are stubs and drivers?</strong>
Testing that separately built parts work together. A stub stands in for a part that is called; a driver stands in for a part that calls. Top-down integration uses stubs, bottom-up uses drivers. (<a href="https://provalab.io/blog/integration-testing/">Part 12</a>)</p>
<p><strong>28. System testing vs end-to-end testing?</strong>
End-to-end follows one complete user journey; system testing covers those journeys plus non-functional checks of the whole system, in a production-like environment. (<a href="https://provalab.io/blog/system-testing/">Part 13</a>)</p>
<p><strong>29. Name the types of performance testing.</strong>
Load (expected peak), stress (past the peak, to the breaking point), spike (sudden rush), soak (hours, for leaks), volume (large data), scalability (does adding servers add capacity). (<a href="https://provalab.io/blog/performance-testing-types/">Part 16</a>)</p>
<p><strong>30. Why use percentiles instead of the average response time?</strong>
The average hides the slow tail. A p95 of 1.8 s means one in twenty users waits that long, even if the average looks fine. (<a href="https://provalab.io/blog/performance-testing-types/">Part 16</a>)</p>
<p><strong>31. What is static testing?</strong>
Finding defects without running the software: reviews, walkthroughs, inspections and static analysis tools. It catches missing requirements and design flaws that no executed test can. (<a href="https://provalab.io/blog/static-testing/">Part 21</a>)</p>
<h2>Runs, coverage and traceability</h2>
<p><strong>32. Test case vs test run vs test result?</strong>
A test case is written once; a run executes a set of cases on a specific build and environment; a result is what happened to one case in one run. (<a href="https://provalab.io/blog/test-runs-and-test-results/">Part 17</a>)</p>
<p><strong>33. What do blocked and skipped mean, and why not count them as failed?</strong>
Blocked: the test couldn&#39;t run because something else is broken. Skipped: deliberately not run. Neither tells you anything about the feature, so they&#39;re reported separately. (<a href="https://provalab.io/blog/test-runs-and-test-results/">Part 17</a>)</p>
<p><strong>34. How do you calculate pass rate?</strong>
Of executed tests: passed ÷ (passed + failed). Of planned tests: passed ÷ all planned. Report both; the gap is the blocked and skipped tests. (<a href="https://provalab.io/blog/test-runs-and-test-results/">Part 17</a>)</p>
<p><strong>35. What is a requirements traceability matrix?</strong>
A table linking each requirement to its test cases, results and bugs. It shows requirements with no tests, failing requirements, and tests that cover nothing. (<a href="https://provalab.io/blog/requirements-traceability-matrix/">Part 11</a>)</p>
<p><strong>36. What kinds of test coverage are there?</strong>
Requirement coverage, execution coverage, code coverage (line, branch, function) and platform coverage. Each has a formula and none of them means bug-free at 100%. (<a href="https://provalab.io/blog/test-coverage-explained/">Part 18</a>)</p>
<h2>Automation and tools</h2>
<p><strong>37. How do you decide what to automate?</strong>
Three questions: will it run again and again, is the feature stable, is pass/fail clear? Yes to all three, automate; any no, keep it manual for now. (<a href="https://provalab.io/blog/manual-vs-automated-testing/">Part 3</a>)</p>
<p><strong>38. What is a flaky test, and why is it dangerous?</strong>
One that passes and fails on different runs with no code change. Once people re-run red tests until they pass, real failures get re-run away too. (<a href="https://provalab.io/blog/ui-testing-explained/">Hands-On 1</a>)</p>
<p><strong>39. What&#39;s the best way to locate elements in a UI test?</strong>
By role and accessible name first, then test IDs, then visible text; CSS tied to styling and positional XPath last. The locator decides whether the test survives a redesign. (<a href="https://provalab.io/blog/ui-testing-explained/">Hands-On 1</a>)</p>
<p><strong>40. Selenium vs Playwright vs Cypress?</strong>
Selenium: the standard, most languages, widest browser and device coverage, manual waiting. Playwright: fast, auto-waiting, all three browser engines, best debugging. Cypress: best developer experience for JavaScript front-end teams. New projects usually pick Playwright. (<a href="https://provalab.io/blog/ui-testing-explained/">Hands-On 1</a>)</p>
<p><strong>41. What is TDD?</strong>
Write a failing test first, write just enough code to pass it, then clean up. The failing test proves the test can catch the bug. (<a href="https://provalab.io/blog/testing-in-agile-and-ci-cd/">Part 23</a>)</p>
<h2>API and security</h2>
<p><strong>42. Why test the API before the UI?</strong>
API tests run in milliseconds, are stable, start before the screens exist, pinpoint the failing endpoint and cover every client at once. Test the business rules there; keep a few UI tests for journeys. (<a href="https://provalab.io/blog/api-testing-explained/">Hands-On 3</a>)</p>
<p><strong>43. 401 vs 403 vs 404?</strong>
401: not authenticated, who are you? 403: authenticated but not allowed. 404: not found. Assert the exact one; an API that returns 404 for someone else&#39;s record leaks that it exists. (<a href="https://provalab.io/blog/first-api-test-pytest/">Hands-On 4</a>)</p>
<p><strong>44. What is the most common serious API security bug?</strong>
Broken access control: a user reading or changing another user&#39;s data. The test is four lines: sign in as one user, request another&#39;s record, expect 403. (<a href="https://provalab.io/blog/security-testing-basics-owasp-top-10/">Hands-On 5</a>)</p>
<p><strong>45. What is the OWASP Top 10?</strong>
The ten most serious web application risks, updated from real-world data. The 2025 edition leads with broken access control and misconfiguration, and adds supply chain failures. (<a href="https://provalab.io/blog/security-testing-basics-owasp-top-10/">Hands-On 5</a>)</p>
<p><strong>46. SAST vs DAST?</strong>
SAST reads the source code without running it, on every commit. DAST attacks the running application from outside, nightly or before release. They find different things. (<a href="https://provalab.io/blog/first-security-scan-owasp-zap/">Hands-On 6</a>)</p>
<h2>Agile, CI/CD and you</h2>
<p><strong>47. How does testing work in an agile team?</strong>
Quality is the whole team&#39;s job, testing happens inside the sprint, acceptance criteria are the test cases, and regression is automated so people can explore. (<a href="https://provalab.io/blog/testing-in-agile-and-ci-cd/">Part 23</a>)</p>
<p><strong>48. What runs at each stage of a CI/CD pipeline?</strong>
Every commit: static analysis and unit tests. Every pull request: integration and API tests. Merge: build and smoke test. Nightly: full regression and performance. Release candidate: system and exploratory testing. Production: smoke test and monitoring. (<a href="https://provalab.io/blog/testing-in-agile-and-ci-cd/">Part 23</a>)</p>
<p><strong>49. Tell me about a bug you found that the tests missed.</strong>
Pick one that came from exploring beyond the script: an unusual sequence, an edge case, a state nobody considered. Explain how you found it, how you reported it, and what test you added so it stays fixed. (<a href="https://provalab.io/blog/exploratory-testing/">Part 22</a>)</p>
<p><strong>50. How do you decide testing is done?</strong>
Against the exit criteria agreed in the plan, never against the deadline alone. Report what was tested, what wasn&#39;t, what&#39;s still open and the risk that remains. (<a href="https://provalab.io/blog/software-testing-life-cycle-stlc/">Part 4</a>)</p>
<h2>How to use this before an interview</h2>
<ol>
<li>Read the short answers once. Mark any you couldn&#39;t explain to a colleague.</li>
<li>Follow the link for each marked one; every post is a ten-minute read.</li>
<li>For questions 7, 21, 25, 26 and 43, the pairs people confuse, practise saying the difference out loud.</li>
<li>Prepare one real story for question 49. Interviewers remember stories, not definitions.</li>
</ol>
<p>Good luck. The whole <a href="https://provalab.io/blog/what-is-software-testing/">Testing Basics</a> series and the <a href="https://provalab.io/blog/ui-testing-explained/">Hands-On Testing</a> series are free, and every code sample in them actually runs.</p>
]]></content:encoded>
    </item>
    <item>
      <title>API testing explained: what it is, why test the API before the UI, and what to check</title>
      <link>https://provalab.io/blog/api-testing-explained/</link>
      <guid isPermaLink="true">https://provalab.io/blog/api-testing-explained/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>hands-on-testing</category>
      <category>api-testing</category>
      <category>rest</category>
      <description>What an API is and how API tests work, why they are faster and more stable than UI tests, the five things to check in every response, the kinds of API tests, and the tools teams use.</description>
      <content:encoded><![CDATA[<p>In <a href="https://provalab.io/blog/first-playwright-test/">Part 2</a> our three browser tests took about 11 seconds to run. Most of that time was spent loading pages, rendering them and waiting for a browser, not checking anything. There is a faster place to test most of what matters, one layer down from the screens: <strong>the API</strong>.</p>
<h2>What is an API?</h2>
<p>An <strong>API</strong> (application programming interface) is the set of requests a program can make to another program. In a modern app, every screen talks to the server through an API: when you tap &quot;Place order&quot;, the mobile app sends a request like <code>POST /orders</code> with the order details, and the server replies with the result.</p>
<p>Most web and mobile APIs are <strong>HTTP APIs</strong>, usually following the <strong>REST</strong> style:</p>
<ul>
<li>A request names a <strong>resource</strong> with a URL path, such as <code>/orders/42</code>.</li>
<li>A <strong>method</strong> says what to do with it: <code>GET</code> (read), <code>POST</code> (create), <code>PUT</code> or <code>PATCH</code> (update), <code>DELETE</code>.</li>
<li>Data travels as <strong>JSON</strong>, a plain-text format of fields and values.</li>
<li>The reply carries a <strong>status code</strong>: <code>200</code> means OK, <code>201</code> created, <code>400</code> bad request, <code>401</code> not signed in, <code>403</code> not allowed, <code>404</code> not found, <code>500</code> the server broke.</li>
</ul>
<p><img src="https://provalab.io/blog/handson/api-layer.svg" alt="Three layers. Top: the mobile app, the website and the restaurant dashboard. Middle: the API, endpoints such as POST /orders, GET /orders/42 and POST /discounts/apply, which every interface calls. Bottom: the services and database. A UI test drives a screen through all three layers in seconds; an API test sends a request straight to the middle layer in milliseconds, with no browser." width="880" height="440" loading="lazy"></p>
<p><strong>The API is where the real logic lives.</strong> Whether a customer orders from the mobile app, the website or the restaurant dashboard, the same <code>POST /discounts/apply</code> decides what the discount is. A bug there shows up once, for everyone.</p>
<h2>What is API testing?</h2>
<p><strong>API testing sends requests directly to the API and checks the responses, without any user interface.</strong></p>
<p>A test is still <a href="https://provalab.io/blog/ui-testing-explained/">locate, act, assert</a>, with &quot;locate&quot; replaced by &quot;build the request&quot;:</p>
<ol>
<li><strong>Build</strong> a request: method, URL, headers, body.</li>
<li><strong>Send</strong> it.</li>
<li><strong>Assert</strong> on the response: status, headers, body and timing.</li>
</ol>
<p>In Python with the <code>requests</code> library it looks like this (we&#39;ll run tests like it for real in Part 4):</p>
<pre tabindex="0"><code class="language-python">response = requests.post(
    &quot;https://api.example.com/discounts/apply&quot;,
    json={&quot;order_total&quot;: 500, &quot;code&quot;: &quot;SAVE10&quot;},
    headers={&quot;Authorization&quot;: f&quot;Bearer {token}&quot;},
)
assert response.status_code == 200
assert response.json()[&quot;total&quot;] == 450
</code></pre>
<p>No browser, no page load, no waiting for elements. That test runs in a few milliseconds.</p>
<h2>Why test the API before the UI</h2>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>API tests</th>
<th>UI tests</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Speed</strong></td>
<td>Milliseconds each; thousands in a minute</td>
<td>Seconds each</td>
</tr>
<tr>
<td><strong>Stability</strong></td>
<td>Very stable: no timing, no layout, no cookie banners</td>
<td>The flakiest kind of test</td>
</tr>
<tr>
<td><strong>When you can start</strong></td>
<td>As soon as the endpoint exists, often before the screen does</td>
<td>Only once the screen is built</td>
</tr>
<tr>
<td><strong>Finds</strong></td>
<td>Logic, data, validation, security and error-handling bugs</td>
<td>What the user actually sees</td>
</tr>
<tr>
<td><strong>Pinpoints the cause</strong></td>
<td>Yes: one endpoint, one response</td>
<td>Rarely: the failure could be anywhere in the stack</td>
</tr>
<tr>
<td><strong>Covers</strong></td>
<td>Every client at once (app, website, partners)</td>
<td>One screen on one client</td>
</tr>
</tbody></table>
<p>The pattern most teams settle on is the <a href="https://provalab.io/blog/types-of-software-testing/">testing pyramid</a> applied to the back end: <strong>test the business rules thoroughly at the API level, and keep a few UI tests for the journeys users see.</strong> The discount logic gets fifty API tests and one UI test, not the other way round.</p>
<p>A second reason that&#39;s easy to miss: <strong>APIs are products too.</strong> If partners or a mobile team build on your API, a change that breaks it breaks them, whether or not your own website still works.</p>
<h2>What to check in every response</h2>
<p><img src="https://provalab.io/blog/handson/api-response-checks.svg" alt="A request, POST /discounts/apply with order_total 500 and code SAVE10, and its response. Five checks: 1, the status code, 200 here; 2, the headers, right content type and nothing secret leaked; 3, the body shape, the fields and types the contract promises; 4, the values, total 450 and discount 50, the business rule; 5, the timing, 42 ms, within budget." width="880" height="500" loading="lazy"></p>
<ol>
<li><strong>Status code.</strong> <code>200</code> for success, the <em>right</em> <code>4xx</code> for bad input or missing permission, and never a <code>500</code>. A <code>500</code> means the server crashed instead of handling the situation.</li>
<li><strong>Headers.</strong> The content type is what the client expects (<code>application/json</code>); caching is right for the data; and nothing sensitive leaks in a header.</li>
<li><strong>Body shape, the contract.</strong> The fields the API promises are present, with the right types: <code>total</code> is a number, not the string <code>&quot;450&quot;</code>. Clients break on shape changes even when the values are right.</li>
<li><strong>Values, the business rule.</strong> 500 with <code>SAVE10</code> must give <code>total: 450</code>. This is the check that finds the bug that charges every customer the wrong amount.</li>
<li><strong>Timing.</strong> The response arrived within the budget, say under 500 ms. One slow endpoint makes every screen that uses it slow.</li>
</ol>
<h2>Kinds of API tests</h2>
<p><strong>Functional tests</strong> check that each endpoint does its job: create an order, read it back, apply a discount. The bulk of your API suite.</p>
<p><strong>Negative tests</strong> send wrong input on purpose, and check that the API refuses it <em>cleanly</em>: a missing field returns <code>400</code> with a helpful message, an unknown order returns <code>404</code>, a discount of 150% is rejected. Many real bugs hide here, as we saw in <a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">test design</a>.</p>
<p><strong>Authentication and authorisation tests</strong> check that requests without a valid token get <code>401</code>, and that a signed-in customer gets <code>403</code> when asking for <em>someone else&#39;s</em> order. The second kind, called broken object-level authorisation, is the most common serious API security bug, and it&#39;s easy to test: sign in as customer A, request customer B&#39;s order, expect <code>403</code>.</p>
<p><strong>Contract tests</strong> check the shape of requests and responses against an agreed definition, usually an <strong>OpenAPI</strong> (formerly Swagger) document. When several teams or partners depend on the API, contract tests catch a renamed field before it breaks anyone. Tools such as Pact go a step further, letting each client declare exactly what it needs.</p>
<p><strong>Integration tests</strong> through the API check that a sequence works end to end: create an order, apply a code, pay, then read the order back and confirm the total. This is <a href="https://provalab.io/blog/integration-testing/">integration testing</a> with the API as the entry point.</p>
<p><strong>Performance tests</strong> send many requests at once to find the breaking point, as described in <a href="https://provalab.io/blog/performance-testing-types/">performance testing</a>. Load tests are nearly always API tests.</p>
<h2>Test data and authentication</h2>
<p>Two practical problems every API tester meets:</p>
<ul>
<li><strong>Tokens.</strong> Most APIs need a key or a bearer token. Tests get one in a setup step (sign in with a test account, or read a test key from an environment variable), then reuse it. <strong>Never put real credentials in the test code or the repository.</strong></li>
<li><strong>Test data.</strong> <code>POST /orders</code> creates real rows. Tests should create what they need, use it, and clean up, or run against a test environment that&#39;s reset regularly. A test that depends on &quot;order 42 already existing&quot; will fail the day someone deletes order 42.</li>
</ul>
<h2>Tools</h2>
<table tabindex="0">
<thead>
<tr>
<th>Tool</th>
<th>Style</th>
<th>Good for</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Postman</strong> / <strong>Bruno</strong></td>
<td>Point-and-click, with collections you can run from the command line</td>
<td>Exploring an API, sharing requests with a team</td>
</tr>
<tr>
<td><strong>pytest + requests</strong> or <strong>httpx</strong></td>
<td>Python code</td>
<td>Suites that live with the code and run in CI; what we use in Part 4</td>
</tr>
<tr>
<td><strong>REST Assured</strong></td>
<td>Java</td>
<td>Java teams</td>
</tr>
<tr>
<td><strong>Karate</strong></td>
<td>Its own readable script language</td>
<td>Teams that want tests non-developers can read</td>
</tr>
<tr>
<td><strong>Playwright&#39;s request API</strong></td>
<td>JavaScript, Python</td>
<td>Mixing API calls into UI tests, such as creating data before a browser test</td>
</tr>
<tr>
<td><strong>Schemathesis</strong> / <strong>Dredd</strong></td>
<td>Generate tests from an OpenAPI document</td>
<td>Contract checking and fuzzing</td>
</tr>
</tbody></table>
<p>If you&#39;re starting from nothing, <strong>pytest with requests</strong> needs the least setup and produces tests that live next to your code.</p>
<h2>API testing and the UI tests you already have</h2>
<p>The two work together. The fastest reliable UI suites use the API to <strong>set the stage</strong>: create the test customer and the order through API calls in a few milliseconds, then open the browser only for the part that needs a browser. The sign-up form gets one UI test; the thousand ways an order can be wrong get API tests.</p>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/first-api-test-pytest/">Part 4</a></strong> we build a small API of our own, so there&#39;s nothing to sign up for and nothing we don&#39;t own, and write a pytest suite against it: functional, negative, authorisation and contract checks, with the real output.</p>
<p>ProvaLab&#39;s QA AI Agent runs API test cases on its own and records each request and response against its test case, and AppSec fuzzes your API for the authorisation and input-handling bugs described above. See it in the <a href="https://provalab.io/#stage-agent">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Test design techniques: black-box, white-box and grey-box testing</title>
      <link>https://provalab.io/blog/black-box-white-box-grey-box-testing/</link>
      <guid isPermaLink="true">https://provalab.io/blog/black-box-white-box-grey-box-testing/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>test-design</category>
      <description>The three ways of looking at software when you design tests, and the techniques for each, from boundary values and state transitions to statement and branch coverage.</description>
      <content:encoded><![CDATA[<p>In <a href="https://provalab.io/blog/how-to-write-test-cases/">Part 6</a> we learned how to <em>write</em> a test case. This part is about how to <em>find</em> the right ones. Testers group their techniques by one simple question: <strong>how much of the inside of the software can you see?</strong></p>
<p>That gives three approaches: <strong>black-box</strong>, <strong>white-box</strong> and <strong>grey-box</strong> testing.</p>
<p><img src="https://provalab.io/blog/basics/black-white-grey-box.svg" alt="Black-box testing sees only inputs and outputs and tests like a user, using partitions, boundaries, decision tables and states. Grey-box testing sees some internals such as the database, APIs and logs. White-box testing sees the code and tests every line and branch, using statement, branch, condition and path coverage." width="880" height="430" loading="lazy"></p>
<h2>Black-box testing</h2>
<p><strong>In black-box testing you test without looking at the code.</strong> You know what goes in, what should come out, and what the requirements say. The inside is a closed box.</p>
<p>This is how users experience software, and it is how most QA testing is done. You need no programming knowledge, and the tests stay valid even when developers rewrite the code inside.</p>
<p>The main black-box techniques:</p>
<h3>1. Equivalence partitioning</h3>
<p>Split the inputs into groups the software should treat the same, and test <strong>one value from each group</strong>. A discount field that accepts 0–100% has three groups: below 0, 0 to 100, above 100. (More detail, with a diagram, in <a href="https://provalab.io/blog/how-to-write-test-cases/">Part 6</a>.)</p>
<h3>2. Boundary value analysis</h3>
<p>Test <strong>at and around every edge</strong>: -1, 0, 1, 99, 100 and 101 for that discount field. Mistakes like <code>&lt;</code> instead of <code>&lt;=</code> only show up at the exact boundary.</p>
<h3>3. Decision table testing</h3>
<p>When the result depends on <strong>combinations of conditions</strong>, list every combination in a table, and turn each row into a test. For example: new customer yes/no × order over 300 yes/no → free delivery yes/no.</p>
<h3>4. State transition testing</h3>
<p>Some features behave differently depending on <strong>what state they are in</strong>. A food order moves through clear states:</p>
<p><img src="https://provalab.io/blog/basics/order-states.svg" alt="An order moves Placed, Accepted, Preparing, Out for delivery, Delivered. It can be Cancelled from Placed, Accepted or Preparing. Cancelling once it is out for delivery is not allowed and should be tested." width="880" height="360" loading="lazy"></p>
<p>Draw the states and the allowed moves, then test:</p>
<ul>
<li><strong>Every allowed move:</strong> placed → accepted, preparing → cancelled, and so on.</li>
<li><strong>The forbidden moves:</strong> can a customer cancel an order that is already out for delivery? They mustn&#39;t, and the app must say so clearly.</li>
</ul>
<p>State transition testing is ideal for orders, payments, sign-in (locked after 3 wrong passwords?), subscriptions and any kind of workflow.</p>
<h3>5. Use case testing</h3>
<p>Test complete journeys the way a real user would: &quot;A customer searches for pizza, adds two, applies a code, pays by card and tracks the delivery.&quot; Each use case has a main flow and alternative flows (the card is declined, the restaurant closes mid-order).</p>
<h3>6. Pairwise testing</h3>
<p>When many settings combine, testing every combination explodes. Three devices × three payment methods × two languages makes 18 combinations. Most bugs are triggered by <strong>one setting, or a pair of settings</strong>, so <strong>pairwise testing</strong> picks a smaller set where every <em>pair</em> appears at least once. Here, 9 tests cover every pair instead of all 18. Free tools such as PICT generate these sets for you.</p>
<h2>White-box testing</h2>
<p><strong>In white-box testing you design tests by looking at the code itself.</strong> You read the logic and make sure every part of it is exercised. It is mostly done by developers, usually as unit tests (see <a href="https://provalab.io/blog/first-automated-test-pytest/">Part 5</a>).</p>
<p>The key white-box idea is <strong>code coverage</strong>: how much of the code your tests actually ran. Here is a small function from our delivery app:</p>
<pre tabindex="0"><code class="language-python">def delivery_fee(order_total, is_member):
    fee = 40
    if order_total &gt;= 300:
        fee = 0
    if is_member:
        fee = fee / 2
    return fee
</code></pre>
<h3>Statement coverage</h3>
<p><strong>Has every line run at least once?</strong> One test is enough here:</p>
<pre tabindex="0"><code class="language-python">def test_big_order_member():
    assert delivery_fee(400, is_member=True) == 0
</code></pre>
<p>Both <code>if</code> conditions are true, so every line runs. We measured it with pytest-cov:</p>
<pre tabindex="0"><code class="language-text">Name          Stmts   Miss  Cover
delivery.py       7      0   100%
</code></pre>
<p>100% statement coverage, from one test. Sounds complete, but it isn&#39;t.</p>
<h3>Branch coverage</h3>
<p><strong>Has every decision gone both ways?</strong> Each <code>if</code> can be true <em>or</em> false, and our single test only ever took the &quot;true&quot; side. Turn on branch coverage and the gap appears:</p>
<pre tabindex="0"><code class="language-text">Name          Stmts   Miss Branch BrPart  Cover   Missing
delivery.py       7      0      4      2    82%   3-&gt;5, 5-&gt;7
</code></pre>
<p><code>3-&gt;5</code> means &quot;the <code>if</code> on line 3 never skipped straight to line 5&quot;: we never tested a small order. Add one test where both conditions are false:</p>
<pre tabindex="0"><code class="language-python">def test_small_order_non_member():
    assert delivery_fee(100, is_member=False) == 40
</code></pre>
<pre tabindex="0"><code class="language-text">Name          Stmts   Miss Branch BrPart  Cover   Missing
delivery.py       7      0      4      0   100%
</code></pre>
<p>Now every decision has gone both ways. This is why <strong>branch coverage is a stronger measure than statement coverage</strong>.</p>
<h3>Condition and path coverage</h3>
<ul>
<li><strong>Condition coverage</strong> checks each part of a combined condition separately, such as <code>if is_member and order_total &gt;= 300</code>: has each half been both true and false?</li>
<li><strong>Path coverage</strong> checks every route through the code. Our function has two <code>if</code>s, so four paths: big+member, big+non-member, small+member, small+non-member. Our two tests cover only two of them. The small-order member case (fee 20) has never been tested.</li>
</ul>
<p>Path coverage grows very fast with real code, so teams usually aim for good branch coverage and add path tests only for critical logic.</p>
<p><strong>One warning:</strong> coverage tells you what code <em>ran</em>, not whether the tests <em>checked</em> the right things. A test with no <code>assert</code> still counts as coverage. Treat coverage as a map of what you haven&#39;t tested, not as proof of quality.</p>
<h2>Grey-box testing</h2>
<p><strong>Grey-box testing sits in between.</strong> You test from the outside, like a user, but you use some knowledge of the inside (the database design, the APIs, the logs or the architecture) to design sharper tests and check results more deeply.</p>
<p>Examples from our app:</p>
<ul>
<li><strong>Check behind the screen:</strong> place an order in the app, then check the database. Is the order saved with the right total and status, or did the screen just <em>show</em> success?</li>
<li><strong>Go around the UI:</strong> the app only lets customers choose quantities 1–10, but what does the API accept if you send 1,000 or -5 directly? Knowing there is an API makes this test possible.</li>
<li><strong>Use the logs:</strong> trigger a failed payment and check the server logs for a clear error, with no card details written in plain text.</li>
<li><strong>Integration points:</strong> knowing that orders are passed to the restaurant through a message queue, test what happens when that queue is slow or down.</li>
</ul>
<p>Grey-box testing is common in <strong>integration</strong>, <strong>API</strong> and <strong>security</strong> testing, where knowing a little about the inside finds bugs a pure black-box tester would never reach.</p>
<h2>Experience-based techniques</h2>
<p>Alongside the three boxes, testers rely on experience:</p>
<ul>
<li><strong>Error guessing:</strong> &quot;what usually breaks?&quot; Empty fields, very long text, special characters, double clicks, the back button, a lost network.</li>
<li><strong>Exploratory testing:</strong> learning, designing and testing at the same time, guided by curiosity and a time limit.</li>
<li><strong>Checklists:</strong> a team&#39;s growing list of things that have gone wrong before.</li>
</ul>
<h2>Comparing the three</h2>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>Black-box</th>
<th>Grey-box</th>
<th>White-box</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Sees</strong></td>
<td>Inputs and outputs</td>
<td>Some internals</td>
<td>The code</td>
</tr>
<tr>
<td><strong>Based on</strong></td>
<td>Requirements</td>
<td>Requirements + architecture</td>
<td>Code structure</td>
</tr>
<tr>
<td><strong>Usually done by</strong></td>
<td>QA testers</td>
<td>QA, automation, security</td>
<td>Developers</td>
</tr>
<tr>
<td><strong>Typical levels</strong></td>
<td>System, acceptance</td>
<td>Integration, API, security</td>
<td>Unit</td>
</tr>
<tr>
<td><strong>Finds</strong></td>
<td>Missing or wrong behaviour</td>
<td>Hidden data and integration problems</td>
<td>Untested logic, dead code</td>
</tr>
<tr>
<td><strong>Misses</strong></td>
<td>Untested code paths</td>
<td>Very deep logic</td>
<td>Missing features (you can&#39;t test code that was never written)</td>
</tr>
</tbody></table>
<p>The last row matters most: <strong>each approach misses what the others catch</strong>. White-box testing can&#39;t find a feature nobody built, and black-box testing can&#39;t see a branch nobody reached. Good teams use all three.</p>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/how-to-write-a-test-plan/">Part 8</a></strong> we put it together in a <strong>test plan</strong>: what to test, how, by whom and when.</p>
<p>ProvaLab helps on every side of the box: Test Management keeps your black-box test cases, the coding agent writes and runs unit tests from the code, and API and security testing reach behind the UI. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>The bug life cycle explained: from New to Closed (and Reopened)</title>
      <link>https://provalab.io/blog/bug-life-cycle/</link>
      <guid isPermaLink="true">https://provalab.io/blog/bug-life-cycle/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>bug-life-cycle</category>
      <description>Every status a bug passes through, who moves it at each step, the side exits like Duplicate and Deferred, one bug followed from start to finish, and the habits that keep a bug tracker healthy.</description>
      <content:encoded><![CDATA[<p>In <a href="https://provalab.io/blog/how-to-write-a-bug-report/">Part 9</a> you wrote a great bug report. Now what? A bug doesn&#39;t get fixed just because it was reported. It travels through a series of <strong>statuses</strong>, passing between testers, leads and developers, until it is finally closed.</p>
<p>That journey is the <strong>bug life cycle</strong>, also called the <strong>defect life cycle</strong>. Knowing it well means fewer bugs get lost, and nobody wonders &quot;wait, who has this one?&quot;</p>
<h2>The bug life cycle at a glance</h2>
<p><img src="https://provalab.io/blog/basics/bug-life-cycle.svg" alt="The bug life cycle. Main path: New, Assigned, Open, Fixed, Ready to retest, Retest, Verified, Closed. If the retest fails the bug is Reopened and goes back to Assigned. Other endings from New: Rejected (not a bug), Duplicate (already reported), Deferred (fix in a later release) and Need more info (can't reproduce)." width="880" height="520" loading="lazy"></p>
<h2>Every status, one by one</h2>
<h3>New</h3>
<p>A tester (or a user, or an automated test) has <strong>reported</strong> the bug. Nobody has looked at it yet. This is where every bug starts.</p>
<h3>Assigned</h3>
<p>A test lead, product owner or developer lead has <strong>triaged</strong> it: confirmed it&#39;s a real bug, set its priority, and assigned it to a developer.</p>
<h3>Open (or In Progress)</h3>
<p>The developer has <strong>started working on it</strong>: reproducing it, finding the cause, writing the fix.</p>
<h3>Fixed</h3>
<p>The developer has <strong>changed the code</strong> and believes the bug is solved. Good practice is to also add an automated test that would have caught it, so it can&#39;t quietly come back.</p>
<h3>Ready to retest</h3>
<p>The fix has been <strong>deployed to the test environment</strong>, and the bug is waiting for a tester. A fix that only exists on the developer&#39;s laptop can&#39;t be retested yet. This status makes that clear.</p>
<h3>Retest</h3>
<p>The tester <strong>follows the original steps</strong> from the bug report again, on the new build.</p>
<h3>Verified</h3>
<p>The retest <strong>passed</strong>: the bug is gone. The tester also runs a few <strong>regression tests</strong> around it, to make sure the fix didn&#39;t break something nearby.</p>
<h3>Closed</h3>
<p>The bug is <strong>done</strong>. Usually the tester closes it after verifying, or it closes automatically when the fix is released.</p>
<h3>Reopened</h3>
<p>The retest <strong>failed</strong>: the bug is still there, or it only went away in some cases. The tester reopens it with a comment on what still happens, and it goes back to the developer.</p>
<h2>The side exits</h2>
<p>Not every bug follows the main path. During triage, a bug can also end up as:</p>
<table tabindex="0">
<thead>
<tr>
<th>Status</th>
<th>Meaning</th>
<th>What the tester should do</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Rejected / Not a bug</strong></td>
<td>The software is working as intended</td>
<td>Check the requirement. If the behaviour is still wrong for users, raise it as a change request instead</td>
</tr>
<tr>
<td><strong>Duplicate</strong></td>
<td>The same bug is already reported</td>
<td>Link to the original; add any new details there</td>
</tr>
<tr>
<td><strong>Deferred / Postponed</strong></td>
<td>Real, but will be fixed in a later release</td>
<td>Make sure the risk is understood and the bug isn&#39;t forgotten</td>
</tr>
<tr>
<td><strong>Need more info / Can&#39;t reproduce</strong></td>
<td>The developer can&#39;t make it happen</td>
<td>Add more detail: exact data, environment, a screen recording</td>
</tr>
</tbody></table>
<p><strong>&quot;Can&#39;t reproduce&quot; is usually a sign the report was missing something</strong>, which is why the habits in Part 9 matter so much.</p>
<h2>Who moves the bug?</h2>
<p>Each status change belongs to someone. If nobody owns a step, bugs get stuck there:</p>
<p><img src="https://provalab.io/blog/basics/bug-roles.svg" alt="Who moves the bug. The tester reports it as New. The lead or product owner triages it to Assigned, or to Rejected, Duplicate or Deferred. The developer moves it from Open to Fixed. The tester retests it and marks it Verified and Closed, or Reopened." width="880" height="330" loading="lazy"></p>
<table tabindex="0">
<thead>
<tr>
<th>Step</th>
<th>Usually done by</th>
</tr>
</thead>
<tbody><tr>
<td>New → Assigned (or Rejected, Duplicate, Deferred)</td>
<td>Test lead, product owner or developer lead</td>
</tr>
<tr>
<td>Assigned → Open → Fixed</td>
<td>Developer</td>
</tr>
<tr>
<td>Fixed → Ready to retest</td>
<td>Developer or the release process</td>
</tr>
<tr>
<td>Retest → Verified → Closed, or Reopened</td>
<td>Tester</td>
</tr>
</tbody></table>
<p><strong>The rule worth remembering: the person who found the bug should be the one who verifies the fix.</strong> They know exactly what was wrong.</p>
<h2>One bug, start to finish</h2>
<p>Let&#39;s follow <strong>BUG-231</strong> from Part 9, &quot;Expired discount code accepted at checkout&quot;:</p>
<ol>
<li><strong>New</strong> (Mon 10:05): the tester reports it, with steps, a recording and a link to test case TC-104.</li>
<li><strong>Assigned</strong> (Mon 11:30): the team lead sets priority High, because it costs money, and assigns it to a developer.</li>
<li><strong>Open</strong> (Mon 14:00): the developer finds that the code checks the start date of a discount but never the end date.</li>
<li><strong>Fixed</strong> (Mon 16:20): the developer adds the missing check and an automated test for an expired code.</li>
<li><strong>Ready to retest</strong> (Tue 09:00): the fix is deployed to staging.</li>
<li><strong>Retest → Reopened</strong> (Tue 10:15): OLD10 is now refused on the web, but <strong>still accepted in the Android app</strong>, which has its own copy of the check. The tester reopens the bug with that detail.</li>
<li><strong>Fixed again</strong> (Tue 15:00): the check moves to the server, so every app uses the same rule.</li>
<li><strong>Retest → Verified</strong> (Wed 09:30): refused on web and Android; regression tests for valid codes still pass.</li>
<li><strong>Closed</strong> (Wed 09:35).</li>
</ol>
<p>The reopen in step 6 is not a failure of the process. <strong>It is the process working.</strong> Without a proper retest, half the bug would have shipped.</p>
<h2>Keeping the bug tracker healthy</h2>
<ul>
<li><strong>Triage regularly</strong>, ideally every day in busy periods, so no bug sits in &quot;New&quot; for a week.</li>
<li><strong>Watch for stuck bugs.</strong> A bug in &quot;Fixed&quot; for days probably never reached the test environment.</li>
<li><strong>Watch the reopen rate.</strong> If many bugs come back, fixes are being rushed or retests skipped.</li>
<li><strong>Don&#39;t let &quot;Deferred&quot; become a graveyard.</strong> Review deferred bugs before each release.</li>
<li><strong>Comment on every status change.</strong> &quot;Reopened: still accepted in Android 2.4.0&quot; is far more useful than a silent change.</li>
</ul>
<h2>Your tool&#39;s names may differ</h2>
<p>Jira, Azure DevOps, GitHub Issues and other trackers each use their own names and allow teams to customise the flow. Some keep it as simple as <strong>To Do → In Progress → Done</strong>. The ideas stay the same: someone reports it, someone decides, someone fixes, someone checks, and only then is it closed.</p>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/requirements-traceability-matrix/">Part 11</a></strong> we connect everything with the <strong>requirements traceability matrix</strong>: the table that proves every requirement has tests, and shows which ones are failing.</p>
<p>In ProvaLab, issues follow this life cycle with their status, priority and assignee, are linked to the test case and test run that found them, and can be raised straight from a failed automated test. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Exploratory testing explained: charters, sessions and heuristics</title>
      <link>https://provalab.io/blog/exploratory-testing/</link>
      <guid isPermaLink="true">https://provalab.io/blog/exploratory-testing/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>exploratory-testing</category>
      <description>What exploratory testing is and isn't, how session-based testing works, how to write a charter, the heuristics skilled testers use to generate ideas, and how to report what you found.</description>
      <content:encoded><![CDATA[<p>Every scripted test in this series checks something somebody already thought of. But the bug that doubles a discount when you change the delivery address and press back? Nobody wrote a test case for that, because nobody imagined it.</p>
<p><strong>Exploratory testing</strong> is how skilled testers find those bugs. It&#39;s the most misunderstood kind of testing: often dismissed as &quot;just clicking around&quot;, when done well it is one of the most effective techniques there is.</p>
<h2>What is exploratory testing?</h2>
<p><strong>In exploratory testing, the tester designs, runs and learns from tests at the same time</strong>, using what they just discovered to decide what to try next.</p>
<p>In scripted testing, the thinking happens first (writing the test case) and the running happens later, often by someone else. In exploratory testing the two happen together: you try something, see what the software does, form a new idea, and try that.</p>
<p>Three things make it different from &quot;just clicking around&quot;:</p>
<ol>
<li><strong>It has a goal.</strong> A written <strong>charter</strong> says what to explore and why.</li>
<li><strong>It is time-boxed.</strong> A <strong>session</strong> lasts a fixed time, typically 60 to 120 minutes.</li>
<li><strong>It is recorded.</strong> The tester keeps notes of what they tried, what they found and what questions came up, and reports them afterwards.</li>
</ol>
<p>This structured form is called <strong>session-based test management</strong>, developed by James and Jonathan Bach.</p>
<h2>Scripted vs exploratory testing</h2>
<p><img src="https://provalab.io/blog/basics/scripted-vs-exploratory.svg" alt="Two overlapping circles. Scripted testing finds known risks: requirements not met, regressions, the same checks every release. Exploratory testing finds the unknown: unexpected sequences, gaps the spec never mentioned, usability problems. Core features sit in the overlap. Scripts guard what you know; exploration finds what you don't." width="880" height="400" loading="lazy"></p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>Scripted testing</th>
<th>Exploratory testing</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Tests designed</strong></td>
<td>In advance, from requirements</td>
<td>During the session, from what you learn</td>
</tr>
<tr>
<td><strong>Best at finding</strong></td>
<td>Requirements not met, regressions</td>
<td>Unexpected behaviour, gaps in the requirements, usability problems</td>
</tr>
<tr>
<td><strong>Repeatable?</strong></td>
<td>Yes, by anyone</td>
<td>Partly: notes record what was tried</td>
</tr>
<tr>
<td><strong>Depends on</strong></td>
<td>Good requirements</td>
<td>Tester skill and curiosity</td>
</tr>
<tr>
<td><strong>Good for automation?</strong></td>
<td>Yes</td>
<td>No, by definition</td>
</tr>
<tr>
<td><strong>Coverage</strong></td>
<td>Known, measurable</td>
<td>Harder to measure; charters track it</td>
</tr>
</tbody></table>
<p>They are not rivals. Scripted tests, especially <a href="https://provalab.io/blog/regression-testing-vs-retesting/">automated regression tests</a>, guard against what you already know can go wrong. Exploratory testing finds what you don&#39;t know yet. <a href="https://provalab.io/blog/seven-principles-of-testing/">Principle 5</a>, tests wear out, is exactly why you need both.</p>
<h2>A session, start to finish</h2>
<p><img src="https://provalab.io/blog/basics/exploratory-session.svg" alt="A session-based exploratory test. The charter: explore discount codes at checkout with unusual sequences and inputs to discover ways the discount can be wrong. The tester's notes have three columns: ideas tried (apply then change address and go back, lower-case code, trailing space, apply twice quickly, remove all items, switch language), bugs found (discount applied twice after going back; trailing space gives &quot;code not recognised&quot; with no hint) and questions (should codes be case-insensitive? what if a code expires during checkout? Android not covered). The debrief after 75 minutes: 2 bugs, 2 questions, next charter the same on Android." width="880" height="484" loading="lazy"></p>
<h3>1. Write the charter</h3>
<p>A charter is one or two sentences describing the mission. A useful template, from Elisabeth Hendrickson&#39;s <em>Explore It!</em>:</p>
<blockquote>
<p><strong>Explore</strong> <em>(the target)</em> <strong>with</strong> <em>(resources or approaches)</em> <strong>to discover</strong> <em>(the information you want)</em>.</p>
</blockquote>
<p>For our food delivery app:</p>
<blockquote>
<p>Explore <strong>discount codes at checkout</strong> with <strong>unusual sequences and inputs</strong> to discover <strong>ways the discount can be wrong</strong>.</p>
</blockquote>
<p>A charter is narrow enough to finish in a session and open enough to leave room for discovery. &quot;Test checkout&quot; is too broad; &quot;apply code SAVE10&quot; is a test case, not a charter.</p>
<h3>2. Run the session</h3>
<p>Set a timer. Start from the charter, and let each result suggest the next idea. Keep notes as you go, in three groups:</p>
<ul>
<li><strong>Ideas tried:</strong> so you and others know what was covered.</li>
<li><strong>Bugs found:</strong> with enough detail to <a href="https://provalab.io/blog/how-to-write-a-bug-report/">write a proper bug report</a> afterwards.</li>
<li><strong>Questions:</strong> things the requirements don&#39;t answer, and areas you didn&#39;t reach.</li>
</ul>
<p>When something looks wrong, <strong>stop and reproduce it</strong> before moving on. A bug you can&#39;t reproduce is a bug you&#39;ll struggle to report.</p>
<h3>3. Debrief</h3>
<p>At the end, report briefly: what was covered, what was found, what&#39;s left. Then decide the next charter. In the example, the discount bugs on the web suggest an obvious follow-up: the same charter on Android.</p>
<h2>Heuristics: where ideas come from</h2>
<p>Experienced testers don&#39;t rely on inspiration. They use <strong>heuristics</strong>: rules of thumb that generate test ideas on demand. A few of the most useful:</p>
<p><strong>Vary the input</strong></p>
<ul>
<li><strong>Boundaries:</strong> zero, one, the maximum, one more than the maximum (see <a href="https://provalab.io/blog/how-to-write-test-cases/">Part 6</a>).</li>
<li><strong>Nothing:</strong> empty fields, missing files, no network.</li>
<li><strong>Too much:</strong> very long text, huge files, thousands of items.</li>
<li><strong>Strange characters:</strong> spaces at the ends, emoji, quotes, <code>&lt;script&gt;</code>, text in another alphabet.</li>
<li><strong>Wrong type:</strong> letters in a number field, a date in the past.</li>
</ul>
<p><strong>Vary the sequence</strong></p>
<ul>
<li>Do things in the wrong order. Pay, then add an item.</li>
<li><strong>Interrupt:</strong> go back, refresh, lose the network, close the app mid-way.</li>
<li><strong>Repeat:</strong> click twice, submit twice, apply the same code twice.</li>
<li><strong>Undo:</strong> add then remove, apply then cancel.</li>
</ul>
<p><strong>Vary the state and environment</strong></p>
<ul>
<li>A new user vs a user with 500 orders.</li>
<li>Two browser tabs doing the same thing at once.</li>
<li>Switch language, time zone or currency mid-flow.</li>
<li>Different devices, screen sizes, slow connections.</li>
</ul>
<p><strong>Follow the data</strong></p>
<ul>
<li>Where does this value go after I enter it? Check the order history, the email, the restaurant&#39;s screen. Is it the same everywhere?</li>
</ul>
<p><strong>Think like different people</strong></p>
<ul>
<li>A first-time user who misreads the screen. An impatient user. A restaurant owner. Someone trying to get a discount they shouldn&#39;t.</li>
</ul>
<p>Keeping a personal list of heuristics, and adding to it every time a new kind of bug turns up, is one of the fastest ways to become a better tester.</p>
<h2>When to use exploratory testing</h2>
<ul>
<li><strong>New features</strong>, before test cases exist or while they&#39;re being written.</li>
<li><strong>Right after a sprint&#39;s work lands</strong>, to find problems the stories didn&#39;t anticipate.</li>
<li><strong>Around a bug fix</strong>, alongside the <a href="https://provalab.io/blog/regression-testing-vs-retesting/">re-test and regression run</a>.</li>
<li><strong>Before a release</strong>, on the riskiest areas, after the scripted suite has passed.</li>
<li><strong>When requirements are thin</strong> and scripted tests would mostly be guesses.</li>
<li><strong>For usability</strong>: scripts can&#39;t tell you a flow is confusing.</li>
</ul>
<h2>Common mistakes</h2>
<ul>
<li><strong>No charter.</strong> Aimless clicking covers the easy paths and misses the rest.</li>
<li><strong>No notes.</strong> If nobody knows what was explored, the session can&#39;t be reported, repeated or built on.</li>
<li><strong>Only happy paths.</strong> The value is in the odd sequences and inputs.</li>
<li><strong>Treating it as a replacement for regression testing.</strong> Exploration doesn&#39;t guard against old bugs returning; automated scripts do.</li>
<li><strong>Only junior testers do it.</strong> It benefits most from experience; put your most knowledgeable people on the riskiest charters.</li>
<li><strong>Not reporting bugs properly.</strong> A finding in your notes isn&#39;t fixed until it&#39;s a <a href="https://provalab.io/blog/how-to-write-a-bug-report/">bug report</a> in the tracker.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/testing-in-agile-and-ci-cd/">Part 23</a></strong>, the last part of Testing Basics, we look at <strong>testing in agile teams and CI/CD pipelines</strong>: how everything in this series fits together when the team ships every week, or every day.</p>
<p>ProvaLab keeps exploratory findings next to scripted results: a bug found in a session is filed with its steps and evidence, linked to the feature, and visible alongside the automated runs for the same release. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Your first API tests in 15 minutes (Python, pytest and requests)</title>
      <link>https://provalab.io/blog/first-api-test-pytest/</link>
      <guid isPermaLink="true">https://provalab.io/blog/first-api-test-pytest/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>hands-on-testing</category>
      <category>api-testing</category>
      <category>pytest</category>
      <category>fastapi</category>
      <description>Build a tiny discount API you own, then write eleven pytest tests against it: functional, contract, negative, authentication and authorisation checks, with the real output and a planted bug.</description>
      <content:encoded><![CDATA[<p><a href="https://provalab.io/blog/api-testing-explained/">Part 3</a> explained what to check in an API response. Now we check it. Rather than test someone else&#39;s API, which you may not have permission to hit, we&#39;ll build a tiny one of our own in about forty lines, then write a proper test suite against it: functional, contract, negative, authentication and authorisation tests.</p>
<p>Every command and every line of output below was run while writing this post.</p>
<p><strong>What you need:</strong> Python 3.10 or newer and a terminal. Nothing to sign up for.</p>
<h2>Step 1: Set up (2 minutes)</h2>
<pre tabindex="0"><code class="language-bash">mkdir first-api-test
cd first-api-test
python3 -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\Activate.ps1
pip install fastapi uvicorn requests pytest
</code></pre>
<p><strong>FastAPI</strong> builds the API, <strong>uvicorn</strong> runs it, <strong>requests</strong> calls it, <strong>pytest</strong> runs the tests.</p>
<h2>Step 2: Build the API (5 minutes)</h2>
<p>Create <code>app.py</code>. It&#39;s the discount feature from our food delivery app, as an API:</p>
<pre tabindex="0"><code class="language-python">from datetime import date

from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel, Field

app = FastAPI(title=&quot;Discount API&quot;)

# Who is who: a bearer token maps to a customer (a real API would check a database)
TOKENS = {&quot;token-alice&quot;: &quot;alice&quot;, &quot;token-bob&quot;: &quot;bob&quot;}

# Discount codes: percentage off and the day they expire
CODES = {
    &quot;SAVE10&quot;: {&quot;percent&quot;: 10, &quot;expires&quot;: date(2099, 1, 1)},
    &quot;OLD10&quot;: {&quot;percent&quot;: 10, &quot;expires&quot;: date(2025, 11, 1)},
}

# Orders and who owns them
ORDERS = {42: {&quot;customer&quot;: &quot;alice&quot;, &quot;total&quot;: 500}, 43: {&quot;customer&quot;: &quot;bob&quot;, &quot;total&quot;: 300}}


def current_customer(authorization: str = Header(default=&quot;&quot;)) -&gt; str:
    token = authorization.removeprefix(&quot;Bearer &quot;).strip()
    if token not in TOKENS:
        raise HTTPException(status_code=401, detail=&quot;sign in first&quot;)
    return TOKENS[token]


class ApplyDiscount(BaseModel):
    order_total: float = Field(gt=0)
    code: str


@app.post(&quot;/discounts/apply&quot;)
def apply_discount(body: ApplyDiscount, customer: str = Depends(current_customer)):
    code = CODES.get(body.code.upper())
    if code is None:
        raise HTTPException(status_code=404, detail=&quot;code not recognised&quot;)
    if code[&quot;expires&quot;] &lt; date.today():
        raise HTTPException(status_code=400, detail=&quot;this code has expired&quot;)
    discount = body.order_total * code[&quot;percent&quot;] / 100
    return {&quot;total&quot;: body.order_total - discount, &quot;discount&quot;: discount, &quot;code&quot;: body.code.upper()}


@app.get(&quot;/orders/{order_id}&quot;)
def get_order(order_id: int, customer: str = Depends(current_customer)):
    order = ORDERS.get(order_id)
    if order is None:
        raise HTTPException(status_code=404, detail=&quot;order not found&quot;)
    if order[&quot;customer&quot;] != customer:
        raise HTTPException(status_code=403, detail=&quot;not your order&quot;)
    return {&quot;id&quot;: order_id, **order}
</code></pre>
<p>Two endpoints:</p>
<ul>
<li><code>POST /discounts/apply</code> takes an order total and a code, and returns the discounted total. Unknown codes get <code>404</code>, expired ones <code>400</code>, and a total of zero or less is rejected with <code>422</code> (FastAPI validates the body for us, because of <code>Field(gt=0)</code>).</li>
<li><code>GET /orders/{id}</code> returns an order, but only to the customer who owns it: <code>401</code> with no valid token, <code>403</code> for someone else&#39;s order, <code>404</code> if it doesn&#39;t exist.</li>
</ul>
<p>Start it in a terminal and leave it running:</p>
<pre tabindex="0"><code class="language-bash">uvicorn app:app
</code></pre>
<p>Then make one request by hand, to see what a response looks like:</p>
<pre tabindex="0"><code class="language-bash">curl -i -X POST http://127.0.0.1:8000/discounts/apply \
  -H &quot;Authorization: Bearer token-alice&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &#39;{&quot;order_total&quot;: 500, &quot;code&quot;: &quot;SAVE10&quot;}&#39;
</code></pre>
<pre tabindex="0"><code class="language-text">HTTP/1.1 200 OK
content-length: 47
content-type: application/json

{&quot;total&quot;:450.0,&quot;discount&quot;:50.0,&quot;code&quot;:&quot;SAVE10&quot;}
</code></pre>
<p>Status, headers, body: the three things from Part 3, in that order.</p>
<h2>Step 3: Fixtures, so every test doesn&#39;t repeat itself (2 minutes)</h2>
<p>Open a <strong>second terminal</strong> in the same folder, with the virtual environment activated. Create <code>conftest.py</code>, where pytest looks for shared setup:</p>
<pre tabindex="0"><code class="language-python">import os

import pytest
import requests

BASE_URL = os.environ.get(&quot;API_URL&quot;, &quot;http://127.0.0.1:8000&quot;)


@pytest.fixture
def api():
    &quot;&quot;&quot;A session signed in as Alice. Every request through it carries her token.&quot;&quot;&quot;
    session = requests.Session()
    session.headers[&quot;Authorization&quot;] = &quot;Bearer token-alice&quot;
    session.base = BASE_URL
    yield session
    session.close()


@pytest.fixture
def anonymous():
    &quot;&quot;&quot;A session with no token at all.&quot;&quot;&quot;
    session = requests.Session()
    session.base = BASE_URL
    yield session
    session.close()
</code></pre>
<p>A <strong>fixture</strong> is setup that a test asks for by name. <code>api</code> gives a session already signed in as Alice; <code>anonymous</code> gives one with no token. The address comes from an environment variable, so the same tests can later point at a staging server: <code>API_URL=https://staging.example.com pytest</code>.</p>
<h2>Step 4: The discount tests (3 minutes)</h2>
<p>Create <code>test_discounts.py</code>:</p>
<pre tabindex="0"><code class="language-python">def test_valid_code_reduces_the_total(api):
    r = api.post(f&quot;{api.base}/discounts/apply&quot;, json={&quot;order_total&quot;: 500, &quot;code&quot;: &quot;SAVE10&quot;})
    assert r.status_code == 200
    assert r.json() == {&quot;total&quot;: 450, &quot;discount&quot;: 50, &quot;code&quot;: &quot;SAVE10&quot;}


def test_response_matches_the_contract(api):
    r = api.post(f&quot;{api.base}/discounts/apply&quot;, json={&quot;order_total&quot;: 500, &quot;code&quot;: &quot;SAVE10&quot;})
    body = r.json()
    assert r.headers[&quot;Content-Type&quot;] == &quot;application/json&quot;
    assert set(body) == {&quot;total&quot;, &quot;discount&quot;, &quot;code&quot;}
    assert isinstance(body[&quot;total&quot;], (int, float))
    assert isinstance(body[&quot;discount&quot;], (int, float))
    assert isinstance(body[&quot;code&quot;], str)
    assert r.elapsed.total_seconds() &lt; 0.5


def test_code_is_not_case_sensitive(api):
    r = api.post(f&quot;{api.base}/discounts/apply&quot;, json={&quot;order_total&quot;: 500, &quot;code&quot;: &quot;save10&quot;})
    assert r.status_code == 200
    assert r.json()[&quot;total&quot;] == 450


def test_unknown_code_is_refused(api):
    r = api.post(f&quot;{api.base}/discounts/apply&quot;, json={&quot;order_total&quot;: 500, &quot;code&quot;: &quot;NOPE99&quot;})
    assert r.status_code == 404
    assert r.json()[&quot;detail&quot;] == &quot;code not recognised&quot;


def test_expired_code_is_refused(api):
    r = api.post(f&quot;{api.base}/discounts/apply&quot;, json={&quot;order_total&quot;: 500, &quot;code&quot;: &quot;OLD10&quot;})
    assert r.status_code == 400
    assert &quot;expired&quot; in r.json()[&quot;detail&quot;]


def test_zero_or_negative_total_is_rejected(api):
    r = api.post(f&quot;{api.base}/discounts/apply&quot;, json={&quot;order_total&quot;: 0, &quot;code&quot;: &quot;SAVE10&quot;})
    assert r.status_code == 422


def test_missing_field_is_rejected(api):
    r = api.post(f&quot;{api.base}/discounts/apply&quot;, json={&quot;order_total&quot;: 500})
    assert r.status_code == 422
</code></pre>
<p>Notice how the five checks from Part 3 show up:</p>
<ul>
<li><strong>Status code</strong> in every test, and the <em>exact</em> one: <code>404</code> for an unknown code, <code>400</code> for an expired one, <code>422</code> for bad input. Not &quot;some 4xx&quot;.</li>
<li><strong>Headers and body shape</strong> in <code>test_response_matches_the_contract</code>: the content type, exactly the three promised fields, the right types. This is the test that catches a renamed field before it breaks the mobile app.</li>
<li><strong>Values</strong> in the first test: 500 with <code>SAVE10</code> is 450, full stop.</li>
<li><strong>Timing</strong> with <code>r.elapsed</code>: under half a second.</li>
<li><strong>Negative cases</strong> are four of the seven tests. That&#39;s roughly the right proportion.</li>
</ul>
<h2>Step 5: The authorisation tests (2 minutes)</h2>
<p>Create <code>test_auth.py</code>:</p>
<pre tabindex="0"><code class="language-python">def test_no_token_means_401(anonymous):
    r = anonymous.post(f&quot;{anonymous.base}/discounts/apply&quot;, json={&quot;order_total&quot;: 500, &quot;code&quot;: &quot;SAVE10&quot;})
    assert r.status_code == 401


def test_customer_can_read_their_own_order(api):
    r = api.get(f&quot;{api.base}/orders/42&quot;)
    assert r.status_code == 200
    assert r.json()[&quot;customer&quot;] == &quot;alice&quot;


def test_customer_cannot_read_someone_elses_order(api):
    r = api.get(f&quot;{api.base}/orders/43&quot;)  # Bob&#39;s order, Alice&#39;s token
    assert r.status_code == 403


def test_unknown_order_is_404(api):
    r = api.get(f&quot;{api.base}/orders/999&quot;)
    assert r.status_code == 404
</code></pre>
<p><img src="https://provalab.io/blog/handson/auth-ladder.svg" alt="Four requests for GET /orders/43 and their correct answers. No token: 401 Unauthorized, authentication failed. Alice's token for Bob's order: 403 Forbidden, authorisation failed, the bug to look for. Bob's token: 200 with the order. Any token for order 999: 404 Not Found. Assert the exact code: a test accepting any 4xx would miss an API that returns 404 instead of 403." width="880" height="400" loading="lazy"></p>
<p><code>test_customer_cannot_read_someone_elses_order</code> is the most valuable test in this post. Signing in as one customer and asking for another&#39;s data is the single most common serious API security bug, and it takes four lines to check. Note the exact code: <code>403</code>, not &quot;any error&quot;. An API that answered <code>404</code> here would be quietly telling an attacker that order 43 exists.</p>
<h2>Step 6: Run it (1 minute)</h2>
<pre tabindex="0"><code class="language-bash">pytest -v
</code></pre>
<pre tabindex="0"><code class="language-text">collected 11 items

test_auth.py::test_no_token_means_401 PASSED                             [  9%]
test_auth.py::test_customer_can_read_their_own_order PASSED              [ 18%]
test_auth.py::test_customer_cannot_read_someone_elses_order PASSED       [ 27%]
test_auth.py::test_unknown_order_is_404 PASSED                           [ 36%]
test_discounts.py::test_valid_code_reduces_the_total PASSED              [ 45%]
test_discounts.py::test_response_matches_the_contract PASSED             [ 54%]
test_discounts.py::test_code_is_not_case_sensitive PASSED                [ 63%]
test_discounts.py::test_unknown_code_is_refused PASSED                   [ 72%]
test_discounts.py::test_expired_code_is_refused PASSED                   [ 81%]
test_discounts.py::test_zero_or_negative_total_is_rejected PASSED        [ 90%]
test_discounts.py::test_missing_field_is_rejected PASSED                 [100%]

============================== 11 passed in 0.08s ==============================
</code></pre>
<p><strong>Eleven tests, 0.08 seconds.</strong> The three browser tests in <a href="https://provalab.io/blog/first-playwright-test/">Part 2</a> took 10.68 seconds. That&#39;s the speed difference from Part 3 made concrete: about a hundred times faster per test.</p>
<p><img src="https://provalab.io/blog/handson/api-suite-map.svg" alt="The eleven tests grouped by kind. Functional, three: valid code reduces the total, code is not case-sensitive, customer can read their own order. Contract, one: exact fields, right types, under 500 ms. Negative, four: unknown code 404, expired 400, zero total 422, missing field 422. Auth, three: no token 401, someone else's order 403, unknown order 404. All in 0.08 seconds. The planted bug broke exactly the two that check values." width="880" height="470" loading="lazy"></p>
<h2>Step 7: Plant a bug (2 minutes)</h2>
<p>Change one line in <code>app.py</code> so the discount is subtracted as a flat amount instead of a percentage:</p>
<pre tabindex="0"><code class="language-python">    discount = code[&quot;percent&quot;]  # bug: flat amount, not a percentage
</code></pre>
<p>Restart <code>uvicorn</code> and run the tests again:</p>
<pre tabindex="0"><code class="language-text">....F.F....                                                              [100%]
=================================== FAILURES ===================================
______________________ test_valid_code_reduces_the_total _______________________

    def test_valid_code_reduces_the_total(api):
        r = api.post(f&quot;{api.base}/discounts/apply&quot;, json={&quot;order_total&quot;: 500, &quot;code&quot;: &quot;SAVE10&quot;})
        assert r.status_code == 200
&gt;       assert r.json() == {&quot;total&quot;: 450, &quot;discount&quot;: 50, &quot;code&quot;: &quot;SAVE10&quot;}
E       AssertionError: assert {&#39;total&#39;: 490...de&#39;: &#39;SAVE10&#39;} == {&#39;total&#39;: 450...de&#39;: &#39;SAVE10&#39;}
E         Differing items:
E         {&#39;discount&#39;: 10} != {&#39;discount&#39;: 50}
E         {&#39;total&#39;: 490.0} != {&#39;total&#39;: 450}

test_discounts.py:4: AssertionError
_______________________ test_code_is_not_case_sensitive ________________________
&gt;       assert r.json()[&quot;total&quot;] == 450
E       assert 490.0 == 450

test_discounts.py:21: AssertionError
========================= short test summary info ============================
FAILED test_discounts.py::test_valid_code_reduces_the_total - AssertionError:...
FAILED test_discounts.py::test_code_is_not_case_sensitive - assert 490.0 == 450
2 failed, 9 passed in 0.06s
</code></pre>
<p>Exactly the two tests that check <strong>values</strong> fail; the nine that check status codes, shape and permissions still pass, because the bug didn&#39;t touch those. pytest&#39;s diff says precisely what&#39;s wrong: <code>discount 10 != 50</code>. Put the line back, and you&#39;re green again.</p>
<h2>What this suite doesn&#39;t cover, and what to add next</h2>
<ul>
<li><strong>A sequence:</strong> create an order, apply a code, read it back. That&#39;s <a href="https://provalab.io/blog/integration-testing/">integration testing</a> through the API.</li>
<li><strong>Contract from a document:</strong> FastAPI generates an OpenAPI document at <code>/openapi.json</code>; tools like Schemathesis can generate hundreds of tests from it.</li>
<li><strong>Load:</strong> the same endpoints under 200 users at once, as described in <a href="https://provalab.io/blog/performance-testing-types/">performance testing types</a>; we build one with k6 later in this series.</li>
<li><strong>Running in CI:</strong> the same <code>pytest</code> step as in Part 2&#39;s workflow, with <code>uvicorn app:app &amp;</code> started first, or a test server fixture.</li>
</ul>
<h2>Habits worth keeping</h2>
<ol>
<li><strong>Assert the exact status code.</strong> The difference between 401, 403 and 404 is the whole point.</li>
<li><strong>Test the contract separately from the values.</strong> Shape changes break clients even when the maths is right.</li>
<li><strong>Make negative tests at least a third of the suite.</strong> That&#39;s where the bugs live.</li>
<li><strong>Always test &quot;someone else&#39;s data&quot;.</strong> One test, four lines, the most common serious API bug.</li>
<li><strong>Never put real tokens in the code.</strong> Read them from the environment; use test accounts.</li>
</ol>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/security-testing-basics-owasp-top-10/">Part 5</a></strong> we move to <strong>security testing</strong>: the OWASP Top 10 in plain language, and which of those risks the tests you just wrote already touch.</p>
<p>ProvaLab&#39;s QA AI Agent runs API test cases like these on its own and records every request and response against its test case; AppSec&#39;s API fuzzing sends thousands of malformed and unauthorised requests to find the 403-that-should-have-been and the 500-that-should-have-been-a-400. See it in the <a href="https://provalab.io/#stage-agent">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Your first automated test in 15 minutes (Python + pytest)</title>
      <link>https://provalab.io/blog/first-automated-test-pytest/</link>
      <guid isPermaLink="true">https://provalab.io/blog/first-automated-test-pytest/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>test-automation</category>
      <category>pytest</category>
      <description>A hands-on, step-by-step guide to writing and running your first automated tests with Python and pytest, including what a failing test looks like and how to read it.</description>
      <content:encoded><![CDATA[<p>In <a href="https://provalab.io/blog/manual-vs-automated-testing/">Part 3</a> we saw why teams automate the tests they run again and again. Now let&#39;s write some. By the end of this post you will have <strong>eight automated tests running in under a second</strong>, and you will know how to read a failing one.</p>
<p>We will use <strong>Python</strong> and <strong>pytest</strong>, the most popular way to write tests in Python. pytest needs almost no setup, and its tests are plain Python functions.</p>
<p><strong>What you need:</strong> Python 3.10 or newer, and a terminal. Check your version with <code>python3 --version</code> (on Windows: <code>python --version</code>). No testing experience required.</p>
<h2>Step 1: Set up a project (2 minutes)</h2>
<p>Create a folder and a <strong>virtual environment</strong>: a private space for this project&#39;s Python packages, so they don&#39;t mix with anything else on your computer.</p>
<pre tabindex="0"><code class="language-bash">mkdir first-test
cd first-test
python3 -m venv .venv
</code></pre>
<p>Turn the environment on:</p>
<pre tabindex="0"><code class="language-bash"># macOS and Linux
source .venv/bin/activate

# Windows (PowerShell)
.venv\Scripts\Activate.ps1
</code></pre>
<p>Your prompt now starts with <code>(.venv)</code>. Install pytest:</p>
<pre tabindex="0"><code class="language-bash">pip install pytest
</code></pre>
<h2>Step 2: Write the code we want to test (2 minutes)</h2>
<p>We will keep using the <strong>food delivery app</strong> from earlier parts. Create a file called <code>shop.py</code> with one function that adds up an order and applies a discount:</p>
<pre tabindex="0"><code class="language-python">def order_total(prices, discount_percent=0):
    &quot;&quot;&quot;Add up the prices and apply a percentage discount.&quot;&quot;&quot;
    if not 0 &lt;= discount_percent &lt;= 100:
        raise ValueError(&quot;discount must be between 0 and 100&quot;)
    subtotal = sum(prices)
    return subtotal - subtotal * discount_percent / 100
</code></pre>
<p>This is exactly the kind of small, important piece of code that <strong>unit tests</strong> (from <a href="https://provalab.io/blog/types-of-software-testing/">Part 2</a>) are made for: if the total is wrong, every customer pays the wrong price.</p>
<h2>Step 3: Write your first tests (3 minutes)</h2>
<p>Create a second file called <code>test_shop.py</code>:</p>
<pre tabindex="0"><code class="language-python">from shop import order_total


def test_total_adds_up_prices():
    assert order_total([200, 200, 50]) == 450


def test_discount_is_applied():
    assert order_total([200, 200, 50], discount_percent=10) == 405


def test_empty_cart_is_zero():
    assert order_total([]) == 0
</code></pre>
<p>That&#39;s it. Three things to notice:</p>
<ul>
<li><strong>The file name starts with <code>test_</code></strong>, and so does each function. That is how pytest finds your tests automatically.</li>
<li><strong>Each test checks one thing</strong>, and its name says what. When it fails, the name alone tells you what broke.</li>
<li><strong><code>assert</code></strong> is plain Python: &quot;this must be true&quot;. If it isn&#39;t, the test fails.</li>
</ul>
<p>Each test follows the pattern from <a href="https://provalab.io/blog/what-is-software-testing/">Part 1</a>: a starting point (some prices), an action (call <code>order_total</code>) and an expected result (the number after <code>==</code>).</p>
<h2>Step 4: Run the tests (1 minute)</h2>
<pre tabindex="0"><code class="language-bash">pytest -v
</code></pre>
<p>You should see:</p>
<pre tabindex="0"><code class="language-text">collected 3 items

test_shop.py::test_total_adds_up_prices PASSED                           [ 33%]
test_shop.py::test_discount_is_applied PASSED                            [ 66%]
test_shop.py::test_empty_cart_is_zero PASSED                             [100%]

============================== 3 passed in 0.01s ===============================
</code></pre>
<p>Three tests, a hundredth of a second. The <code>-v</code> (verbose) flag lists each test by name; without it, pytest prints one dot per passing test.</p>
<h2>Step 5: Break something on purpose (3 minutes)</h2>
<p>A test you have never seen fail proves very little. Let&#39;s plant a bug. In <code>shop.py</code>, change the last line so the discount is subtracted as a flat amount instead of a percentage:</p>
<pre tabindex="0"><code class="language-python">    return subtotal - discount_percent   # bug!
</code></pre>
<p>Run pytest again:</p>
<pre tabindex="0"><code class="language-text">collected 3 items

test_shop.py .F.                                                         [100%]

=================================== FAILURES ===================================
___________________________ test_discount_is_applied ___________________________

    def test_discount_is_applied():
&gt;       assert order_total([200, 200, 50], discount_percent=10) == 405
E       assert 440 == 405
E        +  where 440 = order_total([200, 200, 50], discount_percent=10)

test_shop.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_shop.py::test_discount_is_applied - assert 440 == 405
========================= 1 failed, 2 passed in 0.02s ==========================
</code></pre>
<p>How to read it:</p>
<ul>
<li><strong><code>.F.</code></strong>: the first and third tests passed (<code>.</code>), the second failed (<code>F</code>).</li>
<li><strong>The <code>&gt;</code> line</strong> shows exactly which check failed.</li>
<li><strong><code>assert 440 == 405</code></strong>: the code returned <strong>440</strong>, but the test expected <strong>405</strong>. A 10% discount on 450 should be 45, not 10.</li>
<li><strong><code>test_shop.py:9</code></strong>: the file and line number to look at.</li>
</ul>
<p>The other two tests still pass, because the bug only affects discounts. That&#39;s why small, focused tests are so useful: the failing test&#39;s name points straight at the problem.</p>
<p>Now put the correct line back, run <code>pytest</code> again, and you&#39;re green.</p>
<h2>Step 6: Test many cases at once (2 minutes)</h2>
<p>Writing a new function for every number gets repetitive. pytest&#39;s <strong><code>parametrize</code></strong> runs one test with many inputs. Add this to the end of <code>test_shop.py</code>:</p>
<pre tabindex="0"><code class="language-python">import pytest


@pytest.mark.parametrize(
    &quot;prices, discount, expected&quot;,
    [
        ([100], 0, 100),
        ([100], 50, 50),
        ([100], 100, 0),
        ([99, 1], 25, 75),
    ],
)
def test_order_totals(prices, discount, expected):
    assert order_total(prices, discount_percent=discount) == expected
</code></pre>
<p>Each row is its own test: if one row fails, you see exactly which inputs broke.</p>
<h2>Step 7: Test that bad input is refused (1 minute)</h2>
<p>Good tests check what the code <strong>shouldn&#39;t</strong> do, too. Our function must refuse a discount over 100%:</p>
<pre tabindex="0"><code class="language-python">def test_discount_over_100_is_refused():
    with pytest.raises(ValueError):
        order_total([100], discount_percent=150)
</code></pre>
<p>This test <strong>passes only if</strong> the code raises a <code>ValueError</code>. If someone removes that check one day, this test will catch it.</p>
<p>Run everything:</p>
<pre tabindex="0"><code class="language-text">collected 8 items

test_shop.py::test_total_adds_up_prices PASSED                           [ 12%]
test_shop.py::test_discount_is_applied PASSED                            [ 25%]
test_shop.py::test_empty_cart_is_zero PASSED                             [ 37%]
test_shop.py::test_order_totals[prices0-0-100] PASSED                    [ 50%]
test_shop.py::test_order_totals[prices1-50-50] PASSED                    [ 62%]
test_shop.py::test_order_totals[prices2-100-0] PASSED                    [ 75%]
test_shop.py::test_order_totals[prices3-25-75] PASSED                    [ 87%]
test_shop.py::test_discount_over_100_is_refused PASSED                   [100%]

============================== 8 passed in 0.01s ===============================
</code></pre>
<p><strong>Eight automated tests, done.</strong> Every time you change <code>shop.py</code>, one command tells you in a second whether anything broke.</p>
<h2>Handy pytest commands</h2>
<table tabindex="0">
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>pytest</code></td>
<td>Run all tests, one character per test</td>
</tr>
<tr>
<td><code>pytest -v</code></td>
<td>List every test by name</td>
</tr>
<tr>
<td><code>pytest -k discount</code></td>
<td>Run only tests whose name contains &quot;discount&quot;</td>
</tr>
<tr>
<td><code>pytest -x</code></td>
<td>Stop at the first failure</td>
</tr>
<tr>
<td><code>pytest test_shop.py::test_empty_cart_is_zero</code></td>
<td>Run one specific test</td>
</tr>
<tr>
<td><code>pytest --lf</code></td>
<td>Re-run only the tests that failed last time</td>
</tr>
</tbody></table>
<h2>Five habits of good tests</h2>
<ol>
<li><strong>One behaviour per test</strong>, with a name that says what it checks.</li>
<li><strong>Test the edges</strong>: empty lists, zero, the maximum, and invalid input.</li>
<li><strong>Watch every new test fail once</strong>, so you know it can actually catch a bug.</li>
<li><strong>Keep tests fast</strong>, so people run them all the time.</li>
<li><strong>Run them automatically</strong> on every code change, in a CI pipeline.</li>
</ol>
<h2>What&#39;s next</h2>
<p>You have written <strong>unit tests</strong>. In <strong><a href="https://provalab.io/blog/how-to-write-test-cases/">Part 6</a></strong> we step back to the craft behind every test: <strong>how to write good test cases</strong>, with a template, examples and the techniques testers use to choose them.</p>
<p>When your test suite grows, ProvaLab runs it for you on every change, on real browsers and devices, keeps every result next to its test case, and lets your coding agent read the failures and fix them. See it in the <a href="https://provalab.io/#stage-infra">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Your first Playwright test in 15 minutes (Python)</title>
      <link>https://provalab.io/blog/first-playwright-test/</link>
      <guid isPermaLink="true">https://provalab.io/blog/first-playwright-test/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>hands-on-testing</category>
      <category>ui-testing</category>
      <category>playwright</category>
      <category>pytest</category>
      <description>Install Playwright for Python, write three browser tests against a real website, run them headless, break one on purpose and read the failure, record a trace, and run the suite in CI.</description>
      <content:encoded><![CDATA[<p>In <a href="https://provalab.io/blog/ui-testing-explained/">Part 1</a> we looked at how UI tests work and why Playwright is the tool to start with. Now we build some. By the end you&#39;ll have <strong>three browser tests running against a real website</strong>, and you&#39;ll know how to read a failure, record a trace of it, and run the suite automatically.</p>
<p>Everything below was run while writing this post, and the output is pasted in unedited.</p>
<p><strong>What you need:</strong> Python 3.10 or newer, a terminal, and about 400 MB of disk for a browser. We&#39;ll test <strong>provalab.io</strong>, our own site. <strong>Only ever run UI tests against sites you own or have permission to test</strong>; point the examples at your own app when you&#39;ve got the hang of it.</p>
<h2>Step 1: Install Playwright (3 minutes)</h2>
<p>Create a folder and a virtual environment, as in <a href="https://provalab.io/blog/first-automated-test-pytest/">Testing Basics Part 5</a>:</p>
<pre tabindex="0"><code class="language-bash">mkdir first-ui-test
cd first-ui-test
python3 -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\Activate.ps1
</code></pre>
<p>Install the pytest plugin for Playwright, then the browser it will drive:</p>
<pre tabindex="0"><code class="language-bash">pip install pytest-playwright
playwright install chromium
</code></pre>
<p>The second command downloads a copy of Chromium that Playwright controls. It&#39;s separate from the Chrome you browse with, so tests never touch your own cookies or tabs.</p>
<h2>Step 2: Point the tests at your site (1 minute)</h2>
<p>Create <code>pytest.ini</code> so you don&#39;t have to repeat the site address in every test:</p>
<pre tabindex="0"><code class="language-ini">[pytest]
addopts = --base-url https://provalab.io
</code></pre>
<p>Now <code>page.goto(&quot;/blog/&quot;)</code> means <code>https://provalab.io/blog/</code>. To test a different site, change this one line.</p>
<h2>Step 3: Write three tests (5 minutes)</h2>
<p>Create <code>test_blog.py</code>:</p>
<pre tabindex="0"><code class="language-python">import re

from playwright.sync_api import Page, expect


def test_blog_lists_posts(page: Page):
    page.goto(&quot;/blog/&quot;)
    expect(page.get_by_role(&quot;heading&quot;, level=1)).to_have_text(&quot;ProvaLab Blog&quot;)
    expect(page.get_by_test_id(&quot;blog-card&quot;).first).to_be_visible()


def test_a_post_opens_from_the_list(page: Page):
    page.goto(&quot;/blog/&quot;)
    page.get_by_test_id(&quot;blog-card&quot;).first.get_by_role(&quot;link&quot;).first.click()
    expect(page).to_have_url(re.compile(r&quot;/blog/[a-z0-9-]+/$&quot;))
    expect(page.get_by_role(&quot;heading&quot;, level=1)).to_be_visible()
    expect(page.get_by_role(&quot;link&quot;, name=&quot;All posts&quot;)).to_be_visible()


def test_tour_dialog_opens_and_closes(page: Page):
    page.goto(&quot;/&quot;)
    button = page.locator(&quot;#stage-tms&quot;).get_by_role(&quot;button&quot;, name=&quot;Watch the full tour&quot;)
    button.scroll_into_view_if_needed()
    button.click()
    dialog = page.get_by_role(&quot;dialog&quot;, name=re.compile(&quot;tour&quot;, re.I))
    expect(dialog).to_be_visible()
    page.keyboard.press(&quot;Escape&quot;)
    expect(dialog).to_be_hidden()
</code></pre>
<p>What&#39;s going on:</p>
<ul>
<li><strong><code>page</code></strong> is a fixture the plugin provides: a fresh browser tab for every test, closed afterwards. Tests can&#39;t leak state into each other.</li>
<li><strong>Locate, act, assert</strong>, as in Part 1. <code>get_by_role(&quot;button&quot;, name=&quot;Watch the full tour&quot;)</code> finds the button the way a screen reader would; <code>click()</code> acts; <code>expect(...).to_be_visible()</code> asserts.</li>
<li><strong><code>expect</code> waits for you.</strong> Each assertion retries for up to 5 seconds until the condition is true. That&#39;s why there are no <code>sleep()</code> calls, and why these tests won&#39;t flake when the network is slow.</li>
<li><strong>The third test checks a real interaction:</strong> a button opens a video dialog, and Escape closes it. Checking that something <em>disappears</em> is just as important as checking that it appears.</li>
</ul>
<h2>Step 4: Run them (1 minute)</h2>
<pre tabindex="0"><code class="language-bash">pytest -v
</code></pre>
<pre tabindex="0"><code class="language-text">collected 3 items

test_blog.py::test_blog_lists_posts[chromium] PASSED                     [ 33%]
test_blog.py::test_a_post_opens_from_the_list[chromium] PASSED           [ 66%]
test_blog.py::test_tour_dialog_opens_and_closes[chromium] PASSED         [100%]

======================== 3 passed, 1 warning in 10.68s =========================
</code></pre>
<p>Three real pages loaded in a real browser, a dialog opened and closed, in under 11 seconds. The browser ran <strong>headless</strong> (no window), which is the default and what CI uses.</p>
<p>(The warning is a deprecation notice inside the plugin itself, not in your code. It&#39;s safe to ignore.)</p>
<h3>Watch it happen</h3>
<p>To see the browser while the test runs, slowed down so you can follow:</p>
<pre tabindex="0"><code class="language-bash">pytest --headed --slowmo 500
</code></pre>
<p>Do this whenever a test confuses you. Nine times out of ten you&#39;ll see the problem within seconds: a cookie banner over the button, a page that hasn&#39;t finished loading, a locator pointing at the wrong thing.</p>
<h2>Step 5: Break one on purpose (3 minutes)</h2>
<p>As in Part 5, a test you have never seen fail proves little. Create <code>test_broken.py</code> with a wrong expectation:</p>
<pre tabindex="0"><code class="language-python">from playwright.sync_api import Page, expect


def test_blog_heading_wrong_on_purpose(page: Page):
    page.goto(&quot;/blog/&quot;)
    expect(page.get_by_role(&quot;heading&quot;, level=1)).to_have_text(&quot;ProvaLab Articles&quot;)
</code></pre>
<pre tabindex="0"><code class="language-bash">pytest test_broken.py
</code></pre>
<pre tabindex="0"><code class="language-text">    def test_blog_heading_wrong_on_purpose(page: Page):
        page.goto(&quot;/blog/&quot;)
&gt;       expect(page.get_by_role(&quot;heading&quot;, level=1)).to_have_text(&quot;ProvaLab Articles&quot;)
E       AssertionError: Locator expected to have text &#39;ProvaLab Articles&#39;
E       Actual value: ProvaLab Blog
E       Call log:
E         - Expect &quot;to_have_text&quot; get_by_role(&quot;heading&quot;, level=1) with timeout 5000ms
E         - waiting for get_by_role(&quot;heading&quot;, level=1)
E           14 × locator resolved to &lt;h1 class=&quot;text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900&quot;&gt;ProvaLab Blog&lt;/h1&gt;
E              - unexpected value &quot;ProvaLab Blog&quot;
E
E       Aria snapshot:
E       - heading &quot;ProvaLab Blog&quot; [level=1]

test_broken.py:6: AssertionError
========================= 1 failed, 1 warning in 7.98s =========================
</code></pre>
<p>This is a much richer failure than a plain <code>assert</code>, and learning to read it is the core skill:</p>
<p><img src="https://provalab.io/blog/handson/read-a-failure.svg" alt="An annotated Playwright failure. The AssertionError line restates what the test expected. The Actual value line shows what the page really had. The call log shows the locator resolved to a real h1 fourteen times during the five-second wait, so the element was found and this is a real difference, not a locator or timing problem. The aria snapshot shows the page as a screen reader sees it, useful for choosing a better locator when an element was not found." width="880" height="470" loading="lazy"></p>
<ul>
<li><strong>Expected vs actual</strong> are on adjacent lines. Here the test is wrong, not the site.</li>
<li><strong>&quot;14 × locator resolved to <code>&lt;h1 …&gt;</code>&quot;</strong> says Playwright <em>found</em> the element, checked it 14 times over 5 seconds, and it never had the expected text. So this isn&#39;t a timing or locator problem; it&#39;s a real difference. If the element had <em>not</em> been found, you&#39;d see &quot;waiting for …&quot; with no resolution, and you&#39;d know to fix the locator.</li>
<li><strong>The aria snapshot</strong> shows the page the way a screen reader sees it. When your locator finds nothing, this list tells you what roles and names actually exist, so you can pick the right one.</li>
</ul>
<h2>Step 6: Record a trace (2 minutes)</h2>
<p>A trace is a recording of everything the test did: every action, a screenshot before and after each one, the page&#39;s HTML, console messages and network calls. Keep one for every failure:</p>
<pre tabindex="0"><code class="language-bash">pytest test_broken.py --tracing retain-on-failure
</code></pre>
<pre tabindex="0"><code class="language-text">1 failed, 1 warning in 6.59s
</code></pre>
<p>The trace lands in <code>test-results/test-broken-py-test-blog-heading-wrong-on-purpose-chromium/trace.zip</code>. Open it:</p>
<pre tabindex="0"><code class="language-bash">playwright show-trace test-results/test-broken-py-test-blog-heading-wrong-on-purpose-chromium/trace.zip
</code></pre>
<p>The trace viewer opens in a browser and lets you step through the test, see the page at each action, and inspect what was on screen when it failed. For a failure that only happens in CI, a trace is the difference between fixing it in five minutes and not being able to reproduce it at all.</p>
<p>Two related options, both worth adding to <code>pytest.ini</code>:</p>
<pre tabindex="0"><code class="language-ini">[pytest]
addopts = --base-url https://provalab.io --tracing retain-on-failure --screenshot only-on-failure
</code></pre>
<p>(<code>--video retain-on-failure</code> records a video too, at the cost of slower runs.)</p>
<h2>Step 7: Run it in the pipeline (3 minutes)</h2>
<p>The same tests, run on every change by GitHub Actions, with traces saved when anything fails:</p>
<pre tabindex="0"><code class="language-yaml">name: ui-tests
on: [push, pull_request]

jobs:
  ui:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: &quot;3.12&quot;
      - run: pip install pytest-playwright
      - run: playwright install --with-deps chromium
      - run: pytest --tracing retain-on-failure
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-traces
          path: test-results/
</code></pre>
<p><code>--with-deps</code> installs the system libraries the browser needs on a fresh CI machine. The last step uploads the traces as a downloadable artifact whenever the run is red.</p>
<p><img src="https://provalab.io/blog/handson/run-it-three-places.svg" alt="One test file, three places to run it. On your laptop while writing: headed and slow motion, seconds. In the CI pipeline on every change: headless with traces kept on failure, minutes, blocks the merge if red. On a browser and device grid before a release: many browsers, operating systems and real phones in parallel, every run recorded. The test code is identical everywhere." width="880" height="400" loading="lazy"></p>
<p>When you need more than one machine can give you, many browsers, operating systems and real phones at once, the same tests connect to a <strong>browser grid</strong> instead of a local Chromium. The test code doesn&#39;t change.</p>
<h2>Handy options</h2>
<table tabindex="0">
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>pytest --headed</code></td>
<td>Show the browser window</td>
</tr>
<tr>
<td><code>pytest --slowmo 500</code></td>
<td>Pause 500 ms between actions, so you can watch</td>
</tr>
<tr>
<td><code>pytest --browser firefox</code></td>
<td>Run in Firefox (after <code>playwright install firefox</code>); also <code>webkit</code> for Safari&#39;s engine</td>
</tr>
<tr>
<td><code>pytest --browser chromium --browser firefox</code></td>
<td>Run every test in both</td>
</tr>
<tr>
<td><code>pytest --tracing on</code></td>
<td>Record a trace for every test, not only failures</td>
</tr>
<tr>
<td><code>pytest -k tour</code></td>
<td>Run only tests with &quot;tour&quot; in the name</td>
</tr>
<tr>
<td><code>playwright codegen https://provalab.io</code></td>
<td>Click around a site and have Playwright write the locators for you</td>
</tr>
</tbody></table>
<p><code>codegen</code> is a good way to learn the locator syntax: it records your clicks and suggests role-based locators, which you can then tidy up.</p>
<h2>Habits worth keeping</h2>
<ol>
<li><strong>Prefer <code>get_by_role</code> and <code>get_by_test_id</code>.</strong> The <code>#stage-tms</code> CSS selector in the third test is a reasonable exception: it&#39;s an ID set on purpose, and it narrows the search to one section.</li>
<li><strong>Never <code>time.sleep()</code>.</strong> If you feel the need, you&#39;re missing an <code>expect</code>.</li>
<li><strong>Each test starts from <code>page.goto()</code></strong> and sets up what it needs. No test should depend on another.</li>
<li><strong>Keep traces on failure, always.</strong> They cost nothing when tests pass.</li>
<li><strong>Run headed when confused</strong>, headless everywhere else.</li>
</ol>
<h2>What&#39;s next</h2>
<p>Our three tests all go through the user interface. In <strong><a href="https://provalab.io/blog/api-testing-explained/">Part 3</a></strong> we go one layer down: <strong>API testing</strong>, where the same checks run in milliseconds instead of seconds, and why many teams test the API before the UI.</p>
<p>ProvaLab&#39;s QA Infrastructure is the &quot;third place&quot; in the diagram above: your Playwright and Selenium tests run on real browsers and devices in the cloud, every session is recorded, and when a test fails the recording and logs go straight back to your coding agent. See it in the <a href="https://provalab.io/#stage-infra">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Your first security scan with OWASP ZAP (hands-on)</title>
      <link>https://provalab.io/blog/first-security-scan-owasp-zap/</link>
      <guid isPermaLink="true">https://provalab.io/blog/first-security-scan-owasp-zap/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>hands-on-testing</category>
      <category>security-testing</category>
      <category>owasp-zap</category>
      <category>dast</category>
      <description>Run a real DAST scan with OWASP ZAP against the API from Part 4, read the report, plant a SQL injection and watch the scanner find it, triage the findings, fix the bug and re-scan.</description>
      <content:encoded><![CDATA[<p><a href="https://provalab.io/blog/security-testing-basics-owasp-top-10/">Part 5</a> explained the OWASP Top 10 and the six kinds of security testing. This post does one of them for real: a <strong>DAST scan</strong>, where a tool attacks a running application from the outside, the way an attacker would.</p>
<p>We&#39;ll use <strong>OWASP ZAP</strong>, the most widely used free security scanner, against the discount API we built in <a href="https://provalab.io/blog/first-api-test-pytest/">Part 4</a>. Then we&#39;ll plant a real vulnerability and watch the scanner find it.</p>
<p>Every command and every line of output below was run while writing this post.</p>
<p><strong>Before you start:</strong> a DAST scan sends thousands of hostile requests. <strong>Only ever point it at something you own</strong>, and never at a production system: it will create records, trip rate limits and fill logs. Our target runs on our own laptop.</p>
<h2>How a DAST scan works</h2>
<p><img src="https://provalab.io/blog/handson/dast-how-it-works.svg" alt="Four stages. Discover: the scanner learns the URLs by crawling or importing an OpenAPI document. Passive scan: it inspects normal responses for missing security headers and leaked information without sending anything hostile. Active scan: it sends attack payloads to every input and watches the reaction; test environments only. Report: findings grouped by risk, each with the request that triggered it, the evidence and a fix." width="880" height="420" loading="lazy"></p>
<p>The scanner has to <strong>discover</strong> the URLs first. For a website it crawls the pages; for an API it reads the <strong>OpenAPI document</strong>, which lists every endpoint and parameter. FastAPI generates one for us automatically at <code>/openapi.json</code>, which is why the Part 4 API is a convenient target.</p>
<h2>Step 1: Get ZAP (2 minutes, plus a download)</h2>
<p>ZAP ships as a container image, so there&#39;s nothing to install beyond Docker:</p>
<pre tabindex="0"><code class="language-bash">docker pull ghcr.io/zaproxy/zaproxy:stable
</code></pre>
<p>It&#39;s large (about 3.7 GB), because it carries a full Java runtime and a browser.</p>
<h2>Step 2: Start the target (1 minute)</h2>
<p>In the <code>first-api-test</code> folder from Part 4, start the API and leave it running:</p>
<pre tabindex="0"><code class="language-bash">uvicorn app:app
</code></pre>
<h2>Step 3: Run the scan (5 minutes)</h2>
<p>ZAP includes a ready-made <strong>API scan</strong> script. One command runs discovery, the passive scan and the active scan, and writes a report:</p>
<pre tabindex="0"><code class="language-bash">mkdir -p zap &amp;&amp; chmod 777 zap
docker run --rm --network host -v &quot;$PWD/zap:/zap/wrk:rw&quot; ghcr.io/zaproxy/zaproxy:stable \
  zap-api-scan.py -t http://127.0.0.1:8000/openapi.json -f openapi \
  -J report.json -w report.md -I \
  -z &quot;-config replacer.full_list(0).description=auth \
      -config replacer.full_list(0).enabled=true \
      -config replacer.full_list(0).matchtype=REQ_HEADER \
      -config replacer.full_list(0).matchstr=Authorization \
      -config replacer.full_list(0).regex=false \
      -config replacer.full_list(0).replacement=Bearer\ token-alice&quot;
</code></pre>
<p>What the pieces mean:</p>
<ul>
<li><code>--network host</code> lets the container reach the API on your machine. (On macOS and Windows, use <code>http://host.docker.internal:8000</code> instead of <code>127.0.0.1</code> and drop <code>--network host</code>.)</li>
<li><code>-t …/openapi.json -f openapi</code> tells ZAP to import the endpoints from the OpenAPI document.</li>
<li><code>-J</code> and <code>-w</code> write JSON and Markdown reports into the <code>zap</code> folder.</li>
<li><code>-I</code> stops the script from returning a failure code on warnings, so it finishes cleanly.</li>
<li>The long <code>-z</code> part configures ZAP&#39;s <strong>replacer</strong> to add <code>Authorization: Bearer token-alice</code> to every request. Without it, every endpoint would answer <code>401</code> and the scanner would test nothing but the sign-in wall.</li>
<li>If the output ends with &quot;Failed to start the main proxy: Address already in use&quot;, something on your machine is using port 8080, which ZAP wants for itself. Add <code>-P 8099</code> (any free port) after <code>zap-api-scan.py</code>. This happened to us on the third run.</li>
</ul>
<p>Three minutes later, the end of the output:</p>
<pre tabindex="0"><code class="language-text">Number of Imported URLs: 6
Total of 35 URLs
PASS: Directory Browsing [0]
PASS: Vulnerable JS Library (Powered by Retire.js) [10003]
...
PASS: Remote OS Command Injection (Time Based) [90037]
WARN-NEW: X-Content-Type-Options Header Missing [10021] x 1
	http://127.0.0.1:8000/openapi.json (200 OK)
WARN-NEW: Cross-Origin-Resource-Policy Header Missing or Invalid [90004] x 1
	http://127.0.0.1:8000/openapi.json (200 OK)
FAIL-NEW: 0	FAIL-INPROG: 0	WARN-NEW: 2	WARN-INPROG: 0	INFO: 0	IGNORE: 0	PASS: 118
</code></pre>
<p><strong>118 checks passed, 2 warnings, no failures.</strong> Both warnings are the same kind of thing: a recommended security header the server doesn&#39;t send, flagged on the OpenAPI document itself. ZAP rates them <strong>Low</strong>. For a tiny API with no browser pages they barely matter, but they&#39;re real and the fix is one line of middleware, so they go on the backlog.</p>
<p>(A note on the words: the API-scan script reports every finding as <code>WARN</code> unless you give it a rules file saying which ones should <code>FAIL</code> the build. The actual risk level, High to Informational, is in the report.)</p>
<h2>What the scanner cannot see</h2>
<p>Remember the most valuable test from Part 4: Alice asking for Bob&#39;s order and getting <code>403</code>. <strong>ZAP can&#39;t find that bug.</strong> It has one token, Alice&#39;s, and no way of knowing that order 43 isn&#39;t hers. If the API wrongly returned Bob&#39;s order, the scanner would see a perfectly healthy <code>200</code>.</p>
<p>This is the single most important thing to understand about security scanners: <strong>they find the generic mistakes, not the ones specific to your business rules.</strong> The number-one risk in the OWASP Top 10, broken access control, is mostly found by tests <em>you</em> write. The scanner and your test suite are partners, not alternatives.</p>
<h2>Step 4: Plant a vulnerability (3 minutes)</h2>
<p>To see what a real finding looks like, let&#39;s add two things a team might ship by accident. Append this to <code>app.py</code>:</p>
<pre tabindex="0"><code class="language-python"># ---- two things a real team might ship by accident ----
import sqlite3
import traceback

from fastapi import Request
from fastapi.responses import PlainTextResponse

db = sqlite3.connect(&quot;:memory:&quot;, check_same_thread=False)
db.execute(&quot;create table restaurants(name text, cuisine text)&quot;)
db.executemany(&quot;insert into restaurants values (?, ?)&quot;,
               [(&quot;Pizza Palace&quot;, &quot;pizza&quot;), (&quot;Curry House&quot;, &quot;indian&quot;), (&quot;Sushi Go&quot;, &quot;japanese&quot;)])


@app.get(&quot;/restaurants&quot;)
def search_restaurants(q: str = &quot;&quot;, customer: str = Depends(current_customer)):
    # the query is built by gluing the user&#39;s text into the SQL
    rows = db.execute(f&quot;select name, cuisine from restaurants where name like &#39;%{q}%&#39;&quot;).fetchall()
    return [{&quot;name&quot;: n, &quot;cuisine&quot;: c} for n, c in rows]


@app.exception_handler(Exception)
async def show_errors(request: Request, exc: Exception):
    # &quot;debug mode&quot;: send the full stack trace back to whoever asked
    return PlainTextResponse(&quot;&quot;.join(traceback.format_exception(exc)), status_code=500)
</code></pre>
<p>The first is <strong>A05 Injection</strong>: the search text is glued straight into an SQL statement. The second is <strong>A10 Mishandling of Exceptional Conditions</strong> (and a bit of A02): any crash sends the full stack trace to the caller.</p>
<p>Restart <code>uvicorn</code>, and try the search with a single quote, the classic probe:</p>
<pre tabindex="0"><code class="language-bash">curl &quot;http://127.0.0.1:8000/restaurants?q=%27&quot; -H &quot;Authorization: Bearer token-alice&quot;
</code></pre>
<pre tabindex="0"><code class="language-text">Traceback (most recent call last):
  ...
sqlite3.OperationalError: unrecognized token: &quot;&#39;&quot;
</code></pre>
<p>One character broke the query, and the server told us exactly which database it uses and what went wrong. That&#39;s what an attacker&#39;s first probe looks like, and what their first clue looks like.</p>
<h2>Step 5: Scan again</h2>
<p>Run the same <code>docker run</code> command. This time:</p>
<pre tabindex="0"><code class="language-text">WARN-NEW: A Server Error response code was returned by the server [100000] x 1
WARN-NEW: X-Content-Type-Options Header Missing [10021] x 2
WARN-NEW: SQL Injection [40018] x 1
WARN-NEW: Cross-Origin-Resource-Policy Header Missing or Invalid [90004] x 2
FAIL-NEW: 0	FAIL-INPROG: 0	WARN-NEW: 4	WARN-INPROG: 0	INFO: 0	IGNORE: 0	PASS: 117
</code></pre>
<p>ZAP found the injection. The detail is in the report:</p>
<pre tabindex="0"><code class="language-text">[High (Low)] SQL Injection (plugin 40018) x1  CWE 89
   uri:      http://127.0.0.1:8000/restaurants?q=%27 | method: GET | param: q
   attack:   &#39;
   evidence: HTTP/1.1 500 Internal Server Error
   solution: Do not trust client side input, even if there is client side validation
             in place. In general, type check all data on the server side. ...
             use PreparedStatement ... with parameters passed by &#39;?&#39;

[Low (High)] A Server Error response code was returned by the server (plugin 100000) x1
   uri:      http://127.0.0.1:8000/restaurants?q=5%3BURL%3D... | method: GET
   evidence: 500
</code></pre>
<p>How to read a finding:</p>
<ul>
<li><strong><code>High (Low)</code></strong> means <em>High risk, Low confidence</em>. ZAP saw a <code>500</code> when it sent a quote, which is a strong hint but not proof. <strong>Confirm every finding by hand before you act on it.</strong> We already did: the curl above reproduced it.</li>
<li><strong><code>param: q</code>, <code>attack: &#39;</code></strong> tell you exactly where and how. That&#39;s your bug report&#39;s steps to reproduce.</li>
<li><strong>CWE 89</strong> is the standard identifier for SQL injection, useful for looking up fixes and for compliance reports.</li>
<li>The server-error finding is the <em>symptom</em> of the same bug plus the debug handler: rated Low on its own, but the stack trace it leaks is a gift to an attacker.</li>
</ul>
<h2>Step 6: Triage</h2>
<p>A scan report is not a to-do list. Sort it:</p>
<p><img src="https://provalab.io/blog/handson/triage-findings.svg" alt="The findings sorted into three piles. Fix now: SQL Injection on GET /restaurants?q= (High), reproduced by hand with a single quote, raise a bug and fix today with a parameterised query; and the 500 with a stack trace, same root cause plus debug mode. Backlog, real but minor: X-Content-Type-Options and Cross-Origin-Resource-Policy headers missing (Low), one line of server config each. Not applicable: 22 informational notes that the API returned 404, and 4 about non-storable content, both expected for an API; mark them so the next scan is quieter." width="880" height="440" loading="lazy"></p>
<ul>
<li><strong>Fix now:</strong> the injection and the stack-trace leak. Confirmed, serious, same root cause.</li>
<li><strong>Backlog:</strong> the two headers. Real, minor, one line each.</li>
<li><strong>Not applicable:</strong> the 22 informational notes that the API returned <code>404</code> for things that don&#39;t exist, and the 4 about responses not being cacheable. An API is <em>supposed</em> to do both. Mark them as not applicable in your tracker so the next scan doesn&#39;t repeat them.</li>
</ul>
<p>Thirty lines of report become <strong>two bugs and two backlog items</strong>. The <a href="https://provalab.io/blog/how-to-write-a-bug-report/">bug report</a> for the injection writes itself: the URL, the parameter, the payload <code>&#39;</code>, the expected result (a normal empty list or a <code>400</code>), the actual result (a <code>500</code> with a stack trace), and the CWE.</p>
<h2>Step 7: Fix and re-scan</h2>
<p>Two changes. The query takes a parameter instead of glued text, and the debug handler goes:</p>
<pre tabindex="0"><code class="language-python">    rows = db.execute(&quot;select name, cuisine from restaurants where name like ?&quot;, (f&quot;%{q}%&quot;,)).fetchall()
</code></pre>
<p>Restart, and the single quote now returns an empty list with a <code>200</code>, as it should:</p>
<pre tabindex="0"><code class="language-bash">curl &quot;http://127.0.0.1:8000/restaurants?q=%27&quot; -H &quot;Authorization: Bearer token-alice&quot;
</code></pre>
<pre tabindex="0"><code class="language-text">[]
</code></pre>
<p>Scan a third time:</p>
<pre tabindex="0"><code class="language-text">Number of Imported URLs: 7
Total of 36 URLs
WARN-NEW: X-Content-Type-Options Header Missing [10021] x 2
WARN-NEW: Cross-Origin-Resource-Policy Header Missing or Invalid [90004] x 2
FAIL-NEW: 0	FAIL-INPROG: 0	WARN-NEW: 2	WARN-INPROG: 0	INFO: 0	IGNORE: 0	PASS: 118
</code></pre>
<p>The injection and the server error are gone; 118 checks pass again.</p>
<p>The two header warnings are still there, as expected; they&#39;re on the backlog, and a scanner will keep mentioning them until the header is added. That&#39;s the right behaviour: a scan is a regression test for security.</p>
<h2>Putting it in the pipeline</h2>
<p>The same command runs in CI. Start the API, run the scan against it, and keep the report as an artifact:</p>
<pre tabindex="0"><code class="language-yaml">      - run: pip install -r requirements.txt
      - run: uvicorn app:app &amp;
      - run: mkdir -p zap &amp;&amp; chmod 777 zap
      - run: &gt;
          docker run --rm --network host -v &quot;$PWD/zap:/zap/wrk:rw&quot;
          ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py
          -t http://127.0.0.1:8000/openapi.json -f openapi -J report.json -w report.md
          -z &quot;-config replacer.full_list(0).description=auth -config replacer.full_list(0).enabled=true -config replacer.full_list(0).matchtype=REQ_HEADER -config replacer.full_list(0).matchstr=Authorization -config replacer.full_list(0).regex=false -config replacer.full_list(0).replacement=Bearer\ ${{ secrets.TEST_API_TOKEN }}&quot;
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: zap-report
          path: zap/
</code></pre>
<p>Without <code>-I</code>, the job fails when ZAP finds anything new, which is what you want once the backlog items are either fixed or listed in a rules file as ignored. Run it nightly rather than on every commit: a full active scan takes minutes, not seconds.</p>
<h2>Habits worth keeping</h2>
<ol>
<li><strong>Scan with a signed-in session.</strong> An unauthenticated scan tests your sign-in page and nothing else.</li>
<li><strong>Confirm by hand before raising a bug.</strong> &quot;High (Low)&quot; is a lead, not a verdict.</li>
<li><strong>Triage into fix-now, backlog, not-applicable</strong>, and record the last pile so it stops reappearing.</li>
<li><strong>Keep writing access-control tests yourself.</strong> The scanner can&#39;t.</li>
<li><strong>Never scan what you don&#39;t own.</strong></li>
</ol>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/first-load-test-k6/">Part 7</a></strong>, the last of this series, we go hands-on with <strong>load testing</strong>: a k6 script against the same API, finding where it slows down and where it breaks.</p>
<p>ProvaLab AppSec runs this kind of scan, and API fuzzing, dependency, secrets, container and code scans, against targets you&#39;ve verified you own, from the cloud, and presents the findings as a plain-language fix list ordered by severity, with your confirmed false positives remembered between runs. See it in the <a href="https://provalab.io/#stage-appsec">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How do you test code your AI coding agent wrote?</title>
      <link>https://provalab.io/blog/how-to-test-code-your-ai-agent-wrote/</link>
      <guid isPermaLink="true">https://provalab.io/blog/how-to-test-code-your-ai-agent-wrote/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing</category>
      <category>ai-agents</category>
      <description>AI agents write features in minutes, but most teams still check that code the old way. Here is a practical way to verify AI-written code before it ships.</description>
      <content:encoded><![CDATA[<p>Claude Code, Cursor and Copilot can now write a whole feature while you get a coffee. That part of the job got fast. The part after it, checking that the code actually works, did not.</p>
<p>Most developers have already made the switch. In the <a href="https://survey.stackoverflow.co/2025/ai">Stack Overflow Developer Survey 2025</a>, 84% of developers said they use or plan to use AI tools in their work. But trust went the other way: 46% said they actively distrust the accuracy of what those tools produce.</p>
<p>So teams end up in an awkward place. They write code faster than ever, then either slow down to check it by hand, or ship it and hope.</p>
<h2>Why AI-written code needs a different kind of checking</h2>
<p>AI-written code is not worse than human code by default. It fails in different ways:</p>
<ul>
<li><strong>It looks right.</strong> The code is tidy and confident, so reviewers skim it.</li>
<li><strong>It passes its own tests.</strong> If the agent wrote the code and the tests together, the tests often check what the code does, not what it should do.</li>
<li><strong>It handles the happy path.</strong> Empty lists, slow networks, expired sessions and two users at once are where it tends to break.</li>
<li><strong>There is a lot of it.</strong> A reviewer who could read 200 lines a day now gets 2,000.</li>
</ul>
<p>The answer is not to read every line more carefully. It is to make checking as fast as writing.</p>
<h2>A simple routine for every AI-written change</h2>
<h3>1. Write the test cases before the code</h3>
<p>Ask the agent for the test cases first, in plain language: what the feature must do, and what must never happen. Read that list yourself. It is ten lines instead of five hundred, and it is where your judgement matters most.</p>
<h3>2. Run the tests somewhere real</h3>
<p>Tests that only pass on the developer&#39;s laptop prove little. Run them on real browsers and devices, against a running build, the way a user would meet the feature.</p>
<h3>3. Keep a record of every run</h3>
<p>When a test fails next week, you want to know whether it ever passed, on which build, and what changed. A test case and its run history should live in one place, not in a chat log.</p>
<h3>4. Check what tests don&#39;t cover</h3>
<p>Functional tests don&#39;t tell you whether an API leaks data or falls over when fifty people use it at once. Add a security scan and a short load test before anything important ships.</p>
<h3>5. Send failures back to the agent</h3>
<p>This is the step most teams skip. When a test fails, the agent that wrote the code should get the failure, the logs and a recording, and fix it itself. A person approves the result instead of doing the triage.</p>
<h2>Where ProvaLab fits</h2>
<p>We built ProvaLab so a coding agent can do this whole routine on its own. Through the Claude Code plugin or MCP, the agent runs your tests on cloud browsers and devices, records every result in test management, scans for security issues, runs load tests, and gets the failures back so it can fix them.</p>
<p>You can see it working in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>, or <a href="https://provalab.io/demo">book a 30-minute demo</a> and we will run it against your own app.</p>
<p>AI made writing code fast. Checking it should be just as fast.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to write a good bug report (with template and examples)</title>
      <link>https://provalab.io/blog/how-to-write-a-bug-report/</link>
      <guid isPermaLink="true">https://provalab.io/blog/how-to-write-a-bug-report/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>bug-report</category>
      <description>What makes a bug report easy to act on, every field to include, severity vs priority explained, a good and a bad example side by side, and a copy-ready template.</description>
      <content:encoded><![CDATA[<p>Finding a bug is only half the job. If the developer can&#39;t understand it or reproduce it, the bug doesn&#39;t get fixed. It gets bounced back as &quot;works on my machine&quot;, and everyone loses a day.</p>
<p>A good bug report is one of the most valuable things a tester produces. This part shows you how to write one.</p>
<h2>What is a bug report?</h2>
<p><strong>A bug report (also called a defect report or an issue) is a written record of a problem: what went wrong, how to make it happen again, and why it matters.</strong></p>
<p>Its job is simple: <strong>let someone who has never seen the bug reproduce it, understand it and fix it, without asking you a single question.</strong></p>
<h2>Same bug, two reports</h2>
<p><img src="https://provalab.io/blog/basics/bug-report-good-bad.svg" alt="The same bug reported two ways. The bad report says only &quot;Discount not working. I tried a code and it doesn't work. Please fix ASAP!!&quot; The good report BUG-231 is titled &quot;Expired discount code accepted at checkout; total reduced by 10%&quot;, and gives the environment, three steps to reproduce, expected and actual results, severity, priority, reproducibility 5 out of 5, attachments and links to the test case and requirement." width="880" height="560" loading="lazy"></p>
<p>The bad report starts a conversation: which code, which app, what happened? The good one ends it.</p>
<h2>The fields of a good bug report</h2>
<table tabindex="0">
<thead>
<tr>
<th>Field</th>
<th>What to write</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td><strong>ID</strong></td>
<td>Usually assigned by your tracker</td>
<td>BUG-231</td>
</tr>
<tr>
<td><strong>Title</strong></td>
<td>Where, what goes wrong, and when</td>
<td>Expired discount code accepted at checkout; total reduced by 10%</td>
</tr>
<tr>
<td><strong>Environment</strong></td>
<td>App version, browser or device, OS, server</td>
<td>Web 2.4.0, Chrome 131, Windows 11, staging</td>
</tr>
<tr>
<td><strong>Preconditions</strong></td>
<td>What must be true first</td>
<td>Signed in as test-user-2; code OLD10 expired on 1 Nov</td>
</tr>
<tr>
<td><strong>Steps to reproduce</strong></td>
<td>Short, numbered, the fewest needed</td>
<td>1. Add items worth 500 · 2. Open checkout · 3. Apply OLD10</td>
</tr>
<tr>
<td><strong>Expected result</strong></td>
<td>What should happen</td>
<td>&quot;This code has expired&quot;; total stays 500</td>
</tr>
<tr>
<td><strong>Actual result</strong></td>
<td>What really happens</td>
<td>Code accepted; total becomes 450</td>
</tr>
<tr>
<td><strong>Severity</strong></td>
<td>How badly it breaks the software</td>
<td>High</td>
</tr>
<tr>
<td><strong>Priority</strong></td>
<td>How soon it should be fixed</td>
<td>High</td>
</tr>
<tr>
<td><strong>Reproducibility</strong></td>
<td>How often it happens</td>
<td>5 out of 5 tries</td>
</tr>
<tr>
<td><strong>Attachments</strong></td>
<td>Proof and clues</td>
<td>Screenshot, screen recording, console log, network log</td>
</tr>
<tr>
<td><strong>Links</strong></td>
<td>Where it came from</td>
<td>Test case TC-104, requirement REQ-12</td>
</tr>
</tbody></table>
<h3>Writing a good title</h3>
<p>Most people only ever read the title, in a list of fifty bugs. Use this pattern:</p>
<blockquote>
<p><strong>[Where] + [what goes wrong] + [under what condition]</strong></p>
</blockquote>
<ul>
<li>Bad: &quot;Checkout bug&quot;</li>
<li>Bad: &quot;Discount doesn&#39;t work properly&quot;</li>
<li>Good: &quot;Expired discount code accepted at checkout; total reduced by 10%&quot;</li>
<li>Good: &quot;Order history page blank on Android 14 after switching language&quot;</li>
</ul>
<h3>Expected vs actual: the heart of the report</h3>
<p>Keep these two <strong>side by side and specific</strong>. &quot;It doesn&#39;t work&quot; is not an actual result. &quot;The total becomes 450 and no error appears&quot; is. If the expected behaviour comes from a requirement or test case, link to it: that shows it&#39;s a bug and not a matter of opinion.</p>
<h2>Severity vs priority</h2>
<p>These two get confused constantly, and they are <strong>not the same thing</strong>:</p>
<ul>
<li><strong>Severity</strong> is about the <strong>software</strong>: how badly does the bug break it? Usually set by the tester.</li>
<li><strong>Priority</strong> is about the <strong>business</strong>: how soon must it be fixed? Usually set by the product owner or team lead.</li>
</ul>
<p>They often match, but not always:</p>
<p><img src="https://provalab.io/blog/basics/severity-priority.svg" alt="Severity versus priority. High severity, high priority: payment fails for every card. High severity, low priority: the app crashes exporting orders older than ten years, which almost nobody does. Low severity, high priority: the brand name is misspelled on the home page. Low severity, low priority: a tooltip slightly misaligned on one old browser." width="880" height="460" loading="lazy"></p>
<p>Common severity levels:</p>
<table tabindex="0">
<thead>
<tr>
<th>Severity</th>
<th>Meaning</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Critical</strong></td>
<td>System down, data lost, security hole, no workaround</td>
<td>Payment fails for everyone; other users&#39; orders visible</td>
</tr>
<tr>
<td><strong>High</strong></td>
<td>A major feature is broken</td>
<td>Expired codes are accepted</td>
</tr>
<tr>
<td><strong>Medium</strong></td>
<td>A feature misbehaves, but there is a workaround</td>
<td>Discount only applies after refreshing the page</td>
</tr>
<tr>
<td><strong>Low</strong></td>
<td>Cosmetic or minor</td>
<td>Button text slightly misaligned</td>
</tr>
</tbody></table>
<h2>Attachments that save hours</h2>
<ul>
<li><strong>Screenshot</strong>, with the problem area highlighted.</li>
<li><strong>Screen recording</strong> for anything involving several steps or timing. A short video often replaces a page of text.</li>
<li><strong>Console log</strong> from the browser&#39;s developer tools (F12 → Console) for web bugs.</li>
<li><strong>Network log or HAR file</strong> when a server request fails. It shows exactly what was sent and what came back.</li>
<li><strong>Server or app logs</strong>, with the time the bug happened.</li>
<li><strong>Test data:</strong> the account, order number or file you used.</li>
</ul>
<p><strong>Never attach real passwords, card numbers or customers&#39; personal data.</strong> Use test accounts, and blur anything sensitive in screenshots.</p>
<h2>Ten habits of great bug reporters</h2>
<ol>
<li><strong>Reproduce it first.</strong> Try at least twice before reporting.</li>
<li><strong>Search for duplicates.</strong> Someone may already have reported it.</li>
<li><strong>One bug per report.</strong> Two problems means two reports, because they may be fixed by different people at different times.</li>
<li><strong>Use the fewest steps</strong> that still trigger the bug.</li>
<li><strong>Find the boundaries.</strong> Does it happen in other browsers? With other codes? Only for new customers? Each answer narrows the cause.</li>
<li><strong>Report facts, not guesses.</strong> &quot;The total becomes 450&quot;, not &quot;the discount logic is broken&quot;.</li>
<li><strong>Keep the tone neutral.</strong> No blame, no capitals, no &quot;!!!&quot;. Developers and testers are on the same side.</li>
<li><strong>Write for a stranger.</strong> Assume the reader has never seen this part of the app.</li>
<li><strong>Say if it&#39;s intermittent.</strong> &quot;Happens about 2 in 5 tries&quot; is useful; hiding it wastes time.</li>
<li><strong>Keep it updated.</strong> If you learn more, add it to the report, not to a chat message that will be lost.</li>
</ol>
<h2>A copy-ready template</h2>
<pre tabindex="0"><code class="language-text">Title: [Where] [what goes wrong] [under what condition]

Environment: app version, browser/device, OS, server (staging/production)
Preconditions: account, data or settings needed first

Steps to reproduce:
1.
2.
3.

Expected result:
Actual result:

Severity: Critical / High / Medium / Low
Priority: High / Medium / Low
Reproducibility: e.g. 5/5, or 2 in 5 tries

Attachments: screenshot, recording, console log, network log
Links: test case, requirement, related bugs
</code></pre>
<h2>What&#39;s next</h2>
<p>Once a bug is reported, its journey has only just begun. In <strong><a href="https://provalab.io/blog/bug-life-cycle/">Part 10</a></strong> we follow it through the <strong>bug life cycle</strong>: new, assigned, fixed, retested and closed (or reopened).</p>
<p>In ProvaLab, a failed automated test can raise a bug report with the steps, the error, the screen recording and the logs already attached, linked to its test case and test run. Your coding agent can read it and fix the code. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to write a test plan (with a one-page example)</title>
      <link>https://provalab.io/blog/how-to-write-a-test-plan/</link>
      <guid isPermaLink="true">https://provalab.io/blog/how-to-write-a-test-plan/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>test-plan</category>
      <description>What a test plan is, the sections every plan needs, a one-page example you can copy, risk-based testing, and how test plans work in agile teams.</description>
      <content:encoded><![CDATA[<p>So far you have learned how to <a href="https://provalab.io/blog/how-to-write-test-cases/">write test cases</a> and how to <a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">choose them with test design techniques</a>. But before anyone runs a single test, the team needs to agree on a bigger picture: <strong>what are we testing this time, how, who does it, by when, and how do we know we&#39;re done?</strong></p>
<p>That agreement is the <strong>test plan</strong>. In the <a href="https://provalab.io/blog/software-testing-life-cycle-stlc/">testing life cycle</a> it is Phase 2, and everything after it follows from it.</p>
<h2>What is a test plan?</h2>
<p><strong>A test plan is a document that describes the scope, approach, resources and schedule of the testing for a release or project.</strong></p>
<p>It answers six questions:</p>
<ol>
<li><strong>Why</strong> are we testing? (the objective)</li>
<li><strong>What</strong> will we test, and what won&#39;t we? (scope)</li>
<li><strong>How</strong> will we test it? (approach)</li>
<li><strong>Where</strong> will the tests run? (environment)</li>
<li><strong>Who</strong> does what, and <strong>when</strong>? (people and schedule)</li>
<li><strong>When</strong> do we start, and when are we done? (entry and exit criteria)</li>
</ol>
<p>A good test plan is written for <strong>people</strong>, not for a filing cabinet. Developers, product managers and testers should all be able to read it in a few minutes and agree.</p>
<h2>Test plan vs test strategy</h2>
<ul>
<li>A <strong>test strategy</strong> is the long-term, organisation-wide approach: &quot;We automate all regression tests, every release gets a security scan, and we test on the three most-used browsers.&quot; It rarely changes.</li>
<li>A <strong>test plan</strong> applies that strategy to <strong>one release or project</strong>: &quot;For release 2.4 we test discount codes on web and Android from 3 to 7 November.&quot;</li>
</ul>
<p>Small teams often have no separate strategy document; its key rules simply live inside each plan.</p>
<h2>The sections of a test plan</h2>
<p>Formal templates exist. The IEEE 829 standard was the classic one, and it was later replaced by ISO/IEC/IEEE 29119-3. But most teams use a lighter version of the same ideas:</p>
<table tabindex="0">
<thead>
<tr>
<th>Section</th>
<th>What it says</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Objective</strong></td>
<td>What this testing must prove</td>
<td>Customers can apply valid discount codes and are refused invalid ones</td>
</tr>
<tr>
<td><strong>Scope: in</strong></td>
<td>Features and platforms you will test</td>
<td>Discount codes on web and Android; cart, checkout, order history</td>
</tr>
<tr>
<td><strong>Scope: out</strong></td>
<td>What you deliberately won&#39;t test, and why</td>
<td>iPhone app (next release); restaurant dashboard (unchanged)</td>
</tr>
<tr>
<td><strong>Approach</strong></td>
<td>Test levels, types, and manual vs automated</td>
<td>Functional, API and regression tests; regression automated</td>
</tr>
<tr>
<td><strong>Environment</strong></td>
<td>Where tests run, with what data</td>
<td>Staging server, test codes, 3 test accounts, payment sandbox</td>
</tr>
<tr>
<td><strong>Entry criteria</strong></td>
<td>When testing can start</td>
<td>Build deployed to staging; smoke test passed</td>
</tr>
<tr>
<td><strong>Exit criteria</strong></td>
<td>When testing is done</td>
<td>All high-priority tests passed; no open critical bugs</td>
</tr>
<tr>
<td><strong>Suspension criteria</strong></td>
<td>When to pause testing</td>
<td>Pause if sign-in is broken, because most tests depend on it</td>
</tr>
<tr>
<td><strong>People</strong></td>
<td>Who does what</td>
<td>One test lead, two testers; a developer on call for blockers</td>
</tr>
<tr>
<td><strong>Schedule</strong></td>
<td>Key dates</td>
<td>Testing 3–7 November; summary report 8 November</td>
</tr>
<tr>
<td><strong>Deliverables</strong></td>
<td>What testing produces</td>
<td>Test cases, run results, bug reports, a summary report</td>
</tr>
<tr>
<td><strong>Risks</strong></td>
<td>What could go wrong, and the backup plan</td>
<td>Payment sandbox may be slow: book it early, keep a mock ready</td>
</tr>
</tbody></table>
<p><strong>&quot;Out of scope&quot; is the most underrated line in any test plan.</strong> Writing down what you <em>won&#39;t</em> test avoids the worst surprise of all: a bug in an area everyone thought someone else had checked.</p>
<h2>A one-page example</h2>
<p>For most releases, the whole plan fits on one page:</p>
<p><img src="https://provalab.io/blog/basics/test-plan-one-page.svg" alt="A one-page test plan for release 2.4, discount codes. Objective: customers can apply valid codes and are clearly refused invalid ones. In scope: discount codes on web and Android, cart, checkout and order history. Out of scope: iPhone app and restaurant dashboard. Approach: functional, API and regression tests, regression automated. Environment: staging with test codes, 3 test accounts and a card sandbox. Entry: build on staging, smoke passed. Exit: high-priority tests passed, no critical bugs. People: 1 test lead, 2 testers. Schedule: 3–7 November, report 8 November. Risk: payment sandbox may be slow; book it early and keep a mock ready." width="880" height="560" loading="lazy"></p>
<p>If your plan doesn&#39;t fit on one or two pages, ask whether every section is really helping someone make a decision.</p>
<h2>Risk-based testing: deciding what matters most</h2>
<p>There is never time to test everything equally. <strong>Risk-based testing</strong> spends the most effort where a failure is both <strong>likely</strong> and <strong>harmful</strong>:</p>
<p><img src="https://provalab.io/blog/basics/risk-matrix.svg" alt="A risk matrix of likelihood against impact. High impact and high likelihood, such as payment and discount totals: test first and deepest. High impact, low likelihood, such as sign-in: test well and automate. Low impact, high likelihood, such as a new promo banner: test the main flows. Low and low, such as help page text: a quick check." width="880" height="470" loading="lazy"></p>
<p>For each feature, ask two questions:</p>
<ul>
<li><strong>How likely is it to break?</strong> New code, complex code, code that changed a lot, or code with a history of bugs.</li>
<li><strong>How bad is it if it breaks?</strong> Money, personal data, sign-in and anything that stops customers ordering.</li>
</ul>
<p>Put the answers in the plan. They decide the order of testing and where the deepest test cases go, and they tell everyone what was tested lightly if time runs out.</p>
<h2>How to write one, step by step</h2>
<ol>
<li><strong>Read the requirements</strong>, and ask questions (STLC Phase 1).</li>
<li><strong>Write the objective</strong> in one sentence.</li>
<li><strong>List what&#39;s in and out of scope</strong>, with a reason for each &quot;out&quot;.</li>
<li><strong>Rank the risks</strong>, using the matrix above.</li>
<li><strong>Choose the approach</strong>: which levels and types, and what to automate.</li>
<li><strong>Define entry and exit criteria</strong> that can be checked, not &quot;when it looks good&quot;.</li>
<li><strong>Add people, environment and schedule.</strong></li>
<li><strong>Review it</strong> with developers and the product owner. A plan nobody agreed to isn&#39;t a plan.</li>
</ol>
<h2>Test plans in agile teams</h2>
<p>Agile teams rarely write long plans for every sprint. They usually keep:</p>
<ul>
<li><strong>A light test strategy</strong> for the product, updated a few times a year.</li>
<li><strong>Testing notes in each sprint</strong>: acceptance criteria on every user story, and a short checklist of what is risky this sprint.</li>
<li><strong>A short release plan</strong> for bigger releases: scope, risks and exit criteria.</li>
</ul>
<p>The questions stay the same. Only the paperwork shrinks.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Copying last release&#39;s plan</strong> without changing the scope or risks.</li>
<li><strong>Vague exit criteria</strong> like &quot;testing is complete&quot;. Use measurable ones.</li>
<li><strong>No &quot;out of scope&quot; section,</strong> so gaps stay invisible.</li>
<li><strong>Writing it alone.</strong> Developers know where the code is fragile; product owners know what matters most to customers.</li>
<li><strong>Never updating it.</strong> When the scope changes mid-release, the plan should too.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/how-to-write-a-bug-report/">Part 9</a></strong> we look at what happens when a test fails: <strong>how to write a good bug report</strong> that developers can act on quickly.</p>
<p>In ProvaLab Test Management, a test plan has these fields built in: objective, test lead, dates, entry and exit criteria, and the features in scope. Test cases attach to the plan, and every test run reports back into it. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to write good test cases: template, examples and techniques</title>
      <link>https://provalab.io/blog/how-to-write-test-cases/</link>
      <guid isPermaLink="true">https://provalab.io/blog/how-to-write-test-cases/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>test-cases</category>
      <description>What goes into a test case, a ready-to-use template with real examples, the techniques testers use to pick the right cases, and the mistakes to avoid.</description>
      <content:encoded><![CDATA[<p>Test cases are the building blocks of all testing. Whether a person follows them by hand or code runs them automatically, as in <a href="https://provalab.io/blog/first-automated-test-pytest/">Part 5</a>, every test starts as a test case. In this part you will learn what goes into one, how to write it well, and how to choose <em>which</em> test cases to write.</p>
<h2>What is a test case?</h2>
<p><strong>A test case is a set of steps, with specific data, that checks one thing about the software and says exactly what result to expect.</strong></p>
<p>Anyone on the team should be able to pick it up, follow it and get the same answer: pass or fail. If two testers can read the same test case and do different things, it isn&#39;t finished.</p>
<h2>Test scenario vs test case</h2>
<p>These two get mixed up a lot:</p>
<ul>
<li>A <strong>test scenario</strong> is <em>what</em> to test, in one line: &quot;Check that discount codes work at checkout.&quot;</li>
<li>A <strong>test case</strong> is <em>exactly how</em> to test one part of it: which code, which cart, which clicks, which result.</li>
</ul>
<p>One scenario usually becomes several test cases: a valid code, an expired code, two codes at once, a code on an empty cart, and so on.</p>
<h2>The anatomy of a test case</h2>
<p><img src="https://provalab.io/blog/basics/test-case-anatomy.svg" alt="A test case card, TC-104 Apply an expired discount code. Requirement REQ-12, priority High. Preconditions: signed-in customer, cart total 500. Test data: code OLD10, expired yesterday. Steps: open the cart, enter OLD10, click Apply. Expected result: &quot;This code has expired&quot; is shown and the total stays 500. Actual result and status are filled in when the test is run." width="880" height="560" loading="lazy"></p>
<table tabindex="0">
<thead>
<tr>
<th>Field</th>
<th>What to write</th>
</tr>
</thead>
<tbody><tr>
<td><strong>ID</strong></td>
<td>A unique reference, such as TC-104</td>
</tr>
<tr>
<td><strong>Title</strong></td>
<td>What it checks, in plain words: &quot;Apply an expired discount code&quot;</td>
</tr>
<tr>
<td><strong>Requirement</strong></td>
<td>The requirement or user story it covers (we come back to this in Part 10)</td>
</tr>
<tr>
<td><strong>Priority</strong></td>
<td>How important it is: High, Medium or Low</td>
</tr>
<tr>
<td><strong>Preconditions</strong></td>
<td>What must be true before you start</td>
</tr>
<tr>
<td><strong>Test data</strong></td>
<td>The exact values to use</td>
</tr>
<tr>
<td><strong>Steps</strong></td>
<td>Short, numbered actions</td>
</tr>
<tr>
<td><strong>Expected result</strong></td>
<td>What should happen, precise enough to judge pass or fail</td>
</tr>
<tr>
<td><strong>Actual result</strong></td>
<td>What really happened, filled in during the run</td>
</tr>
<tr>
<td><strong>Status</strong></td>
<td>Pass, Fail, Blocked or Not run</td>
</tr>
</tbody></table>
<h2>Example: discount codes in the food delivery app</h2>
<table tabindex="0">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Test data</th>
<th>Expected result</th>
</tr>
</thead>
<tbody><tr>
<td>TC-101</td>
<td>Apply a valid 10% code</td>
<td>SAVE10, cart 500</td>
<td>Total becomes 450</td>
</tr>
<tr>
<td>TC-102</td>
<td>Code is not case-sensitive</td>
<td>save10, cart 500</td>
<td>Total becomes 450</td>
</tr>
<tr>
<td>TC-103</td>
<td>Apply an unknown code</td>
<td>NOPE99</td>
<td>&quot;Code not recognised&quot;; total stays 500</td>
</tr>
<tr>
<td>TC-104</td>
<td>Apply an expired code</td>
<td>OLD10</td>
<td>&quot;This code has expired&quot;; total stays 500</td>
</tr>
<tr>
<td>TC-105</td>
<td>Apply two codes</td>
<td>SAVE10, then FREEDEL</td>
<td>Second code refused with a clear message</td>
</tr>
<tr>
<td>TC-106</td>
<td>Remove all items after applying a code</td>
<td>SAVE10, then empty the cart</td>
<td>Discount removed; total 0</td>
</tr>
</tbody></table>
<p>Notice the mix: one <strong>positive</strong> case (it works when used correctly) and several <strong>negative</strong> cases (it fails safely when used wrongly). Most real bugs live in the negative cases, so don&#39;t skip them.</p>
<h2>Techniques for choosing test cases</h2>
<p>You can&#39;t test every possible input. These techniques help you pick the few cases that catch the most bugs.</p>
<h3>Equivalence partitioning</h3>
<p>Group inputs that the software should treat the same way, then test <strong>one value from each group</strong>. If a discount field accepts 0 to 100, there are three groups: below 0, 0 to 100, and above 100. Testing 50 tells you about the whole valid group; you don&#39;t need to test 51, 52 and 53.</p>
<h3>Boundary value analysis</h3>
<p>Bugs love edges. A developer might write <code>&lt;</code> where they meant <code>&lt;=</code>, and only the exact edge value shows it. So test <strong>at and around each boundary</strong>:</p>
<p><img src="https://provalab.io/blog/basics/boundary-values.svg" alt="A discount field accepting 0 to 100 percent. Equivalence classes: below 0 invalid, 0 to 100 valid, above 100 invalid. Boundary values to test: −1, 0, 1, 99, 100 and 101. −1 and 101 must be refused; the other four accepted." width="880" height="330" loading="lazy"></p>
<p>Six well-chosen values catch most of the mistakes a hundred random ones would.</p>
<h3>Decision tables</h3>
<p>When the result depends on <strong>combinations</strong> of conditions, list every combination in a table so none is missed. For free delivery:</p>
<table tabindex="0">
<thead>
<tr>
<th>New customer?</th>
<th>Order over 300?</th>
<th>Free delivery?</th>
</tr>
</thead>
<tbody><tr>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td>Yes</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>No</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td>No</td>
<td>No</td>
<td>No</td>
</tr>
</tbody></table>
<p>Each row becomes one test case.</p>
<h3>Error guessing</h3>
<p>Use experience to guess where things break: empty fields, very long text, special characters like <code>&#39;</code> or <code>&lt;</code>, double-clicking a button, going back in the browser, or losing the network mid-payment. Experienced testers keep a personal list of these.</p>
<h2>Seven habits of good test cases</h2>
<ol>
<li><strong>One test, one purpose.</strong> If the title needs &quot;and&quot;, split it.</li>
<li><strong>A clear, specific title.</strong> &quot;Apply an expired discount code&quot;, not &quot;Discount test 3&quot;.</li>
<li><strong>Exact test data.</strong> &quot;Code OLD10&quot;, not &quot;an expired code&quot;.</li>
<li><strong>Short steps anyone can follow,</strong> without knowing the app.</li>
<li><strong>A checkable expected result.</strong> &quot;Total stays 500&quot;, not &quot;it works correctly&quot;.</li>
<li><strong>Independent.</strong> Each test sets up what it needs, so the order doesn&#39;t matter.</li>
<li><strong>Linked to a requirement,</strong> so you know why it exists and what it covers.</li>
</ol>
<h2>Common mistakes</h2>
<ul>
<li><strong>Vague expected results</strong> like &quot;the page should work fine&quot;. Nobody can fail that test.</li>
<li><strong>Only happy paths.</strong> Users mistype, double-click and go back. Test that too.</li>
<li><strong>Giant test cases</strong> with 30 steps. When step 17 fails, the rest are useless.</li>
<li><strong>Copy-paste duplicates</strong> that drift apart over time. Reuse and update instead.</li>
<li><strong>Never reviewing them.</strong> A quick review by another tester catches missing cases early.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">Part 7</a></strong> we go deeper into <strong>test design techniques</strong>: black-box, white-box and grey-box testing, and how each one finds different bugs.</p>
<p>In ProvaLab Test Management, every test case has these fields built in, is grouped into suites, links to its requirement, and keeps the history of every run, manual or automated. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Integration testing explained: approaches, stubs, drivers and examples</title>
      <link>https://provalab.io/blog/integration-testing/</link>
      <guid isPermaLink="true">https://provalab.io/blog/integration-testing/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>integration-testing</category>
      <description>What integration testing is, why bugs hide where components meet, the big bang, top-down, bottom-up and sandwich approaches, stubs and drivers, and what to check at every integration point.</description>
      <content:encoded><![CDATA[<p>In <a href="https://provalab.io/blog/types-of-software-testing/">Part 2</a> we met the four levels of testing. In <a href="https://provalab.io/blog/first-automated-test-pytest/">Part 5</a> we wrote unit tests that check one function on its own. But real software is many pieces talking to each other, and <strong>a surprising number of bugs live not inside the pieces, but in the gaps between them.</strong></p>
<p>Finding those bugs is the job of <strong>integration testing</strong>.</p>
<h2>What is integration testing?</h2>
<p><strong>Integration testing checks that separately built parts of the software work correctly together.</strong> The parts might be code modules, services, a database, or an outside system like a payment provider.</p>
<p>Each part may pass all its own unit tests and the whole thing can still fail, because the parts disagree about something: a field name, a date format, a unit of money, or what happens when one of them is slow.</p>
<h2>Where integration bugs hide</h2>
<p>Here is our food delivery app, broken into its main parts:</p>
<p><img src="https://provalab.io/blog/basics/integration-points.svg" alt="Integration points in a food delivery app: the mobile app talks to the order service; the order service talks to the database, the payment service and the restaurant notifier; the payment service talks to an outside payment provider. Unit tests check the boxes; integration tests check the arrows." width="880" height="430" loading="lazy"></p>
<p><strong>Unit tests check the boxes. Integration tests check the arrows.</strong></p>
<p>A classic example: the order service sends the payment amount as <strong>450</strong> meaning rupees, but the payment service expects the amount in <strong>paise</strong> (hundredths of a rupee), so it charges 4.50 instead of 450. Both services pass their unit tests perfectly. Only a test that runs them <em>together</em> catches the bug.</p>
<p>Other typical integration bugs:</p>
<ul>
<li><strong>Different field names or formats:</strong> one side sends <code>orderId</code>, the other expects <code>order_id</code>; one sends dates as <code>29/10/2026</code>, the other expects <code>2026-10-29</code>.</li>
<li><strong>Missing error handling:</strong> the payment service is down, and the order service crashes instead of showing &quot;payment failed, please try again&quot;.</li>
<li><strong>Timeouts:</strong> the restaurant notifier takes 30 seconds and the app gives up after 10.</li>
<li><strong>Wrong assumptions:</strong> the order service assumes the database always returns orders newest first, but nobody told the database that.</li>
<li><strong>Changes on one side:</strong> a team renames a field in their API, and the services that depend on it break.</li>
</ul>
<h2>Approaches to integration testing</h2>
<p>When there are many parts, in what order do you connect and test them?</p>
<p><img src="https://provalab.io/blog/basics/integration-approaches.svg" alt="Three ways to integrate. Big bang: join everything, then test; quick to start, but it is hard to find which join broke. Top-down: start at the top, replacing missing lower parts with stubs that give fixed answers. Bottom-up: start at the bottom, with small test programs called drivers calling the parts that are ready. Sandwich integration does both at once." width="880" height="470" loading="lazy"></p>
<h3>Big bang integration</h3>
<p><strong>Connect everything at once, then test the whole thing.</strong></p>
<ul>
<li><strong>Good:</strong> simple; nothing extra to build.</li>
<li><strong>Bad:</strong> when a test fails, the bug could be in any of the connections, so finding it is slow. And you can&#39;t start until every part is finished.</li>
</ul>
<p>It is only reasonable for very small systems.</p>
<h3>Incremental integration</h3>
<p><strong>Connect and test the parts a few at a time</strong>, so when something fails you know it is in the piece you just added. There are three ways to do this.</p>
<h4>Top-down</h4>
<p>Start with the <strong>top-level part</strong> (for example, the order service) and add the parts below it one by one. Parts that aren&#39;t ready yet are replaced by <strong>stubs</strong>.</p>
<ul>
<li><strong>Good:</strong> the main flow of the application is tested early.</li>
<li><strong>Bad:</strong> many stubs to write; the lowest-level parts are tested last.</li>
</ul>
<h4>Bottom-up</h4>
<p>Start with the <strong>lowest-level parts</strong> (for example, the database layer and the payment client) and work upwards. The parts above that don&#39;t exist yet are replaced by <strong>drivers</strong>.</p>
<ul>
<li><strong>Good:</strong> the foundations are solid before anything is built on them.</li>
<li><strong>Bad:</strong> the full user flow can only be seen at the end.</li>
</ul>
<h4>Sandwich (hybrid)</h4>
<p>Do top-down and bottom-up <strong>at the same time</strong>, meeting in the middle. It is the most common choice for large systems, because different teams can work in parallel.</p>
<h2>Stubs and drivers (and other test doubles)</h2>
<p>When you test part of a system, the parts it depends on may not be ready, or may be slow, costly or unreliable (no one wants a test that charges a real card). So we replace them with stand-ins, called <strong>test doubles</strong>:</p>
<table tabindex="0">
<thead>
<tr>
<th>Test double</th>
<th>What it does</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Stub</strong></td>
<td>Stands in for a part that is <em>called</em>; returns fixed answers</td>
<td>A fake payment service that always replies &quot;payment approved&quot;</td>
</tr>
<tr>
<td><strong>Driver</strong></td>
<td>Stands in for a part that <em>calls</em>; sends test requests</td>
<td>A small script that calls the order service with sample orders</td>
</tr>
<tr>
<td><strong>Mock</strong></td>
<td>A stub that also checks <em>how</em> it was called</td>
<td>Checks the order service called payment exactly once, with 450</td>
</tr>
<tr>
<td><strong>Fake</strong></td>
<td>A simple working version of a real part</td>
<td>An in-memory database instead of the real one</td>
</tr>
</tbody></table>
<p>Test doubles make tests fast and repeatable. But remember: <strong>a test against a stub only proves your code works with the stub.</strong> At some point you also need tests against the real thing, or against a realistic copy in a test environment.</p>
<h2>Two kinds of integration testing</h2>
<ul>
<li><strong>Component integration testing</strong> checks parts <em>inside</em> your system: the order service with your own database and payment service.</li>
<li><strong>System integration testing (SIT)</strong> checks your system with <em>outside</em> systems: the real payment provider&#39;s sandbox, an SMS gateway, a maps API.</li>
</ul>
<p>System integration tests are usually slower and run less often, because outside systems can be slow, rate-limited or briefly unavailable.</p>
<h2>What to check at every integration point</h2>
<p>For each arrow in your diagram, check:</p>
<ol>
<li><strong>The happy path:</strong> a normal request gets the right response, and data arrives correctly on the other side.</li>
<li><strong>Data format and units:</strong> names, types, dates, currencies, time zones and character encoding all match.</li>
<li><strong>Errors:</strong> what happens when the other side returns an error, is down, or sends something unexpected.</li>
<li><strong>Timeouts and slowness:</strong> the caller waits a sensible time and recovers cleanly.</li>
<li><strong>Retries and duplicates:</strong> if a request is retried, does the customer get charged twice?</li>
<li><strong>Security:</strong> the call is authenticated, and no secrets or personal data leak in the messages or logs.</li>
</ol>
<p>Teams with many services also use <strong>contract testing</strong> (with tools such as Pact): each service writes down what it expects from the others, and both sides test against that agreement, so a change on one side can&#39;t silently break the other.</p>
<h2>Integration testing in practice</h2>
<ul>
<li>Integration tests usually run <strong>in the CI pipeline</strong>, after the unit tests pass.</li>
<li>Tools like <strong>Testcontainers</strong> start a real database or message queue in a container just for the tests, then throw it away.</li>
<li><strong>API tests</strong> (with tools like Postman or pytest with requests) are a natural way to test integration points directly.</li>
<li>Keep them <strong>fewer than unit tests and more than end-to-end tests</strong>, as the <a href="https://provalab.io/blog/types-of-software-testing/">testing pyramid</a> suggests.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/system-testing/">Part 13</a></strong> we move up a level to <strong>system testing</strong>: testing the whole application, end to end, the way a real user would.</p>
<p>ProvaLab runs integration and API tests on every change, records each result next to its test case, and sends failures back to your coding agent with the request, the response and the logs. See it in the <a href="https://provalab.io/#stage-infra">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Manual vs automated testing: what to automate, and what not to</title>
      <link>https://provalab.io/blog/manual-vs-automated-testing/</link>
      <guid isPermaLink="true">https://provalab.io/blog/manual-vs-automated-testing/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>test-automation</category>
      <description>When a person should test and when a machine should, how to decide what to automate, what automation really costs, and the tools teams use.</description>
      <content:encoded><![CDATA[<p>In <a href="https://provalab.io/blog/types-of-software-testing/">Part 2</a> we covered the four levels of testing. Every one of those tests can be done in two ways: <strong>by a person</strong>, or <strong>by code</strong>. Choosing well between the two is one of the most useful skills in testing, and one of the most common places teams go wrong.</p>
<h2>What is manual testing?</h2>
<p><strong>In manual testing, a person uses the software and checks the result themselves.</strong> They click, type, look and decide whether what they see is right.</p>
<p>There are two main styles:</p>
<ul>
<li><strong>Scripted manual testing:</strong> the tester follows written test cases step by step. &quot;Open the cart. Apply code SAVE10. Check the total drops by 10%.&quot;</li>
<li><strong>Exploratory testing:</strong> the tester explores freely, using experience and curiosity. &quot;What happens if I apply two discount codes? Or change the quantity to minus one?&quot;</li>
</ul>
<p>Manual testing is quick to start. You need no code, just the app and a sharp eye.</p>
<h2>What is automated testing?</h2>
<p><strong>In automated testing, code performs the checks.</strong> A test script opens the app, does the steps, and compares the result with what was expected, without a person watching.</p>
<pre tabindex="0"><code class="language-python">def test_discount_code_reduces_total(cart):
    cart.add(&quot;Margherita pizza&quot;, price=200)
    cart.apply_code(&quot;SAVE10&quot;)
    assert cart.total == 180
</code></pre>
<p>Once written, that check runs in a second, as often as you like: on every code change, every night, on every browser. That repeatability is the whole point.</p>
<h2>Where each one shines</h2>
<p><img src="https://provalab.io/blog/basics/manual-vs-automated.svg" alt="Manual testing discovers what you didn't know: exploring new features, judging design and ease of use, testing like a curious user. Automated testing checks what you already know, again and again: regression checks on every change, the same flow on many browsers, many data combinations." width="880" height="430" loading="lazy"></p>
<p>The key idea: <strong>automated tests mostly guard what you already know works.</strong> They are excellent at catching <strong>regressions</strong>, things that used to work and broke. <strong>Most new kinds of bugs are found by people</strong>, exploring in ways nobody wrote a script for.</p>
<h2>Side by side</h2>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>Manual</th>
<th>Automated</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Cost to start</strong></td>
<td>Low: just the app and a person</td>
<td>Higher: someone has to write the code</td>
</tr>
<tr>
<td><strong>Cost to repeat</strong></td>
<td>The same effort every time</td>
<td>Nearly free once written</td>
</tr>
<tr>
<td><strong>Speed</strong></td>
<td>Hours or days for a big suite</td>
<td>Minutes, even for thousands of checks</td>
</tr>
<tr>
<td><strong>Consistency</strong></td>
<td>People get tired and skip steps</td>
<td>Exactly the same steps every time</td>
</tr>
<tr>
<td><strong>Finds new kinds of bugs</strong></td>
<td>Yes, especially exploratory testing</td>
<td>Rarely; it checks what it was told</td>
</tr>
<tr>
<td><strong>Judges design and ease of use</strong></td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td><strong>Needs upkeep</strong></td>
<td>Test cases need updating</td>
<td>Test code needs updating when the app changes</td>
</tr>
</tbody></table>
<h2>What should you automate?</h2>
<p>Ask three questions about any test:</p>
<p><img src="https://provalab.io/blog/basics/should-i-automate.svg" alt="Should I automate this test? Question 1: will you run it again and again? If no, test it manually. Question 2: is the feature stable? If no, test manually now and automate later. Question 3: is the result a clear pass or fail? If no, keep it manual because it needs human judgement. Yes to all three: automate it." width="880" height="520" loading="lazy"></p>
<p><strong>Good candidates for automation:</strong></p>
<ul>
<li><strong>Regression tests:</strong> everything that must keep working after each change.</li>
<li><strong>Smoke tests:</strong> &quot;does the app start, can people sign in?&quot; before every release.</li>
<li><strong>Critical journeys:</strong> sign-up, sign-in, checkout and payment.</li>
<li><strong>Cross-browser and device checks:</strong> the same flow on Chrome, Safari, Android and iPhone.</li>
<li><strong>Data-heavy tests:</strong> the same form with a hundred different inputs.</li>
<li><strong>API tests:</strong> fast, stable and easy to check.</li>
</ul>
<p><strong>Better kept manual:</strong></p>
<ul>
<li><strong>Brand-new features</strong> still changing every week.</li>
<li><strong>Usability and design:</strong> &quot;is this confusing?&quot; needs a person.</li>
<li><strong>Exploratory testing:</strong> looking for the bugs nobody imagined.</li>
<li><strong>One-off checks</strong> you will never repeat.</li>
</ul>
<h2>Automation isn&#39;t free</h2>
<p>Two hidden costs catch many teams out:</p>
<ol>
<li><strong>Maintenance.</strong> When the app changes, tests that depend on it must change too. A button renamed from &quot;Buy&quot; to &quot;Checkout&quot; can break dozens of tests at once.</li>
<li><strong>Flaky tests.</strong> A flaky test sometimes passes and sometimes fails without any code change, often because of timing or test data. Flaky tests are dangerous: once people stop trusting red results, they start ignoring real failures too.</li>
</ol>
<p>Good automation is written like good code: readable, small and reliable. A few trusted tests beat hundreds that nobody believes.</p>
<h2>Three myths about automation</h2>
<ol>
<li><strong>&quot;Automation will replace manual testers.&quot;</strong> It replaces repetitive <em>checking</em>, not thinking. Exploring, questioning and judging still need people.</li>
<li><strong>&quot;We should automate 100% of our tests.&quot;</strong> Some tests cost more to automate and maintain than they will ever save. Automate where it pays back.</li>
<li><strong>&quot;Once written, automated tests run forever.&quot;</strong> They need the same care as the product code they test.</li>
</ol>
<h2>Common automation tools</h2>
<table tabindex="0">
<thead>
<tr>
<th>What you test</th>
<th>Popular tools</th>
</tr>
</thead>
<tbody><tr>
<td>Unit tests</td>
<td>pytest (Python), JUnit (Java), Jest (JavaScript)</td>
</tr>
<tr>
<td>Web UI</td>
<td>Playwright, Selenium, Cypress</td>
</tr>
<tr>
<td>Mobile apps</td>
<td>Appium, Espresso (Android), XCUITest (iOS)</td>
</tr>
<tr>
<td>APIs</td>
<td>Postman, REST Assured, pytest with requests</td>
</tr>
<tr>
<td>Load and performance</td>
<td>k6, JMeter, Locust</td>
</tr>
</tbody></table>
<p>Automated tests usually run in a <strong>CI pipeline</strong> (continuous integration): every time someone changes the code, the tests run automatically and report the result before the change is merged.</p>
<h2>Where AI fits in</h2>
<p>AI coding agents can now write automated tests from a plain description, run them and read the failures. That makes automation much cheaper to start. It doesn&#39;t change the rules above: you still decide <em>what</em> is worth automating, and a person still explores and judges.</p>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/software-testing-life-cycle-stlc/">Part 4</a></strong> we put it all in order with the <strong>Software Testing Life Cycle (STLC)</strong>: the six phases of testing, from the first requirement to the release.</p>
<p>ProvaLab runs your automated tests on real browsers and devices, keeps every result next to its test case, and sends failures back to your coding agent with a video and logs. See it in the <a href="https://provalab.io/#stage-infra">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>MCP, plugins and skills, explained: how AI agents use ProvaLab</title>
      <link>https://provalab.io/blog/mcp-plugins-and-skills-explained/</link>
      <guid isPermaLink="true">https://provalab.io/blog/mcp-plugins-and-skills-explained/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>mcp</category>
      <category>claude-code</category>
      <category>ai-agents</category>
      <description>What MCP is, how Claude Code plugins and skills fit around it, what changed in the new stateless MCP, and how your coding agent can use ProvaLab through it.</description>
      <content:encoded><![CDATA[<p>Three words keep coming up when people talk about AI coding agents: <strong>MCP</strong>, <strong>plugins</strong> and <strong>skills</strong>. They are related, but they do different jobs. This post explains each one in plain language, shows what changed in the newest version of MCP, and walks through how your agent uses them to work with ProvaLab.</p>
<h2>What is MCP?</h2>
<p>The <strong>Model Context Protocol (MCP)</strong> is an open standard, introduced by Anthropic in November 2024, for connecting AI apps to the tools and data they need.</p>
<p>Before MCP, every AI app needed its own custom integration for every tool. MCP replaces that with one standard plug. A tool is wrapped once as an <strong>MCP server</strong>, and any app that speaks MCP, an <strong>MCP client</strong>, can use it. People often compare it to USB-C: one shape of connector for many devices.</p>
<p><img src="https://provalab.io/blog/mcp/what-is-mcp.svg" alt="An AI app (the MCP client) connects over MCP to several MCP servers, such as ProvaLab, GitHub and a database. Each server offers tools, resources and prompts." width="880" height="460" loading="lazy"></p>
<p>An MCP server can offer three kinds of things:</p>
<ul>
<li><strong>Tools</strong>: actions the model can take, such as &quot;run this test suite&quot; or &quot;create an issue&quot;.</li>
<li><strong>Resources</strong>: data the model can read, such as a file or a test report.</li>
<li><strong>Prompts</strong>: ready-made instructions a user can pick, such as &quot;summarise last night&#39;s run&quot;.</li>
</ul>
<p>The important part: the <strong>model decides</strong> which tool to call and when, based on what you asked. You don&#39;t click buttons in another dashboard; you ask in plain language, and the agent does the work through MCP.</p>
<h2>What is a skill?</h2>
<p>MCP gives an agent <em>access</em> to a tool. It does not teach the agent <em>how to use it well</em>. That is what a <strong>skill</strong> is for.</p>
<p>A skill is a folder with a <code>SKILL.md</code> file: a short name, a one-line description of when to use it, and step-by-step instructions. The agent only sees the name and description until your request matches. Then it loads the full instructions. So you can have many skills installed without filling up the agent&#39;s memory.</p>
<p>For example, ProvaLab&#39;s <code>triage-failures</code> skill tells the agent: read the actual error text before describing a failure, group failures by cause rather than by test, and flag skipped tests. Without it, an agent with the same MCP access might just list twenty red tests that all come from one broken selector.</p>
<h2>What is a plugin?</h2>
<p>A <strong>plugin</strong> is the package that ships it all in one install. A Claude Code plugin can bundle:</p>
<ul>
<li><strong>Skills</strong>: the know-how.</li>
<li><strong>Slash commands</strong>: shortcuts like <code>/provalab-test</code>.</li>
<li><strong>MCP servers</strong>: the connection, configured in a <code>.mcp.json</code> file.</li>
<li>Optionally, sub-agents and hooks.</li>
</ul>
<p><img src="https://provalab.io/blog/mcp/plugin-skills-mcp.svg" alt="The ProvaLab plugin bundles skills (the know-how), slash commands (shortcuts) and an MCP connection (the access). The connection reaches the ProvaLab MCP server, where each product feature is an MCP tool." width="880" height="500" loading="lazy"></p>
<p>In one line: <strong>MCP is the access, skills are the know-how, and the plugin is the box they come in.</strong></p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>What it is</th>
<th>What it does</th>
<th>ProvaLab example</th>
</tr>
</thead>
<tbody><tr>
<td><strong>MCP server</strong></td>
<td>A service that speaks the MCP standard</td>
<td>Gives the agent tools and data</td>
<td><code>mcp.provalab.io</code></td>
</tr>
<tr>
<td><strong>MCP tool</strong></td>
<td>One action on that server</td>
<td>Does one job when the agent calls it</td>
<td><code>execute_pytest</code>, <code>tms_get_test_run_failures</code></td>
</tr>
<tr>
<td><strong>Skill</strong></td>
<td>A <code>SKILL.md</code> instruction file</td>
<td>Teaches the agent when and how to use the tools</td>
<td><code>run-tests</code>, <code>load-test</code>, <code>triage-failures</code></td>
</tr>
<tr>
<td><strong>Slash command</strong></td>
<td>A saved prompt</td>
<td>A shortcut you type</td>
<td><code>/provalab-test checkout</code></td>
</tr>
<tr>
<td><strong>Plugin</strong></td>
<td>A package</td>
<td>Installs all of the above at once</td>
<td><code>provalab</code></td>
</tr>
</tbody></table>
<h2>&quot;MCP 1.0&quot; vs &quot;MCP 2.0&quot;: what changed?</h2>
<p>Officially, MCP versions are named by date, not by number. The original specification is <strong>2024-11-05</strong>, and it was revised in 2025-03-26, 2025-06-18 and 2025-11-25. The <strong>2026-07-28</strong> revision changed the protocol&#39;s core design so much that many people call it the next generation of MCP, or &quot;MCP 2.0&quot;. Here, &quot;MCP 1.0&quot; means the original design that ran through 2025-11-25.</p>
<p><img src="https://provalab.io/blog/mcp/mcp-old-vs-new.svg" alt="Left: the original MCP opens a session with an initialize handshake and a session id, and the server can ask questions back on an open stream. Right: in the 2026-07-28 revision every request stands alone, carrying its own version and capabilities; when the server needs more input it returns input_required and the client retries." width="880" height="540" loading="lazy"></p>
<p>The headline change: <strong>MCP became stateless.</strong> In the original design, a client opened a session with an <code>initialize</code> handshake, got a session id, and the server had to remember every connected client. In the 2026-07-28 revision there is no handshake and no session id. Every request carries its own protocol version and capabilities, so any server instance can answer any request. That makes MCP servers much easier to run at scale, behind load balancers and gateways.</p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>&quot;MCP 1.0&quot; (original design)</th>
<th>&quot;MCP 2.0&quot; (2026-07-28 revision)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Connection</strong></td>
<td><code>initialize</code> handshake, then a session</td>
<td>No handshake; each request is self-contained</td>
</tr>
<tr>
<td><strong>Session id</strong></td>
<td><code>Mcp-Session-Id</code> header ties calls together</td>
<td>Removed; tools pass their own handles if they need state</td>
</tr>
<tr>
<td><strong>Learning what a server can do</strong></td>
<td>During the handshake</td>
<td>Optional <code>server/discover</code> call</td>
</tr>
<tr>
<td><strong>Server needs more input</strong></td>
<td>Server sends a request back on an open stream</td>
<td>Server returns <code>input_required</code>; client retries with the answer (Multi Round-Trip Requests)</td>
</tr>
<tr>
<td><strong>Routing</strong></td>
<td>Gateways must read the request body</td>
<td><code>Mcp-Method</code> and <code>Mcp-Name</code> headers on each HTTP request</td>
</tr>
<tr>
<td><strong>Tool lists</strong></td>
<td>Fetched again and again</td>
<td>Cacheable, with <code>ttlMs</code> and <code>cacheScope</code> hints</td>
</tr>
<tr>
<td><strong>Long-running jobs</strong></td>
<td>Experimental tasks inside the core</td>
<td>Tasks moved to an official extension</td>
</tr>
<tr>
<td><strong>Deprecated</strong></td>
<td>—</td>
<td>Roots, Sampling and Logging, the old HTTP+SSE transport, and Dynamic Client Registration (in favour of Client ID Metadata Documents)</td>
</tr>
</tbody></table>
<p>Deprecated features keep working for at least twelve months under MCP&#39;s new deprecation policy, so existing servers don&#39;t break overnight. Teams that relied on session ids will have some migration work. The details are in the <a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog">official changelog</a> and the <a href="https://blog.modelcontextprotocol.io/posts/2026-07-28/">release announcement</a>.</p>
<p><strong>What this means for you as a user:</strong> very little changes day to day. You still install a server or plugin and ask in plain language. The difference shows up as faster, more reliable connections, because the servers behind them are simpler to run.</p>
<h2>How to use ProvaLab through MCP</h2>
<p>ProvaLab&#39;s features are exposed as tools on one MCP server at <code>https://mcp.provalab.io/mcp</code>. Running tests, reading results, triaging failures, load tests and security scans can all be driven by your coding agent.</p>
<h3>Option 1: the Claude Code plugin (recommended)</h3>
<p>The plugin gives you the MCP connection plus the skills and slash commands.</p>
<pre tabindex="0"><code>/plugin marketplace add provalab-dev/claude-code-plugin
/plugin install provalab@provalab-marketplace
</code></pre>
<p>Then sign in once. The sign-in works on any machine, including SSH sessions and CI, because you approve it in a browser on any device:</p>
<pre tabindex="0"><code>curl -fsSL https://plugins.provalab.io/install.sh | sh
provalab-auth login
</code></pre>
<p>Restart Claude Code, then check the connection with <code>claude mcp list</code>. You should see the ProvaLab server marked as connected.</p>
<h3>Option 2: the claude.ai connector</h3>
<p>If you work in claude.ai rather than Claude Code, add the ProvaLab connector instead. It points at the same server and gives you the same tools. Use either the connector or the plugin, not both.</p>
<h3>What you can ask</h3>
<p>Once connected, just ask. The skills pick up your intent:</p>
<ul>
<li><em>&quot;Run the login tests for the checkout project&quot;</em>: runs the suite on ProvaLab&#39;s cloud browsers and records the run in Test Management.</li>
<li><em>&quot;Generate tests for <a href="https://staging.example.com/signup">https://staging.example.com/signup</a> and run them&quot;</em>: writes the tests from the live page, then runs them.</li>
<li><em>&quot;Why did last night&#39;s run fail?&quot;</em>: reads the failures, groups them by cause and explains them.</li>
<li><em>&quot;Load test our API up to 200 users&quot;</em>: writes the load test from your repo, shows the cost, and runs it when you say go.</li>
</ul>
<p>Or use the slash commands: <code>/provalab-test</code>, <code>/provalab-status</code>, <code>/provalab-results</code> and <code>/provalab-load</code>.</p>
<p><img src="https://provalab.io/blog/mcp/provalab-flow.svg" alt="One request end to end: you ask Claude Code to run the checkout tests, the run-tests skill decides how, the agent calls the execute_pytest tool over MCP, ProvaLab runs the tests on cloud browsers and records the run, and the result comes back so the agent can explain or fix the failures." width="880" height="330" loading="lazy"></p>
<h3>A few tips</h3>
<ul>
<li><strong>Set your project.</strong> If your organization has more than one project, set <code>PROVALAB_PROJECT_ID</code> (and <code>PROVALAB_ORG_ID</code> if you belong to several organizations) so results land in the right place.</li>
<li><strong>Let the agent fix, but you approve.</strong> The real gain comes when failures go straight back to the agent that wrote the code. Keep a person on the final approval.</li>
<li><strong>Allow the tools by name.</strong> Plugin tools are named <code>mcp__plugin_provalab_api__&lt;tool&gt;</code>. Use that pattern in your permission rules if you want to skip repeated approval prompts.</li>
</ul>
<h2>The short version</h2>
<ul>
<li><strong>MCP</strong> is the standard plug that lets AI agents use tools.</li>
<li><strong>Skills</strong> teach the agent how to use those tools well.</li>
<li><strong>Plugins</strong> package both into one install.</li>
<li>The <strong>2026-07-28</strong> revision (&quot;MCP 2.0&quot;) made MCP stateless and easier to scale, without changing how you use it.</li>
<li>With the <strong>ProvaLab plugin</strong>, your coding agent can run tests, read results, triage failures and run load tests, all from a plain-language request.</li>
</ul>
<p>Want to see it working? Watch the <a href="https://provalab.io/#stage-infra">two-minute product tour</a> or <a href="https://provalab.io/demo">book a 30-minute demo</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Performance testing and its types: load, stress, spike, soak, volume and scalability</title>
      <link>https://provalab.io/blog/performance-testing-types/</link>
      <guid isPermaLink="true">https://provalab.io/blog/performance-testing-types/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>performance-testing</category>
      <category>load-testing</category>
      <description>What performance testing is, the six main types and when to use each, the metrics that matter (and why averages lie), how to run a performance test step by step, and the common mistakes.</description>
      <content:encoded><![CDATA[<p>Your food delivery app passes every functional test. Then Friday evening arrives, thousands of hungry people open the app at once, pages take ten seconds to load, payments time out, and orders are lost.</p>
<p>The app <em>worked</em>. It just didn&#39;t work <strong>fast enough, for enough people at once</strong>. Finding that out before your customers do is the job of <strong>performance testing</strong>.</p>
<h2>What is performance testing?</h2>
<p><strong>Performance testing checks how fast, stable and scalable a system is under different amounts of use.</strong> It is a <a href="https://provalab.io/blog/system-testing/">non-functional</a> type of testing: it doesn&#39;t ask &quot;does checkout work?&quot; but &quot;does checkout still work, quickly, when 2,000 people use it at the same time?&quot;</p>
<p>It answers questions like:</p>
<ul>
<li>How fast does the app respond with a normal number of users?</li>
<li>How many users can it handle before it slows down or fails?</li>
<li>What happens during a sudden rush?</li>
<li>Does it stay healthy after running for hours or days?</li>
<li>If we add servers, can we serve more users?</li>
</ul>
<h2>The six types of performance testing</h2>
<p>Each type answers a different question, and each has its own shape of load over time:</p>
<p><img src="https://provalab.io/blog/basics/performance-test-types.svg" alt="Six small charts of load over time. Load test: ramp up to the expected peak and hold it. Stress test: keep increasing beyond the peak until the system breaks. Spike test: a sudden jump to many users and back down. Soak test: a moderate, steady load held for hours. Volume test: normal users with a very large amount of data. Scalability test: increase users in steps while adding servers." width="880" height="540" loading="lazy"></p>
<h3>1. Load testing</h3>
<p><strong>Can the system handle the load we expect?</strong></p>
<p>Ramp up to the expected number of users, for example the Friday dinner peak, hold it for a while, and check that response times and error rates stay within your targets. This is the most common performance test, and the one to run before every major release.</p>
<p><em>Example:</em> 2,000 users browsing menus and placing orders for 15 minutes; 95% of pages must load in under 1 second, with less than 1% errors.</p>
<h3>2. Stress testing</h3>
<p><strong>Where does the system break, and how does it break?</strong></p>
<p>Keep increasing the load <strong>beyond</strong> the expected peak until something fails. The goal isn&#39;t to pass; it&#39;s to learn the breaking point and the failure mode. Does the app slow down gracefully and show a friendly &quot;busy, please wait&quot; message, or does it crash and lose orders? Does it recover by itself when the load drops?</p>
<h3>3. Spike testing</h3>
<p><strong>Can the system survive a sudden rush?</strong></p>
<p>Jump from a small load to a very large one in seconds, then back down. Real spikes happen: a push notification about a 50% discount, a TV advert, a cricket match ending. Auto-scaling systems in particular need this test, because adding servers takes time and the spike arrives first.</p>
<h3>4. Soak testing (endurance testing)</h3>
<p><strong>Does the system stay healthy over a long time?</strong></p>
<p>Run a moderate, realistic load for many hours, or even days. Some problems only appear slowly: memory that is never freed (a <strong>memory leak</strong>), database connections that aren&#39;t returned, log files that fill the disk, or caches that grow forever. A one-hour test will never find these.</p>
<h3>5. Volume testing</h3>
<p><strong>Does the system stay fast with a lot of data?</strong></p>
<p>Keep the number of users normal, but fill the system with a large amount of <strong>data</strong>: millions of past orders, a customer with 5,000 orders in their history, a restaurant with a huge menu. Queries that are instant on a small test database can be very slow on a full-sized one.</p>
<h3>6. Scalability testing</h3>
<p><strong>Does adding resources let us serve more users?</strong></p>
<p>Increase the load in steps while adding servers or capacity, and check that the system&#39;s capacity grows with them. If doubling the servers doesn&#39;t come close to doubling the capacity, something, often the database, is a bottleneck that more servers can&#39;t fix.</p>
<h3>The six types at a glance</h3>
<table tabindex="0">
<thead>
<tr>
<th>Type</th>
<th>Question</th>
<th>Load shape</th>
<th>Typical length</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Load</strong></td>
<td>Can it handle the expected load?</td>
<td>Ramp to the expected peak, hold</td>
<td>15–60 minutes</td>
</tr>
<tr>
<td><strong>Stress</strong></td>
<td>Where and how does it break?</td>
<td>Keep increasing past the peak</td>
<td>Until it breaks</td>
</tr>
<tr>
<td><strong>Spike</strong></td>
<td>Does it survive a sudden rush?</td>
<td>Sudden jump, then drop</td>
<td>Minutes</td>
</tr>
<tr>
<td><strong>Soak</strong></td>
<td>Does it stay healthy over time?</td>
<td>Moderate and steady</td>
<td>Hours to days</td>
</tr>
<tr>
<td><strong>Volume</strong></td>
<td>Is it fast with lots of data?</td>
<td>Normal users, huge data</td>
<td>Varies</td>
</tr>
<tr>
<td><strong>Scalability</strong></td>
<td>Do more resources mean more capacity?</td>
<td>Steps up, adding servers</td>
<td>Varies</td>
</tr>
</tbody></table>
<p>You may also hear <strong>capacity testing</strong> (finding the maximum number of users that still meets your targets), which sits between load and stress testing.</p>
<h2>The metrics that matter</h2>
<table tabindex="0">
<thead>
<tr>
<th>Metric</th>
<th>What it means</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Response time</strong></td>
<td>How long a request takes, from sending it to getting the answer</td>
</tr>
<tr>
<td><strong>Throughput</strong></td>
<td>How many requests the system handles per second</td>
</tr>
<tr>
<td><strong>Error rate</strong></td>
<td>The share of requests that fail or time out</td>
</tr>
<tr>
<td><strong>Concurrent users</strong></td>
<td>How many people are using the system at the same moment</td>
</tr>
<tr>
<td><strong>Resource use</strong></td>
<td>CPU, memory, disk and network on the servers, and database connections</td>
</tr>
</tbody></table>
<h3>Why averages lie: use percentiles</h3>
<p>The <strong>average</strong> response time hides your slowest users:</p>
<p><img src="https://provalab.io/blog/basics/percentiles.svg" alt="An example run: most requests take 200 to 400 milliseconds, but the slowest few take 2 to 4 seconds. The average, about 450 ms, looks fine. The p95 is about 1.8 seconds, meaning 1 in 20 users waits that long or longer; the p99 is about 3.5 seconds." width="880" height="380" loading="lazy"></p>
<p>That&#39;s why performance targets use <strong>percentiles</strong>:</p>
<ul>
<li><strong>p50 (median):</strong> half of requests are faster than this.</li>
<li><strong>p95:</strong> 95% of requests are faster than this; 1 in 20 users waits longer.</li>
<li><strong>p99:</strong> 99% are faster; 1 in 100 waits longer.</li>
</ul>
<p>When thousands of people use your app, &quot;1 in 20&quot; is a lot of unhappy customers. A good target sounds like: <strong>&quot;p95 under 1 second, error rate under 1%, at 2,000 users.&quot;</strong></p>
<h2>How to run a performance test, step by step</h2>
<ol>
<li><strong>Set clear goals.</strong> Which pages and APIs, how many users, and what response time and error rate count as a pass.</li>
<li><strong>Model realistic behaviour.</strong> Real users don&#39;t all hit &quot;Pay&quot; at the same millisecond. Mix the journeys (browse, search, order) in realistic proportions, and add <strong>think time</strong>: the pause while a user reads a menu.</li>
<li><strong>Use a production-like environment.</strong> Same server sizes, same configuration and a realistic amount of data. Results from a tiny test server don&#39;t predict production.</li>
<li><strong>Prepare test data.</strong> Enough test accounts, restaurants and codes that thousands of virtual users don&#39;t all fight over the same row in the database.</li>
<li><strong>Write the test script.</strong> Here is a load test written for k6, a popular open-source tool:</li>
</ol>
<pre tabindex="0"><code class="language-javascript">import http from &#39;k6/http&#39;;
import { check, sleep } from &#39;k6&#39;;

export const options = {
  stages: [
    { duration: &#39;2m&#39;, target: 200 },  // ramp up to 200 users
    { duration: &#39;5m&#39;, target: 200 },  // hold
    { duration: &#39;1m&#39;, target: 0 },    // ramp down
  ],
  thresholds: {
    http_req_duration: [&#39;p(95)&lt;1000&#39;], // 95% of requests under 1 second
    http_req_failed: [&#39;rate&lt;0.01&#39;],     // less than 1% errors
  },
};

export default function () {
  const res = http.get(&#39;https://staging.example.com/api/restaurants?q=pizza&#39;);
  check(res, { &#39;status is 200&#39;: (r) =&gt; r.status === 200 });
  sleep(1); // think time
}
</code></pre>
<ol start="6">
<li><strong>Run it, and watch the servers too.</strong> Response times tell you <em>that</em> it&#39;s slow; CPU, memory and database graphs tell you <em>why</em>.</li>
<li><strong>Find the bottleneck, fix it, and run it again.</strong> Performance work is a loop.</li>
</ol>
<p><strong>Only ever test systems you own or have permission to test.</strong> Sending heavy load to someone else&#39;s website or API can look like an attack, and can take their service down.</p>
<h2>Common bottlenecks</h2>
<ul>
<li><strong>Slow database queries</strong>, often a missing index.</li>
<li><strong>The &quot;N+1&quot; problem:</strong> loading a list of orders, then making a separate database call for each one.</li>
<li><strong>Too few database connections</strong> in the connection pool.</li>
<li><strong>No caching</strong> of data that rarely changes, like menus.</li>
<li><strong>Slow or rate-limited outside services</strong>, such as a payment provider or a maps API.</li>
<li><strong>Large pages and images</strong> that take long to download on mobile networks.</li>
</ul>
<h2>Common mistakes</h2>
<ul>
<li><strong>Only looking at averages</strong> instead of p95 and p99.</li>
<li><strong>Testing from an overloaded load generator.</strong> If the machine <em>sending</em> the traffic is at 100% CPU, you&#39;re measuring it, not your app.</li>
<li><strong>Unrealistic scenarios:</strong> no think time, every user doing the same thing, or a test database with ten rows.</li>
<li><strong>Testing once, just before launch.</strong> Run load tests regularly, so you see when a change makes things slower.</li>
<li><strong>Ignoring errors</strong> because response times looked good. Fast errors are still errors.</li>
</ul>
<h2>Popular tools</h2>
<table tabindex="0">
<thead>
<tr>
<th>Tool</th>
<th>Good for</th>
</tr>
</thead>
<tbody><tr>
<td><strong>k6</strong></td>
<td>Scripted load tests in JavaScript; good for developers and CI</td>
</tr>
<tr>
<td><strong>Apache JMeter</strong></td>
<td>A long-standing, GUI-based tool with many plugins</td>
</tr>
<tr>
<td><strong>Gatling</strong></td>
<td>High-load tests written as code (Scala, Java, Kotlin)</td>
</tr>
<tr>
<td><strong>Locust</strong></td>
<td>Load tests written in Python</td>
</tr>
</tbody></table>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/test-runs-and-test-results/">Part 17</a></strong> we look at <strong>test runs and test results</strong>: what happens each time a set of tests is run, and how to read the results.</p>
<p>ProvaLab&#39;s load testing runs k6 tests from the cloud, shows response time percentiles, throughput and errors live, and can find your app&#39;s breaking point step by step. Your coding agent can write the test from your code and explain the slow spots afterwards. See it in the <a href="https://provalab.io/#stage-load">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>QA vs QC vs testing, and verification vs validation, explained</title>
      <link>https://provalab.io/blog/qa-vs-qc-verification-vs-validation/</link>
      <guid isPermaLink="true">https://provalab.io/blog/qa-vs-qc-verification-vs-validation/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>quality-assurance</category>
      <description>The difference between quality assurance, quality control and testing, and between verification and validation, with simple definitions, examples, the V-model, and comparison tables.</description>
      <content:encoded><![CDATA[<p>Two pairs of terms cause more confusion in software quality than any others: <strong>QA vs QC</strong> (and where testing fits), and <strong>verification vs validation</strong>. They&#39;re also near the top of every list of QA interview questions.</p>
<p>The short version:</p>
<ul>
<li><strong>QA</strong> improves the <strong>process</strong> to <em>prevent</em> defects. <strong>QC</strong> checks the <strong>product</strong> to <em>find</em> them. <strong>Testing</strong> is the main way QC finds them.</li>
<li><strong>Verification</strong> asks <em>&quot;are we building the product right?&quot;</em> <strong>Validation</strong> asks <em>&quot;are we building the right product?&quot;</em></li>
</ul>
<p>Now the longer version, with examples.</p>
<h2>QA vs QC vs testing</h2>
<p><img src="https://provalab.io/blog/basics/qa-qc-testing.svg" alt="Quality management is the umbrella. Quality assurance focuses on the process and preventing defects: coding and testing standards, definitions of done and checklists, process reviews and audits, training and tools, learning from past defects. Quality control focuses on the product and finding defects: inspections and reviews of finished work, and testing, which means running and examining the software to find defects." width="880" height="440" loading="lazy"></p>
<h3>Quality assurance (QA)</h3>
<p><strong>Quality assurance is about the process.</strong> Its goal is to <strong>prevent</strong> defects by making sure the team works in a way that produces good software in the first place.</p>
<p>QA activities in our food delivery app team might include:</p>
<ul>
<li>Agreeing <strong>coding standards</strong> and a <strong>definition of done</strong> (&quot;a story is done when it has tests, a code review and updated docs&quot;).</li>
<li>Using <strong>templates</strong> for <a href="https://provalab.io/blog/how-to-write-a-test-plan/">test plans</a> and <a href="https://provalab.io/blog/how-to-write-a-bug-report/">bug reports</a>.</li>
<li>Making <strong>code review</strong> mandatory for every change.</li>
<li><strong>Learning from defects:</strong> &quot;we had five payment bugs this quarter; let&#39;s add a payment checklist to code reviews.&quot;</li>
<li><strong>Training</strong> the team, and choosing good tools.</li>
</ul>
<p>QA is <strong>proactive</strong>, runs through the <strong>whole project</strong>, and is everyone&#39;s responsibility.</p>
<h3>Quality control (QC)</h3>
<p><strong>Quality control is about the product.</strong> Its goal is to <strong>find</strong> defects in what has been built, before it reaches users.</p>
<p>QC activities include:</p>
<ul>
<li><strong>Reviewing</strong> finished work: requirements, designs and code.</li>
<li><strong>Testing</strong> the software: everything from <a href="https://provalab.io/blog/first-automated-test-pytest/">unit tests</a> to <a href="https://provalab.io/blog/system-testing/">system tests</a>.</li>
<li><strong>Checking</strong> releases against <a href="https://provalab.io/blog/software-testing-life-cycle-stlc/">exit criteria</a> before they ship.</li>
</ul>
<p>QC is <strong>reactive</strong>: it looks at a product that already exists and asks &quot;is this right?&quot;</p>
<h3>Testing</h3>
<p><strong>Testing is one part of quality control:</strong> running and examining the software to find defects and to provide information about its quality. Everything in this series so far, from test cases to regression runs, is testing.</p>
<h3>The differences at a glance</h3>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>Quality assurance</th>
<th>Quality control</th>
<th>Testing</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Focus</strong></td>
<td>The process</td>
<td>The product</td>
<td>The product</td>
</tr>
<tr>
<td><strong>Goal</strong></td>
<td>Prevent defects</td>
<td>Find defects</td>
<td>Find defects, give information about quality</td>
</tr>
<tr>
<td><strong>Approach</strong></td>
<td>Proactive</td>
<td>Reactive</td>
<td>Reactive</td>
</tr>
<tr>
<td><strong>When</strong></td>
<td>Throughout the project</td>
<td>After something is built</td>
<td>After something is built</td>
</tr>
<tr>
<td><strong>Examples</strong></td>
<td>Standards, reviews of how work is done, training, process improvement</td>
<td>Inspections, reviews of finished work, testing</td>
<td>Running test cases, exploratory testing, automated test runs</td>
</tr>
<tr>
<td><strong>Question</strong></td>
<td>Are we working the right way?</td>
<td>Is this product right?</td>
<td>Does this behave as it should?</td>
</tr>
</tbody></table>
<p>A simple analogy from a restaurant kitchen: <strong>QA</strong> is the recipe, the hygiene rules and the chef&#39;s training. <strong>QC</strong> is tasting each dish before it leaves the kitchen.</p>
<h3>&quot;But my job title is QA engineer...&quot;</h3>
<p>In everyday industry use, &quot;QA&quot; often just means &quot;testing&quot;: a <strong>QA engineer</strong> or <strong>QA team</strong> usually spends most of their time testing. That&#39;s fine in practice. But in interviews and in quality-management standards (such as ISO 9001), QA and QC have the distinct meanings above, so it&#39;s worth knowing the difference.</p>
<h2>Verification vs validation</h2>
<p>The classic definitions, from software engineer Barry Boehm:</p>
<ul>
<li><strong>Verification: &quot;Are we building the product right?&quot;</strong> Does the product meet its <strong>specification</strong>: the requirements, designs and standards written for it?</li>
<li><strong>Validation: &quot;Are we building the right product?&quot;</strong> Does the product meet the <strong>real needs</strong> of its users and the business?</li>
</ul>
<h3>Verification</h3>
<p>Verification checks each piece of work <strong>against what it was supposed to be</strong>. Much of it happens <strong>without running the software</strong>:</p>
<ul>
<li><strong>Reviewing</strong> requirements for gaps and contradictions.</li>
<li><strong>Reviewing</strong> designs against requirements.</li>
<li><strong>Code review</strong> and <strong>static analysis</strong> tools, which check code without running it.</li>
<li><strong>Testing against the specification</strong>: does the discount function return exactly what the requirement says?</li>
</ul>
<p><em>Example:</em> the requirement says &quot;expired codes must be refused with a clear message&quot;. A tester checks that the app does exactly that. That&#39;s verification.</p>
<h3>Validation</h3>
<p>Validation checks the product <strong>against what users actually need</strong>. It usually happens <strong>by running the software with real or realistic use</strong>:</p>
<ul>
<li><strong>Acceptance testing</strong> with the product owner or customers.</li>
<li><strong>Usability testing</strong> with real users.</li>
<li><strong>Beta releases</strong> to a small group of real customers.</li>
<li><strong>Demos</strong> to stakeholders at the end of each sprint.</li>
</ul>
<p><em>Example:</em> the new restaurant dashboard matches its specification perfectly, which is verified. But restaurant owners find it too slow to use during the dinner rush. It fails validation. (This is <a href="https://provalab.io/blog/seven-principles-of-testing/">principle 7</a>: bug-free can still be wrong.)</p>
<h3>The differences at a glance</h3>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>Verification</th>
<th>Validation</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Question</strong></td>
<td>Are we building the product right?</td>
<td>Are we building the right product?</td>
</tr>
<tr>
<td><strong>Checks against</strong></td>
<td>Requirements, designs, standards</td>
<td>Real user and business needs</td>
</tr>
<tr>
<td><strong>Typical activities</strong></td>
<td>Reviews, inspections, static analysis, testing against the spec</td>
<td>Acceptance testing, usability testing, beta releases, demos</td>
</tr>
<tr>
<td><strong>Software running?</strong></td>
<td>Often not (reviews), sometimes yes</td>
<td>Usually yes</td>
</tr>
<tr>
<td><strong>When</strong></td>
<td>Throughout development</td>
<td>Mostly towards the end, and with every release to users</td>
</tr>
<tr>
<td><strong>Done by</strong></td>
<td>Developers, testers, reviewers</td>
<td>Customers, users, product owners, testers</td>
</tr>
<tr>
<td><strong>Finds</strong></td>
<td>Mistakes in building to the spec</td>
<td>A spec that doesn&#39;t match what users need</td>
</tr>
</tbody></table>
<p><strong>You need both.</strong> Verification without validation can build a perfect version of the wrong product. Validation without verification finds the right product, full of bugs.</p>
<h2>The V-model: where they meet</h2>
<p>The <strong>V-model</strong> shows how each step of building software has a matching level of testing:</p>
<p><img src="https://provalab.io/blog/basics/v-model.svg" alt="The V-model. Down the left side: business requirements, system specification, architecture design and detailed design, down to coding at the bottom. Up the right side, each test level checks a left-side step: unit testing checks the detailed design, integration testing the architecture, system testing the specification, and acceptance testing the business requirements. Verification asks whether we are building the product right; validation, at the top right, asks whether we are building the right product." width="880" height="480" loading="lazy"></p>
<ul>
<li>Each step down the left is <strong>verified</strong> against the one before it, through reviews.</li>
<li>Each test level up the right checks the matching step on the left: <a href="https://provalab.io/blog/first-automated-test-pytest/">unit tests</a> check the detailed design, <a href="https://provalab.io/blog/integration-testing/">integration tests</a> the architecture, <a href="https://provalab.io/blog/system-testing/">system tests</a> the specification.</li>
<li><strong>Acceptance testing</strong>, at the top, <strong>validates</strong> the product against the business&#39;s real needs.</li>
</ul>
<p>The V-model also explains why <a href="https://provalab.io/blog/seven-principles-of-testing/">testing should start early</a>: the acceptance tests can be designed as soon as the business requirements are written, long before any code exists.</p>
<h2>Quick interview answers</h2>
<ul>
<li><strong>QA vs QC?</strong> QA prevents defects by improving the process; QC finds defects by checking the product. Testing is the main part of QC.</li>
<li><strong>Verification vs validation?</strong> Verification: are we building the product right (against the spec)? Validation: are we building the right product (for the users)?</li>
<li><strong>Is a code review QA or QC?</strong> Reviewing a specific piece of code is QC (and verification). <em>Requiring</em> code reviews for every change, and improving how they&#39;re done, is QA.</li>
<li><strong>Can something pass verification but fail validation?</strong> Yes: a product that exactly matches a specification that missed what users need.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/static-testing/">Part 21</a></strong> we look at <strong>static testing</strong>: reviews, walkthroughs, inspections and static analysis, the cheapest way to find bugs, because it happens before the software even runs.</p>
<p>ProvaLab supports both sides: Test Management links requirements to test cases and results for verification, the PR Bot reviews every code change before it&#39;s merged, and acceptance and exploratory results live alongside automated ones, so you can see the whole picture of quality in one place. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Regression testing vs re-testing: what they are and why we do both</title>
      <link>https://provalab.io/blog/regression-testing-vs-retesting/</link>
      <guid isPermaLink="true">https://provalab.io/blog/regression-testing-vs-retesting/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>regression-testing</category>
      <category>retesting</category>
      <description>Why every bug fix needs re-testing, how regression testing catches what a change broke elsewhere, the differences between them, how to choose regression tests, and how to keep a regression suite fast.</description>
      <content:encoded><![CDATA[<p>A developer fixes a bug and says &quot;done&quot;. Two questions follow, and they sound similar but are very different:</p>
<ol>
<li><strong>Is the bug really fixed?</strong></li>
<li><strong>Did the fix break anything else?</strong></li>
</ol>
<p>The first is answered by <strong>re-testing</strong>. The second is answered by <strong>regression testing</strong>. Every fix needs both, and mixing them up is one of the most common ways bugs reach customers.</p>
<h2>What is re-testing?</h2>
<p><strong>Re-testing (also called confirmation testing) means running the exact test that failed again, after the bug has been fixed, to confirm the fix works.</strong></p>
<h3>Why do we re-test?</h3>
<p>Because <strong>a fix is only a claim until someone checks it.</strong> In real teams, &quot;fixed&quot; turns out not to be fixed surprisingly often:</p>
<ul>
<li><strong>The fix is partial.</strong> It works for the case the developer tried, but not for the one in the bug report.</li>
<li><strong>It works in one place but not another.</strong> Remember <a href="https://provalab.io/blog/bug-life-cycle/">BUG-231</a>: the expired-code fix worked on the web, but the Android app had its own copy of the check, so the bug was still there.</li>
<li><strong>The fix never arrived.</strong> It&#39;s on the developer&#39;s machine or in a branch, but not in the build being tested.</li>
<li><strong>The developer misunderstood the bug</strong> and fixed something else.</li>
<li><strong>The environment hid the bug,</strong> so it passed on the developer&#39;s laptop but fails with real test data.</li>
</ul>
<h3>How to re-test</h3>
<ul>
<li>Use the <strong>same steps, the same data and the same kind of environment</strong> as the original bug report.</li>
<li>Test on <strong>every platform the bug affected</strong> (web, Android, iPhone).</li>
<li>Try <strong>one or two small variations</strong> around the bug: another expired code, a code that expired one minute ago.</li>
<li>Then update the bug: <strong>Verified</strong> if it passed, <strong>Reopened</strong> with details if not (see <a href="https://provalab.io/blog/bug-life-cycle/">the bug life cycle</a>).</li>
</ul>
<p>The person who found the bug is usually the best one to re-test it.</p>
<h2>What is regression testing?</h2>
<p><strong>Regression testing means re-running tests that used to pass, after a change, to make sure nothing that worked before is now broken.</strong></p>
<p>A <strong>regression</strong> is something that worked yesterday and doesn&#39;t work today. Regressions happen because software is connected: code is shared, data flows between features, and a change in one place can ripple into others.</p>
<h3>Why do we do regression testing?</h3>
<p>Changes break things in places nobody expected:</p>
<ul>
<li><strong>Shared code:</strong> the fix for BUG-231 moved the discount check to the server. Every app now uses it, including flows nobody touched.</li>
<li><strong>Side effects:</strong> a faster search query returns results in a different order, and the &quot;recommended&quot; section breaks.</li>
<li><strong>Merges:</strong> two developers&#39; correct changes conflict when combined.</li>
<li><strong>Upgrades:</strong> a new version of a library, the database or the browser changes behaviour.</li>
<li><strong>Configuration:</strong> a changed setting in one environment affects features far away.</li>
</ul>
<p>Developers can&#39;t always predict these ripples. Regression tests catch them.</p>
<h2>Re-testing vs regression testing for one fix</h2>
<p><img src="https://provalab.io/blog/basics/retest-vs-regression.svg" alt="After the fix for BUG-231: re-testing runs the one failed test, TC-104, at the centre. Targeted regression checks the ring of nearby features the change could affect: valid codes, cart, checkout totals, free delivery, order history and the Android app. The wider regression suite covers the rest of the app, such as sign-in, search, menu, profile, tracking and the restaurant dashboard, nightly and before every release." width="880" height="420" loading="lazy"></p>
<p>For the BUG-231 fix:</p>
<ul>
<li><strong>Re-test:</strong> TC-104 (expired code refused), on web <strong>and</strong> Android.</li>
<li><strong>Targeted regression:</strong> valid codes still work, cart and checkout totals, free-delivery rule, order history totals, the Android checkout. These all touch the discount code that changed.</li>
<li><strong>Wider regression:</strong> the full suite runs that night, and again before the release.</li>
</ul>
<h2>The differences at a glance</h2>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>Re-testing</th>
<th>Regression testing</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Question</strong></td>
<td>Is this bug really fixed?</td>
<td>Did the change break anything else?</td>
</tr>
<tr>
<td><strong>What is run</strong></td>
<td>The test(s) that failed</td>
<td>Tests that previously passed</td>
</tr>
<tr>
<td><strong>When</strong></td>
<td>After a specific bug is fixed</td>
<td>After any change: fixes, features, upgrades</td>
</tr>
<tr>
<td><strong>Scope</strong></td>
<td>Narrow: the bug</td>
<td>Wide: the areas around the change, or everything</td>
</tr>
<tr>
<td><strong>Planned in advance?</strong></td>
<td>No, it depends on which bugs were found</td>
<td>Yes, a regression suite is built and maintained</td>
</tr>
<tr>
<td><strong>Good for automation?</strong></td>
<td>Sometimes; often quicker by hand</td>
<td>Yes, it&#39;s the classic case for automation</td>
</tr>
<tr>
<td><strong>Expected result</strong></td>
<td>The test that failed now passes</td>
<td>Tests that passed still pass</td>
</tr>
</tbody></table>
<p>Re-testing usually comes <strong>first</strong>: there&#39;s no point running a big regression suite on a fix that doesn&#39;t work. (A quick <a href="https://provalab.io/blog/smoke-vs-sanity-testing/">sanity check</a> often comes before both.)</p>
<h2>How to choose which regression tests to run</h2>
<p>Running every test after every change is the safest choice, but rarely practical. There are three common strategies:</p>
<ol>
<li><strong>Retest all:</strong> run the whole regression suite. Most thorough, slowest. Good before a release.</li>
<li><strong>Regression test selection:</strong> run only the tests related to what changed. A <a href="https://provalab.io/blog/requirements-traceability-matrix/">traceability matrix</a> and good test tagging (by feature, by area of code) make this possible.</li>
<li><strong>Prioritisation:</strong> run tests in order of risk, with critical journeys first (<a href="https://provalab.io/blog/how-to-write-a-test-plan/">risk-based testing</a>), so the most important results come back first.</li>
</ol>
<p>Most teams combine them with a schedule like this:</p>
<p><img src="https://provalab.io/blog/basics/regression-cadence.svg" alt="Which regression tests to run and when. Every commit: unit tests, thousands in about a minute. Every merge or pull request: integration tests and tests for the changed areas, in minutes. Every night: the full automated regression suite of UI, API and system tests on all browsers. Before each release: the full suite plus manual exploratory checks of the risky areas." width="880" height="360" loading="lazy"></p>
<h2>Building and maintaining a regression suite</h2>
<p>A regression suite is a living thing. Some habits that keep it useful:</p>
<ul>
<li><strong>Add a test for every bug you fix.</strong> If a bug happened once, it can come back. The test from the BUG-231 fix joins the suite permanently.</li>
<li><strong>Automate it.</strong> Regression tests are run again and again, which is exactly when automation pays off (see <a href="https://provalab.io/blog/manual-vs-automated-testing/">Part 3</a>).</li>
<li><strong>Tag tests</strong> by feature, priority and speed, so you can run &quot;all checkout tests&quot; or &quot;critical tests only&quot;.</li>
<li><strong>Remove or update obsolete tests.</strong> A test for a feature that no longer exists only slows everyone down.</li>
<li><strong>Fix flaky tests quickly.</strong> If people learn to ignore red results, real regressions slip through.</li>
<li><strong>Watch the run time.</strong> If the suite gets too slow to run often, split it into faster and slower layers.</li>
</ul>
<h2>Common mistakes</h2>
<ul>
<li><strong>Re-testing only on one platform</strong> when the bug affected several.</li>
<li><strong>Skipping regression for &quot;small&quot; fixes.</strong> Small changes to shared code cause big regressions.</li>
<li><strong>Running the full suite on a fix that hasn&#39;t been re-tested,</strong> then wasting the run when the fix turns out not to work.</li>
<li><strong>Never adding tests for fixed bugs,</strong> so the same bug returns months later.</li>
<li><strong>Treating regression testing as manual work forever.</strong> It grows with every release; without automation it becomes impossible to keep up.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/performance-testing-types/">Part 16</a></strong> we look at <strong>performance testing and its types</strong>: load, stress, spike, soak, volume and scalability testing.</p>
<p>ProvaLab runs your regression suite on every change and every night, on real browsers and devices, and shows each test&#39;s history: when it last passed, and the build where it started failing. When a regression appears, your coding agent gets the failure with its logs and recording, and can fix it. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>The requirements traceability matrix (RTM): prove everything is tested</title>
      <link>https://provalab.io/blog/requirements-traceability-matrix/</link>
      <guid isPermaLink="true">https://provalab.io/blog/requirements-traceability-matrix/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>traceability</category>
      <category>test-coverage</category>
      <description>What a traceability matrix is, how to build one step by step, forward and backward traceability, how to measure coverage, and how it answers &quot;are we ready to release?&quot;</description>
      <content:encoded><![CDATA[<p>You have <a href="https://provalab.io/blog/software-testing-life-cycle-stlc/">requirements</a>, <a href="https://provalab.io/blog/how-to-write-test-cases/">test cases</a>, <a href="https://provalab.io/blog/how-to-write-a-test-plan/">a test plan</a>, test runs and <a href="https://provalab.io/blog/how-to-write-a-bug-report/">bug reports</a>. Each is useful on its own. But on release day, someone will ask the questions that need all of them at once:</p>
<ul>
<li><strong>Is every requirement tested?</strong></li>
<li><strong>Which requirements are failing right now?</strong></li>
<li><strong>This requirement just changed: which tests do we re-run?</strong></li>
</ul>
<p>The tool that answers all three is the <strong>requirements traceability matrix</strong>, usually just called the <strong>RTM</strong> or <strong>traceability matrix</strong>. In the final part of this series, we build one.</p>
<h2>What is a traceability matrix?</h2>
<p><strong>A traceability matrix is a table that links each requirement to the test cases that check it, and to their latest results and bugs.</strong></p>
<p>&quot;Traceability&quot; means you can <strong>trace</strong> a line from any requirement to its tests, and from any test or bug back to its requirement. The matrix makes those lines visible.</p>
<h2>An example</h2>
<p>Here are the discount-code requirements from our food delivery app, matched against the test cases from <a href="https://provalab.io/blog/how-to-write-test-cases/">Part 6</a>:</p>
<p><img src="https://provalab.io/blog/basics/rtm-matrix.svg" alt="A traceability matrix with requirements as rows and test cases TC-101 to TC-106 as columns. REQ-10 apply a valid code: TC-101 and TC-102 passed. REQ-11 refuse unknown codes: TC-103 passed. REQ-12 refuse expired codes: TC-104 failed, linked to BUG-231. REQ-13 one code per order: TC-105 passed. REQ-14 codes in the Android app: no test case, a coverage gap. TC-106 is linked to no requirement." width="880" height="480" loading="lazy"></p>
<p>The same information as a table, the form most teams keep it in:</p>
<table tabindex="0">
<thead>
<tr>
<th>Requirement</th>
<th>Description</th>
<th>Priority</th>
<th>Test cases</th>
<th>Latest result</th>
<th>Bugs</th>
</tr>
</thead>
<tbody><tr>
<td>REQ-10</td>
<td>Apply a valid discount code</td>
<td>High</td>
<td>TC-101, TC-102</td>
<td>Passed</td>
<td>—</td>
</tr>
<tr>
<td>REQ-11</td>
<td>Refuse unknown codes</td>
<td>Medium</td>
<td>TC-103</td>
<td>Passed</td>
<td>—</td>
</tr>
<tr>
<td>REQ-12</td>
<td>Refuse expired codes</td>
<td>High</td>
<td>TC-104</td>
<td><strong>Failed</strong></td>
<td>BUG-231</td>
</tr>
<tr>
<td>REQ-13</td>
<td>Only one code per order</td>
<td>Medium</td>
<td>TC-105</td>
<td>Passed</td>
<td>—</td>
</tr>
<tr>
<td>REQ-14</td>
<td>Codes work in the Android app</td>
<td>High</td>
<td><strong>None</strong></td>
<td><strong>Not tested</strong></td>
<td>—</td>
</tr>
</tbody></table>
<p>At a glance, the matrix shows three problems that would otherwise stay hidden:</p>
<ol>
<li><strong>A coverage gap:</strong> REQ-14 has no tests at all. Remember the Android bug from <a href="https://provalab.io/blog/bug-life-cycle/">Part 10</a>? Without the matrix, nobody would notice that Android was never tested.</li>
<li><strong>A failing requirement:</strong> REQ-12 is tested, but its test fails. The release isn&#39;t ready until BUG-231 is fixed.</li>
<li><strong>An orphan test:</strong> TC-106 (&quot;removing all items removes the discount&quot;) isn&#39;t linked to any requirement. Either the behaviour matters and a requirement is missing, or the test is out of date.</li>
</ol>
<h2>Forward, backward and bidirectional traceability</h2>
<p><img src="https://provalab.io/blog/basics/traceability-chain.svg" alt="Traceability works both ways along the chain requirement REQ-12, test case TC-104, test result failed on 2.4.0, and bug BUG-231. Forward: is every requirement tested, and passing? Backward: why does this test exist, and which requirement does this bug hurt?" width="880" height="300" loading="lazy"></p>
<ul>
<li><strong>Forward traceability</strong> goes from requirements to tests: <em>is every requirement covered, and is it passing?</em> This finds <strong>gaps</strong>.</li>
<li><strong>Backward traceability</strong> goes from tests and bugs back to requirements: <em>why does this test exist, and which requirement does this bug affect?</em> This finds <strong>orphans</strong> and shows the business impact of each bug.</li>
<li><strong>Bidirectional traceability</strong> is both at once, and it is what a good RTM gives you.</li>
</ul>
<h2>Measuring coverage</h2>
<p>The matrix turns &quot;are we done?&quot; into numbers:</p>
<ul>
<li><strong>Requirement coverage</strong> = requirements with at least one test ÷ all requirements.
In our example: 4 of 5 = <strong>80%</strong>. REQ-14 is the missing 20%.</li>
<li><strong>Passing requirements</strong> = requirements whose tests all pass ÷ all requirements.
In our example: 3 of 5 = <strong>60%</strong>. REQ-12 is failing and REQ-14 is untested.</li>
</ul>
<p>Weight these by <strong>priority</strong>. Three of the five requirements are high priority, and two of those three have a problem: REQ-12 is failing and REQ-14 is untested. That tells you far more than the percentage does.</p>
<p><strong>Requirement coverage is not the same as code coverage</strong> from <a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">Part 7</a>. Code coverage asks &quot;which lines of code ran?&quot; Requirement coverage asks &quot;which promises to the user did we check?&quot; You need both.</p>
<h2>Impact analysis: when a requirement changes</h2>
<p>Requirements change all the time. Say the business decides <strong>expired codes should show a &quot;renew your code&quot; link</strong> instead of just an error. The RTM tells you instantly:</p>
<ul>
<li><strong>Which tests to update:</strong> TC-104.</li>
<li><strong>Which bugs are affected:</strong> BUG-231 (the fix must include the new link).</li>
<li><strong>What to re-run before release:</strong> everything linked to REQ-12.</li>
</ul>
<p>Without the matrix, someone has to search through hundreds of test cases and hope they found them all.</p>
<h2>How to build an RTM, step by step</h2>
<ol>
<li><strong>List the requirements</strong> (or user stories), each with a unique ID such as REQ-12.</li>
<li><strong>Write test cases</strong>, each with its own ID (TC-104), as in Part 6.</li>
<li><strong>Link each test case to the requirements it covers.</strong> Do this when you write the test, not weeks later.</li>
<li><strong>Add the latest result</strong> of each test after every run.</li>
<li><strong>Link bugs</strong> to the failing test and the requirement.</li>
<li><strong>Review the gaps</strong> before each release: requirements with no tests, failing high-priority requirements, and orphan tests.</li>
</ol>
<h2>Where the RTM fits in the life cycle</h2>
<p>The RTM isn&#39;t a one-time document. It grows through the whole <a href="https://provalab.io/blog/software-testing-life-cycle-stlc/">testing life cycle</a>:</p>
<table tabindex="0">
<thead>
<tr>
<th>STLC phase</th>
<th>What happens to the RTM</th>
</tr>
</thead>
<tbody><tr>
<td>Requirement analysis</td>
<td>Requirements are listed with IDs</td>
</tr>
<tr>
<td>Test case design</td>
<td>Test cases are linked to requirements; gaps found early</td>
</tr>
<tr>
<td>Test execution</td>
<td>Results and bugs are added after every run</td>
</tr>
<tr>
<td>Test closure</td>
<td>The final RTM shows exactly what was covered and what passed</td>
</tr>
</tbody></table>
<p>In agile teams, the &quot;requirements&quot; are usually <strong>user stories and their acceptance criteria</strong>, and the matrix links tests to stories.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>A spreadsheet nobody updates.</strong> A stale RTM is worse than none, because it gives false confidence. Link tests and results where they live, so the matrix updates itself.</li>
<li><strong>Counting tests instead of requirements.</strong> Two hundred tests on one feature and none on another is not good coverage.</li>
<li><strong>Linking after the fact.</strong> Link each test to its requirement the moment you write it.</li>
<li><strong>Ignoring orphan tests.</strong> They often point to missing requirements, or to wasted effort on tests nobody needs.</li>
<li><strong>Only checking coverage, never results.</strong> &quot;Tested&quot; is not the same as &quot;passing&quot;.</li>
</ul>
<h2>Testing Basics: the whole series</h2>
<p>That completes our <strong>Testing Basics</strong> series. You now know:</p>
<ol>
<li><a href="https://provalab.io/blog/what-is-software-testing/">What software testing is</a>, and why every team needs it</li>
<li><a href="https://provalab.io/blog/types-of-software-testing/">The types and levels of testing</a>, and the testing pyramid</li>
<li><a href="https://provalab.io/blog/manual-vs-automated-testing/">Manual vs automated testing</a>, and what to automate</li>
<li><a href="https://provalab.io/blog/software-testing-life-cycle-stlc/">The software testing life cycle (STLC)</a></li>
<li><a href="https://provalab.io/blog/first-automated-test-pytest/">How to write your first automated test</a> with pytest</li>
<li><a href="https://provalab.io/blog/how-to-write-test-cases/">How to write good test cases</a></li>
<li><a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">Black-box, white-box and grey-box testing</a></li>
<li><a href="https://provalab.io/blog/how-to-write-a-test-plan/">How to write a test plan</a></li>
<li><a href="https://provalab.io/blog/how-to-write-a-bug-report/">How to write a good bug report</a></li>
<li><a href="https://provalab.io/blog/bug-life-cycle/">The bug life cycle</a></li>
<li>The requirements traceability matrix (this post)</li>
</ol>
<p>Next we go deeper into specific kinds of testing, starting with <strong><a href="https://provalab.io/blog/integration-testing/">Part 12: integration testing</a></strong>, then system testing, smoke and sanity testing, regression testing and performance testing.</p>
<p>ProvaLab Test Management keeps this matrix up to date for you: requirements and features link to test cases, every manual or automated run records its results, and issues link back to the test and requirement they affect, so coverage reports are always current. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Security testing basics: the OWASP Top 10 (2025) in plain language</title>
      <link>https://provalab.io/blog/security-testing-basics-owasp-top-10/</link>
      <guid isPermaLink="true">https://provalab.io/blog/security-testing-basics-owasp-top-10/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>hands-on-testing</category>
      <category>security-testing</category>
      <category>owasp</category>
      <description>What security testing is, the ten risks in the OWASP Top 10 2025 explained with everyday examples, the six kinds of security testing and which tools do them, and the checks you can add this week.</description>
      <content:encoded><![CDATA[<p>Every test so far in this series asked &quot;does it work?&quot; Security testing asks a different question: <strong>&quot;what happens when someone tries to make it do something it shouldn&#39;t?&quot;</strong></p>
<p>Users mistype. Attackers don&#39;t. They send exactly the input that breaks a check, ask for exactly the record they&#39;re not allowed to see, and try the default password first. Security testing means doing that before they do.</p>
<h2>What is security testing?</h2>
<p><strong>Security testing checks that the software protects its data and its users against misuse</strong>: that people can only see and do what they&#39;re allowed to, that data is protected in storage and in transit, and that the system fails safely when something unexpected happens.</p>
<p>It&#39;s a <a href="https://provalab.io/blog/system-testing/">non-functional</a> kind of testing, and the one with the highest cost of failure. A functional bug annoys a customer. A security bug can expose every customer at once.</p>
<h2>The OWASP Top 10</h2>
<p><strong>OWASP</strong> (the Open Worldwide Application Security Project) is a non-profit that publishes free security guidance. Its best-known document is the <strong>OWASP Top 10</strong>: the ten most serious risks to web applications, updated every few years from real-world data. The current edition is <strong>2025</strong>. If you learn one thing about security testing, learn these ten.</p>
<p><img src="https://provalab.io/blog/handson/owasp-top-10.svg" alt="The OWASP Top 10, 2025 edition. A01 Broken Access Control. A02 Security Misconfiguration. A03 Software Supply Chain Failures. A04 Cryptographic Failures. A05 Injection. A06 Insecure Design. A07 Authentication Failures. A08 Software or Data Integrity Failures. A09 Security Logging and Alerting Failures. A10 Mishandling of Exceptional Conditions." width="880" height="560" loading="lazy"></p>
<p>Here is each one, with an example from our food delivery app.</p>
<h3>A01 · Broken Access Control</h3>
<p><strong>Users doing things they&#39;re not allowed to.</strong> Alice reads Bob&#39;s order by changing <code>42</code> to <code>43</code> in the URL. A customer reaches the restaurant dashboard. A regular user calls an admin-only endpoint.</p>
<p>This has been number one for years, because it&#39;s common, easy to get wrong and devastating. The 2025 edition also folds in <strong>server-side request forgery (SSRF)</strong>, where an attacker tricks your server into fetching internal resources on their behalf.</p>
<p><em>You&#39;ve already tested for it:</em> <code>test_customer_cannot_read_someone_elses_order</code> in <a href="https://provalab.io/blog/first-api-test-pytest/">Part 4</a> is an access-control test.</p>
<h3>A02 · Security Misconfiguration</h3>
<p><strong>The software is fine; the settings aren&#39;t.</strong> Debug mode left on in production, so errors show internal details. Default admin passwords never changed. Directory listings enabled. Cloud storage buckets open to the world. Missing security headers.</p>
<p>It climbed from fifth to second in 2025, because modern systems have so many settings to get wrong.</p>
<h3>A03 · Software Supply Chain Failures</h3>
<p><strong>A vulnerability in something you installed becomes your vulnerability.</strong> Your app may be perfect, but it runs on hundreds of libraries, a build tool, a container image and some CI plugins. Any of them can carry a known flaw, or be tampered with. New in 2025, replacing and widening the old &quot;vulnerable and outdated components&quot;.</p>
<h3>A04 · Cryptographic Failures</h3>
<p><strong>Data that should be protected isn&#39;t.</strong> Passwords stored in plain text (or with weak hashing). Card numbers kept when they didn&#39;t need to be. Traffic sent without TLS. Home-made encryption. The fix is mostly &quot;use the standard, well-tested thing and don&#39;t invent your own&quot;.</p>
<h3>A05 · Injection</h3>
<p><strong>Untrusted input gets run as code.</strong> The classic is <strong>SQL injection</strong>: a search box that turns <code>&#39; OR 1=1 --</code> into a database query that returns everything. Also OS command injection, and <strong>cross-site scripting (XSS)</strong>, where a comment containing <code>&lt;script&gt;</code> runs in every other user&#39;s browser.</p>
<p>Injection is almost always prevented the same way: never build queries or commands by gluing strings together; use parameterised queries and output encoding.</p>
<h3>A06 · Insecure Design</h3>
<p><strong>A flaw in the plan, not the code.</strong> The discount feature works exactly as specified, but the specification never limited how many codes one order can use, so a customer stacks ten. Password reset by &quot;mother&#39;s maiden name&quot;. No limit on how many OTPs can be requested. No amount of bug-free code fixes a design that&#39;s wrong. This is what <a href="#three-checks-you-can-do-this-week">threat modelling</a> during design is for.</p>
<h3>A07 · Authentication Failures</h3>
<p><strong>Proving who you are, done badly.</strong> Weak passwords accepted. No rate limit on sign-in, so an attacker tries a million passwords. Session tokens that never expire, or that stay valid after sign-out. &quot;Remember me&quot; implemented with the password in a cookie.</p>
<p><em>Already tested:</em> <code>test_no_token_means_401</code> in Part 4 is the simplest authentication test.</p>
<h3>A08 · Software or Data Integrity Failures</h3>
<p><strong>Trusting code or data without checking it.</strong> Auto-updates with no signature check. Deserialising objects from an untrusted source. A CI pipeline anyone can push to. Closely related to supply chain, but about <em>verification</em>: did this come from where it claims, unchanged?</p>
<h3>A09 · Security Logging &amp; Alerting Failures</h3>
<p><strong>Attacks nobody notices.</strong> Failed sign-ins aren&#39;t logged. Logs exist but nobody looks, and nothing alerts. A breach is discovered months later, from the outside. Renamed in 2025 to stress that logging without <strong>alerting</strong> isn&#39;t enough.</p>
<h3>A10 · Mishandling of Exceptional Conditions</h3>
<p><strong>Errors that fail the wrong way.</strong> An exception in the permission check gets caught and the request continues as if allowed (&quot;fail open&quot;). A crash shows users a stack trace with file paths and library versions. An out-of-memory condition leaves half an order saved. New in 2025.</p>
<p><em>Already tested:</em> every &quot;never a 500&quot; assertion in Part 4 is a small check of this. An API that answers <code>422</code> to bad input, instead of crashing, is handling the exceptional condition.</p>
<h2>The six kinds of security testing</h2>
<p>Different risks need different kinds of testing. Three run on the <strong>code</strong> and three on the <strong>running system</strong>:</p>
<p><img src="https://provalab.io/blog/handson/security-testing-kinds.svg" alt="Six kinds of security testing. On the code, every commit: SAST reads the source without running it (Semgrep, Bandit, CodeQL); SCA checks libraries against known vulnerabilities (pip-audit, npm audit, Trivy); secrets scanning finds committed passwords and keys (gitleaks, TruffleHog). On the running system, nightly or before release: DAST attacks the running app from outside (OWASP ZAP, Burp Suite); API fuzzing sends thousands of malformed and unauthorised requests (Schemathesis, ZAP); a penetration test is a skilled person attacking the system." width="880" height="470" loading="lazy"></p>
<table tabindex="0">
<thead>
<tr>
<th>Kind</th>
<th>Looks at</th>
<th>Finds</th>
<th>Typical tools</th>
<th>When</th>
</tr>
</thead>
<tbody><tr>
<td><strong>SAST</strong> (static application security testing)</td>
<td>Source code, without running it</td>
<td>Injection patterns, unsafe functions, hard-coded secrets</td>
<td>Semgrep, Bandit, CodeQL</td>
<td>Every commit</td>
</tr>
<tr>
<td><strong>SCA</strong> (software composition analysis)</td>
<td>Your dependencies</td>
<td>Libraries with known vulnerabilities (A03)</td>
<td>pip-audit, npm audit, Trivy, Dependabot</td>
<td>Every commit, and daily as new flaws are published</td>
</tr>
<tr>
<td><strong>Secrets scanning</strong></td>
<td>The repository, including history</td>
<td>Committed passwords, API keys, tokens</td>
<td>gitleaks, TruffleHog</td>
<td>Every commit</td>
</tr>
<tr>
<td><strong>DAST</strong> (dynamic application security testing)</td>
<td>The running application, from outside</td>
<td>Injection, misconfiguration, missing headers, auth problems</td>
<td>OWASP ZAP, Burp Suite</td>
<td>Nightly, before release</td>
</tr>
<tr>
<td><strong>API fuzzing</strong></td>
<td>The API, with generated requests</td>
<td>Crashes (500s), missing auth checks, validation gaps</td>
<td>Schemathesis, ZAP API scan</td>
<td>Nightly, before release</td>
</tr>
<tr>
<td><strong>Penetration testing</strong></td>
<td>Everything, by a skilled person</td>
<td>Design flaws and chained attacks tools miss</td>
<td>A person</td>
<td>Before major releases, yearly</td>
</tr>
</tbody></table>
<p>The first three are <a href="https://provalab.io/blog/static-testing/">static testing</a> applied to security: cheap, fast, on every change. DAST and fuzzing are <a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">dynamic</a> and mostly black-box or grey-box. A penetration test adds the one thing none of the tools have: creativity.</p>
<h3>Which kind finds which risk</h3>
<table tabindex="0">
<thead>
<tr>
<th>Risk</th>
<th>Best found by</th>
</tr>
</thead>
<tbody><tr>
<td>A01 Access control</td>
<td>API tests you write (as in Part 4), fuzzing, pentest</td>
</tr>
<tr>
<td>A02 Misconfiguration</td>
<td>DAST, configuration scanners, cloud posture tools</td>
</tr>
<tr>
<td>A03 Supply chain</td>
<td>SCA, image scanning, pinned and verified dependencies</td>
</tr>
<tr>
<td>A04 Cryptography</td>
<td>SAST, code review, TLS checkers</td>
</tr>
<tr>
<td>A05 Injection</td>
<td>SAST, DAST, fuzzing</td>
</tr>
<tr>
<td>A06 Insecure design</td>
<td>Threat modelling, design review, pentest</td>
</tr>
<tr>
<td>A07 Authentication</td>
<td>API tests, DAST, pentest</td>
</tr>
<tr>
<td>A08 Integrity</td>
<td>Code review, pipeline hardening, signature checks</td>
</tr>
<tr>
<td>A09 Logging</td>
<td>Review and a drill: do an attack, see if anyone is alerted</td>
</tr>
<tr>
<td>A10 Exceptional conditions</td>
<td>Negative tests, fuzzing, code review of error handling</td>
</tr>
</tbody></table>
<p>Notice how many rows say &quot;API tests you write&quot;. <strong>Ordinary functional and negative tests, written with security in mind, are the first and cheapest layer of security testing.</strong> The 403 test, the 401 test, the &quot;never a 500&quot; assertion: you don&#39;t need a security tool to write those.</p>
<h2>Three checks you can do this week</h2>
<ol>
<li><strong>Add one access-control test per resource.</strong> For every kind of record (orders, profiles, addresses), sign in as one user and request another user&#39;s record. Expect <code>403</code> or <code>404</code>, never <code>200</code>. This single habit covers the number-one risk.</li>
<li><strong>Run a dependency scan.</strong> <code>pip-audit</code> for Python, <code>npm audit</code> for JavaScript, or Trivy for containers. It takes a minute and often finds something.</li>
<li><strong>Do a 30-minute threat model.</strong> Draw the app&#39;s data flows on a whiteboard (what goes where, who can reach it) and ask, for each arrow: <em>what&#39;s the worst thing someone could do here?</em> This is the design-stage activity that catches A06, and no tool replaces it.</li>
</ol>
<h2>Rules of the road</h2>
<ul>
<li><strong>Only test what you own or have written permission to test.</strong> Running a scanner or a fuzzer against someone else&#39;s site is illegal in most countries, and it looks exactly like an attack because it is one.</li>
<li><strong>Test against a test environment</strong>, with test data. A scanner will try to submit forms, create records and trip rate limits.</li>
<li><strong>Fix in order of severity</strong>, not in order found. A scanner&#39;s 200 findings usually contain 3 that matter.</li>
<li><strong>Verify, then report.</strong> Scanners produce false positives. Confirm a finding by hand before raising it, and write it up as a <a href="https://provalab.io/blog/how-to-write-a-bug-report/">bug report</a> with steps, not as &quot;the scanner said so&quot;.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/first-security-scan-owasp-zap/">Part 6</a></strong> we run a real <strong>DAST scan with OWASP ZAP</strong> against the little API from Part 4, read the findings, and sort the real ones from the noise.</p>
<p>ProvaLab AppSec runs web app (DAST), API fuzzing, dependency, secrets, container and code scans against targets you&#39;ve verified you own, and turns the findings into a plain-language fix list ordered by severity, with the false positives you&#39;ve checked marked as such. See it in the <a href="https://provalab.io/#stage-appsec">two-minute product tour</a>.</p>
<p>Sources: the <a href="https://owasp.org/Top10/2025/0x00_2025-Introduction/">OWASP Top 10:2025 introduction</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>The 7 principles of software testing, explained with examples</title>
      <link>https://provalab.io/blog/seven-principles-of-testing/</link>
      <guid isPermaLink="true">https://provalab.io/blog/seven-principles-of-testing/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>testing-principles</category>
      <description>The seven testing principles from the ISTQB syllabus in plain language, each with a real-world example and what it means for how you test day to day.</description>
      <content:encoded><![CDATA[<p>Over 18 parts we have covered a lot of the <em>how</em> of testing. This part steps back to the <em>why</em>: seven principles that experienced testers have learned the hard way, and that explain almost every practice in this series.</p>
<p>They come from the <strong>ISTQB Certified Tester Foundation Level</strong> syllabus, the most widely used testing certification, and they&#39;re a favourite interview topic. More importantly, they change how you test.</p>
<p><img src="https://provalab.io/blog/basics/seven-principles.svg" alt="The seven principles: 1, testing shows bugs are there, not that they are gone. 2, exhaustive testing is impossible. 3, test early: it saves time and money. 4, defects cluster together. 5, tests wear out (the pesticide paradox). 6, testing depends on context. 7, bug-free can still be wrong (the absence-of-defects fallacy)." width="880" height="560" loading="lazy"></p>
<h2>1. Testing shows the presence of defects, not their absence</h2>
<p><strong>Testing can show that bugs are there. It can never prove that there are none.</strong></p>
<p>If 500 tests pass, you know the software works <em>in those 500 situations</em>. You don&#39;t know about the 501st. Every passing test reduces the risk of an unknown bug, but never removes it.</p>
<p><em>Example:</em> the discount-code tests all pass. A week after release, a customer finds that applying a code, changing the delivery address and going back to the cart doubles the discount. No test tried that exact sequence.</p>
<p><strong>What it means for you:</strong> never promise &quot;bug-free&quot;. Report what was tested, what wasn&#39;t, and the risk that remains, as in a good <a href="https://provalab.io/blog/software-testing-life-cycle-stlc/">test summary report</a>.</p>
<h2>2. Exhaustive testing is impossible</h2>
<p><strong>Testing every possible input and combination is impossible, except in trivially small cases.</strong></p>
<p>A search box that accepts 10 characters of letters and digits has 36¹⁰ possible inputs: over 3.6 quadrillion. At a thousand tests per second, trying them all would take more than 100,000 years. And that&#39;s one field.</p>
<p><em>Example:</em> the food app has thousands of restaurants, many browsers and phones, two languages, dozens of payment options and endless orders of steps. Testing every combination isn&#39;t hard; it&#39;s impossible.</p>
<p><strong>What it means for you:</strong> choose tests wisely, using <a href="https://provalab.io/blog/how-to-write-a-test-plan/">risk</a> and <a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">test design techniques</a> like equivalence partitioning, boundary values and pairwise testing. Good choices find most bugs with a small fraction of the tests.</p>
<h2>3. Early testing saves time and money</h2>
<p><strong>The earlier a defect is found, the cheaper it is to fix.</strong></p>
<p>A mistake caught while reading the requirements is a five-minute conversation. The same mistake found by customers in production means support tickets, an urgent fix, a new release, and lost trust.</p>
<p><em>Example:</em> while reviewing the requirement &quot;codes give 10% off&quot;, a tester asks, &quot;before or after the delivery fee?&quot; Nobody had decided. Settling it then costs nothing; finding it after release means refunds.</p>
<p><strong>What it means for you:</strong> test from day one. Review requirements, write test cases before the code, and run tests on every change. This is the &quot;shift left&quot; idea from <a href="https://provalab.io/blog/what-is-software-testing/">Part 1</a>, and the reason the <a href="https://provalab.io/blog/software-testing-life-cycle-stlc/">STLC</a> runs alongside development.</p>
<h2>4. Defects cluster together</h2>
<p><strong>A small number of areas usually contain most of the defects.</strong> This is often linked to the 80/20 (Pareto) rule, though the exact split varies.</p>
<p><img src="https://provalab.io/blog/basics/defect-clustering.svg" alt="An example of bugs found per area of the food app in three months, 74 in total: checkout and payment 34, discount codes 21, search 7, order tracking 5, menus 4, profile 2, help pages 1. Two of the seven areas hold 55 of the 74 bugs." width="880" height="380" loading="lazy"></p>
<p>Bugs cluster where code is <strong>complex</strong>, <strong>changes often</strong>, was <strong>written in a hurry</strong>, or connects to <strong>outside systems</strong>.</p>
<p><strong>What it means for you:</strong> use your bug history. Where bugs were found before, more are likely hiding. Test those areas more deeply, and ask <em>why</em> they&#39;re fragile: often the real fix is simpler code or better tests, not just one more bug report.</p>
<h2>5. Tests wear out (the pesticide paradox)</h2>
<p><strong>If you run the same tests over and over, they eventually stop finding new bugs.</strong></p>
<p>The older name, the <strong>pesticide paradox</strong>, compares this to insects becoming resistant to a pesticide used again and again. Once the bugs your tests can find are fixed, the same tests just keep passing, while new bugs appear in places they don&#39;t look.</p>
<p><em>Example:</em> the checkout regression suite has passed every night for six months. Meanwhile a new &quot;pay later&quot; option was added, and no test touches it.</p>
<p><strong>What it means for you:</strong> keep <a href="https://provalab.io/blog/regression-testing-vs-retesting/">regression tests</a> (they still protect against old bugs returning), but <strong>review and extend them regularly</strong>. Add tests for new features and new risks, vary the test data, and mix in <a href="https://provalab.io/blog/manual-vs-automated-testing/">exploratory testing</a> to find what scripts miss.</p>
<h2>6. Testing is context dependent</h2>
<p><strong>There is no single right way to test. The right approach depends on what you&#39;re testing and why.</strong></p>
<p>A banking app, a mobile game, a hospital system and a startup&#39;s first prototype need very different testing: different risks, rules, depth and speed.</p>
<p><em>Example:</em> for the food app, a bug that charges the wrong amount is critical and gets deep testing; a slightly misaligned icon on the help page is not. A medical device would need formal documentation and strict regulation that a food app doesn&#39;t.</p>
<p><strong>What it means for you:</strong> don&#39;t copy another team&#39;s process blindly. Choose your <a href="https://provalab.io/blog/how-to-write-a-test-plan/">test plan</a>, level of documentation, amount of automation and <a href="https://provalab.io/blog/test-coverage-explained/">coverage</a> targets based on your product&#39;s risks.</p>
<h2>7. The absence-of-defects fallacy</h2>
<p><strong>Finding and fixing lots of bugs doesn&#39;t guarantee success. Software with no known bugs can still be the wrong software.</strong></p>
<p>If the system is hard to use, too slow, or simply doesn&#39;t do what users need, a clean bug list doesn&#39;t help.</p>
<p><em>Example:</em> the new restaurant dashboard passes every test, with zero open bugs. But restaurants refuse to use it, because it takes six clicks to accept an order during the dinner rush.</p>
<p><strong>What it means for you:</strong> test against <strong>users&#39; real needs</strong>, not only the written requirements. That&#39;s what <a href="https://provalab.io/blog/types-of-software-testing/">acceptance testing</a> and usability testing are for, along with talking to real users early.</p>
<h2>The seven principles at a glance</h2>
<table tabindex="0">
<thead>
<tr>
<th>#</th>
<th>Principle</th>
<th>In one line</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Testing shows the presence of defects</td>
<td>Passing tests never prove there are no bugs</td>
</tr>
<tr>
<td>2</td>
<td>Exhaustive testing is impossible</td>
<td>Choose tests by risk and technique</td>
</tr>
<tr>
<td>3</td>
<td>Early testing saves time and money</td>
<td>Start testing on day one</td>
</tr>
<tr>
<td>4</td>
<td>Defects cluster together</td>
<td>Test harder where bugs were found</td>
</tr>
<tr>
<td>5</td>
<td>Tests wear out</td>
<td>Review and refresh your tests</td>
</tr>
<tr>
<td>6</td>
<td>Testing is context dependent</td>
<td>Fit the testing to the product and its risks</td>
</tr>
<tr>
<td>7</td>
<td>Absence-of-defects fallacy</td>
<td>Bug-free isn&#39;t enough if it&#39;s the wrong product</td>
</tr>
</tbody></table>
<h2>How the principles connect to this series</h2>
<p>Almost everything in earlier parts follows from these principles:</p>
<ul>
<li><strong>Test design techniques</strong> (Parts 6 and 7) exist because of principle 2.</li>
<li><strong>The STLC and test planning</strong> (Parts 4 and 8) put principle 3 into practice.</li>
<li><strong>Risk-based testing</strong> (Part 8) uses principles 4 and 6.</li>
<li><strong>Regression maintenance</strong> (Part 15) answers principle 5.</li>
<li><strong>Acceptance testing</strong> (Part 2) protects against principle 7.</li>
<li><strong>Coverage</strong> (Part 18) is limited by principles 1 and 2.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/qa-vs-qc-verification-vs-validation/">Part 20</a></strong> we clear up three terms that are often mixed up: <strong>QA, QC and testing</strong>, and <strong>verification vs validation</strong>.</p>
<p>ProvaLab helps put the principles into practice: tests run on every change (principle 3), each test case keeps a history so you can see where failures cluster (principle 4), and coverage reports show which requirements have gone untested (principles 1 and 2). See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Smoke testing vs sanity testing: the difference, with examples</title>
      <link>https://provalab.io/blog/smoke-vs-sanity-testing/</link>
      <guid isPermaLink="true">https://provalab.io/blog/smoke-vs-sanity-testing/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>smoke-testing</category>
      <category>sanity-testing</category>
      <description>What smoke testing and sanity testing are, how they differ, when to use each, example checklists for a real app, and how to automate them.</description>
      <content:encoded><![CDATA[<p>Imagine the team spends a whole morning preparing to test a new build, and the first tester finds that <strong>nobody can even sign in</strong>. Every other test is now blocked, and the morning is gone.</p>
<p>Two quick checks exist to stop that from happening: <strong>smoke testing</strong> and <strong>sanity testing</strong>. They are among the most confused terms in testing, and a favourite interview question. This post clears them up.</p>
<h2>What is smoke testing?</h2>
<p><strong>A smoke test is a quick, shallow check of the most important features of a new build, to decide whether it is stable enough to test further.</strong></p>
<p>It answers one question: <strong>&quot;Is this build worth testing at all?&quot;</strong></p>
<p>The name comes from hardware: switch a new device on, and if smoke comes out, there&#39;s no point testing anything else. Smoke testing is also called <strong>build verification testing (BVT)</strong> or <strong>confidence testing</strong>.</p>
<p>Key traits:</p>
<ul>
<li>Runs on <strong>every new build</strong>, before any deeper testing.</li>
<li>Covers <strong>the main features, lightly</strong>: wide, not deep.</li>
<li>Takes <strong>minutes</strong>, not hours.</li>
<li>Is usually <strong>scripted and automated</strong>.</li>
<li><strong>Fail = reject the build.</strong> Testing doesn&#39;t start until it&#39;s fixed.</li>
</ul>
<h3>Example smoke test checklist</h3>
<p>For our food delivery app, a smoke test might be just ten checks:</p>
<ol>
<li>The app opens and the home page loads.</li>
<li>A test customer can sign in.</li>
<li>Searching for &quot;pizza&quot; returns restaurants.</li>
<li>A restaurant menu opens.</li>
<li>An item can be added to the cart.</li>
<li>Checkout opens and shows the right total.</li>
<li>A test payment in the sandbox succeeds.</li>
<li>The order appears in &quot;My orders&quot;.</li>
<li>The restaurant dashboard receives the order.</li>
<li>The customer can sign out.</li>
</ol>
<p>Notice that none of these go deep. There&#39;s no discount-code edge case, no closed-restaurant scenario. That&#39;s the job of the full test cycle that comes <strong>after</strong> the smoke test passes.</p>
<h2>What is sanity testing?</h2>
<p><strong>A sanity test is a quick, focused check of one area after a small change or bug fix, to confirm the change makes sense before investing in further testing.</strong></p>
<p>It answers a different question: <strong>&quot;Did this specific change work, and does this area still behave sensibly?&quot;</strong></p>
<p>Key traits:</p>
<ul>
<li>Runs <strong>after a small change</strong>: a bug fix, a minor feature, a configuration change.</li>
<li>Covers <strong>one area, a little deeper</strong>: narrow, not wide.</li>
<li>Takes <strong>minutes</strong>.</li>
<li>Is often <strong>unscripted</strong>: a tester who knows the area checks it quickly.</li>
<li><strong>Fail = back to the developer</strong> before wasting time on full re-testing and regression.</li>
</ul>
<h3>Example sanity test</h3>
<p>The developer has just fixed <strong>BUG-231</strong> from <a href="https://provalab.io/blog/how-to-write-a-bug-report/">Part 9</a>: expired discount codes were being accepted. A quick sanity check of the discount area:</p>
<ul>
<li>An expired code (OLD10) is now refused with the right message.</li>
<li>A valid code (SAVE10) still works.</li>
<li>Removing a code restores the full total.</li>
<li>The checkout page still loads normally.</li>
</ul>
<p>If that looks sensible, the tester moves on to the formal <strong>re-test</strong> of the bug and the <strong>regression</strong> run (both covered in Part 15). If the valid code is now broken too, the build goes straight back. No point running 200 regression tests on it.</p>
<h2>Smoke vs sanity: the difference at a glance</h2>
<p><img src="https://provalab.io/blog/basics/smoke-vs-sanity.svg" alt="Smoke testing is wide and shallow: it touches every main feature lightly (sign-in, search, menu, cart, discounts, payment, tracking, profile). Sanity testing is narrow and focused: it goes deeper into one area, such as discounts, after a change there." width="880" height="420" loading="lazy"></p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>Smoke testing</th>
<th>Sanity testing</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Question</strong></td>
<td>Is this build stable enough to test?</td>
<td>Did this change work, and is the area sensible?</td>
</tr>
<tr>
<td><strong>When</strong></td>
<td>Every new build</td>
<td>After a small fix or change</td>
</tr>
<tr>
<td><strong>Scope</strong></td>
<td>Wide and shallow: all main features</td>
<td>Narrow: one area</td>
</tr>
<tr>
<td><strong>Depth</strong></td>
<td>Just &quot;does it basically work?&quot;</td>
<td>A bit deeper in that area</td>
</tr>
<tr>
<td><strong>Scripted?</strong></td>
<td>Usually scripted and automated</td>
<td>Often unscripted</td>
</tr>
<tr>
<td><strong>Done by</strong></td>
<td>Testers, or automatically in CI</td>
<td>Usually testers</td>
</tr>
<tr>
<td><strong>If it fails</strong></td>
<td>Reject the whole build</td>
<td>Send the change back</td>
</tr>
<tr>
<td><strong>Related to</strong></td>
<td>Build acceptance</td>
<td>Part of regression and re-testing</td>
</tr>
</tbody></table>
<p><strong>A simple way to remember it:</strong> smoke checks the <strong>whole house</strong> has power; sanity checks that <strong>the one room you just rewired</strong> has working lights.</p>
<p><strong>A word of warning:</strong> teams and textbooks don&#39;t all use these terms the same way. Some glossaries treat &quot;sanity test&quot; as just another name for a smoke test. In day-to-day practice, most teams use the distinction above. If you join a new team, ask what <em>they</em> mean by each.</p>
<h2>Where they fit in the testing process</h2>
<p><img src="https://provalab.io/blog/basics/smoke-sanity-flow.svg" alt="Top: a new build is deployed and a smoke test runs. If it passes, full testing starts; if it fails, the build is rejected. Bottom: a small fix such as BUG-231 is deployed and a sanity test runs. If it passes, the bug is re-tested and regression runs; if it fails, the fix goes back to the developer." width="880" height="380" loading="lazy"></p>
<p>In the <a href="https://provalab.io/blog/software-testing-life-cycle-stlc/">testing life cycle</a>, a passing smoke test is usually part of the <strong>entry criteria</strong> for test execution: &quot;build deployed and smoke test passed&quot;.</p>
<h2>Automating smoke tests</h2>
<p>Smoke tests are ideal for automation: few in number, run on every build, and with a clear pass or fail.</p>
<ul>
<li><strong>Run them automatically after every deployment</strong> to a test environment, as a step in the CI/CD pipeline.</li>
<li><strong>Keep them fast:</strong> a few minutes at most. If the smoke suite takes an hour, it has turned into a regression suite.</li>
<li><strong>Keep them stable:</strong> a flaky smoke test blocks every build, so these need to be the most reliable tests you have.</li>
<li><strong>Run them in production too:</strong> after each release, a small set of safe, read-only smoke checks (the home page loads, search works) confirms the release went out cleanly.</li>
</ul>
<p>Sanity tests are usually quicker to do by hand, because each one targets whatever just changed.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Skipping the smoke test</strong> &quot;because the change was small&quot;. Small changes break builds too.</li>
<li><strong>Letting the smoke suite grow</strong> until it takes as long as full testing.</li>
<li><strong>Testing deeply during a smoke test.</strong> Its job is go / no-go, not finding every bug.</li>
<li><strong>Treating a sanity pass as &quot;done&quot;.</strong> Sanity only says the change is worth testing properly: re-testing and regression still follow.</li>
</ul>
<h2>What&#39;s next</h2>
<p>After the sanity check, two more things happen to every fix: the bug is <strong>re-tested</strong>, and <strong>regression tests</strong> check that nothing else broke. In <strong><a href="https://provalab.io/blog/regression-testing-vs-retesting/">Part 15</a></strong> we explain <strong>regression testing vs re-testing</strong>, and how to keep regression suites fast.</p>
<p>ProvaLab can run your smoke suite automatically after every deployment, on real browsers and devices, and report the result in seconds, so a broken build is caught before anyone starts testing it. See it in the <a href="https://provalab.io/#stage-infra">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>The Software Testing Life Cycle (STLC): all six phases explained</title>
      <link>https://provalab.io/blog/software-testing-life-cycle-stlc/</link>
      <guid isPermaLink="true">https://provalab.io/blog/software-testing-life-cycle-stlc/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>stlc</category>
      <description>The six phases of the STLC in plain language, with what goes in and comes out of each, entry and exit criteria, how STLC fits the SDLC, and how it works in agile teams.</description>
      <content:encoded><![CDATA[<p>So far in this series we have covered <a href="https://provalab.io/blog/what-is-software-testing/">what testing is</a>, <a href="https://provalab.io/blog/types-of-software-testing/">the four levels of testing</a> and <a href="https://provalab.io/blog/manual-vs-automated-testing/">manual vs automated testing</a>. Now we put it in order: <strong>what does a tester actually do, step by step, from the first requirement to the release?</strong></p>
<p>The answer is the <strong>Software Testing Life Cycle</strong>, or <strong>STLC</strong>: six phases that take testing from &quot;what should we test?&quot; to &quot;we&#39;re done, here&#39;s what we found&quot;. It is also one of the most common topics in QA job interviews.</p>
<h2>What is the STLC?</h2>
<p><strong>The STLC is the sequence of activities a team follows to plan, design, run and finish testing.</strong> Each phase has:</p>
<ul>
<li><strong>Entry criteria:</strong> what must be true before the phase can start.</li>
<li><strong>Activities:</strong> the work done in the phase.</li>
<li><strong>Deliverables:</strong> what the phase produces.</li>
<li><strong>Exit criteria:</strong> what must be true before you move on.</li>
</ul>
<p>Here are the six phases at a glance:</p>
<p><img src="https://provalab.io/blog/basics/stlc-phases.svg" alt="The six STLC phases: 1 requirement analysis, producing testable requirements and an RTM; 2 test planning, producing the test plan; 3 test case design, producing test cases and test data; 4 environment setup, producing a ready environment; 5 test execution, producing results and defect reports; 6 test closure, producing the test summary report. The cycle repeats for every release or sprint." width="880" height="560" loading="lazy"></p>
<p>We will use the same <strong>food delivery app</strong> from Part 2, and follow one new feature through all six phases: <strong>discount codes at checkout</strong>.</p>
<h2>Phase 1: Requirement analysis</h2>
<p><strong>Goal: understand exactly what needs to be tested.</strong></p>
<p>The testers read the requirements and ask questions <em>before</em> any code is written. For discount codes:</p>
<ul>
<li>Can a customer use two codes on one order?</li>
<li>Does the code apply before or after the delivery fee?</li>
<li>What message appears for an expired code?</li>
</ul>
<p>Every unanswered question here is a bug avoided later. Testers also decide which requirements can be tested and how (manual, automated, or both).</p>
<p><strong>Deliverables:</strong> a list of testable requirements, open questions, and often a <strong>Requirements Traceability Matrix (RTM)</strong>: a table that links every requirement to the tests that cover it, so nothing is forgotten.</p>
<h2>Phase 2: Test planning</h2>
<p><strong>Goal: decide how the testing will be done.</strong></p>
<p>A test lead writes the <strong>test plan</strong>, which answers:</p>
<ul>
<li><strong>Scope:</strong> what will be tested, and what won&#39;t. (&quot;Discount codes on web and Android. iPhone next release.&quot;)</li>
<li><strong>Approach:</strong> which test levels and types, and what to automate.</li>
<li><strong>Resources:</strong> who tests what, and with which tools.</li>
<li><strong>Schedule and effort:</strong> how long it will take.</li>
<li><strong>Risks:</strong> what could go wrong, and the backup plan.</li>
<li><strong>Entry and exit criteria</strong> for the whole cycle.</li>
</ul>
<p><strong>Deliverables:</strong> the test plan and effort estimate.</p>
<h2>Phase 3: Test case design</h2>
<p><strong>Goal: write the actual tests.</strong></p>
<p>Testers turn the requirements into <strong>test cases</strong>, each with steps and an expected result, plus the <strong>test data</strong> they need:</p>
<table tabindex="0">
<thead>
<tr>
<th>ID</th>
<th>Test case</th>
<th>Expected result</th>
</tr>
</thead>
<tbody><tr>
<td>TC-01</td>
<td>Apply a valid 10% code to a 500 order</td>
<td>Total becomes 450</td>
</tr>
<tr>
<td>TC-02</td>
<td>Apply an expired code</td>
<td>&quot;This code has expired&quot; message, total unchanged</td>
</tr>
<tr>
<td>TC-03</td>
<td>Apply two codes</td>
<td>Second code is refused with a clear message</td>
</tr>
<tr>
<td>TC-04</td>
<td>Apply a code, then empty the cart</td>
<td>Discount is removed</td>
</tr>
</tbody></table>
<p>Test cases are reviewed by another tester or a developer. Automation engineers start writing automated scripts for the cases chosen in planning.</p>
<p><strong>Deliverables:</strong> reviewed test cases, test data and automation scripts. The RTM is updated so every requirement maps to its test cases.</p>
<h2>Phase 4: Test environment setup</h2>
<p><strong>Goal: get a place ready to run the tests.</strong></p>
<p>Tests need somewhere to run that behaves like production: servers, a database with test data, browsers, phones and the right app version. Often a separate team prepares it, and testers do a quick <strong>smoke test</strong> to check it actually works before starting.</p>
<p>This phase often runs <strong>at the same time</strong> as Phase 3.</p>
<p><strong>Deliverables:</strong> a working test environment and smoke test results.</p>
<h2>Phase 5: Test execution</h2>
<p><strong>Goal: run the tests and record what happens.</strong></p>
<p>Testers run the test cases, manual and automated, and mark each one <strong>passed</strong>, <strong>failed</strong>, <strong>blocked</strong> (can&#39;t run, because something else is broken) or <strong>not run</strong>.</p>
<p>When a test fails, they log a <strong>defect</strong> (bug report) with the steps to reproduce it, what was expected, what actually happened, and screenshots or a video. When developers fix it, testers <strong>re-test</strong> that fix, then run <strong>regression tests</strong> to make sure the fix didn&#39;t break anything else.</p>
<p><strong>Deliverables:</strong> test results, defect reports and an updated RTM showing which requirements passed.</p>
<h2>Phase 6: Test closure</h2>
<p><strong>Goal: finish properly and learn for next time.</strong></p>
<p>The team checks the exit criteria. For example: &quot;all critical tests passed, no open critical bugs, at least 95% of planned tests run&quot;. Then they write a <strong>test summary report</strong>:</p>
<ul>
<li>What was tested, and what wasn&#39;t.</li>
<li>How many tests passed, failed and were blocked.</li>
<li>Bugs found, fixed and still open, by severity.</li>
<li>Risks the release still carries.</li>
<li>Lessons learned for the next cycle.</li>
</ul>
<p><strong>Deliverables:</strong> the test summary report, and the test cases and scripts saved for reuse.</p>
<h2>Entry and exit criteria</h2>
<p>Entry and exit criteria stop two classic problems: <strong>starting too early</strong> (testing a build that doesn&#39;t even start) and <strong>never finishing</strong> (testing forever, or stopping on a deadline without knowing the risk).</p>
<table tabindex="0">
<thead>
<tr>
<th>Phase</th>
<th>Example entry criteria</th>
<th>Example exit criteria</th>
</tr>
</thead>
<tbody><tr>
<td>Test case design</td>
<td>Requirements reviewed and signed off</td>
<td>All test cases written and reviewed</td>
</tr>
<tr>
<td>Test execution</td>
<td>Environment ready, smoke test passed</td>
<td>All planned tests run; critical bugs fixed</td>
</tr>
<tr>
<td>Test closure</td>
<td>Execution finished</td>
<td>Summary report shared and signed off</td>
</tr>
</tbody></table>
<h2>How the STLC fits the SDLC</h2>
<p>The <strong>SDLC</strong> (Software Development Life Cycle) covers building the whole product. The STLC is the testing part of it, and it runs <strong>alongside</strong> development, not after it:</p>
<p><img src="https://provalab.io/blog/basics/sdlc-stlc.svg" alt="The SDLC and STLC side by side: requirement analysis runs while requirements are gathered, test planning during design, test design and environment setup while the code is written, test execution once the build is ready, and test closure at release." width="880" height="470" loading="lazy"></p>
<p>This is the &quot;test early&quot; idea from Part 1. When testers start on day one, the test cases are ready the moment the build is.</p>
<h2>STLC in agile teams</h2>
<p>In agile teams, the STLC doesn&#39;t disappear. It gets <strong>smaller and faster</strong>, and repeats every sprint:</p>
<ul>
<li><strong>Requirement analysis</strong> happens during backlog refinement, when testers ask questions about each user story.</li>
<li><strong>Planning</strong> is part of sprint planning, often with an overall test strategy instead of a long plan per sprint.</li>
<li><strong>Design, execution and closure</strong> happen inside the sprint, story by story.</li>
<li><strong>Automation</strong> carries the regression checks from sprint to sprint.</li>
</ul>
<p>Same six steps, just in days instead of months.</p>
<h2>Quick interview recap</h2>
<ol>
<li><strong>STLC</strong> = the six phases of testing: requirement analysis, test planning, test case design, environment setup, test execution, test closure.</li>
<li>Each phase has <strong>entry criteria</strong>, <strong>activities</strong>, <strong>deliverables</strong> and <strong>exit criteria</strong>.</li>
<li>The <strong>RTM</strong> links requirements to test cases, to prove everything is covered.</li>
<li>The STLC runs <strong>in parallel</strong> with the SDLC, and repeats every sprint in agile.</li>
</ol>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/first-automated-test-pytest/">Part 5</a></strong> we get hands-on: <strong>your first automated test in 15 minutes</strong>, with Python and pytest.</p>
<p>ProvaLab&#39;s Test Management follows the same life cycle: requirements linked to test cases, test plans, test runs with every result recorded, and defects linked back to the failing test. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Static testing explained: reviews, walkthroughs, inspections and static analysis</title>
      <link>https://provalab.io/blog/static-testing/</link>
      <guid isPermaLink="true">https://provalab.io/blog/static-testing/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>static-testing</category>
      <category>code-review</category>
      <description>How to find defects without running the software: the four types of review, the review process, static analysis tools with a real example, and why static testing is the cheapest testing there is.</description>
      <content:encoded><![CDATA[<p>Every test in this series so far has needed one thing: running software. But many defects can be found <strong>before anything runs</strong>, by reading. A requirement that contradicts itself, a design that forgets the error case, a line of code with a typo in a variable name: none of these need a running program to be spotted.</p>
<p>That&#39;s <strong>static testing</strong>, and <a href="https://provalab.io/blog/seven-principles-of-testing/">principle 3</a> explains why it matters so much: the earlier a defect is found, the cheaper it is to fix.</p>
<h2>Static vs dynamic testing</h2>
<p><img src="https://provalab.io/blog/basics/static-vs-dynamic.svg" alt="Static testing examines work products without running the software: requirements, designs, code, test cases and documents, through reviews by people and static analysis by tools. Dynamic testing runs the software and checks its behaviour through unit, integration, system and acceptance tests. Static testing finds the cause directly, from day one; dynamic testing finds failures and needs working software." width="880" height="420" loading="lazy"></p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>Static testing</th>
<th>Dynamic testing</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Software runs?</strong></td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td><strong>What is examined</strong></td>
<td>Requirements, designs, code, test cases, user guides</td>
<td>The software&#39;s behaviour</td>
</tr>
<tr>
<td><strong>How</strong></td>
<td>Reviews (people) and static analysis (tools)</td>
<td>Executing tests</td>
</tr>
<tr>
<td><strong>Finds</strong></td>
<td>The defect itself, and its cause</td>
<td>A failure; the cause must then be traced</td>
</tr>
<tr>
<td><strong>Can start</strong></td>
<td>As soon as a document or code exists</td>
<td>Only when runnable software exists</td>
</tr>
<tr>
<td><strong>Typical defects found</strong></td>
<td>Missing requirements, contradictions, design flaws, unreachable code, security weaknesses, standard violations</td>
<td>Wrong behaviour, performance problems, integration failures</td>
</tr>
</tbody></table>
<p>The two find <strong>different</strong> defects. A requirement that was never written can&#39;t be caught by running tests, because no test was written for it either. Only a review notices what&#39;s missing.</p>
<h2>What can be statically tested?</h2>
<p>Almost anything the team writes down:</p>
<ul>
<li><strong>Requirements and user stories:</strong> are they complete, consistent, testable?</li>
<li><strong>Designs and architecture documents</strong></li>
<li><strong>Code</strong></li>
<li><strong>Test plans and test cases</strong> (yes, tests get reviewed too)</li>
<li><strong>User guides, API documentation, configuration files</strong></li>
</ul>
<h2>Reviews: static testing by people</h2>
<p>A <strong>review</strong> is a person, or a group, examining a piece of work to find defects and improve it. Reviews range from a quick &quot;can you look at this?&quot; to a formal procedure with defined roles.</p>
<p><img src="https://provalab.io/blog/basics/review-types.svg" alt="Four types of review on a scale from informal to formal. Informal review: a colleague reads it and comments, no set process. Walkthrough: the author presents, the group discusses, for understanding and consensus. Technical review: experts check the work against standards, with a moderator and recorded findings. Inspection: defined roles, entry and exit criteria, checklists and metrics, for critical work such as payment or safety code." width="880" height="420" loading="lazy"></p>
<h3>Informal review</h3>
<p>A colleague reads the work and comments. No defined process, nothing recorded. Fast and cheap, and far better than no review. Most pair programming and quick desk checks fall here.</p>
<h3>Walkthrough</h3>
<p>The <strong>author leads</strong>: they walk the group through the document or code, explaining it, while the group asks questions and spots problems. The main goals are <strong>shared understanding and learning</strong>, as well as finding defects. Walkthroughs are good for designs and for bringing new team members up to speed.</p>
<h3>Technical review</h3>
<p>A more structured discussion led by a <strong>moderator</strong> rather than the author, with technical experts checking the work against standards, designs and best practice. Findings are <strong>recorded</strong> and followed up. Typical for architecture and API designs.</p>
<h3>Inspection</h3>
<p>The most formal type. Inspections have <strong>defined roles</strong> (moderator, author, reader, reviewers, scribe), <strong>entry criteria</strong> (the work is complete enough to inspect), <strong>checklists</strong>, <strong>metrics</strong> (defects found per page, time spent) and <strong>exit criteria</strong>. They&#39;re expensive, so they&#39;re reserved for critical work: payment logic, safety-related code, legal text.</p>
<h3>Where code review fits</h3>
<p>The <strong>code review</strong> that happens on every pull request is the most common review in modern teams. Depending on the team it sits somewhere between an informal review and a technical review: a reviewer reads the change, comments, and the author fixes issues before the change is merged.</p>
<p>Good code reviews check:</p>
<ul>
<li><strong>Correctness:</strong> does the change do what the ticket says, including error cases?</li>
<li><strong>Tests:</strong> are there tests, and do they test the right things?</li>
<li><strong>Readability:</strong> could someone else maintain this?</li>
<li><strong>Security:</strong> any injection risks, secrets in the code, missing permission checks?</li>
<li><strong>Performance:</strong> anything that will be slow with real data?</li>
</ul>
<h2>The review process</h2>
<p>A formal review follows roughly these steps:</p>
<ol>
<li><strong>Planning:</strong> decide what to review, who reviews it, and the entry criteria.</li>
<li><strong>Kick-off:</strong> share the work and the goals with the reviewers.</li>
<li><strong>Individual review:</strong> each reviewer reads the work <strong>alone first</strong> and notes potential defects. This step is where most defects are found.</li>
<li><strong>Review meeting (or discussion):</strong> reviewers share findings, decide which are real defects, and agree on actions.</li>
<li><strong>Rework:</strong> the author fixes the agreed defects.</li>
<li><strong>Follow-up:</strong> someone checks the fixes were made and the exit criteria are met.</li>
</ol>
<p>Even for a pull request, step 3 matters: a reviewer who reads carefully on their own finds more than a group skimming together.</p>
<h3>A requirements review checklist</h3>
<p>Reviewing requirements is the highest-value static testing there is, because every later step builds on them. For each requirement, ask:</p>
<ul>
<li><strong>Complete:</strong> does it say what happens on errors and edge cases?</li>
<li><strong>Unambiguous:</strong> could two people read it differently? (&quot;Codes give 10% off&quot;: before or after the delivery fee?)</li>
<li><strong>Consistent:</strong> does it contradict another requirement?</li>
<li><strong>Testable:</strong> could you write a test case with a clear pass or fail? (&quot;The app should be fast&quot; is not testable; &quot;search results appear within 1 second&quot; is.)</li>
<li><strong>Necessary and traceable:</strong> does anyone need this, and can it be linked to a business goal?</li>
</ul>
<p>Every question a tester asks here is a bug that will never be written.</p>
<h2>Static analysis: static testing by tools</h2>
<p><strong>Static analysis tools read code without running it</strong> and flag problems automatically. They are built into most modern development setups and run in seconds.</p>
<p>Here is a small, deliberately flawed Python function from our food delivery app:</p>
<pre tabindex="0"><code class="language-python">import os
import datetime


def apply_discount(total, code, codes):
    discount = 0
    if code in codes:
        discount = codes[code]
    if discount &gt; 100:
        pass
    final = total - total * discount / 100
    return finall
</code></pre>
<p>Running <strong>Ruff</strong>, a popular Python linter, on it:</p>
<pre tabindex="0"><code class="language-text">discount.py:1:8: F401 [*] `os` imported but unused
discount.py:2:8: F401 [*] `datetime` imported but unused
discount.py:11:5: F841 Local variable `final` is assigned to but never used
discount.py:12:12: F821 Undefined name `finall`
Found 5 errors.
</code></pre>
<p>In under a second, without running anything, the tool found:</p>
<ul>
<li>two <strong>unused imports</strong> (clutter, and sometimes a sign of leftover code);</li>
<li>a variable that is <strong>assigned but never used</strong>;</li>
<li>an <strong>undefined name</strong>: <code>finall</code> is a typo for <code>final</code>. This function would crash the first time it ran. Dynamic testing would catch it too, but only after someone wrote and ran a test; static analysis caught it the moment the file was saved.</li>
</ul>
<p>(The fifth finding was about import ordering, a style issue.) Notice what the tool did <strong>not</strong> find: the <code>if discount &gt; 100: pass</code> block does nothing, which a human reviewer would question. Tools and people complement each other.</p>
<h3>What static analysis tools check</h3>
<table tabindex="0">
<thead>
<tr>
<th>Category</th>
<th>Examples</th>
<th>Typical tools</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Bugs</strong></td>
<td>Undefined names, unreachable code, null dereferences, resource leaks</td>
<td>Ruff, pyflakes, ESLint, SpotBugs</td>
</tr>
<tr>
<td><strong>Style and formatting</strong></td>
<td>Naming, line length, consistent layout</td>
<td>Black, Prettier, Checkstyle</td>
</tr>
<tr>
<td><strong>Complexity</strong></td>
<td>Functions that are too long or too deeply nested</td>
<td>radon, SonarQube</td>
</tr>
<tr>
<td><strong>Security</strong></td>
<td>SQL injection, hard-coded passwords, unsafe functions</td>
<td>Bandit, Semgrep, CodeQL (this is called <strong>SAST</strong>: static application security testing)</td>
</tr>
<tr>
<td><strong>Type errors</strong></td>
<td>Passing a string where a number is expected</td>
<td>mypy, TypeScript</td>
</tr>
<tr>
<td><strong>Dependencies</strong></td>
<td>Known vulnerable library versions</td>
<td>pip-audit, npm audit, Dependabot</td>
</tr>
</tbody></table>
<p>Run them <strong>in the CI pipeline</strong>, so every change is checked automatically before review.</p>
<h2>Why static testing pays off</h2>
<ul>
<li><strong>It&#39;s early.</strong> Requirements can be reviewed before a line of code exists.</li>
<li><strong>It finds the cause, not just the symptom.</strong> A review finds the ambiguous sentence; a failing test only shows that two developers read it differently.</li>
<li><strong>It finds what dynamic testing can&#39;t:</strong> missing requirements, poor design, unreadable code, and security weaknesses that no functional test would trigger.</li>
<li><strong>It&#39;s cheap to repeat.</strong> Static analysis runs on every commit in seconds.</li>
<li><strong>It spreads knowledge.</strong> Reviews teach the team the codebase and each other&#39;s reasoning.</li>
</ul>
<h2>Common mistakes</h2>
<ul>
<li><strong>Skipping requirement reviews</strong> and discovering ambiguity during system testing, when it&#39;s expensive.</li>
<li><strong>Rubber-stamp reviews:</strong> approving a pull request without reading it. A review that finds nothing on a 500-line change probably wasn&#39;t a review.</li>
<li><strong>Reviewing too much at once.</strong> Defect detection drops sharply on huge changes. Smaller changes get better reviews.</li>
<li><strong>Treating linter warnings as noise</strong> and letting hundreds pile up, so the important ones are lost.</li>
<li><strong>Making reviews personal.</strong> Review the work, not the author. The goal is a better product, not a verdict on a person.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/exploratory-testing/">Part 22</a></strong> we look at <strong>exploratory testing</strong>: how skilled testers find the bugs that no script or checklist anticipated.</p>
<p>ProvaLab&#39;s PR Bot performs static testing on every pull request, combining static analysis, security checks and an AI review of the change, and feeds its findings back to the coding agent that wrote the code, so problems are fixed before a human reviewer looks. See it in the <a href="https://provalab.io/#stage-prbot">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>System testing explained: testing the whole product, end to end</title>
      <link>https://provalab.io/blog/system-testing/</link>
      <guid isPermaLink="true">https://provalab.io/blog/system-testing/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>system-testing</category>
      <description>What system testing is, how it differs from integration and acceptance testing, its functional and non-functional types, example test cases, and how to do it well.</description>
      <content:encoded><![CDATA[<p>In <a href="https://provalab.io/blog/integration-testing/">Part 12</a> we checked that the pieces of our food delivery app work together, one join at a time. Now every piece is in place. The next question is the one users actually care about: <strong>does the finished product work?</strong></p>
<p>That&#39;s <strong>system testing</strong>, the third of the <a href="https://provalab.io/blog/types-of-software-testing/">four levels of testing</a>.</p>
<h2>What is system testing?</h2>
<p><strong>System testing checks the complete, integrated system against its requirements, from the outside, the way a real user would use it.</strong></p>
<p>Three things make it different from the levels below it:</p>
<ul>
<li><strong>The whole system</strong> is tested, not parts of it: apps, servers, database, notifications and outside services, all running together.</li>
<li><strong>From the outside</strong>, mostly as <a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">black-box testing</a>: through the app, the website or the API, without looking at the code.</li>
<li><strong>In a production-like environment</strong>: set up like the live system, with realistic data.</li>
</ul>
<p><img src="https://provalab.io/blog/basics/system-under-test.svg" alt="System testing: the tester works from outside the system, like a user, through the mobile app, website and API, following journeys such as search, order, pay and track. Inside is the complete integrated system (order, payment, restaurant, delivery, database and notifications) running in a production-like test environment with realistic data and a sandbox payment provider." width="880" height="400" loading="lazy"></p>
<h2>System testing vs integration and acceptance testing</h2>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>Integration testing</th>
<th>System testing</th>
<th>Acceptance testing</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Question</strong></td>
<td>Do the parts work together?</td>
<td>Does the whole system meet its requirements?</td>
<td>Is this what the business and users need?</td>
</tr>
<tr>
<td><strong>Scope</strong></td>
<td>Two or more parts</td>
<td>The complete system</td>
<td>The complete system, in real use</td>
</tr>
<tr>
<td><strong>Done by</strong></td>
<td>Developers, QA</td>
<td>QA team</td>
<td>Customers, product owners, real users</td>
</tr>
<tr>
<td><strong>Based on</strong></td>
<td>Interfaces and design</td>
<td>Requirements and specifications</td>
<td>Business needs and acceptance criteria</td>
</tr>
<tr>
<td><strong>Environment</strong></td>
<td>Test or CI</td>
<td>Production-like test environment</td>
<td>Staging or production-like, often with real users</td>
</tr>
</tbody></table>
<p>A system can pass system testing and still fail acceptance, if it does exactly what the requirements said but the requirements missed what users needed.</p>
<h2>System testing vs end-to-end testing</h2>
<p>The two terms are often used as if they meant the same thing, but there is a small difference:</p>
<ul>
<li><strong>End-to-end (E2E) testing</strong> follows one <strong>complete user journey</strong> through every part of the system.</li>
<li><strong>System testing</strong> is broader: it covers those journeys <strong>plus</strong> non-functional checks like performance, security and compatibility of the whole system.</li>
</ul>
<p>So E2E tests are one important part of system testing.</p>
<h2>Types of system testing</h2>
<p><img src="https://provalab.io/blog/basics/system-test-types.svg" alt="What system testing covers. Functional: complete user journeys, business rules, calculations and data flowing correctly from start to end. Non-functional: performance, security, usability, accessibility, compatibility, recovery, install and upgrade, and localisation." width="880" height="440" loading="lazy"></p>
<h3>Functional system testing</h3>
<p>Does the whole system <strong>do the right thing</strong>?</p>
<ul>
<li><strong>Complete journeys:</strong> sign up, search, order, pay, track and rate.</li>
<li><strong>Business rules:</strong> free delivery over 300, one discount code per order, no orders when the restaurant is closed.</li>
<li><strong>Data end to end:</strong> the order shown to the customer, the restaurant and the delivery partner has the same items and the same total.</li>
</ul>
<h3>Non-functional system testing</h3>
<p>How <strong>well</strong> does the whole system do it?</p>
<ul>
<li><strong>Performance:</strong> does it stay fast with many users at once? (We cover this in Part 16.)</li>
<li><strong>Security:</strong> can users see only their own data? Can someone order without paying?</li>
<li><strong>Usability:</strong> can a first-time user order without help?</li>
<li><strong>Accessibility:</strong> does it work with a screen reader, with only a keyboard, with large text?</li>
<li><strong>Compatibility:</strong> does it work on the browsers, phones and operating systems your users have?</li>
<li><strong>Recovery:</strong> if a server restarts mid-payment, is the order lost, or charged twice?</li>
<li><strong>Installation and upgrade:</strong> does the new app version install cleanly and keep the user&#39;s saved addresses?</li>
<li><strong>Localisation:</strong> are languages, dates, currencies and phone numbers right in every country you serve?</li>
</ul>
<h2>Example system test cases</h2>
<table tabindex="0">
<thead>
<tr>
<th>ID</th>
<th>Test case</th>
<th>Expected result</th>
</tr>
</thead>
<tbody><tr>
<td>ST-01</td>
<td>New customer signs up, orders 2 pizzas, pays by card</td>
<td>Order confirmed; restaurant and delivery partner see it; confirmation SMS and email arrive</td>
</tr>
<tr>
<td>ST-02</td>
<td>Order 350 worth of food</td>
<td>Delivery fee is 0 (free delivery over 300)</td>
</tr>
<tr>
<td>ST-03</td>
<td>Restaurant closes while customer is at checkout</td>
<td>Customer is told clearly before paying; no charge</td>
</tr>
<tr>
<td>ST-04</td>
<td>Payment provider is down</td>
<td>&quot;Payment failed, please try again&quot;; no order created; no charge</td>
</tr>
<tr>
<td>ST-05</td>
<td>Place an order on Android, view it on the website</td>
<td>Same items, total and status on both</td>
</tr>
<tr>
<td>ST-06</td>
<td>Order in Hindi with the app set to Hindi</td>
<td>All text, dates and prices shown correctly</td>
</tr>
<tr>
<td>ST-07</td>
<td>Navigate the whole checkout with only a keyboard</td>
<td>Every step reachable and usable</td>
</tr>
</tbody></table>
<p>Notice how each one exercises <strong>many parts of the system at once</strong>, and how several test what happens when something goes wrong.</p>
<h2>Entry and exit criteria for system testing</h2>
<p><strong>Start system testing when:</strong></p>
<ul>
<li>Integration testing is complete, with no open critical integration bugs.</li>
<li>The full system is deployed to a production-like environment.</li>
<li>Test data and test accounts are ready.</li>
<li>A smoke test of the build has passed (Part 14).</li>
</ul>
<p><strong>Finish system testing when:</strong></p>
<ul>
<li>All planned system tests have been run.</li>
<li>All high-priority tests pass.</li>
<li>No critical or high-severity bugs are open (or the remaining ones are accepted risks).</li>
<li>The test summary report is shared.</li>
</ul>
<h2>Doing system testing well</h2>
<ol>
<li><strong>Use a production-like environment.</strong> Bugs that only appear with the real configuration, a real-sized database or the real network setup won&#39;t show up on a developer&#39;s laptop.</li>
<li><strong>Use realistic test data.</strong> Long names, addresses with special characters, customers with a hundred past orders. Never use real customers&#39; personal data; create test data that looks like it.</li>
<li><strong>Test the unhappy paths.</strong> Payment failures, closed restaurants, lost network, expired sessions. Users meet these every day.</li>
<li><strong>Think like different users:</strong> a first-time customer, a regular, a restaurant owner, a delivery partner, an admin.</li>
<li><strong>Automate the critical journeys.</strong> Sign-up, sign-in and checkout should run automatically on every release, with tools like Playwright or Selenium. Keep the rest manual or exploratory, as the <a href="https://provalab.io/blog/types-of-software-testing/">testing pyramid</a> suggests.</li>
<li><strong>Trace every test to a requirement</strong>, using the <a href="https://provalab.io/blog/requirements-traceability-matrix/">traceability matrix</a>, so you know the whole specification is covered.</li>
</ol>
<h2>Common mistakes</h2>
<ul>
<li><strong>Testing on a developer environment</strong> that differs from production in small but important ways.</li>
<li><strong>Only testing happy paths</strong> because &quot;the requirements didn&#39;t mention errors&quot;.</li>
<li><strong>Skipping non-functional testing</strong> until after release, then discovering the app is slow or inaccessible.</li>
<li><strong>Too many automated end-to-end tests</strong>, making the suite slow and flaky. Automate the journeys that matter most.</li>
<li><strong>Starting before integration is stable</strong>, so system testers spend their days reporting integration bugs.</li>
</ul>
<h2>What&#39;s next</h2>
<p>Before deep testing like this begins, teams run a quick check to make sure the build is even worth testing. In <strong><a href="https://provalab.io/blog/smoke-vs-sanity-testing/">Part 14</a></strong> we cover <strong>smoke testing and sanity testing</strong>: what each one is, and when to use which.</p>
<p>ProvaLab runs your end-to-end and system tests on real browsers and devices, records a video of every run, and keeps each result linked to its test case and requirement, so system test reports are always up to date. See it in the <a href="https://provalab.io/#stage-infra">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Test coverage explained: the types, and how to calculate each one</title>
      <link>https://provalab.io/blog/test-coverage-explained/</link>
      <guid isPermaLink="true">https://provalab.io/blog/test-coverage-explained/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>test-coverage</category>
      <description>The main kinds of test coverage (requirement, execution, code and platform), the formula for each with worked examples, sensible targets, and why 100% coverage isn't bug-free.</description>
      <content:encoded><![CDATA[<p>&quot;What&#39;s our test coverage?&quot; is one of the most common questions in software teams, and one of the most misunderstood. The answer depends on <strong>what</strong> you are measuring the coverage <em>of</em>. A team can honestly say &quot;we have 90% coverage&quot; while a whole feature has never been tested.</p>
<p>In this part we look at the main kinds of test coverage, how to calculate each one, and how to use them without being fooled.</p>
<h2>What is test coverage?</h2>
<p><strong>Test coverage measures how much of something your tests have checked, as a percentage.</strong> That &quot;something&quot; can be:</p>
<ul>
<li>the <strong>requirements</strong> (what the software should do),</li>
<li>the <strong>test cases</strong> you planned to run,</li>
<li>the <strong>code</strong> itself,</li>
<li>the <strong>platforms</strong> your users have: browsers, devices and operating systems.</li>
</ul>
<p>Each kind answers a different question:</p>
<p><img src="https://provalab.io/blog/basics/coverage-types.svg" alt="Four kinds of coverage. Requirement coverage: did we test everything we promised? Requirements with at least one test divided by all requirements, for example 4 of 5, 80%. Execution coverage: did we run the tests we planned? Test cases run divided by test cases planned, for example 113 of 120, 94%. Code coverage: which code did the tests actually run? For example 2 of 4 branches, 50%. Platform coverage: did we test where our users are? Browser and device combinations tested divided by all supported, for example 4 of 6, 67%." width="880" height="470" loading="lazy"></p>
<h2>1. Requirement coverage</h2>
<p><strong>Question:</strong> did we test everything the software is supposed to do?</p>
<blockquote>
<p><strong>Requirement coverage = (requirements with at least one test ÷ all requirements) × 100</strong></p>
</blockquote>
<p><strong>Example:</strong> from the <a href="https://provalab.io/blog/requirements-traceability-matrix/">traceability matrix</a> in Part 11, the discount-code feature has 5 requirements, and 4 of them have test cases (REQ-14, Android, has none).</p>
<p>4 ÷ 5 × 100 = <strong>80%</strong></p>
<p>A stricter version only counts requirements whose tests <strong>pass</strong>:</p>
<blockquote>
<p><strong>Passing requirement coverage = (requirements whose tests all pass ÷ all requirements) × 100</strong></p>
</blockquote>
<p>In the same example, REQ-12 is tested but failing, so: 3 ÷ 5 × 100 = <strong>60%</strong>.</p>
<p>Requirement coverage is the one business people care about most: it speaks the language of features, not code.</p>
<h2>2. Execution coverage</h2>
<p><strong>Question:</strong> did we actually run the tests we planned?</p>
<blockquote>
<p><strong>Execution coverage = (test cases run ÷ test cases planned) × 100</strong></p>
</blockquote>
<p>&quot;Run&quot; means the test produced a real verdict: <strong>passed or failed</strong>. Blocked, skipped and not-run tests don&#39;t count, because we learned nothing from them.</p>
<p><strong>Example:</strong> in Run 42 from <a href="https://provalab.io/blog/test-runs-and-test-results/">Part 17</a>, 120 test cases were planned: 104 passed, 9 failed, 4 were blocked and 3 skipped.</p>
<p>(104 + 9) ÷ 120 × 100 = <strong>94%</strong></p>
<p>Don&#39;t confuse this with the <strong>pass rate</strong>, which asks how many of the tests that ran passed: 104 ÷ 113 = 92%. Execution coverage tells you <em>how much you tested</em>; pass rate tells you <em>how well it did</em>.</p>
<h2>3. Code coverage</h2>
<p><strong>Question:</strong> which parts of the code did the tests actually run?</p>
<p>Code coverage is measured by a tool while the tests run: coverage.py for Python, JaCoCo for Java, Istanbul/nyc or Jest&#39;s built-in coverage for JavaScript, and so on. There are several kinds:</p>
<blockquote>
<p><strong>Line (statement) coverage = (lines executed ÷ all executable lines) × 100</strong></p>
<p><strong>Branch coverage = (branch outcomes taken ÷ all branch outcomes) × 100</strong></p>
<p><strong>Function coverage = (functions called ÷ all functions) × 100</strong></p>
</blockquote>
<p>The difference between line and branch coverage matters a lot. Here is the delivery-fee function from <a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">Part 7</a>, with a single test:</p>
<p><img src="https://provalab.io/blog/basics/line-vs-branch.svg" alt="The delivery_fee function tested once with a 400 order for a member. All 7 lines run, so line coverage is 7 of 7, 100%. But each of the two if statements can be true or false, and the test only took the true side of both, so branch coverage is 2 of 4, 50%. Small orders and non-members were never tested." width="880" height="400" loading="lazy"></p>
<ul>
<li><strong>Line coverage:</strong> all 7 lines ran → 7 ÷ 7 = <strong>100%</strong>.</li>
<li><strong>Branch coverage:</strong> each <code>if</code> has two outcomes (true and false), so there are 4 outcomes. The test only took the 2 &quot;true&quot; ones → 2 ÷ 4 = <strong>50%</strong>.</li>
</ul>
<p>100% line coverage, yet half the decisions in the code were never tested. That&#39;s why <strong>branch coverage is the more honest number</strong>.</p>
<p><strong>A note on tools:</strong> some tools combine lines and branches into one figure. In Part 7, coverage.py reported <strong>82%</strong> for this same test: it counts the 7 lines plus the 4 branch outcomes, and the test covered 7 + 2 = 9 of those 11. (9 ÷ 11 = 82%.) When you compare coverage numbers, check exactly what each tool is counting.</p>
<p><strong>Path coverage</strong> goes further still: every route through the code. With two <code>if</code> statements there are four paths (big order + member, big + non-member, small + member, small + non-member). Path coverage is very thorough, but grows so fast in real code that it is usually only used for critical logic.</p>
<h2>4. Platform coverage</h2>
<p><strong>Question:</strong> did we test on the browsers and devices our users actually have?</p>
<blockquote>
<p><strong>Platform coverage = (supported platform combinations tested ÷ all supported combinations) × 100</strong></p>
</blockquote>
<p><strong>Example:</strong> the food app officially supports Chrome, Safari and Firefox on desktop, plus the Android app, the iPhone app, and Chrome on Android phones: 6 combinations. This release was tested on 4 of them.</p>
<p>4 ÷ 6 × 100 = <strong>67%</strong></p>
<p>Use your analytics to weight this: missing a browser that 2% of users have matters far less than missing the one that 50% use.</p>
<h2>Other coverage measures you may hear</h2>
<ul>
<li><strong>Risk coverage:</strong> how many of the identified <a href="https://provalab.io/blog/how-to-write-a-test-plan/">high-risk areas</a> have tests.</li>
<li><strong>Feature coverage:</strong> like requirement coverage, but counted per feature or user story.</li>
<li><strong>Mutation score:</strong> a test of your tests. A tool makes small deliberate changes to the code (for example, <code>&gt;=</code> becomes <code>&gt;</code>) and checks whether any test fails. Mutation score = changes caught ÷ changes made × 100. Tests with no real assertions score badly here, even with high code coverage.</li>
</ul>
<h2>What coverage target should you aim for?</h2>
<p>There is no universal right number. Some sensible rules of thumb:</p>
<ul>
<li><strong>Requirement coverage:</strong> aim for <strong>100% of high-priority requirements</strong> before release. Any gap should be a deliberate, documented decision, not a surprise.</li>
<li><strong>Execution coverage:</strong> aim for <strong>100% of planned tests run</strong>, and explain every blocked or skipped one in the test report.</li>
<li><strong>Code coverage:</strong> many teams aim for around 70–80% line or branch coverage, with higher targets for critical code like payments. Chasing 100% everywhere often produces low-value tests written just to touch lines.</li>
<li><strong>Platform coverage:</strong> cover the combinations used by most of your real users, plus anything business-critical.</li>
</ul>
<p>More useful than any single target: <strong>watch the trend</strong>, and make sure coverage doesn&#39;t drop when new code is added.</p>
<h2>Why 100% coverage doesn&#39;t mean bug-free</h2>
<p>Coverage tells you what your tests <strong>touched</strong>. It does not tell you whether they <strong>checked the right things</strong>:</p>
<ul>
<li>A test with no <code>assert</code> still gives coverage.</li>
<li>100% requirement coverage only means each requirement has <em>a</em> test, not a good one.</li>
<li>Code coverage can&#39;t find a feature that was <strong>never built</strong>. You can&#39;t cover code that doesn&#39;t exist (see <a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">Part 7</a>).</li>
<li><a href="https://provalab.io/blog/how-to-write-test-cases/">You can&#39;t test every possible input</a>, so coverage will always be of a chosen sample.</li>
</ul>
<p>So use coverage as a <strong>map of what you haven&#39;t tested</strong>, not as proof of quality. Low coverage is a clear warning; high coverage is a good sign, not a guarantee.</p>
<h2>Quick reference</h2>
<table tabindex="0">
<thead>
<tr>
<th>Coverage</th>
<th>Formula</th>
<th>Answers</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Requirement</strong></td>
<td>requirements with tests ÷ all requirements</td>
<td>Did we test everything we promised?</td>
</tr>
<tr>
<td><strong>Execution</strong></td>
<td>tests run ÷ tests planned</td>
<td>Did we run what we planned?</td>
</tr>
<tr>
<td><strong>Line / statement</strong></td>
<td>lines run ÷ all lines</td>
<td>Which code was touched?</td>
</tr>
<tr>
<td><strong>Branch</strong></td>
<td>branch outcomes taken ÷ all outcomes</td>
<td>Did every decision go both ways?</td>
</tr>
<tr>
<td><strong>Function</strong></td>
<td>functions called ÷ all functions</td>
<td>Which functions were used?</td>
</tr>
<tr>
<td><strong>Platform</strong></td>
<td>combinations tested ÷ combinations supported</td>
<td>Did we test where our users are?</td>
</tr>
</tbody></table>
<h2>What&#39;s next</h2>
<p>That completes this batch of the Testing Basics series, from <a href="https://provalab.io/blog/what-is-software-testing/">what testing is</a> to coverage. Coming up next: <strong><a href="https://provalab.io/blog/seven-principles-of-testing/">the 7 principles of testing</a></strong>, <strong>QA vs QC</strong>, <strong>static testing</strong>, <strong>exploratory testing</strong> and <strong>testing in agile and CI/CD</strong>.</p>
<p>ProvaLab Test Management keeps requirement and execution coverage up to date automatically: requirements link to test cases, every manual or automated run records its results, and coverage reports show the gaps by feature, test plan and run. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Test runs and test results explained: statuses, pass rates and how to read them</title>
      <link>https://provalab.io/blog/test-runs-and-test-results/</link>
      <guid isPermaLink="true">https://provalab.io/blog/test-runs-and-test-results/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>test-execution</category>
      <description>What a test run and a test result are, how they relate to test cases, every result status, how to calculate pass rates correctly, and how to read a run so nothing important is missed.</description>
      <content:encoded><![CDATA[<p>So far in this series you have <a href="https://provalab.io/blog/how-to-write-test-cases/">written test cases</a>, <a href="https://provalab.io/blog/how-to-write-a-test-plan/">planned the testing</a> and learned about <a href="https://provalab.io/blog/types-of-software-testing/">the kinds of testing</a>. But a test case on its own doesn&#39;t tell you anything about the software. Only when it is <strong>run</strong> do you learn something.</p>
<p>This part explains <strong>test runs</strong> and <strong>test results</strong>: what they are, what each result status means, how to calculate a pass rate correctly, and how to read a run like an experienced tester.</p>
<h2>Test case, test run and test result</h2>
<p>These three are easy to confuse, but each has a different job:</p>
<ul>
<li><strong>Test case:</strong> <em>what</em> to check. Written once and kept in the catalogue. &quot;TC-104: an expired discount code must be refused.&quot;</li>
<li><strong>Test run:</strong> <em>one execution</em> of a set of test cases, at a specific time, on a specific <strong>build</strong> and <strong>environment</strong>. &quot;Run 42: release 2.4 regression, build 2.4.0, staging, 4 November.&quot;</li>
<li><strong>Test result:</strong> <em>what happened</em> to <strong>one test case in one run</strong>. &quot;TC-104 in Run 42: Failed. The total became 450 instead of showing &#39;code expired&#39;.&quot;</li>
</ul>
<p><img src="https://provalab.io/blog/basics/case-run-result.svg" alt="Test cases TC-101, TC-104 and TC-105 are written once in the catalogue. Run 41, on build 2.3.9 on 28 October, recorded three passed results. Run 42, on build 2.4.0 on 4 November, recorded TC-101 passed, TC-104 failed with video and logs attached and BUG-231 raised, and TC-105 passed. One test case across many runs builds a history of results." width="880" height="470" loading="lazy"></p>
<p><strong>A test case is written once. It is run many times. Every run gives it a new result.</strong> Over months, those results become the test case&#39;s <strong>history</strong>: &quot;TC-104 passed on every build until 2.4.0&quot;.</p>
<p>You may also hear <strong>test execution</strong> (the act of running tests) and <strong>test cycle</strong> (one round of testing, often made up of several runs, such as &quot;the release 2.4 test cycle&quot;).</p>
<h2>What a test run records</h2>
<table tabindex="0">
<thead>
<tr>
<th>Field</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Name</strong></td>
<td>Release 2.4 regression</td>
</tr>
<tr>
<td><strong>Test plan</strong></td>
<td>Release 2.4: discount codes</td>
</tr>
<tr>
<td><strong>Build / version</strong></td>
<td>2.4.0</td>
</tr>
<tr>
<td><strong>Environment</strong></td>
<td>Staging, Chrome 131 and Android 14</td>
</tr>
<tr>
<td><strong>Test cases included</strong></td>
<td>120 from the regression suite</td>
</tr>
<tr>
<td><strong>Run by</strong></td>
<td>A tester, or automated (nightly pipeline)</td>
</tr>
<tr>
<td><strong>Started / finished</strong></td>
<td>4 Nov 02:00 – 02:47</td>
</tr>
<tr>
<td><strong>Status</strong></td>
<td>Completed</td>
</tr>
</tbody></table>
<p>The <strong>build</strong> and <strong>environment</strong> are what make a run meaningful. &quot;TC-104 failed&quot; is not useful on its own; &quot;TC-104 failed on build 2.4.0 on staging, and passed on 2.3.9&quot; tells a developer exactly where to look.</p>
<h2>What a test result records</h2>
<table tabindex="0">
<thead>
<tr>
<th>Field</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Status</strong></td>
<td>Failed</td>
</tr>
<tr>
<td><strong>Actual result</strong></td>
<td>Code OLD10 accepted; total became 450</td>
</tr>
<tr>
<td><strong>Error message</strong></td>
<td><code>AssertionError: expected 500, got 450</code> (for automated tests)</td>
</tr>
<tr>
<td><strong>Duration</strong></td>
<td>8.2 seconds</td>
</tr>
<tr>
<td><strong>Evidence</strong></td>
<td>Screenshot, screen recording, console and network logs</td>
</tr>
<tr>
<td><strong>Linked bug</strong></td>
<td>BUG-231</td>
</tr>
<tr>
<td><strong>Comment</strong></td>
<td>Fails on web and Android; passed on 2.3.9</td>
</tr>
</tbody></table>
<p>For a passed test, the status is often enough. For a failure, the evidence is what turns a red mark into something a developer can fix.</p>
<h2>Every result status explained</h2>
<table tabindex="0">
<thead>
<tr>
<th>Status</th>
<th>Meaning</th>
<th>What to do</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Passed</strong></td>
<td>The actual result matched the expected result</td>
<td>Nothing; its history grows</td>
</tr>
<tr>
<td><strong>Failed</strong></td>
<td>The actual result was different</td>
<td>Investigate; raise or link a bug</td>
</tr>
<tr>
<td><strong>Blocked</strong></td>
<td>The test couldn&#39;t run because something else is broken</td>
<td>Fix or report the blocker, then run it</td>
</tr>
<tr>
<td><strong>Skipped</strong></td>
<td>Deliberately not run this time</td>
<td>Make sure the reason is known and acceptable</td>
</tr>
<tr>
<td><strong>Not run / Untested</strong></td>
<td>Planned, but not run yet</td>
<td>Run it, or explain why not</td>
</tr>
<tr>
<td><strong>In progress</strong></td>
<td>Being run right now</td>
<td>Wait for the result</td>
</tr>
</tbody></table>
<p><strong>Blocked is not the same as failed.</strong> If sign-in is broken, every test that needs a signed-in user is <em>blocked</em>: we learned nothing about those features, good or bad. Counting them as failed exaggerates the damage; ignoring them hides how much is untested.</p>
<h2>Calculating the pass rate correctly</h2>
<p>Here is the summary of Run 42:</p>
<p><img src="https://provalab.io/blog/basics/run-summary.svg" alt="Run 42, the release 2.4 regression on build 2.4.0, with 120 test cases planned: 104 passed, 9 failed, 4 blocked, 3 skipped. Pass rate of executed tests: 104 divided by 113, 92 percent. Pass rate of planned tests: 104 divided by 120, 87 percent. The 9 failures grouped by cause: 6 from a renamed checkout button with outdated test selectors, 2 from BUG-231, and 1 timeout in the payment sandbox." width="880" height="420" loading="lazy"></p>
<p>There are two different pass rates, and both are useful:</p>
<ul>
<li><strong>Pass rate of executed tests</strong> = passed ÷ (passed + failed)
= 104 ÷ 113 = <strong>92%</strong>. <em>How good is the software in the parts we actually tested?</em></li>
<li><strong>Pass rate of planned tests</strong> = passed ÷ all planned tests
= 104 ÷ 120 = <strong>87%</strong>. <em>How much of the plan has been proven to work?</em></li>
</ul>
<p>The gap between them is the <strong>blocked</strong> and <strong>skipped</strong> tests: 7 tests, nearly 6% of the plan, that told us nothing. A report that only says &quot;92% passed&quot; hides that.</p>
<h2>How to read a test run</h2>
<p>A run with 9 red results is not 9 problems. Read it like this:</p>
<ol>
<li><strong>Check the run is valid.</strong> Right build? Right environment? Did it finish, or stop halfway?</li>
<li><strong>Look at blocked and skipped first.</strong> They show what you <em>didn&#39;t</em> learn.</li>
<li><strong>Group failures by cause, not by test.</strong> In Run 42, six failures come from <strong>one</strong> cause: a renamed button that the tests still look for by its old name. That&#39;s one fix to the test code, not six bugs.</li>
<li><strong>Separate real bugs from test or environment problems.</strong><ul>
<li><strong>Product bug:</strong> the app is wrong (BUG-231).</li>
<li><strong>Test problem:</strong> the test is outdated or wrong (the renamed button).</li>
<li><strong>Environment problem:</strong> something around the app failed (the payment sandbox timeout).</li>
</ul>
</li>
<li><strong>Compare with the previous run.</strong> A test that passed yesterday and fails today on a new build is a likely <strong>regression</strong> (see <a href="https://provalab.io/blog/regression-testing-vs-retesting/">Part 15</a>). A test that fails on some runs and passes on others with no change is probably <strong>flaky</strong>.</li>
<li><strong>Read the actual error before describing a failure.</strong> Don&#39;t guess from a test&#39;s name what went wrong; open the result, its message and its recording.</li>
<li><strong>Link every real failure to a bug</strong> (see <a href="https://provalab.io/blog/how-to-write-a-bug-report/">Part 9</a>), so the run, the test case and the bug can all be traced to each other.</li>
</ol>
<h2>Manual and automated runs</h2>
<ul>
<li><strong>Manual runs:</strong> a tester works through the test cases and records each result by hand, with screenshots for failures.</li>
<li><strong>Automated runs:</strong> a tool runs the tests (often every night, or on every code change) and records the results itself, often with logs and video.</li>
</ul>
<p>Most teams have both, and the best practice is to <strong>keep their results in the same place</strong>, so a release report covers everything tested, however it was run.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Only reporting &quot;X% passed&quot;</strong>, without the blocked and skipped tests.</li>
<li><strong>Re-running failed tests until they pass</strong>, then reporting green. That hides flaky tests and real intermittent bugs.</li>
<li><strong>Counting failures instead of causes</strong>, so one broken selector looks like a disaster.</li>
<li><strong>Not recording the build and environment</strong>, so nobody can tell what the result applies to.</li>
<li><strong>Results scattered across spreadsheets, chat messages and CI logs</strong>, so nobody has the full picture.</li>
</ul>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/test-coverage-explained/">Part 18</a></strong>, the last of this set, we look at <strong>test coverage</strong>: the different kinds, and how to calculate each one.</p>
<p>In ProvaLab Test Management, every run, manual or automated, records its build, environment and a result for each test case, with the error, screen recording and logs attached to failures. Each test case keeps its full history across runs, and failures can be grouped by cause and linked to bugs. See it in the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Testing in agile and CI/CD: how it all fits together</title>
      <link>https://provalab.io/blog/testing-in-agile-and-ci-cd/</link>
      <guid isPermaLink="true">https://provalab.io/blog/testing-in-agile-and-ci-cd/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>agile-testing</category>
      <category>ci-cd</category>
      <description>How testing works when a team ships every week or every day: whole-team quality, the agile testing quadrants, shift-left and shift-right, TDD and BDD, and testing at every stage of a CI/CD pipeline.</description>
      <content:encoded><![CDATA[<p>Most of the ideas in this series are decades old. What has changed is <strong>speed</strong>. Teams that used to release twice a year now release every week, every day, or many times a day. There is no longer a &quot;testing phase&quot; at the end, because there is no end: the next change is always on its way.</p>
<p>This final part of Testing Basics shows how everything we&#39;ve covered fits together when a team works like that.</p>
<h2>Testing in agile teams</h2>
<p>Agile methods such as Scrum and Kanban build software in short cycles (<strong>sprints</strong> of one to four weeks, or a continuous flow of work). Four things change for testing:</p>
<h3>1. Quality is the whole team&#39;s job</h3>
<p>There&#39;s no separate testing phase, so there can&#39;t be a separate testing team that gets the software at the end. Developers write <a href="https://provalab.io/blog/first-automated-test-pytest/">unit tests</a> and review each other&#39;s code. Testers join the planning conversations, ask the awkward questions early, and spend their time on the things only people can do: <a href="https://provalab.io/blog/exploratory-testing/">exploratory testing</a>, test design and risk. This is often called the <strong>whole-team approach</strong>.</p>
<h3>2. Testing happens inside the sprint</h3>
<p>Each user story is tested in the same sprint it&#39;s built. A story isn&#39;t &quot;done&quot; when the code is written; it&#39;s done when it meets the team&#39;s <strong>definition of done</strong>, which usually includes: tests written and passing, code reviewed, acceptance criteria checked, and no open bugs of high severity.</p>
<h3>3. Acceptance criteria are the test cases</h3>
<p>Each user story carries <strong>acceptance criteria</strong> agreed before development starts. They play the role that requirements and test cases played in the <a href="https://provalab.io/blog/software-testing-life-cycle-stlc/">STLC</a>: they say what &quot;working&quot; means, and each one becomes at least one test. A common format is <strong>Given / When / Then</strong>:</p>
<blockquote>
<p><strong>Given</strong> a signed-in customer with 500 in the cart
<strong>When</strong> they apply the expired code OLD10
<strong>Then</strong> they see &quot;This code has expired&quot; and the total stays 500</p>
</blockquote>
<h3>4. Automation is not optional</h3>
<p>If the regression suite takes a week to run by hand, you can&#39;t release every week. Agile teams <strong>automate</strong> their <a href="https://provalab.io/blog/regression-testing-vs-retesting/">regression tests</a> and run them continuously, so the humans are free to explore.</p>
<h2>The agile testing quadrants</h2>
<p>Brian Marick&#39;s <strong>agile testing quadrants</strong>, made popular by Lisa Crispin and Janet Gregory, are a map of all the testing an agile team does. They answer the question &quot;are we doing all the kinds of testing we need, or only the easy ones?&quot;</p>
<p><img src="https://provalab.io/blog/basics/agile-quadrants.svg" alt="The agile testing quadrants. Q1, technology-facing and supporting the team: unit, component and integration tests, static analysis and code review, automated. Q2, business-facing and supporting the team: functional and story tests, examples and acceptance criteria, prototypes, automated and manual. Q3, business-facing and critiquing the product: exploratory testing, usability and user acceptance testing, alpha and beta releases, manual. Q4, technology-facing and critiquing the product: performance, load, security, compatibility and recovery tests, tool-based." width="880" height="520" loading="lazy"></p>
<ul>
<li><strong>Q1 and Q2 support the team while building:</strong> they tell developers quickly whether the code does what the story asked.</li>
<li><strong>Q3 and Q4 critique the finished product:</strong> they find what the stories didn&#39;t anticipate.</li>
</ul>
<p>Most teams are strong in Q1 and Q2 and weak in Q3 and Q4. If your team has thousands of unit tests but nobody has done a <a href="https://provalab.io/blog/performance-testing-types/">load test</a> or an exploratory session this quarter, the quadrants show the gap.</p>
<h2>Shift left, shift right</h2>
<p><strong>Shift left</strong> means moving testing <strong>earlier</strong> in the process: reviewing requirements, writing tests before code, running tests on every commit. Everything from <a href="https://provalab.io/blog/seven-principles-of-testing/">principle 3</a> (early testing) and <a href="https://provalab.io/blog/static-testing/">static testing</a> is shifting left.</p>
<p><strong>Shift right</strong> means continuing to test <strong>after release</strong>, in production: monitoring, alerts, feature flags that release a change to 1% of users first, and small smoke checks that run against the live site. Shift right accepts that some problems only show up with real users and real data, and makes sure you learn about them in minutes rather than weeks.</p>
<p>Modern teams do both: catch what you can early, and watch closely for what you couldn&#39;t.</p>
<h2>TDD and BDD</h2>
<p>Two practices shape how tests are written in agile teams:</p>
<p><strong>Test-driven development (TDD)</strong> is a developer practice: write a failing unit test <em>first</em>, write just enough code to make it pass, then clean up, and repeat. The tests become a design tool, and every line of code exists because a test asked for it. (You saw the value of a test you&#39;ve watched fail in <a href="https://provalab.io/blog/first-automated-test-pytest/">Part 5</a>.)</p>
<p><strong>Behaviour-driven development (BDD)</strong> applies the same idea at the story level, using the Given/When/Then format above. The acceptance criteria are written so that tools such as Cucumber, Behave or SpecFlow can run them directly as automated tests. The main benefit is shared language: product owners, developers and testers all read the same examples.</p>
<p>Neither is mandatory for agile, but both make &quot;acceptance criteria are the test cases&quot; literally true.</p>
<h2>Continuous integration and continuous delivery</h2>
<p><strong>Continuous integration (CI)</strong> means every change is merged into the shared codebase frequently (at least daily) and <strong>automatically built and tested</strong> each time. <strong>Continuous delivery (CD)</strong> extends the pipeline so that every change that passes the tests is ready to release, and <strong>continuous deployment</strong> releases it automatically.</p>
<p>The pipeline is where all the test types in this series take their places:</p>
<p><img src="https://provalab.io/blog/basics/ci-cd-pipeline.svg" alt="A seven-stage pipeline. Commit: static analysis and unit tests, about a minute. Pull request: code review, integration and API tests, security scan, about ten minutes. Merge to main: build, deploy to a test environment, smoke test. Nightly: full regression on all browsers and performance tests, hours. Release candidate: system, exploratory and acceptance testing on staging, days. Production: a smoke test on production and monitoring. Each stage is a gate; testing is fast on the left and deep on the right." width="880" height="440" loading="lazy"></p>
<table tabindex="0">
<thead>
<tr>
<th>Stage</th>
<th>What runs</th>
<th>How long</th>
<th>What it catches</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Every commit</strong></td>
<td><a href="https://provalab.io/blog/static-testing/">Static analysis</a>, unit tests</td>
<td>About a minute</td>
<td>Typos, logic errors, broken units</td>
</tr>
<tr>
<td><strong>Every pull request</strong></td>
<td>Code review, <a href="https://provalab.io/blog/integration-testing/">integration</a> and API tests, security scan</td>
<td>About 10 minutes</td>
<td>Broken joins, insecure code</td>
</tr>
<tr>
<td><strong>Merge to main</strong></td>
<td>Build, deploy to test environment, <a href="https://provalab.io/blog/smoke-vs-sanity-testing/">smoke test</a></td>
<td>About 15 minutes</td>
<td>A build that doesn&#39;t even start</td>
</tr>
<tr>
<td><strong>Nightly</strong></td>
<td>Full <a href="https://provalab.io/blog/regression-testing-vs-retesting/">regression</a> on every browser, <a href="https://provalab.io/blog/performance-testing-types/">performance tests</a></td>
<td>Hours</td>
<td>Regressions, slowdowns</td>
</tr>
<tr>
<td><strong>Release candidate</strong></td>
<td><a href="https://provalab.io/blog/system-testing/">System</a>, <a href="https://provalab.io/blog/exploratory-testing/">exploratory</a> and acceptance testing on staging</td>
<td>Days</td>
<td>What the scripts didn&#39;t think of</td>
</tr>
<tr>
<td><strong>Production</strong></td>
<td>Smoke test on the live site, monitoring and alerts</td>
<td>Minutes, then continuous</td>
<td>Problems only real traffic reveals</td>
</tr>
</tbody></table>
<p>Three rules make a pipeline work:</p>
<ol>
<li><strong>Each stage is a gate.</strong> A failure stops the change from going further, and the team fixes it before doing anything else. A red pipeline that everyone ignores protects nobody.</li>
<li><strong>Fast first, deep later.</strong> The tests that run on every commit must be fast, or developers stop committing often. The slow, thorough tests run less frequently.</li>
<li><strong>Every result is recorded.</strong> Which build, which environment, which tests, which result (see <a href="https://provalab.io/blog/test-runs-and-test-results/">test runs</a>), so a failure can be traced and a release can be reported on.</li>
</ol>
<h3>What a pipeline looks like</h3>
<p>Here is a minimal GitHub Actions workflow that runs the static analysis and unit tests from Parts 5 and 21 on every push:</p>
<pre tabindex="0"><code class="language-yaml">name: tests
on: [push, pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: &quot;3.12&quot;
      - run: pip install pytest ruff
      - run: ruff check .        # static analysis
      - run: pytest              # unit tests
</code></pre>
<p>If either command fails, the workflow fails, the pull request shows a red cross, and the change can&#39;t be merged until it&#39;s fixed. Real pipelines add more stages, but they all start like this.</p>
<h2>Where AI coding agents fit</h2>
<p>The newest change to this picture is that much of the code is now written by AI coding agents such as Claude Code and Cursor. That makes two things in this series more important, not less:</p>
<ul>
<li><strong>Shift left matters more</strong>, because code is produced faster than people can review it. Static analysis, unit tests and automated review on every pull request are what keep up.</li>
<li><strong>The pipeline becomes the agent&#39;s feedback loop.</strong> When a test fails, the agent that wrote the code can read the failure, the logs and the recording, fix the code and run the tests again, with a person approving the result rather than doing the checking by hand.</li>
</ul>
<p>That is the idea ProvaLab is built around, and the subject of our first post, <a href="https://provalab.io/blog/how-to-test-code-your-ai-agent-wrote/">how to test code your AI coding agent wrote</a>.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Keeping a separate &quot;testing sprint&quot; at the end.</strong> That&#39;s a waterfall with shorter phases.</li>
<li><strong>Ignoring red pipelines.</strong> Once one failure is tolerated, every failure is.</li>
<li><strong>A slow pipeline.</strong> If the commit stage takes 30 minutes, people batch up changes, and the fast feedback is lost.</li>
<li><strong>Only Q1 and Q2 testing.</strong> Unit tests and story tests don&#39;t tell you the product is slow, insecure or confusing.</li>
<li><strong>No shift right.</strong> Releasing without monitoring means customers are your alerting system.</li>
</ul>
<h2>Testing Basics: the complete series</h2>
<p>That&#39;s the end of Testing Basics. Over 23 parts you&#39;ve learned:</p>
<p><strong>Foundations:</strong> <a href="https://provalab.io/blog/what-is-software-testing/">what testing is</a> · <a href="https://provalab.io/blog/types-of-software-testing/">types and levels</a> · <a href="https://provalab.io/blog/manual-vs-automated-testing/">manual vs automated</a> · <a href="https://provalab.io/blog/software-testing-life-cycle-stlc/">the STLC</a> · <a href="https://provalab.io/blog/seven-principles-of-testing/">the 7 principles</a> · <a href="https://provalab.io/blog/qa-vs-qc-verification-vs-validation/">QA vs QC, verification vs validation</a></p>
<p><strong>The craft:</strong> <a href="https://provalab.io/blog/first-automated-test-pytest/">your first automated test</a> · <a href="https://provalab.io/blog/how-to-write-test-cases/">writing test cases</a> · <a href="https://provalab.io/blog/black-box-white-box-grey-box-testing/">test design techniques</a> · <a href="https://provalab.io/blog/how-to-write-a-test-plan/">test plans</a> · <a href="https://provalab.io/blog/how-to-write-a-bug-report/">bug reports</a> · <a href="https://provalab.io/blog/bug-life-cycle/">the bug life cycle</a> · <a href="https://provalab.io/blog/requirements-traceability-matrix/">the traceability matrix</a> · <a href="https://provalab.io/blog/test-runs-and-test-results/">test runs and results</a> · <a href="https://provalab.io/blog/test-coverage-explained/">test coverage</a></p>
<p><strong>The test types:</strong> <a href="https://provalab.io/blog/integration-testing/">integration</a> · <a href="https://provalab.io/blog/system-testing/">system</a> · <a href="https://provalab.io/blog/smoke-vs-sanity-testing/">smoke and sanity</a> · <a href="https://provalab.io/blog/regression-testing-vs-retesting/">regression and re-testing</a> · <a href="https://provalab.io/blog/performance-testing-types/">performance</a> · <a href="https://provalab.io/blog/static-testing/">static testing</a> · <a href="https://provalab.io/blog/exploratory-testing/">exploratory testing</a> · agile and CI/CD (this post)</p>
<p>Next on the blog, the <strong>Hands-On Testing</strong> series: deeper guides to UI testing, API testing, security testing and load testing, each with examples you can run. It starts with <strong><a href="https://provalab.io/blog/ui-testing-explained/">Part 1: UI testing explained</a></strong>.</p>
<p>ProvaLab is built for the pipeline described here: the PR Bot checks every pull request, QA Infrastructure runs the tests on real browsers and devices, Test Management records every run, and AppSec and load testing cover Q4, with your coding agent able to drive all of it. See it in the <a href="https://provalab.io/#stage-prbot">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Types of software testing explained: unit, integration, system and acceptance</title>
      <link>https://provalab.io/blog/types-of-software-testing/</link>
      <guid isPermaLink="true">https://provalab.io/blog/types-of-software-testing/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>software-testing</category>
      <description>The four levels of software testing in plain language, with one running example, plus the testing pyramid and the other testing words you will hear at work.</description>
      <content:encoded><![CDATA[<p>In <a href="https://provalab.io/blog/what-is-software-testing/">Part 1</a> we saw what software testing is and why every team needs it. Now the obvious next question: <em>what exactly do you test, and in what order?</em></p>
<p>Testers answer that with <strong>four levels of testing</strong>. Each level looks at the software from a different distance, from one small piece of code up to the whole product in a customer&#39;s hands. We will walk through all four using one example: a <strong>food delivery app</strong>.</p>
<h2>The four levels at a glance</h2>
<p><img src="https://provalab.io/blog/basics/test-levels.svg" alt="The four levels of testing on a food delivery app. Unit: the function that adds up the order total. Integration: the order service saves the order and tells the restaurant. System: search, add to cart, pay and track the delivery. Acceptance: a restaurant owner confirms orders arrive the way they need." width="880" height="540" loading="lazy"></p>
<h2>1. Unit testing</h2>
<p><strong>A unit test checks one small piece of code on its own</strong>, usually a single function.</p>
<p>In our app, a unit test might check the function that adds up an order:</p>
<ul>
<li>Two pizzas at 200 and a drink at 50 should total <strong>450</strong>.</li>
<li>A 10% discount code should bring it to <strong>405</strong>.</li>
<li>An empty cart should total <strong>0</strong>, not crash.</li>
</ul>
<p>Developers write unit tests while they write the code. They run in milliseconds, so a project can have thousands of them and still check everything on every change.</p>
<p><strong>What it can&#39;t tell you:</strong> whether the pieces work <em>together</em>. A perfect total function is no use if the checkout page never calls it.</p>
<h2>2. Integration testing</h2>
<p><strong>An integration test checks that pieces work together</strong>: your code with the database, with another service, or with an outside system like a payment provider.</p>
<p>In our app: when a customer places an order, does the order service <strong>save it to the database</strong> and <strong>notify the restaurant</strong>? Each piece may pass its own unit tests and still fail here. A field could be named differently on each side, or a date could be sent in the wrong format.</p>
<p>Integration tests are slower than unit tests, because they need real databases or services (or realistic stand-ins). Teams usually have fewer of them.</p>
<h2>3. System testing</h2>
<p><strong>A system test checks the whole app, end to end, the way a user would use it.</strong></p>
<p>In our app: open the app, search for &quot;pizza&quot;, add two to the cart, pay, and follow the delivery on the map. Every part of the system takes part: the screens, the servers, the database and the payment provider.</p>
<p>System testing also covers the non-functional questions from Part 1: is it fast enough, is it secure, is it easy to use? This is usually where QA engineers and automated UI tests do most of their work. You will often hear these called <strong>end-to-end (E2E) tests</strong>.</p>
<h2>4. Acceptance testing</h2>
<p><strong>An acceptance test checks that the software actually solves the business need</strong>, and is ready to release.</p>
<p>The question changes from &quot;does it work?&quot; to &quot;is this what we needed?&quot; In our app, a restaurant owner might try the new order screen and confirm that orders arrive with everything their kitchen needs. If the app works perfectly but shows orders in a way the kitchen can&#39;t use, it fails acceptance.</p>
<p>Acceptance testing is often done by customers, product managers or a small group of real users. You will hear names like <strong>UAT</strong> (user acceptance testing) and <strong>beta testing</strong> (a release to a small group of real users first).</p>
<h2>The testing pyramid: how many of each?</h2>
<p>If end-to-end tests are closest to what users do, why not write only those? Because they are slow, costly to maintain, and when one fails it can take a while to find out why.</p>
<p>The <strong>testing pyramid</strong>, an idea popularised by Mike Cohn, gives a simple rule of thumb:</p>
<p><img src="https://provalab.io/blog/basics/test-pyramid.svg" alt="The testing pyramid: many unit tests at the bottom, which are fast and cheap; some integration tests in the middle; and a few end-to-end tests at the top, which are slow and costly but closest to what users do." width="880" height="470" loading="lazy"></p>
<ul>
<li><strong>Many unit tests</strong> at the base: fast, cheap, and they point straight at the broken line.</li>
<li><strong>Some integration tests</strong> in the middle.</li>
<li><strong>A few end-to-end tests</strong> at the top, for the journeys that matter most, such as sign-up and checkout.</li>
</ul>
<p>The opposite shape, lots of slow end-to-end tests and few unit tests, is sometimes called the <strong>ice-cream cone</strong>. Its test runs are slow, flaky and hard to debug.</p>
<h2>Other testing words you will hear</h2>
<p>Not every testing word is a level. Some describe <em>why</em> or <em>when</em> you run tests, at any level:</p>
<table tabindex="0">
<thead>
<tr>
<th>Term</th>
<th>What it means</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Smoke test</strong></td>
<td>A quick check that the most basic things work, such as &quot;the app starts and the home page loads&quot;, before deeper testing</td>
</tr>
<tr>
<td><strong>Sanity test</strong></td>
<td>A quick, narrow check that one specific fix or change works</td>
</tr>
<tr>
<td><strong>Regression test</strong></td>
<td>Re-running existing tests to make sure a change didn&#39;t break something that used to work</td>
</tr>
<tr>
<td><strong>End-to-end (E2E) test</strong></td>
<td>A test of a full user journey through the whole system</td>
</tr>
<tr>
<td><strong>Black-box testing</strong></td>
<td>Testing without looking at the code, only at inputs and outputs</td>
</tr>
<tr>
<td><strong>White-box testing</strong></td>
<td>Testing with knowledge of the code, to cover its paths and branches</td>
</tr>
</tbody></table>
<h2>Putting it together</h2>
<table tabindex="0">
<thead>
<tr>
<th>Level</th>
<th>Checks</th>
<th>Who usually</th>
<th>Speed</th>
<th>How many</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Unit</strong></td>
<td>One piece of code</td>
<td>Developers</td>
<td>Milliseconds</td>
<td>Many</td>
</tr>
<tr>
<td><strong>Integration</strong></td>
<td>Pieces working together</td>
<td>Developers, QA</td>
<td>Seconds</td>
<td>Some</td>
</tr>
<tr>
<td><strong>System (E2E)</strong></td>
<td>The whole app like a user</td>
<td>QA, automation</td>
<td>Minutes</td>
<td>Few</td>
</tr>
<tr>
<td><strong>Acceptance</strong></td>
<td>The real business need</td>
<td>Customers, product</td>
<td>Days</td>
<td>A handful</td>
</tr>
</tbody></table>
<p>A healthy project has all four. Each level catches problems the others miss.</p>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/manual-vs-automated-testing/">Part 3</a></strong> we look at <strong>manual vs automated testing</strong>: what to automate, what not to, and why.</p>
<p>Running end-to-end tests is where ProvaLab helps most: your coding agent runs them on real browsers and devices, every result is kept in one place, and failures come back with a video and logs. See it in the <a href="https://provalab.io/#stage-infra">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>UI testing explained: what it is, how it works, and Selenium vs Playwright vs Cypress</title>
      <link>https://provalab.io/blog/ui-testing-explained/</link>
      <guid isPermaLink="true">https://provalab.io/blog/ui-testing-explained/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>hands-on-testing</category>
      <category>ui-testing</category>
      <category>playwright</category>
      <category>selenium</category>
      <category>cypress</category>
      <description>What UI testing is and what it's for, how a browser test actually works, how to choose locators that don't break, why UI tests go flaky, and an honest comparison of Selenium, Playwright and Cypress.</description>
      <content:encoded><![CDATA[<p><a href="https://provalab.io/blog/testing-in-agile-and-ci-cd/">Testing Basics</a> covered the ideas. This new series, <strong>Hands-On Testing</strong>, covers the practice: UI testing, API testing, security testing and load testing, each with a concepts post and a post where you build something that runs.</p>
<p>We start with the kind of testing most people picture when they hear the word: <strong>a program that opens a browser and uses your app like a person would</strong>.</p>
<h2>What is UI testing?</h2>
<p><strong>UI testing (user interface testing) checks an application through the same interface users see</strong>: pages, buttons, forms and screens, in a real browser or on a real device.</p>
<p>It sits at the top of the <a href="https://provalab.io/blog/types-of-software-testing/">testing pyramid</a>: the closest to a real user, and also the slowest and most expensive kind of test to write and maintain. That&#39;s why the pyramid says to have <em>a few</em> UI tests, for the journeys that matter most, not hundreds.</p>
<p>Two terms you&#39;ll hear alongside it:</p>
<ul>
<li><strong>End-to-end (E2E) testing</strong>: a UI test that follows a complete journey through the whole system, such as search → add to cart → pay → see confirmation. Most UI tests of any value are end-to-end tests.</li>
<li><strong>Visual testing</strong>: comparing screenshots against a known-good version to catch layout and styling changes. A specialised branch of UI testing.</li>
</ul>
<h2>What UI tests are good at, and bad at</h2>
<table tabindex="0">
<thead>
<tr>
<th>Good at</th>
<th>Bad at</th>
</tr>
</thead>
<tbody><tr>
<td>Proving a whole journey works, front to back</td>
<td>Pinpointing <em>why</em> something failed</td>
</tr>
<tr>
<td>Catching integration problems between front end and back end</td>
<td>Running fast (seconds per test, not milliseconds)</td>
</tr>
<tr>
<td>Checking what users actually see, in real browsers</td>
<td>Being stable: they depend on timing, data and the environment</td>
</tr>
<tr>
<td>Cross-browser and cross-device checks</td>
<td>Testing business logic (unit tests do that far more cheaply)</td>
</tr>
</tbody></table>
<p>The rule of thumb: <strong>test the logic with unit tests, the joins with <a href="https://provalab.io/blog/integration-testing/">integration tests</a>, and the journeys with UI tests.</strong> A UI test that checks fifty discount-code combinations is doing a unit test&#39;s job, fifty times slower.</p>
<h2>How a UI test works</h2>
<p><img src="https://provalab.io/blog/handson/ui-test-anatomy.svg" alt="A UI test script talks to a driver or automation protocol, which controls a real browser, which loads your app. The test repeats three steps: locate an element (the Sign in button, the Email box), act on it (type, click), and assert what the page shows (the heading reads Your orders). Most flaky tests fail at the locate step; pass or fail is decided at assert." width="880" height="440" loading="lazy"></p>
<p>Every UI test, in every tool, is the same three steps repeated:</p>
<ol>
<li><strong>Locate</strong> an element on the page: the email field, the &quot;Sign in&quot; button.</li>
<li><strong>Act</strong> on it: type, click, select, scroll.</li>
<li><strong>Assert</strong> what the page shows afterwards: the heading, the URL, a message, the absence of an error.</li>
</ol>
<p>Here is a complete sign-in test in Playwright for Python. Even if you&#39;ve never seen Playwright, you can read it:</p>
<pre tabindex="0"><code class="language-python">def test_customer_can_sign_in(page):
    page.goto(&quot;https://staging.example.com/sign-in&quot;)
    page.get_by_label(&quot;Email&quot;).fill(&quot;test-user-2@example.com&quot;)
    page.get_by_label(&quot;Password&quot;).fill(&quot;correct-horse-battery&quot;)
    page.get_by_role(&quot;button&quot;, name=&quot;Sign in&quot;).click()
    expect(page.get_by_role(&quot;heading&quot;, name=&quot;Your orders&quot;)).to_be_visible()
</code></pre>
<p>Locate (<code>get_by_label</code>), act (<code>fill</code>, <code>click</code>), assert (<code>expect … to_be_visible</code>). We&#39;ll build and run this for real in Part 2.</p>
<p>Between the script and the browser sits a <strong>driver</strong> or <strong>automation protocol</strong>. Selenium uses the <strong>WebDriver</strong> standard, a W3C protocol every major browser supports. Playwright and Cypress talk to browsers more directly, which is a large part of why they feel faster.</p>
<h2>Locators: the skill that decides whether your tests survive</h2>
<p><strong>The single biggest cause of broken UI tests is the way they find elements.</strong> A test that looks for <code>div.col-md-6 &gt; form &gt; button.btn-primary</code> breaks the day a designer changes a class name, even though the app works perfectly.</p>
<p><img src="https://provalab.io/blog/handson/locator-ladder.svg" alt="A ladder of locator strategies, best first. 1, by role and name: get_by_role button named Sign in; what users see, survives redesigns. 2, by test id: get_by_test_id checkout-total; stable, added for testing. 3, by visible text or label; readable but breaks if wording changes. 4, by CSS selector tied to class names; breaks when styling changes. 5, by XPath tied to position; breaks on any layout change." width="880" height="440" loading="lazy"></p>
<p>The order of preference most teams settle on:</p>
<ol>
<li><strong>Role and accessible name</strong> (<code>get_by_role(&quot;button&quot;, name=&quot;Sign in&quot;)</code>). This is how a screen reader sees the page, so it matches what users see and ignores styling. It also quietly pushes your app towards being accessible.</li>
<li><strong>Test IDs</strong> (<code>data-testid=&quot;checkout-total&quot;</code>), attributes added to the HTML purely for tests. Very stable; the only cost is asking developers to add them.</li>
<li><strong>Visible text and labels</strong>. Readable, but tied to wording and language.</li>
<li><strong>CSS selectors</strong>. Fine for structure that won&#39;t change; fragile when tied to styling classes.</li>
<li><strong>XPath by position</strong>. Avoid. It breaks whenever anything moves.</li>
</ol>
<p>A good test reads like a description of what a user does. If you can&#39;t tell what a locator points at without opening the page, it&#39;s the wrong locator.</p>
<h2>Why UI tests go flaky, and what to do about it</h2>
<p>A <strong>flaky test</strong> passes and fails on different runs with no code change. UI tests are the flakiest kind, for predictable reasons:</p>
<table tabindex="0">
<thead>
<tr>
<th>Cause</th>
<th>What it looks like</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Timing</strong></td>
<td>The test clicks before the button exists, or asserts before data loads</td>
<td>Use the tool&#39;s built-in waiting (Playwright and Cypress wait for elements automatically). Never use fixed sleeps</td>
</tr>
<tr>
<td><strong>Test data</strong></td>
<td>Two tests use the same account and collide; yesterday&#39;s order changes today&#39;s counts</td>
<td>Each test creates or picks its own data; reset state between tests</td>
</tr>
<tr>
<td><strong>Environment</strong></td>
<td>The staging server is slow or restarting</td>
<td>Run a <a href="https://provalab.io/blog/smoke-vs-sanity-testing/">smoke test</a> first; retry only network-level failures</td>
</tr>
<tr>
<td><strong>Order dependence</strong></td>
<td>Test B only passes if test A ran first</td>
<td>Every test signs in and sets up what it needs itself</td>
</tr>
<tr>
<td><strong>Animations and pop-ups</strong></td>
<td>A cookie banner covers the button; a fade-in isn&#39;t finished</td>
<td>Dismiss banners in setup; assert on the final state, not the animation</td>
</tr>
<tr>
<td><strong>Fragile locators</strong></td>
<td>The class name changed</td>
<td>Climb the locator ladder above</td>
</tr>
</tbody></table>
<p>Flaky tests are dangerous, not just annoying: once people learn to re-run red tests until they pass, real failures get re-run away too (see <a href="https://provalab.io/blog/test-runs-and-test-results/">Part 17</a>). Treat a flaky test as a bug in the test suite and fix it the same week.</p>
<h2>Selenium vs Playwright vs Cypress</h2>
<p>The three tools almost every team chooses between:</p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th><strong>Selenium</strong></th>
<th><strong>Playwright</strong></th>
<th><strong>Cypress</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>First released</strong></td>
<td>2004</td>
<td>2020</td>
<td>2017</td>
</tr>
<tr>
<td><strong>Languages</strong></td>
<td>Java, Python, C#, JavaScript, Ruby, Kotlin</td>
<td>JavaScript/TypeScript, Python, Java, C#</td>
<td>JavaScript/TypeScript only</td>
</tr>
<tr>
<td><strong>Browsers</strong></td>
<td>All major browsers, via WebDriver</td>
<td>Chromium, Firefox, WebKit (Safari engine)</td>
<td>Chrome-family, Firefox, WebKit (experimental)</td>
</tr>
<tr>
<td><strong>How it drives the browser</strong></td>
<td>WebDriver protocol (a W3C standard)</td>
<td>Direct protocol connections; one API for all three engines</td>
<td>Runs <em>inside</em> the browser alongside your app</td>
</tr>
<tr>
<td><strong>Automatic waiting</strong></td>
<td>No; you write explicit waits</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td><strong>Multiple tabs and origins</strong></td>
<td>Yes</td>
<td>Yes</td>
<td>Limited</td>
</tr>
<tr>
<td><strong>Mobile</strong></td>
<td>Via Appium (real devices)</td>
<td>Device emulation in desktop browsers</td>
<td>Viewport emulation only</td>
</tr>
<tr>
<td><strong>Built-in trace, video, screenshots</strong></td>
<td>Screenshots; the rest via add-ons</td>
<td>Yes, all three, with a trace viewer</td>
<td>Yes</td>
</tr>
<tr>
<td><strong>Best for</strong></td>
<td>Large existing suites, language flexibility, the widest browser and device coverage</td>
<td>New projects wanting speed and reliability across all engines</td>
<td>Front-end teams working in JavaScript who want a fast developer experience</td>
</tr>
</tbody></table>
<p>Some honest observations:</p>
<ul>
<li><strong>Selenium</strong> is the industry standard with the largest ecosystem and the most job listings. Its WebDriver protocol is why it works everywhere, including real mobile devices through Appium. Its main cost is that you manage waiting yourself, which is where many flaky Selenium suites come from.</li>
<li><strong>Playwright</strong> was built by the team that originally created Puppeteer at Google, after they moved to Microsoft. It&#39;s fast, waits automatically, tests all three browser engines with one API, and its trace viewer (a recording of every step, with screenshots and network calls) is the best debugging tool of the three. It is the default choice for new projects today.</li>
<li><strong>Cypress</strong> has the most pleasant developer experience for front-end engineers: tests run in the browser with a live, time-travelling view. The trade-offs are JavaScript-only, weaker multi-tab and cross-origin support, and a paid service for parallel runs and recordings.</li>
</ul>
<p><strong>If you&#39;re starting from nothing, pick Playwright.</strong> If your team already has a Selenium suite that works, there is no need to rewrite it. If your team is all front-end JavaScript developers, Cypress will make them happiest.</p>
<h2>Where UI tests run</h2>
<ul>
<li><strong>On your laptop</strong> while writing them.</li>
<li><strong>In the CI pipeline</strong> on every change, usually headless (no visible browser window), as described in <a href="https://provalab.io/blog/testing-in-agile-and-ci-cd/">Part 23 of Testing Basics</a>.</li>
<li><strong>On a browser grid or device cloud</strong> when you need many browsers, operating systems and real phones at once, far more than one machine can hold. This is what services like ProvaLab&#39;s QA Infrastructure, BrowserStack and Sauce Labs provide.</li>
</ul>
<h2>Seven habits of good UI tests</h2>
<ol>
<li><strong>Test journeys, not pages.</strong> &quot;A new customer can sign up and place an order&quot; beats &quot;the sign-up page has an email field&quot;.</li>
<li><strong>Keep them few and valuable.</strong> Push everything else down the pyramid.</li>
<li><strong>Use role-based locators or test IDs.</strong></li>
<li><strong>Make every test independent:</strong> its own data, its own sign-in.</li>
<li><strong>Never sleep.</strong> Wait for a condition, not a number of seconds.</li>
<li><strong>Assert on what the user sees</strong>, not on internal state.</li>
<li><strong>Record on failure.</strong> A screenshot, video or trace turns a red test into a bug report in seconds.</li>
</ol>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/first-playwright-test/">Part 2</a></strong> we build real tests like the one above: install Playwright, write the test, run it headless and headed, watch it fail, read the trace, and add it to a pipeline.</p>
<p>ProvaLab&#39;s QA Infrastructure runs Playwright and Selenium tests on real browsers and devices in the cloud, records every session, and hands the recording and logs back to your coding agent when a test fails. See it in the <a href="https://provalab.io/#stage-infra">two-minute product tour</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>What is software testing, and why does every team need it?</title>
      <link>https://provalab.io/blog/what-is-software-testing/</link>
      <guid isPermaLink="true">https://provalab.io/blog/what-is-software-testing/</guid>
      <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>ProvaLab team</dc:creator>
      <category>testing-basics</category>
      <category>software-testing</category>
      <description>A plain-language introduction to software testing: what it is, what it checks, when it happens, who does it, and the myths that trip teams up.</description>
      <content:encoded><![CDATA[<p>Every app you use today, your bank, your food delivery, the page you are reading, was tested before it reached you. Usually you only notice testing when it was skipped: a payment that fails, a page that never loads, a &quot;something went wrong&quot; at the worst moment.</p>
<p>This is Part 1 of our <strong>Testing Basics</strong> series. No prior knowledge needed. By the end you will know what software testing is, what it checks, when it happens and who does it.</p>
<h2>What is software testing?</h2>
<p><strong>Software testing is checking that software does what it should, and doesn&#39;t do what it shouldn&#39;t.</strong></p>
<p>That sentence has two halves, and both matter:</p>
<ul>
<li><strong>Does what it should:</strong> a user can sign up, add items to a cart and pay.</li>
<li><strong>Doesn&#39;t do what it shouldn&#39;t:</strong> a user can&#39;t see someone else&#39;s orders, the app doesn&#39;t crash on an empty search, and a wrong password doesn&#39;t let anyone in.</li>
</ul>
<p>A <strong>test</strong> is one small check of that kind. It has three parts:</p>
<ol>
<li><strong>A starting point:</strong> &quot;A signed-in user with one item in their cart.&quot;</li>
<li><strong>An action:</strong> &quot;They click Pay.&quot;</li>
<li><strong>An expected result:</strong> &quot;They see the order confirmation, and the cart is empty.&quot;</li>
</ol>
<p>If what actually happens matches the expected result, the test <strong>passes</strong>. If not, it <strong>fails</strong>, and you have found a <strong>bug</strong>: a difference between what the software does and what it should do.</p>
<h2>What does testing check?</h2>
<p>Testing answers four big questions about your software:</p>
<p><img src="https://provalab.io/blog/basics/what-testing-checks.svg" alt="Testing answers four questions. Does it work: functional testing. Is it fast enough: performance and load testing. Is it safe: security testing. Is it easy to use: usability and accessibility testing." width="880" height="470" loading="lazy"></p>
<p>The first question, <strong>&quot;does it work?&quot;</strong>, is called <strong>functional testing</strong>. The other three are <strong>non-functional testing</strong>: they check <em>how well</em> the software works, not just whether it does. Each one gets its own posts later in this series.</p>
<h2>Why does every team need testing?</h2>
<ul>
<li><strong>Users leave quickly.</strong> If the checkout breaks, most people don&#39;t report it. They just go to a competitor.</li>
<li><strong>Bugs get more expensive the later you find them.</strong> A mistake caught while writing the code takes minutes to fix. The same mistake found by a customer means support tickets, an urgent fix, a new release and lost trust.</li>
<li><strong>Security problems are business problems.</strong> One leaked customer record can cost far more than years of testing.</li>
<li><strong>Changes break old things.</strong> Every new feature can break an old one. Tests that run on every change catch that, so the team can move fast without fear.</li>
<li><strong>Code is written faster than ever.</strong> With AI coding assistants, teams produce far more code. Checking it has to keep up.</li>
</ul>
<h2>When does testing happen?</h2>
<p>A common mistake is to test only at the end, just before release. Good teams test at every stage:</p>
<p><img src="https://provalab.io/blog/basics/test-early.svg" alt="Testing at every stage: test cases from requirements while planning, unit tests while writing code, automated checks during review, UI, API, security and load tests before release, and monitoring in production. The later a bug is found, the more work it takes to fix." width="880" height="400" loading="lazy"></p>
<p>This idea is often called <strong>&quot;shift left&quot;</strong>: move testing earlier (to the left) on the timeline, where bugs are cheapest to fix.</p>
<h2>Who does the testing?</h2>
<ul>
<li><strong>Developers</strong> write <strong>unit tests</strong> that check small pieces of their own code.</li>
<li><strong>QA engineers</strong> (quality assurance) plan what to test, test like a real user would, and look for the cases nobody thought of.</li>
<li><strong>Automation engineers</strong> turn repeated checks into code that runs by itself, on every change.</li>
<li><strong>Security and performance specialists</strong> test the harder questions: can it be attacked, and does it hold up under heavy traffic?</li>
<li><strong>AI agents</strong> are the newest member of the team: they can now write and run tests, and read the results.</li>
</ul>
<p>In a small team, one person may do all of these. What matters is that each job gets done, not who has which title.</p>
<h2>Manual and automated testing</h2>
<p>Testing can be done in two ways:</p>
<ul>
<li><strong>Manual testing:</strong> a person uses the app and checks the result by eye. Best for new features, design and &quot;does this feel right?&quot;</li>
<li><strong>Automated testing:</strong> code performs the checks. Best for anything you need to check again and again, like &quot;can users still sign in?&quot; on every release.</li>
</ul>
<p>Most teams use both. We cover automation in detail in <a href="https://provalab.io/blog/manual-vs-automated-testing/">Part 3</a>.</p>
<h2>Four myths about testing</h2>
<ol>
<li><strong>&quot;Testing proves there are no bugs.&quot;</strong> It can&#39;t. Testing shows bugs are there; it never proves they are all gone. Its job is to find the important ones before users do.</li>
<li><strong>&quot;Testing is the QA team&#39;s job.&quot;</strong> Quality is everyone&#39;s job. Developers, reviewers and testers each catch different problems.</li>
<li><strong>&quot;We&#39;ll test at the end.&quot;</strong> By the end, bugs are tangled together and expensive. Test as you go.</li>
<li><strong>&quot;100% test coverage means we&#39;re safe.&quot;</strong> Coverage tells you which lines of code ran during tests, not whether the tests checked the right things.</li>
</ol>
<h2>Key terms</h2>
<table tabindex="0">
<thead>
<tr>
<th>Term</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Test case</strong></td>
<td>One check: a starting point, an action and an expected result</td>
</tr>
<tr>
<td><strong>Bug (defect)</strong></td>
<td>A difference between what the software does and what it should do</td>
</tr>
<tr>
<td><strong>Test suite</strong></td>
<td>A group of related test cases, such as &quot;all checkout tests&quot;</td>
</tr>
<tr>
<td><strong>Test run</strong></td>
<td>One time a set of tests is run, with a pass or fail result for each</td>
</tr>
<tr>
<td><strong>Regression</strong></td>
<td>Something that used to work and is now broken by a change</td>
</tr>
</tbody></table>
<h2>What&#39;s next</h2>
<p>In <strong><a href="https://provalab.io/blog/types-of-software-testing/">Part 2</a></strong> we look at the types of testing (unit, integration, system and acceptance tests) and when to use each.</p>
<p>Want to see testing done by an AI agent on a real app? Watch the <a href="https://provalab.io/#stage-tms">two-minute product tour</a>. ProvaLab keeps your test cases, runs them on real browsers and devices, and records every result in one place.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
