QA How-To
Test MCP Server Secret Leakage: A Practical Security Tutorial
Learn how to test MCP server secret leakage with repeatable canary tests that inspect tool results, errors, logs, resources, prompts, and transport traffic.
20 min read | 2,736 words
TL;DR
To test MCP server secret leakage, inject unique fake canaries into the server environment, exercise every MCP capability and failure path, capture protocol messages plus logs, and fail the test if any raw or encoded canary escapes. Never run these tests with production credentials.
Key Takeaways
- Plant unique canary values instead of using real credentials in leakage tests.
- Inspect tool results, resources, prompts, error data, logs, and raw transport output.
- Test both successful and failed requests because exception paths often reveal more context.
- Scan decoded and normalized variants to catch encoded or reformatted secrets.
- Keep scanners precise enough to block canaries and credential formats without flooding CI with false positives.
- Treat redaction as defense in depth, not a substitute for least-privilege data handling.
- Run leakage tests in CI with isolated test credentials and sanitized artifacts.
To test MCP server secret leakage, plant unmistakable fake secrets, call the server through its real MCP transport, and scan every observable output. Cover successful results, validation failures, handler exceptions, resources, prompts, logs, and transport-level messages. A passing functional test is not enough if a token appears in an error or diagnostic line.
This tutorial builds a small Node.js security harness around an MCP server. It uses canary credentials, which are harmless values created only for the test, so a failure proves exposure without risking a real account. For the broader threat model and release checklist, read the MCP Security Testing Complete Guide for 2026.
You will test a deliberately vulnerable fixture first, watch the suite catch leaks, harden the fixture, and rerun the same checks. The pattern works for local stdio servers and can be adapted to Streamable HTTP without letting secrets enter test reports.
What You Will Build
You will build a repeatable leakage gate that:
- starts an MCP server over stdio with fake canary secrets in its environment;
- enumerates and exercises tools, resources, and prompts through the official MCP SDK client;
- captures results, protocol errors, stderr, and process output without printing sensitive values;
- detects raw, URL-encoded, Base64-encoded, and JSON-escaped canary variants; and
- exits nonzero in CI when a secret crosses the server boundary.
The final test targets observable behavior. It does not depend on a specific logging framework or on reading the server implementation. That makes it useful for third-party MCP servers that you can execute but cannot modify.
Prerequisites
Use Node.js 22 LTS or newer and npm 10 or newer. Create an empty working directory outside your production server, then install the current MCP TypeScript SDK and Vitest:
mkdir mcp-leakage-lab
cd mcp-leakage-lab
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install --save-dev vitest typescript @types/node
Add ESM mode and scripts to package.json:
{
"type": "module",
"scripts": {
"test": "vitest run",
"test:leakage": "vitest run test/leakage.test.ts"
}
}
Keep the package versions selected by your lockfile. The examples use stable SDK concepts: McpServer, Client, StdioServerTransport, and StdioClientTransport. If your installed SDK contains a migration note, follow that package's documented import paths. Do not copy production .env files into this lab.
Verification: Run node --version, npm --version, and npm ls @modelcontextprotocol/sdk vitest. Each command should complete successfully and show installed versions.
Step 1: Define Safe Canary Secrets
A canary should be unique, recognizable, fake, and restricted to the test environment. Avoid realistic live-token prefixes if a security product might quarantine the value. Use fixed strings locally for easy debugging, but generate a unique suffix in CI so parallel runs cannot contaminate one another.
Create test/canaries.ts:
import { randomUUID } from "node:crypto";
const runId = process.env.CI ? randomUUID() : "local-run-7f3a";
export const canaries = {
apiKey: `qa_canary_api_${runId}_DO_NOT_USE`,
databaseUrl: `postgresql://canary:${runId}@invalid.example/test`,
bearerToken: `qa_canary_bearer_${runId}_DO_NOT_USE`,
} as const;
export const canaryEnvironment = {
TEST_API_KEY: canaries.apiKey,
TEST_DATABASE_URL: canaries.databaseUrl,
TEST_BEARER_TOKEN: canaries.bearerToken,
};
The hostname uses the reserved .example domain, and the values grant no access. Keep canary names out of normal application copy so exact matching remains high signal. Do not log the object during test setup. Even fake secrets train teams into unsafe debugging habits and can obscure whether the scanner captured the server or the test itself.
Verification: Import canaryEnvironment in a temporary local test and assert that every value contains qa_canary or invalid.example. Do not snapshot the values. Delete any temporary output before committing.
Step 2: Create a Deliberately Vulnerable MCP Fixture
Build a controlled fixture so you can prove the test fails for the right reason. Create fixtures/leaky-server.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "leakage-fixture", version: "1.0.0" });
server.registerTool(
"account_status",
{
description: "Returns a fake account status",
inputSchema: { accountId: z.string().min(1) },
},
async ({ accountId }) => ({
content: [{
type: "text",
text: `Account ${accountId}; debug key=${process.env.TEST_API_KEY}`,
}],
}),
);
server.registerTool(
"fail_lookup",
{ description: "Exercises an exception path", inputSchema: {} },
async () => {
throw new Error(`Upstream rejected ${process.env.TEST_BEARER_TOKEN}`);
},
);
console.error(`connecting to ${process.env.TEST_DATABASE_URL}`);
await server.connect(new StdioServerTransport());
This fixture leaks through three channels: a successful tool result, an exception, and stderr. Never point the fixture at a network service. The test must own the child process and terminate it after each run.
If you test an existing server instead, keep this fixture as a scanner self-test. A scanner that only passes against clean software can silently break and still look green.
Verification: Run npx tsc --noEmit --module nodenext --moduleResolution nodenext --target es2022 fixtures/leaky-server.ts test/canaries.ts. TypeScript should report no errors. Do not manually invoke the stdio server because it waits for framed protocol input.
Step 3: Build a Variant-Aware Secret Scanner
Exact raw matching catches the most important leak but misses common transformations. A server might URL-encode a database URL, Base64-encode a token, escape characters inside JSON, or split a secret across line wrapping. Create test/secret-scanner.ts:
export type Leak = { label: string; variant: string };
function variants(secret: string): Array<[string, string]> {
return [
["raw", secret],
["url-encoded", encodeURIComponent(secret)],
["base64", Buffer.from(secret, "utf8").toString("base64")],
["json-escaped", JSON.stringify(secret).slice(1, -1)],
];
}
export function findLeaks(
observed: unknown,
secrets: Record<string, string>,
): Leak[] {
const text = typeof observed === "string"
? observed
: JSON.stringify(observed);
const compact = text.replace(/\s+/g, "");
const found: Leak[] = [];
for (const [label, secret] of Object.entries(secrets)) {
for (const [variant, candidate] of variants(secret)) {
if (text.includes(candidate) || compact.includes(candidate.replace(/\s+/g, ""))) {
found.push({ label, variant });
}
}
}
return found;
}
export function assertNoLeaks(
channel: string,
observed: unknown,
secrets: Record<string, string>,
): void {
const leaks = findLeaks(observed, secrets);
if (leaks.length > 0) {
const summary = leaks.map(({ label, variant }) => `${label}:${variant}`).join(", " );
throw new Error(`Secret leakage in ${channel}: ${summary}`);
}
}
The failure reports labels and encodings, never matched values. That detail matters because CI output is another exfiltration channel. Keep regex-based credential discovery separate from exact canary detection. Generic patterns help find unknown tokens, but they also require allowlists and careful review.
| Detection method | Finds | Main limitation | CI use |
|---|---|---|---|
| Exact canary match | Known injected test values | Misses unknown secrets | Blocking |
| Encoded canary variants | Common transformations | Cannot cover encryption or arbitrary splitting | Blocking |
| Credential regex | Token-like unknown values | False positives and format drift | Review, then blocking |
| Entropy heuristic | Unusually random strings | Flags IDs and hashes | Triage signal |
Verification: Add a unit assertion that findLeaks(Buffer.from(canaries.apiKey).toString("base64"), canaries) returns an item with variant base64. Also assert that ordinary text returns an empty array.
Step 4: Connect Through the Real MCP Stdio Transport
Do not call handler functions directly. Use an MCP client so serialization, protocol errors, capability negotiation, and child-process streams are part of the test. Create test/harness.ts:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { canaryEnvironment } from "./canaries.js";
export async function startHarness() {
const stderr: string[] = [];
const transport = new StdioClientTransport({
command: process.execPath,
args: ["--import", "tsx", "fixtures/leaky-server.ts"],
env: {
...Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] =>
typeof entry[1] === "string"
),
),
...canaryEnvironment,
},
stderr: "pipe",
});
transport.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk.toString("utf8")));
const client = new Client({ name: "leakage-test-client", version: "1.0.0" });
await client.connect(transport);
return {
client,
readStderr: () => stderr.join(""),
close: () => client.close(),
};
}
Install tsx as a development dependency because the child process executes TypeScript:
npm install --save-dev tsx
Passing the inherited environment is convenient for local execution, but a stricter production harness should use an explicit allowlist such as PATH, HOME, and required runtime variables. Never give an untrusted MCP server your whole developer environment.
Verification: Start the harness in a Vitest beforeAll, call client.listTools(), and assert that account_status and fail_lookup are present. Close the client in afterAll so the test process exits cleanly.
Step 5: Test MCP Server Secret Leakage in Results and Errors
Create test/leakage.test.ts and inspect both returned data and thrown errors:
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { canaries } from "./canaries.js";
import { assertNoLeaks } from "./secret-scanner.js";
import { startHarness } from "./harness.js";
describe("MCP secret leakage", () => {
let harness: Awaited<ReturnType<typeof startHarness>>;
beforeAll(async () => { harness = await startHarness(); });
afterAll(async () => { await harness.close(); });
it("does not leak through successful tool content", async () => {
const result = await harness.client.callTool({
name: "account_status",
arguments: { accountId: "A-100" },
});
expect(() => assertNoLeaks("tool result", result, canaries)).not.toThrow();
});
it("does not leak through MCP error responses", async () => {
let observed: unknown;
try {
observed = await harness.client.callTool({ name: "fail_lookup", arguments: {} });
} catch (error) {
observed = error instanceof Error
? { name: error.name, message: error.message, cause: error.cause }
: error;
}
expect(() => assertNoLeaks("tool error", observed, canaries)).not.toThrow();
});
});
The first run should fail. That is the expected proof that the detector works. Do not approve the failure output if it includes the actual canary. The scanner's error should name only the channel, secret label, and variant.
Also send invalid arguments, missing arguments, oversized strings within your lab limits, and wrong types. Schema validation errors sometimes echo rejected input, so never place a secret in an argument unless the test specifically verifies input redaction. For systematic malformed-input coverage, continue with JSON Schema fuzzing for MCP tool arguments.
Verification: Run npm run test:leakage. Confirm that account_status reports apiKey:raw and fail_lookup reports bearerToken:raw, while the canary values themselves remain absent from the test report.
Step 6: Inspect Resources, Prompts, Logs, and Stderr
Tools are only one MCP surface. If the server advertises resources or prompts, list and read every safe fixture URI and retrieve each prompt with synthetic arguments. Scan complete response objects, including annotations and metadata. Then inspect stderr after the server has flushed its startup logs.
Add this test to the same suite:
it("does not leak through process diagnostics", async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
expect(() => assertNoLeaks("server stderr", harness.readStderr(), canaries)).not.toThrow();
});
it("does not leak through advertised resources and prompts", async () => {
const capabilities = harness.client.getServerCapabilities();
if (capabilities?.resources) {
const listed = await harness.client.listResources();
assertNoLeaks("resource list", listed, canaries);
for (const resource of listed.resources) {
const result = await harness.client.readResource({ uri: resource.uri });
assertNoLeaks(`resource ${resource.name}`, result, canaries);
}
}
if (capabilities?.prompts) {
const listed = await harness.client.listPrompts();
assertNoLeaks("prompt list", listed, canaries);
}
});
Only invoke resource URIs intended for testing. Resource templates may require generated parameters, and reading an arbitrary production-like URI can trigger external access. For prompts with required arguments, maintain a fixture map of synthetic values and call getPrompt. Scan server-initiated log messages too if your client registers a logging notification handler.
Stdio reserves stdout for protocol traffic. A stray console.log can corrupt framing or expose data in captured raw output. Send sanitized diagnostics to stderr, then treat stderr as sensitive and scan it.
Verification: Run the suite against the leaky fixture. The diagnostic test should fail with databaseUrl:raw. If capabilities are absent, the resource and prompt test should pass without making unsupported requests.
Step 7: Fix the Server at the Data Boundary
Remove unnecessary secret access before adding redaction. Return allowlisted business fields, translate internal exceptions into stable public errors, and log structured events without credentials. Replace the vulnerable handlers and startup log with this pattern:
server.registerTool(
"account_status",
{
description: "Returns a fake account status",
inputSchema: { accountId: z.string().min(1) },
},
async ({ accountId }) => ({
content: [{ type: "text", text: `Account ${accountId} is active` }],
}),
);
server.registerTool(
"fail_lookup",
{ description: "Exercises an exception path", inputSchema: {} },
async () => {
try {
throw new Error("simulated upstream failure");
} catch {
console.error(JSON.stringify({ event: "lookup_failed", code: "UPSTREAM_REJECTED" }));
return {
isError: true,
content: [{ type: "text", text: "Lookup failed. Retry later." }],
};
}
},
);
console.error(JSON.stringify({ event: "server_starting" }));
Do not use a broad replacement such as message.replace(secret, "[REDACTED]") as the primary fix. Copies can exist in nested objects, headers, URLs, stack traces, or encoded values. Stop sensitive data from entering response and logging objects in the first place. Add centralized redaction as a backup before serialization and log export.
Limit which tools can read each environment variable. A process-wide environment makes every handler and dependency able to access every credential. Separate high-risk integrations into different server processes or inject narrow clients into only the handlers that need them. Pair leakage checks with tests for MCP tool permission boundaries.
Verification: Rerun npm run test:leakage. All tool-result, error, and stderr checks should pass. Confirm that the functional assertions still verify account status and the stable error code, rather than merely asserting an absence of text.
Step 8: Add the Leakage Gate to CI
Run the suite in an isolated job with no production secrets. The job needs only repository access and package installation. A GitHub Actions example is:
name: MCP leakage tests
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
leakage:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:leakage
env:
CI: "true"
Do not upload raw protocol captures or stderr as artifacts by default. If investigation requires an artifact, sanitize it before upload, restrict retention, and review access. Keep CI permissions read-only unless another step clearly requires more. Disable outbound network access at the runner or container level when the fixture does not need it.
Expand the matrix to start each supported server configuration. Include debug mode because verbose logging often exposes configuration. Include authentication failures, timeouts, upstream parse errors, cancellation, and shutdown. A release gate should fail on an exact canary match even if the functional request already failed. Follow the test framework CI setup guide for pipeline configuration.
Verification: Open a test pull request with the vulnerable fixture and confirm the job blocks the change. Apply the hardened fixture and confirm the same job turns green. Inspect the job log to ensure it contains labels only, not canary values.
Test MCP Server Secret Leakage Coverage Checklist
A useful suite covers channels and lifecycle states, not just tool names. Track this matrix in code review:
| Surface | Success path | Validation failure | Internal failure | Cancellation or timeout |
|---|---|---|---|---|
| Tools | Result content and metadata | Invalid arguments | Sanitized handler error | Late logs and cleanup |
| Resources | List and read response | Invalid URI | Provider error | Read cancellation |
| Prompts | List and rendered messages | Missing argument | Template error | Generation cancellation |
| Transport | Protocol frames | Parse or method error | Disconnect | Shutdown output |
| Diagnostics | Startup logs | Rejected input log | Stack and cause | Cleanup log |
Add sampling and roots flows if the server uses them. Treat any server-to-client request as a possible data boundary. Verify that elicitation or user-facing messages never contain credentials. When testing remote Streamable HTTP, capture response bodies and safe header names, but never dump authorization headers into failure output. See OWASP API security testing.
Your suite should also test indirect exposure. A tool might place a secret in a file and return its URI, or embed it in model-visible content that later appears in another tool call. Those cases require filesystem isolation, egress controls, and multi-step scenarios. Combine this tutorial with MCP prompt injection attack testing because hostile instructions often try to make a server reveal environment data through legitimate capabilities.
Troubleshooting
Problem: The stdio client hangs during connection -> Confirm the server writes no ordinary text to stdout. Use stderr for diagnostics, ensure the server calls connect, and close the client in afterAll. Run with a test timeout so a broken child process cannot stall CI indefinitely.
Problem: TypeScript cannot resolve .js imports for .ts files -> Use NodeNext module resolution and run the fixture with tsx. ESM source commonly imports .js specifiers even when TypeScript resolves the corresponding .ts file. Keep "type": "module" in the lab package.
Problem: The scanner reports its own canary setup -> Scan only server observations, not the test configuration object or child-process launch arguments. Never serialize the environment into a snapshot. Separate detector unit tests from end-to-end test reports.
Problem: Stderr checks are flaky -> Attach the listener before connecting and wait for a semantic event when possible. A short delay is acceptable in a fixture, but production tests should wait for initialization or a known request completion rather than guessing at timing.
Problem: Generic token patterns create too many failures -> Keep exact canary matches blocking. Run broad credential regexes in report mode, classify recurring false positives, and add narrow allowlists with owners and expiration dates. Never allowlist an entire output channel.
Problem: A remote MCP server returns an HTTP authorization error -> Do not include the supplied bearer token in assertion messages, traces, or request dumps. Assert status, stable error code, and sanitized body separately. Use a dedicated test credential with minimal scope and immediate revocation.
Where To Go Next
Use the complete MCP security testing guide to place leakage checks inside a full threat model, test plan, and release gate. Then deepen the suite in three directions:
- Run MCP prompt injection attack tests to check whether untrusted content can convince tools or models to disclose protected context.
- Validate MCP tool permission boundaries so a low-trust tool cannot reach secrets owned by another integration.
- Fuzz MCP tool arguments from JSON Schema to exercise validation, parser, and exception paths at scale.
Keep the canary gate small and deterministic, then add targeted scenarios whenever an incident, code review, or new capability reveals another output channel. The best leakage suite evolves with the server's capabilities instead of relying on one universal secret regex.
Interview Questions and Answers
Q: Why should an MCP leakage test use canary secrets?
A canary creates a safe, exact oracle. If it appears outside the intended boundary, the test can fail confidently without exposing a real credential. Unique per-run canaries also reveal cross-test contamination and stale caches.
Q: Which MCP outputs should a tester inspect?
Inspect tool results, structured content, resource responses, rendered prompts, protocol errors, logging notifications, stderr, and raw transport output where safe. Include initialization, cancellation, timeout, and shutdown paths because leakage is not limited to successful requests.
Q: Is log redaction enough to prevent secret leakage?
No. Redaction is defense in depth and can miss nested, encoded, or copied values. The stronger design prevents secrets from entering response and log objects, restricts secret access by component, and uses redaction immediately before the remaining output boundaries.
Q: How do you avoid leaking the secret from the test itself?
Never interpolate matched values into assertion messages. Report a logical label and detected variant, keep raw captures out of artifacts, and scan only server observations. Use fake isolated credentials even when the test framework promises masked secrets.
Q: Why test error paths more heavily than success paths?
Errors often include exception messages, causes, request data, configuration, stack frames, and upstream responses. Those details help developers diagnose problems but can cross the MCP boundary or enter shared logs. Validation, authentication, timeout, and cleanup failures each need explicit coverage.
Q: How would you test a remote Streamable HTTP MCP server?
Use a dedicated low-privilege test identity and run the same capability scenarios through the supported HTTP client transport. Scan response bodies, MCP messages, safe headers, client logs, and server-side test logs. Avoid recording authorization headers or cookies, and sanitize all traces before retention.
Best Practices
- Use fake canaries and isolated infrastructure. Never prove a scanner with a production key.
- Start with exact and encoded canary matching, then add reviewed generic detectors.
- Exercise every advertised capability and every meaningful failure mode.
- Keep assertion messages free of observed payloads and matched secret values.
- Allowlist response fields instead of serializing internal objects wholesale.
- Give the test runner minimal permissions and deny unnecessary network egress.
- Retest with debug logging enabled and after dependency or transport upgrades.
- Preserve functional assertions so security filtering cannot turn useful responses into empty ones.
Conclusion
To test MCP server secret leakage reliably, control the secret, exercise the real protocol, and observe every boundary. Unique fake canaries make the result deterministic, while variant scanning catches common encoding changes. Successful results deserve scrutiny, but validation errors, exceptions, diagnostics, and shutdown behavior usually provide the richest leakage targets.
Put the test in CI with no production credentials, block exact canary matches, and keep evidence sanitized. Then expand from leakage into prompt injection, permission boundaries, and schema fuzzing so the MCP server is tested as a complete security system rather than a collection of happy-path tools.
Interview Questions and Answers
What is the safest oracle for MCP secret leakage testing?
A unique fake canary is the safest oracle because it has no operational value and supports exact matching. Inject it only into the isolated server environment, then scan every observable boundary. Report the canary's label and encoding, never its value.
Why must MCP secret tests cover both success and failure paths?
Success responses can accidentally serialize configuration or internal objects. Failure paths are often riskier because exceptions, validation messages, upstream responses, and stack details contain extra context. Both paths cross the same trust boundary and require assertions.
Which channels belong in an MCP leakage test matrix?
Include tool results, resources, prompts, structured metadata, protocol errors, logging notifications, stderr, and transport captures. Also test startup, authentication failure, timeout, cancellation, and shutdown. Add sampling, roots, or elicitation when the server supports those capabilities.
How do you keep the leakage detector from becoming a leak?
Do not print observed payloads or matched strings. Failure output should identify only the channel, logical secret label, and transformation type. Keep captures ephemeral, sanitize artifacts, and use fake credentials even if CI offers masking.
What is the difference between exact canary matching and entropy detection?
Exact matching is deterministic and high confidence for injected values, so it can block CI immediately. Entropy detection looks for random-looking unknown strings, but IDs and hashes produce false positives. Use entropy as a triage signal until the team has tuned and reviewed it.
How would you remediate a secret exposed in an MCP error?
First revoke any real exposed credential and preserve sanitized evidence. Then prevent the secret from entering the exception, translate internal errors into stable public responses, and log structured allowlisted fields. Add a regression canary that reproduces the same failure path.
Frequently Asked Questions
How do I test an MCP server for secret leakage?
Inject unique fake canary secrets into an isolated server process, exercise every supported MCP capability and failure path, and scan results, errors, logs, and transport output. Fail on raw or encoded canary matches without printing the matched value.
Should I use real API keys in MCP leakage tests?
No. Use harmless canaries or dedicated test credentials with minimal scope. A real credential can escape through the exact channel the test is trying to discover, including CI logs and retained artifacts.
What MCP surfaces can expose secrets?
Tool results, resources, prompts, structured content, error objects, logging notifications, stderr, and raw transport output can all expose secrets. Initialization, cancellation, timeout, and shutdown code also produce observable data.
How can a scanner detect encoded secrets?
Generate expected variants of each canary, such as URL encoding, Base64, JSON escaping, and whitespace-normalized forms. Match those variants exactly and add generic token patterns only after measuring false positives.
Can secret redaction make an MCP server safe?
Redaction helps, but it is not sufficient by itself. Prevent secrets from entering output objects, apply least privilege to handlers, return allowlisted fields, and use centralized redaction as a final backup at serialization and logging boundaries.
How should MCP leakage tests run in CI?
Run them in an isolated, time-limited job with read-only repository permissions, fake canaries, and restricted network access. Do not upload raw protocol or log captures, and ensure failure messages contain labels rather than secret values.
Related Guides
- Playwright Test WebSocket Reconnection Logic: A Practical Tutorial
- Test AI Agent Loop Termination: A Practical Tutorial
- API security testing basics: A Practical Guide (2026)
- Appium 3 Driver Version Management: A Practical Tutorial
- Building an MCP server for test automation (2026)
- Debug Java Test Automation Race Conditions: A Practical Guide