<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jeff</title>
    <description>The latest articles on DEV Community by Jeff (@jeff_pdc).</description>
    <link>https://dev.to/jeff_pdc</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4152922%2F10cb455a-2068-4ef5-824e-86a9b10e2cd6.png</url>
      <title>DEV Community: Jeff</title>
      <link>https://dev.to/jeff_pdc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeff_pdc"/>
    <language>en</language>
    <item>
      <title>MCP Tool Versioning and Deprecated Tools</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Sat, 14 Nov 2026 15:27:21 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/mcp-tool-versioning-and-deprecated-tools-37n5</link>
      <guid>https://dev.to/jeff_pdc/mcp-tool-versioning-and-deprecated-tools-37n5</guid>
      <description>&lt;p&gt;Quick answer: MCP does not define a standard version or deprecated field for a tool, so you evolve tools by convention: keep breaking changes under a new name (such as &lt;code&gt;search_v2&lt;/code&gt;), mark the old tool as deprecated in its description, keep it working with a warning, and remove it only after a fixed sunset window. Additive changes can stay on the same name; anything that changes the input shape or the meaning of output needs a new version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why tools need versioning
&lt;/h2&gt;

&lt;p&gt;An MCP server keeps changing after clients connect: a tool gains a parameter, a field changes type, or a value changes unit. Saved agents and prompts still call the old name, and a silent change can produce wrong results with no signal. Versioning makes the change visible so callers move on their own schedule rather than getting a new contract on an old name.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the protocol gives you
&lt;/h2&gt;

&lt;p&gt;A tool returned from &lt;code&gt;tools/list&lt;/code&gt; carries a name, an optional description, and an input schema. There is no required semantic-version field and no boolean that marks a tool as deprecated; the optional tool annotations cover hints such as read-only or idempotent, not retirement. That means versioning and deprecation are conventions you enforce, not fields the host will act on automatically. Treat the tool name as the stable contract the model relies on.&lt;/p&gt;

&lt;h2&gt;
  
  
  How teams version a tool
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What it looks like&lt;/th&gt;
&lt;th&gt;When to use it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Versioned name&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;search_v1&lt;/code&gt;, &lt;code&gt;search_v2&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Any breaking change; the model sees a distinct tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Description note&lt;/td&gt;
&lt;td&gt;Version stated in the description&lt;/td&gt;
&lt;td&gt;Communicating revision without a new name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema field&lt;/td&gt;
&lt;td&gt;Optional &lt;code&gt;version&lt;/code&gt; input parameter&lt;/td&gt;
&lt;td&gt;Caller needs to select behavior explicitly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server metadata&lt;/td&gt;
&lt;td&gt;Server version in the init handshake&lt;/td&gt;
&lt;td&gt;Tracking the whole server, not one tool&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The versioned name is the most reliable lever because the model chooses tools primarily by name and description. A new name is a new tool, so an old agent that named &lt;code&gt;search_v1&lt;/code&gt; does not silently get &lt;code&gt;v2&lt;/code&gt; behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to deprecate without breaking callers
&lt;/h2&gt;

&lt;p&gt;Do not delete the old tool on the day the new one ships. Saved prompts and long-running agents still reference the old name, and a missing tool becomes a hard failure. First, prefix the old description with a clear &lt;code&gt;DEPRECATED&lt;/code&gt; note and name the replacement. Keep the tool functioning but return a warning alongside its result so the model is nudged toward the new one. Publish a sunset date, then remove the name only after that window; logging calls to the old tool shows whether anything still depends on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compatible versus breaking changes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Same name or new&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Add optional input parameter&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;td&gt;Old callers omit it and still work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add a response field&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;td&gt;Callers that ignore unknown fields are fine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change a field type or unit&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;td&gt;Old parsing and assumptions break&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove or rename a parameter&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;td&gt;Existing calls fail or misbehave&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change what a value means&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;td&gt;Correct output silently becomes wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the dangerous one: a field that keeps its name but changes meaning passes every schema check and still breaks reasoning. To confirm a &lt;code&gt;v2&lt;/code&gt; output really differs from &lt;code&gt;v1&lt;/code&gt;, send both requests side by side and compare the schemas; Powerduck keeps the two responses visible so the difference is evidence rather than a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the model actually sees
&lt;/h2&gt;

&lt;p&gt;The model reads the tool name and description at selection time; it does not run a version resolver, and it is not guaranteed to honor a deprecation note the way a compiled client would honor a typed header. A warning in the result helps, but the host cannot assume every call migrates by the sunset date. That is why the old name must stay functional through the window rather than throwing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;These patterns are conventions, not protocol guarantees, and different hosts may surface descriptions differently. A deprecation note does not prove the model switches tools, and naming alone does not prevent collisions across many servers, which needs separate prefixing. Registry-level lifecycle, where a published package is listed, retired, or replaced across an ecosystem, is a separate concern from versioning a tool inside your own server. Importing or connecting a server also does not deploy a backend, and the protocol does not validate that a new tool's behavior is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does MCP have a built-in tool version field?&lt;/strong&gt; No. A tool exposes name, description, and input schema; teams add versioning through naming and descriptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I mark a tool as deprecated?&lt;/strong&gt; Add a clear &lt;code&gt;DEPRECATED&lt;/code&gt; note to its description, name the replacement, and return a warning while keeping the tool working until a sunset date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I change an existing tool's input schema in place?&lt;/strong&gt; Only for additive, optional changes. Anything that breaks old callers should ship under a new versioned name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When can I remove the old tool?&lt;/strong&gt; After the published sunset window, once logs show no remaining calls to the old name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Because MCP leaves versioning and deprecation to you, use the tool name as the contract: ship breaking changes under a new versioned name, keep the old one alive with a visible warning and a fixed sunset, and reserve same-name edits for purely additive changes. For more on building and operating these servers, see the &lt;a href="https://www.powerduck.com/blog/mcp-server-guide-hub/" rel="noopener noreferrer"&gt;MCP server guide hub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>tools</category>
      <category>programming</category>
    </item>
    <item>
      <title>What Is the Model Context Protocol?</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Sat, 14 Nov 2026 13:30:31 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/what-is-the-model-context-protocol-aia</link>
      <guid>https://dev.to/jeff_pdc/what-is-the-model-context-protocol-aia</guid>
      <description>&lt;p&gt;Quick answer: The Model Context Protocol (MCP) is an open, client-server protocol that standardizes how an AI application connects to external tools and data sources. Instead of building a separate custom integration for every tool, an app talks to lightweight MCP servers over one common interface, and each server exposes a set of tools, resources, and prompts the model can use. It defines the connection and the message format, not the model itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem it solves
&lt;/h2&gt;

&lt;p&gt;Without a shared protocol, every AI application needs its own connector for every data source and service it wants to reach. As the number of apps and services grows, the number of one-off integrations multiplies, and each one handles auth, error handling, and data shapes differently. MCP collapses that into a single client-server contract. A server written once can be used by any compatible host, and a host can support many servers without learning each vendor's private API. The common analogy is a universal port: one connector shape on both sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  The roles
&lt;/h2&gt;

&lt;p&gt;The architecture has three parts that are easy to conflate. The host is the application the user runs and in which the model lives. Inside the host, a client manages the connection to a single server, with one client per server. The server is a separate, small program that exposes capabilities and runs outside the model, often as a local process.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;The AI application&lt;/td&gt;
&lt;td&gt;Runs the model, holds context, enforces consent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;Connector inside the host&lt;/td&gt;
&lt;td&gt;One per server, manages the session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;td&gt;External program&lt;/td&gt;
&lt;td&gt;Exposes tools, resources, prompts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The three primitives
&lt;/h2&gt;

&lt;p&gt;A server can offer three kinds of capability. Tools are functions the model can invoke, such as running a query or creating a record; they may have side effects, so a host normally asks for user approval before one runs. Resources are readable data, like a file or a database row, that the model or user can pull into context. Prompts are reusable templates and slash commands that shape how a task is started. Tools are actions the model takes, resources are data it reads, and prompts are authored starting points.&lt;/p&gt;

&lt;h2&gt;
  
  
  How they talk
&lt;/h2&gt;

&lt;p&gt;Messages use JSON-RPC 2.0, with requests, responses, and notifications flowing in both directions. When a session starts, the client and server exchange capabilities so each side knows what the other supports. Two transports are common: stdio, where the host launches a local server as a subprocess and communicates over standard input and output, and a remote HTTP transport for servers reached over a network. The same primitives work over either transport; only the carrying mechanism changes. If you want to see what a tool call actually does before wiring it into an agent, you can exercise the underlying HTTP request in a local workspace; Powerduck keeps that request and its response visible rather than hiding behavior behind a tool name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security and consent
&lt;/h2&gt;

&lt;p&gt;MCP does not grant a server automatic trust. The host stays in control of which tools run and can require confirmation for actions with side effects, and the model cannot reach a resource the user has not authorized. Remote servers use standard authorization such as OAuth, and the principle is least privilege: a server gets access to only what it needs. Connecting a server does not make its output correct, and a malicious or poorly written server is still a risk the host and user must evaluate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;The protocol standardizes the connection, not the quality or safety of what sits behind it. It does not validate that a tool behaves correctly, that returned data is accurate, or that a server handles errors well, and it does not solve naming collisions, versioning, or rate limits across many servers on its own. Those remain engineering and operational choices. MCP is also not a model and does not run inference, and importing or connecting a server does not deploy a backend. Treat it as a well-defined transport for context and actions, with the real guarantees coming from how each server is built and reviewed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is MCP an AI model?&lt;/strong&gt; No. It is a protocol that connects an AI application to external tools and data; the model stays inside the host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between a tool and a resource?&lt;/strong&gt; A tool performs an action and may have side effects; a resource is data that is read into context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does an MCP server run locally or remotely?&lt;/strong&gt; It can do either. Local servers commonly use stdio, while remote servers use an HTTP transport.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does connecting a server make it safe?&lt;/strong&gt; No. The host still controls consent and authorization, and the server's behavior and security need separate review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;MCP gives AI applications one standard client-server interface for reaching tools, resources, and prompts over JSON-RPC, replacing a sprawl of custom connectors, while leaving trust, consent, and data quality to the host and the server authors. For more on building and operating these servers, see the &lt;a href="https://www.powerduck.com/blog/mcp-server-guide-hub/" rel="noopener noreferrer"&gt;MCP server guide hub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>tools</category>
      <category>programming</category>
    </item>
    <item>
      <title>Testing Bulk Endpoint Partial Failure</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Sat, 14 Nov 2026 03:26:23 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/testing-bulk-endpoint-partial-failure-31nj</link>
      <guid>https://dev.to/jeff_pdc/testing-bulk-endpoint-partial-failure-31nj</guid>
      <description>&lt;p&gt;Quick answer: A bulk endpoint accepts many items in one request and can succeed on some while rejecting others, so the top-level status code does not tell you what happened. First pin down whether the endpoint is atomic (all items commit or all roll back) or non-atomic (each item is independent), then test the per-item result array across all-success, all-fail, and mixed cases, match every result back to its request item by identifier, and confirm the client lets the user retry only the failed entries without replaying the ones that already landed. Asserting on the single outer status is the mistake that hides a half-applied batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  First pin down the contract
&lt;/h2&gt;

&lt;p&gt;The two designs behave differently under failure and the tests follow from which one you have. An atomic endpoint treats the whole batch as one transaction: if any item is invalid it returns a single 4xx and nothing is written, so there is no partial state to reconcile. A non-atomic endpoint processes each item independently and returns a result per entry, so a batch can leave some resources created and others rejected. The documentation should state which model applies; if it does not, send a mixed batch and observe whether the valid items persist before you write the suite. Do not assume the presence of a bulk endpoint means a transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The response shapes
&lt;/h2&gt;

&lt;p&gt;The explicit form is 207 Multi-Status, whose body lists each entry with its own status, identifier, and error detail. Other APIs return 200 with a result array where every item carries a success flag, or 202 Accepted and process the batch asynchronously with a status endpoint to poll. In every non-atomic shape the meaningful result is the per-entry outcome, not the outer code. A 207 can contain both 201 and 409 entries, and a 200 can carry failed items, so a client that branches on the outer status alone will misread the batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Expected per-item result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;All valid&lt;/td&gt;
&lt;td&gt;Clean items&lt;/td&gt;
&lt;td&gt;Every entry 2xx, all side effects present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All invalid&lt;/td&gt;
&lt;td&gt;Bad items&lt;/td&gt;
&lt;td&gt;Every entry 4xx, nothing written&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mixed&lt;/td&gt;
&lt;td&gt;Valid and bad items&lt;/td&gt;
&lt;td&gt;Success and failure entries both returned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Empty or single&lt;/td&gt;
&lt;td&gt;Zero or one item&lt;/td&gt;
&lt;td&gt;Handled, no crash or false result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate id&lt;/td&gt;
&lt;td&gt;Same identifier twice&lt;/td&gt;
&lt;td&gt;Conflict flagged, not silently merged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Over size limit&lt;/td&gt;
&lt;td&gt;More than the cap&lt;/td&gt;
&lt;td&gt;Rejected or bounded, never truncated silently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial then retry&lt;/td&gt;
&lt;td&gt;Re-send failed items&lt;/td&gt;
&lt;td&gt;Failed items fixed, landed items not duplicated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Matching results and retrying
&lt;/h2&gt;

&lt;p&gt;Match each result to its request by the client-supplied identifier or the resource id, not by array position, because the server may reorder or drop entries. For the mixed case, assert the successful items exist and the rejected ones have no side effect, then check the UI renders the two groups separately so the user can act on the failures. A retry should send only the failed entries and rely on idempotency so the already-created items are not duplicated; re-sending the entire batch risks a second set of resources. In a desktop API workspace you can run the bulk request and the follow-up retry as an ordered scenario and inspect each sub-result. Powerduck supports this flow, keeping the per-item sequence deterministic rather than eyeballing a large response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;These tests prove the client handles per-item results and retries correctly, but they do not prove the real server's transaction boundaries, partial-commit behavior, database constraints, or how it processes a large batch under load. A local mock cannot reproduce an async worker that fails after accepting the job, race conditions between concurrent batches, or the latency of a queue, so confirm the production path with a genuine mixed batch and verify which items actually persisted. A passing run says nothing about whether the server stays consistent when a worker crashes mid-batch, and importing a schema does not deploy a backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does 207 mean the request failed?&lt;/strong&gt; No. It means the response carries multiple independent statuses; read each entry rather than treating the whole call as an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I know if the endpoint is atomic?&lt;/strong&gt; Send a mixed batch and check whether the valid items persist. If they do, it is non-atomic; if nothing is written, it is atomic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What should I do after a partial failure?&lt;/strong&gt; Retry only the failed items by id, confirm the landed items are not re-sent, and verify the retry does not create duplicates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I trust result order?&lt;/strong&gt; No. Match by identifier, since the server may process or return items in a different order.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Decide atomicity first, then drive the per-item result array across all-success, all-fail, mixed, duplicate, and retry cases, matching entries by identifier and asserting both the response and the side effects. Confirm the real server's partial-commit behavior separately. For more on this approach, see the &lt;a href="https://www.powerduck.com/blog/api-testing-guide-hub/" rel="noopener noreferrer"&gt;API testing guide hub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>testing</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Testing Cache Invalidation Headers</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Fri, 13 Nov 2026 19:26:31 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/testing-cache-invalidation-headers-5bk1</link>
      <guid>https://dev.to/jeff_pdc/testing-cache-invalidation-headers-5bk1</guid>
      <description>&lt;p&gt;Quick answer: Cache invalidation is controlled by response headers, not by a separate delete call. &lt;code&gt;Cache-Control&lt;/code&gt; sets how long a response stays fresh and whether it can be stored, and &lt;code&gt;ETag&lt;/code&gt; plus a conditional request handles revalidation once that window passes. Test the fresh window, the stale window with a 304, a changed ETag returning 200 with a new body, &lt;code&gt;no-store&lt;/code&gt; never being cached, and &lt;code&gt;Vary&lt;/code&gt; splitting the cache by the right dimension. The assertions should cover both whether the client sends a request and which conditional headers it carries, since a stale response that silently serves old data is the failure you are hunting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the headers mean
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Cache-Control: max-age=60&lt;/code&gt; marks the response fresh for 60 seconds; &lt;code&gt;no-cache&lt;/code&gt; forces revalidation before reuse even though the response may be stored; &lt;code&gt;no-store&lt;/code&gt; forbids storage entirely; &lt;code&gt;private&lt;/code&gt; limits the response to a single user's cache while &lt;code&gt;public&lt;/code&gt; allows shared caches. The server also sends an &lt;code&gt;ETag&lt;/code&gt;, and on the next request the client echoes it in &lt;code&gt;If-None-Match&lt;/code&gt;. If the resource is unchanged the server returns 304 with no body and the client keeps its copy; if it changed, the server returns 200 with a new body and a new ETag. &lt;code&gt;Last-Modified&lt;/code&gt; and &lt;code&gt;If-Modified-Since&lt;/code&gt; do the same job at one-second precision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Response headers&lt;/th&gt;
&lt;th&gt;Next request&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fresh window&lt;/td&gt;
&lt;td&gt;&lt;code&gt;max-age=60&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None sent&lt;/td&gt;
&lt;td&gt;Cached copy used&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stale, unchanged&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;no-cache&lt;/code&gt;, same ETag&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;If-None-Match&lt;/code&gt; sent&lt;/td&gt;
&lt;td&gt;304, copy reused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stale, changed&lt;/td&gt;
&lt;td&gt;New ETag&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;If-None-Match&lt;/code&gt; sent&lt;/td&gt;
&lt;td&gt;200, new body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Never stored&lt;/td&gt;
&lt;td&gt;&lt;code&gt;no-store&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full request each time&lt;/td&gt;
&lt;td&gt;200, no reuse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Split cache&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Vary: Authorization&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Separate request per identity&lt;/td&gt;
&lt;td&gt;No cross-user copy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Private data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;private, max-age=0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Revalidated per user&lt;/td&gt;
&lt;td&gt;Not shared&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Deterministic timing and revalidation
&lt;/h2&gt;

&lt;p&gt;Real wall-clock waits make these tests slow and flaky, so drive the clock or set short, fixed max-age values against a mock you control. Assert the request count: inside the fresh window the client should not call the network at all, and after expiry it should send a conditional request rather than a blind full fetch. When the ETag matches, confirm the client treats 304 as a valid response and does not reset state or flash a loading state; when it differs, confirm the new body replaces the old one and the stored ETag updates. A client that ignores 304 and shows an empty view, or one that never revalidates and serves stale data forever, both fail here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vary and cache busting
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Vary&lt;/code&gt; tells the cache which request headers change the response, so a response that differs per user or per encoding must list those dimensions or one user gets another's copy. Assert that two identities with the same URL do not share a stored response. For resources that change on deploy, fingerprinting the URL with a content hash is more reliable than waiting for expiry: a new file name is a cache miss by definition, while the old URL stays cached. In a desktop API workspace you can run each header combination as an ordered scenario and inspect the exact conditional request and response. Powerduck works this way, making the revalidation sequence repeatable instead of relying on browser cache behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;These tests prove the client and HTTP layer honor the headers you configure, but they do not prove how a real browser, CDN, or corporate proxy will cache the same response. A local mock cannot reproduce multi-layer caches, clock skew, purge propagation delay, or a CDN's default rules, so confirm the production path with a small set of genuine requests and check the &lt;code&gt;Age&lt;/code&gt; header. The checks do not cover server-side cache invalidation or a purge that fails silently, and a passing run says nothing about whether the stored content is correct. Importing a schema does not deploy a backend, and a green run is not evidence about the upstream service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between no-cache and no-store?&lt;/strong&gt; &lt;code&gt;no-cache&lt;/code&gt; allows storage but requires revalidation before reuse; &lt;code&gt;no-store&lt;/code&gt; forbids storing the response at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a 304 return a body?&lt;/strong&gt; No. The server sends headers only, and the client keeps using its stored body when the ETag still matches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my changed response still show old data?&lt;/strong&gt; The max-age window has not expired, the ETag did not change, or &lt;code&gt;Vary&lt;/code&gt; is missing. A fingerprinted URL avoids the first two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I test real wall-clock expiry?&lt;/strong&gt; Use short fixed ages or a controllable clock for determinism, then confirm one genuine end-to-end path against the real CDN.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Treat invalidation as explicit branches: fresh reuse, stale revalidation with 304, changed ETag with 200, no-store, and Vary-split entries, asserting the request count and conditional headers rather than only the rendered view. Confirm the real CDN behavior separately. For more on this approach, see the &lt;a href="https://www.powerduck.com/blog/api-testing-guide-hub/" rel="noopener noreferrer"&gt;API testing guide hub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>testing</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>Testing Webhook Signature Verification</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Fri, 13 Nov 2026 17:31:08 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/testing-webhook-signature-verification-2bfm</link>
      <guid>https://dev.to/jeff_pdc/testing-webhook-signature-verification-2bfm</guid>
      <description>&lt;p&gt;Quick answer: A webhook signature is an HMAC computed over the raw request body with a shared secret, sent in a header the receiver checks before doing any work. Test it with a fixed set of cases: a valid signature accepted, a tampered body rejected, a wrong secret rejected, a missing or malformed header rejected, and a stale timestamp rejected as a replay. On every rejection the receiver should return 401 and skip the side effect, and the signature comparison should run in constant time. The signature must be computed on the original bytes, not on JSON re-serialized by the framework, since reordering fields changes the digest.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the signature is built
&lt;/h2&gt;

&lt;p&gt;The sender concatenates a timestamp and the raw body, hashes the result with the shared secret, and puts the digest in a header, often in a form such as &lt;code&gt;t=1700000000,v1=...&lt;/code&gt;. The receiver rebuilds the same digest from the bytes it received and compares it against the header. Because the secret never travels in the request, an attacker who cannot produce the digest cannot forge a call, which is why unsigned or badly signed requests must be dropped before the handler runs. Pin the exact scheme and header format your provider uses rather than assuming a generic shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Signature header&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;th&gt;Side effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Valid&lt;/td&gt;
&lt;td&gt;Correct digest, fresh timestamp&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Handler runs once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tampered body&lt;/td&gt;
&lt;td&gt;Digest from the original body&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong secret&lt;/td&gt;
&lt;td&gt;Digest built with another key&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing header&lt;/td&gt;
&lt;td&gt;Absent or empty&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stale timestamp&lt;/td&gt;
&lt;td&gt;Valid digest, old timestamp&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rotated secret&lt;/td&gt;
&lt;td&gt;New &lt;code&gt;v1&lt;/code&gt; alongside old &lt;code&gt;v0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Handler runs once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The tampered-body case is the one that matters most: keep the signature from the original payload while changing one byte, so the digest no longer matches. The rotated-secret case covers key rollover, where the receiver accepts more than one version for a window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use the raw body, not re-serialized JSON
&lt;/h2&gt;

&lt;p&gt;A common failure is verifying the signature against a parsed and re-serialized object instead of the original bytes. JSON allows different field order and whitespace, so the same logical object re-serialized by the framework produces a different digest and rejects genuine calls, or worse, a loose parser accepts a body the signature never covered. Capture the raw request buffer and run the HMAC over those exact bytes, then parse the body only after the check passes. The test should include a payload with reordered or extra whitespace to confirm the receiver hashes what actually arrived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Constant-time comparison and replay window
&lt;/h2&gt;

&lt;p&gt;Do not compare digests with a plain equality check that exits on the first differing byte, since the response time leaks how many leading bytes matched. Use a constant-time comparison provided by the platform so every signature takes the same path regardless of how close it is. The timestamp gives a separate replay control: define a tolerance window, reject signatures outside it even if the digest is valid, and confirm the test for an old timestamp returns 401. In a desktop API workspace you can run the whole matrix as an ordered scenario that builds each header variant and asserts the receiver status and recorded side effects. Powerduck works this way, turning each signature branch into a repeatable request instead of a handcrafted curl.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;These tests prove the receiver follows the verification logic you wrote, but they do not prove the shared secret is stored safely, rotated in production, or protected from logs and crash dumps. A local mock can stand in for the provider, but it cannot confirm the real provider's header scheme, clock skew, or retry behavior, so pin the scheme against genuine documentation and send a small number of real webhooks. The checks do not cover TLS termination, network-level attackers, or a secret that has already leaked, and a passing signature says nothing about whether the payload content is correct. Importing a schema does not deploy a backend, and a passing run is not evidence about the upstream service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why verify the raw body instead of parsed JSON?&lt;/strong&gt; Parsing and re-serializing can change field order and whitespace, which changes the digest. The HMAC has to match the exact bytes the sender signed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does the timestamp protect against?&lt;/strong&gt; It limits replay. A captured request with a valid signature is rejected once the timestamp falls outside the tolerance window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a 401 the only rejection response?&lt;/strong&gt; Most receivers use 401, but pin the status your framework returns. The key assertion is that no handler side effect runs on any rejected branch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why use constant-time comparison?&lt;/strong&gt; A normal string comparison returns early on the first mismatch, leaking timing information. A constant-time check avoids that signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Treat webhook verification as a set of explicit branches and run the valid, tampered, wrong-secret, missing-header, stale-timestamp, and key-rotation cases, hashing the raw bytes and comparing in constant time. Assert that every rejection returns 401 and performs no side effect, and confirm the real provider scheme separately. For more on this approach, see the &lt;a href="https://www.powerduck.com/blog/api-testing-guide-hub/" rel="noopener noreferrer"&gt;API testing guide hub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>testing</category>
      <category>debugging</category>
      <category>security</category>
    </item>
    <item>
      <title>Testing File Upload Size Boundaries</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Fri, 13 Nov 2026 15:28:06 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/testing-file-upload-size-boundaries-ig3</link>
      <guid>https://dev.to/jeff_pdc/testing-file-upload-size-boundaries-ig3</guid>
      <description>&lt;p&gt;Quick answer: A file upload limit is a byte boundary, so test it with files deliberately placed at the boundary rather than roughly sized samples: empty, one byte, limit minus one, the exact limit, and limit plus one. The exact-limit case settles whether the rule is "at or below" or strictly below, and a rejected file must never be stored. Per-file and total caps are separate checks, and a local mock can return a fixed 413 for each threshold while you confirm the client message and cleanup without moving real large files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limit is a byte boundary, not a rough size
&lt;/h2&gt;

&lt;p&gt;A cap such as 5 MB is enforced on a precise count, so a file that looks close tells you nothing until you know its exact bytes. Generate deterministic buffers of a chosen length instead of picking documents by eye, and pin down whether the rule is less than or equal, since an off-by-one at the boundary either accepts a file the server meant to reject or blocks a valid one. Add one file far above the cap to confirm the rejection is not a fluke, and give every case a known expected result before running it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-file and total request limits
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Byte size&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;th&gt;Side effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Empty file&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Accepted or clear rule&lt;/td&gt;
&lt;td&gt;No stored content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Just under&lt;/td&gt;
&lt;td&gt;limit - 1&lt;/td&gt;
&lt;td&gt;200/201&lt;/td&gt;
&lt;td&gt;File stored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exact limit&lt;/td&gt;
&lt;td&gt;limit&lt;/td&gt;
&lt;td&gt;Pin accept or reject&lt;/td&gt;
&lt;td&gt;Matches the rule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One over&lt;/td&gt;
&lt;td&gt;limit + 1&lt;/td&gt;
&lt;td&gt;413&lt;/td&gt;
&lt;td&gt;Nothing stored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two valid files&lt;/td&gt;
&lt;td&gt;Each under, sum over total&lt;/td&gt;
&lt;td&gt;413&lt;/td&gt;
&lt;td&gt;Neither stored&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row catches a common gap: each file passes its own cap while the combined body exceeds the request-level limit. Servers often enforce both, so the suite needs a per-file threshold and a total threshold treated as distinct assertions rather than one "large upload" check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch how the size is measured
&lt;/h2&gt;

&lt;p&gt;The number the client displays and the number the server counts can differ. Base64 encoding grows payloads by about a third, and a multipart body adds boundary and per-part header bytes on top of the file, so the raw file size, the encoded size, and the Content-Length are not the same value. State the unit plainly, because MB can mean either a million bytes or a mebibyte, and the error message should show both the limit and the actual size. A client-side pre-check gives fast feedback, but the server stays the authority, and both should use the same exact threshold rather than rounded numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assert beyond the status code
&lt;/h2&gt;

&lt;p&gt;A 413 Payload Too Large is common, though some APIs use 422, so pin the status your API returns and check Retry-After if present. Then confirm the rejected file was not stored or processed, because a status code alone does not prove the side effect was avoided. The upload should stop cleanly, the progress state reset, and the client should not resend the same oversized file in a retry loop. For the accepted boundary case, verify the file really lands and its recorded size matches. In a desktop API workspace, run the byte-sized cases as an ordered scenario against one local mock with a size-keyed response, and inspect the recorded requests. Powerduck works this way, turning each boundary into a repeatable fixed response instead of a slow manual upload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;A local mock proves the client handles each threshold, maps the message, and cleans up, but it does not prove the real server's byte cap. A reverse proxy or load balancer often rejects an oversized body before the application ever sees it, sometimes with its own error page, and gateway settings such as a maximum body size need a small set of genuine uploads to confirm. The mock does not store files, run malware scans, enforce disk quotas, or handle chunked and resumable uploads, and it says nothing about streaming memory use or upload timeouts on very large files. Importing a schema does not deploy a backend, and a passing mock run is not evidence about the upstream service or the network in front of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why test the exact limit instead of just one large file?&lt;/strong&gt; The exact-limit case determines whether the rule accepts or rejects a file at the cap, which is the off-by-one a rough sample never reaches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between per-file and total limits?&lt;/strong&gt; A per-file cap checks each part, while a total cap checks the whole body. Two valid files can each pass individually yet fail when combined.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a 413 mean the file was not saved?&lt;/strong&gt; Not by itself. Assert against storage or a follow-up read to confirm nothing was stored, since an application can reject the request after writing part of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why might the client and server disagree on size?&lt;/strong&gt; Encoding overhead, multipart header bytes, and MB versus mebibyte rounding produce different numbers, so the test should use exact byte counts and a shared unit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Treat the upload cap as an exact byte boundary and run the empty, under, exact, and over cases against both per-file and total limits, then assert storage side effects and clean failure handling rather than trusting the status code. Verify the real gateway and server caps with a few genuine uploads, since the mock only covers client behavior. For more on this approach, see the &lt;a href="https://www.powerduck.com/blog/api-testing-guide-hub/" rel="noopener noreferrer"&gt;API testing guide hub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>testing</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>Testing Backwards-Compatible Additive Changes</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Fri, 13 Nov 2026 13:30:38 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/testing-backwards-compatible-additive-changes-22d9</link>
      <guid>https://dev.to/jeff_pdc/testing-backwards-compatible-additive-changes-22d9</guid>
      <description>&lt;p&gt;Quick answer: A change is backwards compatible only when a client that knows nothing about it keeps working, so test it by sending the old request against the new server and confirming every old field behaves exactly as before. Add the new field in a separate case, and keep the two directions distinct: the new client using the new field, and the old client ignoring it. The failures that slip through are not deletions, they are changes that look additive, such as a new enum value or a shifted default, that a strict parser rejects.&lt;/p&gt;

&lt;h2&gt;
  
  
  What additive actually means
&lt;/h2&gt;

&lt;p&gt;An additive change adds without removing or redefining: a new optional request parameter, a new response field, a new endpoint, or a new header. The new request parameter must be optional with a defined default so an old client that never sends it gets the old behavior. A new response field is safer because an old client is expected to skip what it does not recognize, but that only holds if the client tolerates unknown fields. The moment you delete a field, rename it, change its type, or tighten its validation, the change is no longer additive and needs a version or a deprecation path instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the old client against the new server
&lt;/h2&gt;

&lt;p&gt;Capture the pre-change contract as a baseline, then drive the new server with two identities. The old client sends a payload without the new field and receives a response that still contains the new field; assert that the old fields are unchanged and that the extra field is ignored rather than causing a parse failure. The new client sends the field and asserts it takes effect. Keep both sequences in the same suite so a server that only works for the newest client is caught immediately. A desktop API workspace can hold the two payloads as variables and run them as ordered scenarios against one local mock, with a response override for the new field. Powerduck works this way, keeping the old-and-new comparison local and repeatable instead of relying on a deployed server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Changes that look additive but break
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Why it is not safe&lt;/th&gt;
&lt;th&gt;Test setup&lt;/th&gt;
&lt;th&gt;Expected result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New enum value&lt;/td&gt;
&lt;td&gt;Strict enum parser rejects the unknown value&lt;/td&gt;
&lt;td&gt;Return the new value to the old client&lt;/td&gt;
&lt;td&gt;Client tolerates or maps it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New required request field&lt;/td&gt;
&lt;td&gt;Old client cannot send it&lt;/td&gt;
&lt;td&gt;Call without the field&lt;/td&gt;
&lt;td&gt;Old default path still works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tightened validation&lt;/td&gt;
&lt;td&gt;Inputs once accepted now fail&lt;/td&gt;
&lt;td&gt;Replay a previously valid request&lt;/td&gt;
&lt;td&gt;Same success, not a 400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changed default&lt;/td&gt;
&lt;td&gt;Omitting the field changes behavior&lt;/td&gt;
&lt;td&gt;Send no field, compare to baseline&lt;/td&gt;
&lt;td&gt;Old behavior preserved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New response field&lt;/td&gt;
&lt;td&gt;Whole-body exact match now fails&lt;/td&gt;
&lt;td&gt;Assert only the old subset&lt;/td&gt;
&lt;td&gt;Old fields unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Assert on the subset, not the whole body
&lt;/h2&gt;

&lt;p&gt;Do not compare the entire response to a stored literal, because a correctly added field turns that assertion into a false failure. Instead, validate that the old fields still satisfy the old schema and check the new field on its own, confirming it is optional and never alters the old path. Verify the parser's tolerance for unknown fields explicitly rather than assuming it, and pin the default the server uses when the new request parameter is absent. When a new enum value is involved, the old client should ignore or map it rather than crash, and the server should keep accepting the old values. These checks turn compatibility from a hope into a named set of assertions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;A local mock proves field-level compatibility and the client's tolerance for unknown data, but it does not exercise the real server's serialization, data migration, or gateway layers. It cannot cover every old client in the wild, including third-party integrations and already shipped versions, so the test represents a chosen baseline rather than the full population. Semantic and enum-level breakage also needs real traffic and monitoring to confirm. Calling a change backwards compatible is a statement about the known clients, not a guarantee that no consumer depends on behavior the test did not model. Importing a schema does not deploy a backend, and a passing mock run says nothing about production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is adding a response field always safe?&lt;/strong&gt; It is safe only if the client ignores unknown fields. A client that validates the whole body against a strict schema can fail on the new field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is a new enum value risky?&lt;/strong&gt; A parser that expects a closed set of values can reject the new one even though nothing was removed. Test that the old client tolerates or maps it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What proves the old default still works?&lt;/strong&gt; Send the request without the new parameter and compare the result to the pre-change baseline. The server must preserve the old behavior, not just return 200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I assert the full response equals a saved example?&lt;/strong&gt; No. Assert the old field subset against the old schema and check the new field separately, so a valid addition does not break the test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Backwards compatibility is proven by the old client working against the new server, not by the new field functioning in isolation. Run both directions, treat new enum values and shifted defaults as the real risks, and assert the old subset while tolerating unknown fields, then verify real server behavior and shipped clients separately. For more on this approach, see the &lt;a href="https://www.powerduck.com/blog/api-testing-guide-hub/" rel="noopener noreferrer"&gt;API testing guide hub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>testing</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>Testing the Permission Matrix</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Fri, 13 Nov 2026 03:31:19 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/testing-the-permission-matrix-2fh9</link>
      <guid>https://dev.to/jeff_pdc/testing-the-permission-matrix-2fh9</guid>
      <description>&lt;p&gt;Quick answer: A permission matrix is a table of roles, resources, and actions where every cell is either allow or deny, and it should be tested as a data-driven set rather than a handful of hand-written calls. Encode the matrix as the expected result, then for each role send every action against every resource and assert the exact status code, the response body, and the side effect. The cases that expose real holes are the deny cells, vertical and horizontal escalation, and the default-deny rule for anything the matrix does not name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The matrix is the oracle, not the happy path
&lt;/h2&gt;

&lt;p&gt;Write the matrix down first: list the principals such as anonymous, viewer, editor, and owner, the resources they touch, and the actions each one performs. That table is the oracle your tests compare against, so a missing cell is a gap in the test before it is a bug in the code. Most teams only exercise the admin path, which is all allow, plus one obvious deny. That leaves the combinations where a lower role reaches a high-priv action or one user reaches another user's object. A matrix also fixes the default rule: any combination not explicitly allowed is denied, and that default needs its own assertion rather than being assumed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drive every cell instead of hand-writing cases
&lt;/h2&gt;

&lt;p&gt;The number of cells grows quickly, so hand-writing each request is how cases get skipped. Build a parameterized list from the matrix and loop over it, selecting the token for the role, issuing the action against the resource, and comparing the result to the expected cell. Keep one token per role so the identity under test is never in doubt, and keep the expected allow or deny next to the inputs so a wrong result is easy to read. A desktop API workspace can store each role's token in a variable and run an ordered scenario that walks the matrix, asserting status, headers, and body at each step. Powerduck works this way, with a local mock returning the allow or deny conditionally so the full matrix runs without a live authorization server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The privilege failures that matter
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure class&lt;/th&gt;
&lt;th&gt;Definition&lt;/th&gt;
&lt;th&gt;Test setup&lt;/th&gt;
&lt;th&gt;Expected result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vertical escalation&lt;/td&gt;
&lt;td&gt;Lower role calls a high-priv action&lt;/td&gt;
&lt;td&gt;Viewer token against an admin endpoint&lt;/td&gt;
&lt;td&gt;403, no side effect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Horizontal (IDOR)&lt;/td&gt;
&lt;td&gt;Same role, another user's resource&lt;/td&gt;
&lt;td&gt;User A token with user B's resource id&lt;/td&gt;
&lt;td&gt;403, no data leaked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anonymous&lt;/td&gt;
&lt;td&gt;No identity at all&lt;/td&gt;
&lt;td&gt;Request without a token&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default deny&lt;/td&gt;
&lt;td&gt;Combination not in the matrix&lt;/td&gt;
&lt;td&gt;Action left unlisted&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Owner versus member&lt;/td&gt;
&lt;td&gt;Ownership changes the result&lt;/td&gt;
&lt;td&gt;Owner and collaborator tokens&lt;/td&gt;
&lt;td&gt;Allow only where the matrix says&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Assert beyond the status code
&lt;/h2&gt;

&lt;p&gt;A correct 403 is not enough on its own. On a deny, confirm the response body does not include the protected fields and that no side effect occurred, because an endpoint can return 403 while still writing a row. On an allow, confirm the action truly executes rather than only that the status is 200. Watch the distinction between a missing resource and a forbidden one: returning 404 for a real object the caller cannot see can hide the resource, while 403 reveals it exists, so choose deliberately and test it. Sub-resources should inherit the parent's permission, and a batch call needs a rule for partial authorization rather than silently applying the forbidden items.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;A local mock returns allow or deny from rules you wrote, so it proves the matrix is fully covered and the client handles each result, but it does not verify the real authorization engine, row-level database permissions, middleware order, or policy caching. The mock is not the source of truth for access, and a rule there can quietly differ from the live policy. A passing run only shows the implementation matches the matrix you wrote; it does not prove the matrix captured every business rule. Confirm genuine escalation and object-level access against a real environment with realistic data, and treat the matrix itself as something a reviewer checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between vertical and horizontal escalation?&lt;/strong&gt; Vertical is a lower role reaching a higher-priv action. Horizontal is a user reaching another user's resource at the same level, often called IDOR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why use a data-driven test instead of writing each call?&lt;/strong&gt; The matrix has many cells, and a loop generated from the table covers every one without skipping a combination or letting a case drift from the expected result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a 403 enough to prove a deny?&lt;/strong&gt; No. Also assert the body leaks no protected fields and no side effect occurred, since a write can happen before the status is returned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What should happen to a combination not in the matrix?&lt;/strong&gt; It should be denied by default. Add an explicit case for an unlisted action so the default-deny rule is verified rather than assumed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Treat the permission matrix as the test oracle and drive every role, resource, and action cell with a parameterized run. Cover vertical and horizontal escalation, anonymous access, and default deny, then assert the body and side effect on each deny and verify the real authorization engine separately. For more on this approach, see the &lt;a href="https://www.powerduck.com/blog/api-testing-guide-hub/" rel="noopener noreferrer"&gt;API testing guide hub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>testing</category>
      <category>debugging</category>
      <category>security</category>
    </item>
    <item>
      <title>Testing Client Timeout Behavior</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Fri, 13 Nov 2026 01:26:35 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/testing-client-timeout-behavior-413</link>
      <guid>https://dev.to/jeff_pdc/testing-client-timeout-behavior-413</guid>
      <description>&lt;p&gt;Quick answer: A timeout is the client deciding to stop waiting, not a response from the server, so test it with fixed mock latency that lands just before and just after the threshold. Assert that the call aborts with the right error, the loading state resets, timers and in-flight work are cleaned up, retries follow a backoff schedule, and a response that arrives after the abort is ignored rather than applied twice. If you only test the fast, successful call, the slow path is still untested.&lt;/p&gt;

&lt;h2&gt;
  
  
  A timeout is a client decision, not a server error
&lt;/h2&gt;

&lt;p&gt;When the threshold passes, the server may still be processing the request, which is why a blind retry can create a duplicate side effect. Three distinct decisions get conflated under the word timeout. A connect timeout means the TCP connection never established. A read or request timeout means the connection opened but the full response did not arrive in time. An idle timeout means the response started and then stalled, such as headers arriving with no body. Each needs its own case because the cleanup and the error the caller sees are different. Default values in an HTTP library are generic and rarely match your real latency distribution, so leaving them untouched either makes users stare at a spinner or kills slow but successful calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pin the latency to the threshold
&lt;/h2&gt;

&lt;p&gt;Use a local mock with a fixed, known delay rather than a real network, and set the delay to the threshold value and to one millisecond on either side. The boundary case is the one that reveals off-by-one configuration: a response that resolves at the limit should still succeed, while one a moment later should abort. Keep the delays large and stable so a busy machine cannot blur them, and run the near and far cases in the same test so the threshold is judged against both. A desktop API workspace can set mock latency directly and drive conditional overrides, so the timing is chosen in advance and the same run reproduces every time. Powerduck works this way, keeping the timeout test local and repeatable instead of dependent on live network conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timeout types worth covering
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Timeout type&lt;/th&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Mock setup&lt;/th&gt;
&lt;th&gt;Expected client behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Connect&lt;/td&gt;
&lt;td&gt;TCP connection never opens&lt;/td&gt;
&lt;td&gt;Endpoint that refuses or drops the connection&lt;/td&gt;
&lt;td&gt;Fail fast, retry with backoff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read/request&lt;/td&gt;
&lt;td&gt;Connected, full response too slow&lt;/td&gt;
&lt;td&gt;Fixed latency just over the threshold&lt;/td&gt;
&lt;td&gt;Abort, show error, reset loading&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle/stream&lt;/td&gt;
&lt;td&gt;Response stalls after headers&lt;/td&gt;
&lt;td&gt;Send headers, then hold the body&lt;/td&gt;
&lt;td&gt;Abort the partial read, clean up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Late response&lt;/td&gt;
&lt;td&gt;Result arrives just after abort&lt;/td&gt;
&lt;td&gt;Latency just past the threshold&lt;/td&gt;
&lt;td&gt;Ignore the late result, no double apply&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What to assert after the abort
&lt;/h2&gt;

&lt;p&gt;Check the error type and message rather than only that a promise rejected, and confirm the loading flag returns to idle so the UI is not stuck. Verify the timer that drove the timeout is cleared and the in-flight request is cancelled rather than left running. For retries, assert the count and spacing against a backoff schedule and an overall deadline, and treat a non-idempotent POST carefully because an automatic retry can repeat a charge or a create. The late-response row matters most: when the answer arrives after the abort, the client must not apply it to state that already moved on, and must not process the same result twice. A 200 that lands late is still a response the client should treat as stale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;A local mock runs in the desktop main process and reproduces waiting time, but it does not model the full shape of a real failure. It cannot reproduce a half-open TCP connection, a network reset, packet loss, or a server that keeps processing after the client leaves, and application-level fixed delays do not exercise the operating system's real socket behavior. The mock proves the named timeout paths and the client's handling of them; it does not replace genuine network tests or a check of what the server does when the caller disconnects. Importing a schema does not deploy a backend, and a passing mock run is not evidence about production connectivity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between a connect and a read timeout?&lt;/strong&gt; A connect timeout means the connection never opened. A read timeout means it opened but the response did not finish in time, so the cleanup and error differ.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why test a response that arrives after the timeout?&lt;/strong&gt; The server may still answer. The client must ignore that late result instead of applying it to stale state or handling the same response twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should a timed-out POST be retried automatically?&lt;/strong&gt; Only if the operation is idempotent or carries an idempotency key. Otherwise the retry can repeat a side effect that the first request already performed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How should I choose the threshold?&lt;/strong&gt; Base it on the real latency distribution rather than a local round trip, set separate connect and read values, and confirm the choice against live measurements.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Treat timeout as client behavior and pin it with fixed mock latency around the exact threshold, covering the connect, read, idle, and late-response cases. Assert on the error, loading reset, cleanup, backoff-driven retries, and the late response that gets ignored, then verify real socket behavior and server-side handling separately. For more on this approach, see the &lt;a href="https://www.powerduck.com/blog/api-testing-guide-hub/" rel="noopener noreferrer"&gt;API testing guide hub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>testing</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>Testing Concurrent Requests and Race Conditions</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Thu, 12 Nov 2026 23:37:25 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/testing-concurrent-requests-and-race-conditions-47ae</link>
      <guid>https://dev.to/jeff_pdc/testing-concurrent-requests-and-race-conditions-47ae</guid>
      <description>&lt;p&gt;Quick answer: Concurrent bugs depend on timing, so ordinary tests see them at random and a green run proves little. Make the interleaving deterministic by releasing every request from one start barrier and using fixed delays to control completion order, then assert on the final state, looking for lost updates, duplicate side effects, and stale results from out-of-order responses. If the same test passes or fails without a code change, the test has not yet pinned down the race.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why concurrency slips past tests
&lt;/h2&gt;

&lt;p&gt;A loop that fires several requests is not truly concurrent, because each call starts a few instructions apart and the scheduler decides the rest. The failure only appears under one particular ordering, which means the bug hides on most runs and shows up in production under load. Two things need coverage: how the client handles overlapping calls, and how the shared resource behaves when more than one request touches it. On the client side, responses can resolve in a different order than the requests were sent, and shared in-memory state can be updated by callbacks that no longer reflect the latest call. On the resource side, read-modify-write and check-then-act sequences can interleave so the last writer quietly undoes the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the interleaving deterministic
&lt;/h2&gt;

&lt;p&gt;Use a start barrier, a latch or a promise gate, so every request is built first and released at the same instant instead of trickling out of a loop. Then use fixed, known delays rather than real network timing to choose which response lands first. Hold one request at a synchronization point while the other runs to reproduce a specific crossing, and run both orderings, A first and B first, as separate cases. Assert on the resulting state rather than on timing, and keep the delay values large enough that a slow machine cannot blur them. A desktop API workspace lets you drive these calls against a local mock with fixed latency and conditional responses, so the interleaving is chosen in advance and the same run reproduces every time. Powerduck works this way, keeping the concurrency test local and repeatable rather than dependent on live scheduling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The race patterns worth pinning down
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Race pattern&lt;/th&gt;
&lt;th&gt;Interleaving&lt;/th&gt;
&lt;th&gt;Result without a guard&lt;/th&gt;
&lt;th&gt;Typical guard&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lost update&lt;/td&gt;
&lt;td&gt;Both read, then both write&lt;/td&gt;
&lt;td&gt;Last write wins, first update lost&lt;/td&gt;
&lt;td&gt;Version or ETag, atomic update&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check-then-act&lt;/td&gt;
&lt;td&gt;Both pass the check, both act&lt;/td&gt;
&lt;td&gt;Duplicate side effect&lt;/td&gt;
&lt;td&gt;Unique constraint, lock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One-time initialization&lt;/td&gt;
&lt;td&gt;Both see it missing, both initialize&lt;/td&gt;
&lt;td&gt;Double setup&lt;/td&gt;
&lt;td&gt;Once primitive, atomic create&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Out-of-order completion&lt;/td&gt;
&lt;td&gt;B resolves before A&lt;/td&gt;
&lt;td&gt;Stale state shown to the user&lt;/td&gt;
&lt;td&gt;Sequence check, not arrival order&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For each row, assert the final resource state and the side effect count, not just the status codes. A 200 from each request does not show that one update vanished or that an effect ran twice. When the client aggregates calls, also pin down the failure contract: whether one rejected request rejects the whole set and whether cancellation stops the in-flight calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;A local mock runs in the desktop main process and verifies the request ordering and the client's handling of it, but it does not exercise the real server's locks, transaction isolation, or database constraints. It cannot reproduce true thread scheduling, kernel-level contention, or the full range of network jitter across separate machines. Deterministic local tests prove the code paths you can name; they do not replace load testing against a real system or a check of the actual isolation level. Importing a schema does not deploy a backend, and a passing local run is not evidence about production behavior under contention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does my concurrent test fail only sometimes?&lt;/strong&gt; The ordering is left to the scheduler, so the dangerous interleaving appears on only a fraction of runs. A start barrier and fixed delays remove that randomness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between a lost update and a duplicate effect?&lt;/strong&gt; A lost update comes from read-modify-write and overwrites a change. A duplicate effect comes from check-then-act and runs an action twice, such as a double charge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need to test both completion orders?&lt;/strong&gt; Yes, if the client holds shared state. Responses can arrive out of order, and the client should apply the latest sequence rather than whichever call happened to resolve last.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a mock replace a load test?&lt;/strong&gt; No. The mock proves the named interleavings and client logic, while a real load test checks server-side locking, isolation, and contention that the local process cannot model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Treat flaky concurrent tests as a signal that the timing is still uncontrolled, then use a start barrier and fixed delays to make each interleaving a deliberate case. Assert on final state and side-effect counts across the lost-update, check-then-act, initialization, and out-of-order patterns, and verify real server-side locking and isolation separately. For more on this approach, see the &lt;a href="https://www.powerduck.com/blog/api-testing-guide-hub/" rel="noopener noreferrer"&gt;API testing guide hub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>testing</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>Testing Pagination Boundaries: Catching Off-by-One Errors</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Thu, 12 Nov 2026 21:25:33 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/testing-pagination-boundaries-catching-off-by-one-errors-1mj1</link>
      <guid>https://dev.to/jeff_pdc/testing-pagination-boundaries-catching-off-by-one-errors-1mj1</guid>
      <description>&lt;p&gt;Quick answer: Pagination off-by-one bugs show up at the edges, where a row is duplicated across two pages or skipped entirely. Test them with fixed datasets sized around exact multiples of the page size, walk every page, and collect the ids into one set so a duplicate or missing row cannot hide behind a correct count. Cover the zero-item, exact-multiple, last-page, and out-of-range cases rather than only the happy path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the off-by-one hides
&lt;/h2&gt;

&lt;p&gt;A page-based API takes a page number and a size, and the offset is usually &lt;code&gt;(page - 1) * size&lt;/code&gt;. The errors cluster around a few comparisons. Treating the first page as zero instead of one shifts every offset by a full page. Using floor instead of ceiling for the page count drops the final partial page. When the total is an exact multiple of the size, a wrong loop condition can produce an extra empty page. The comparison at the boundary, using greater-than versus greater-than-or-equal, decides whether the row at the edge lands on one page or both. Limit and offset endpoints have the same problem written differently, and cursor endpoints can repeat or skip the last seen id.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build boundary-sized datasets
&lt;/h2&gt;

&lt;p&gt;Use a stable, ordered list of known ids and choose totals that sit on and around the boundaries, rather than testing one arbitrary count. With a size of 10, use totals of 0, 1, 9, 10, 11, and 20.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Expected behavior&lt;/th&gt;
&lt;th&gt;Common off-by-one&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;25 items, size 10&lt;/td&gt;
&lt;td&gt;3 pages, last page holds 5&lt;/td&gt;
&lt;td&gt;Floor gives 2 pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20 items, size 10&lt;/td&gt;
&lt;td&gt;2 pages, no empty page 3&lt;/td&gt;
&lt;td&gt;Extra empty page returned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page 1&lt;/td&gt;
&lt;td&gt;Offset 0, first 10 rows&lt;/td&gt;
&lt;td&gt;Offset 10 skips the first page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page past the last&lt;/td&gt;
&lt;td&gt;Empty list or a 400&lt;/td&gt;
&lt;td&gt;Repeats the last page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size 0 or negative&lt;/td&gt;
&lt;td&gt;A 400&lt;/td&gt;
&lt;td&gt;Division error or all rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge ids 10 and 11&lt;/td&gt;
&lt;td&gt;One id per page&lt;/td&gt;
&lt;td&gt;Duplicate or missing row&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Assert the response metadata, total and page count and next and previous links, matches the rows actually returned, since the two can drift independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assert on ids, not just counts
&lt;/h2&gt;

&lt;p&gt;Walk every page and gather the ids into a single set, then compare that set against the full dataset. A count alone cannot tell you that two pages each returned id 10 while id 11 never appeared. Check that the set has no duplicates, covers every id exactly once, and that each full page has the right size and the last page has the expected remainder. Add explicit calls for page zero, the page after the last, and an oversized or zero size so the error contract is pinned down. A desktop API workspace lets you run these pages as an ordered scenario, carry the collected ids through the steps, and assert on each response against a fixed local list. Powerduck works this way, so the boundary walk is repeatable without depending on changing live data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;A local mock runs in the desktop main process and returns a fixed ordered list, which is ideal for the arithmetic and comparison bugs but does not verify the real database query, index, or sort stability. It cannot reproduce rows moving while a client pages through, since inserts and deletes during a real walk can cause duplicates or gaps, and it does not prove ties in the sort key are ordered consistently. Confirm those behaviors against genuine data with a stable sort that includes a unique key. Importing a schema does not deploy a backend, and a passing mock run is not evidence about production query behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does a correct total count still fail?&lt;/strong&gt; Counting returned rows does not detect a duplicate id paired with a missing id. Collect the ids into a set and compare it with the source list to catch both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why test exact multiples of the page size?&lt;/strong&gt; That is where an extra empty page or a missing final page appears, because the loop boundary lands exactly on the last row.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What should a page past the last return?&lt;/strong&gt; Either an empty list or a documented 400, applied consistently. The bug is silently repeating the last page so the client never stops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do cursor APIs have the same problem?&lt;/strong&gt; Yes, around the last seen id. Repeating the cursor returns the same row, and advancing past it skips a row, so assert on the edge id.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Pagination bugs live at the page edges, so size the test data around exact multiples and the zero and one-item cases, then collect ids across every page and assert on the set rather than a count. Pin down the out-of-range and invalid-size contracts, and verify real database sorting and concurrent changes separately. For more on this approach, see the &lt;a href="https://www.powerduck.com/blog/api-testing-guide-hub/" rel="noopener noreferrer"&gt;API testing guide hub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>testing</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>Mocking Correlation IDs for Request Tracing</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Thu, 12 Nov 2026 19:25:46 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/mocking-correlation-ids-for-request-tracing-3d7i</link>
      <guid>https://dev.to/jeff_pdc/mocking-correlation-ids-for-request-tracing-3d7i</guid>
      <description>&lt;p&gt;Quick answer: A correlation ID is a single value that follows a request through several services, so their logs can be joined into one chain. Mock the header exchange by having the mock echo the ID, generate one when the client sends none, and return a mismatch or a missing value on demand. This lets you verify that the client generates, sends, reads, and logs the ID correctly without depending on a real distributed system.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a correlation ID does
&lt;/h2&gt;

&lt;p&gt;A request often crosses a gateway, one or more services, and a queue, and each component writes its own log. Without a shared identifier, those entries are hard to connect when something fails. The correlation ID, carried in a header such as &lt;code&gt;X-Correlation-ID&lt;/code&gt; or &lt;code&gt;X-Request-ID&lt;/code&gt;, gives every hop the same value. It has two common origins: the client generates one and sends it, or the entry gateway assigns one when the header is absent and returns it in the response. The response header usually carries the same value, so a support engineer can take the ID shown to the user and pull the matching logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mock the header exchange
&lt;/h2&gt;

&lt;p&gt;Use conditional responses to control what comes back, and inspect the recorded requests to see exactly what the client sent. Cover the cases below.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Request header&lt;/th&gt;
&lt;th&gt;Mock response header&lt;/th&gt;
&lt;th&gt;Client expectation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client generated&lt;/td&gt;
&lt;td&gt;ID present&lt;/td&gt;
&lt;td&gt;Echoes the same ID&lt;/td&gt;
&lt;td&gt;Log that ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gateway generated&lt;/td&gt;
&lt;td&gt;No ID&lt;/td&gt;
&lt;td&gt;A newly generated ID&lt;/td&gt;
&lt;td&gt;Adopt the returned ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Downstream differs&lt;/td&gt;
&lt;td&gt;ID present&lt;/td&gt;
&lt;td&gt;A different ID&lt;/td&gt;
&lt;td&gt;Follow the documented rule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing on response&lt;/td&gt;
&lt;td&gt;ID present&lt;/td&gt;
&lt;td&gt;No ID header&lt;/td&gt;
&lt;td&gt;Keep its own ID, no crash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry&lt;/td&gt;
&lt;td&gt;Same logical call&lt;/td&gt;
&lt;td&gt;Echoes the value&lt;/td&gt;
&lt;td&gt;Reuse or renew per convention&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mismatch row matters because a real downstream service can replace the value, and the client needs one clear rule for which ID wins rather than silently logging two unrelated values.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the client must handle
&lt;/h2&gt;

&lt;p&gt;Put ID generation and reading in one middleware layer so the behavior is consistent instead of being rebuilt per request. Generate a unique, well-formed value for each new logical request, send it on the header, and record it alongside the call. When the response carries an ID, read it and follow the agreed precedence, typically adopting the gateway value or keeping the one the client sent. Decide the retry convention up front: reusing the same ID keeps the attempts tied together, while a new ID distinguishes each attempt, and either is valid if the team applies it consistently. A desktop API workspace lets you set these conditional responses, inspect the recorded headers, and assert on each case in the same place. Powerduck works this way, so the header exchange is a fixed set of local responses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;A local mock runs in the desktop main process and verifies one hop of header handling, not the full behavior of a distributed trace. It does not model propagation across many real services, sampling, or a trace standard such as W3C Trace Context, and it does not prove the live services forward the ID or that the logging backend can actually query it. The echo and precedence rules are ones you define, while a real downstream service can truncate, replace, or drop the value. Confirm end-to-end propagation with a small set of genuine requests. Importing a schema does not deploy a backend, and a passing mock run is not evidence about production observability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between a correlation ID and a trace ID?&lt;/strong&gt; A correlation ID joins logs for one request across services. A trace ID is part of a tracing standard and often carries span and sampling context. They overlap, but the correlation ID is the simpler header-level value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who should generate the ID?&lt;/strong&gt; Either the client or the entry gateway. If the client sends one, the gateway keeps it; if not, the gateway assigns one and returns it. The mock covers both paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens when the response has no ID?&lt;/strong&gt; The client should keep the ID it sent and continue without crashing, rather than failing the request or logging an empty value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should a retry reuse the same ID?&lt;/strong&gt; It depends on the convention. Reusing it links the attempts; renewing it separates them. Pick one and apply it consistently so the logs stay interpretable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Use a correlation ID to connect the logs for a request, and mock the header exchange to prove the client handles the generated, gateway-assigned, mismatched, and missing cases. Keep generation and reading in one layer, define the precedence and retry rules explicitly, and verify real cross-service propagation separately. For more on this approach, see the &lt;a href="https://www.powerduck.com/blog/api-mocking-guide-hub/" rel="noopener noreferrer"&gt;API mocking guide hub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>testing</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
