Test Case Design and Edge Case Analysis Questions
Systematically deriving the cases, inputs, and conditions most likely to expose defects. Covers formal test-design techniques (equivalence partitioning, boundary value analysis, decision tables, state transitions, and pairwise/combinatorial design) and writing clear, maintainable test cases with documented expected results. Also covers the edge-case mindset: boundary conditions, invalid and unexpected inputs, corner cases, and the attention to detail that anticipates failures when validating complex behavior.
You need to ensure the checkout flow is robust across edge cases (payment provider failures, slow networks, multi-tab use, partial failures). Propose a balanced test suite across unit, integration, and E2E tests, listing specific test cases for edge behaviors, test data strategies, decisions for mocking versus using sandbox endpoints, and techniques to reduce E2E flakiness.
Sample Answer
Direct answer
A robust test suite for a checkout flow needs three layers with genuinely different jobs, not the same edge cases repeated at three levels: unit tests pin the exact logic of individual failure-handling decisions, integration tests verify the interaction between the client and the payment provider's contract (including sandbox behavior), and end-to-end (E2E) tests verify the small number of full user journeys that actually matter (a payment failure mid-flow, a multi-tab conflict, an interrupted network). Getting the split right, and being deliberate about mocking versus real sandbox endpoints, is what keeps this suite fast and trustworthy instead of slow and flaky.
Structured elaboration
| Layer | What it owns | Specific edge-case tests |
|---|---|---|
| Unit | Pure logic: given a specific provider response or error code, what does the checkout state machine decide to do next | Payment provider returns a declined-card error code vs. a network-timeout error code (these must route to different UI states, not one generic "payment failed" message); a duplicate submit is prevented by disabling the submit control the instant a request starts, tested by simulating two rapid submit events and asserting only one request fires |
| Integration | The real contract between the client code and the payment provider's actual API (application programming interface) shape, run against the provider's sandbox environment, not a hand-written mock of it | A sandbox-triggered decline response is parsed into the correct internal error type; a sandbox-triggered slow response (most providers' sandboxes support an artificial-delay test card or flag) is handled by the timeout logic without the UI hanging indefinitely; the client's idempotency-key header is actually present and correctly formed on a real request, which a hand-written mock cannot verify since it never sees the real serialized request |
| End-to-end | The handful of full user journeys where the FAILURE is the point, exercised through the real UI | Multi-tab: the same cart open in two tabs, one tab completes checkout, and the second tab's stale checkout attempt is rejected (or gracefully informed the order already completed) rather than double-charging; slow network: checkout submitted on a throttled connection shows an appropriate pending state rather than appearing to hang or allowing a second submit; partial failure: payment succeeds but the confirmation page fails to load, and reloading or returning to the site does not re-trigger payment |
Test data strategy: use the payment provider's own documented test card numbers and test scenarios for the sandbox tier (nearly every major provider publishes specific card numbers that deterministically trigger a decline, an insufficient-funds error, or a timeout), rather than inventing arbitrary fake numbers whose behavior against the real sandbox is unverified. For the E2E tier, seed a dedicated test account and cart state per test run so tests are independent and repeatable, rather than sharing mutable state across test runs.
Mocking vs. sandbox decision: mock the payment provider only at the unit-test layer, where the goal is to test the CLIENT's own decision logic in isolation and a real network call would only slow the test down without adding coverage of anything the client controls. Use the real sandbox at the integration layer specifically because that is where the client's actual serialized requests and the provider's actual response shapes need to agree, a hand-rolled mock of the provider's API can silently drift from the real contract as the provider's API evolves, passing tests against a mock that no longer matches reality.
Optimistic UI update edge cases from WebSocket message delivery: checkout flows that show optimistic UI state (e.g. "processing", then flipping to "confirmed") driven by WebSocket (a persistent, bidirectional connection protocol) events from the backend must handle messages arriving out of order or duplicated, both of which a plain network is free to do. A payment_confirmed event arriving before its own payment_processing event (reordering) must not leave the UI stuck showing "processing" forever once the actual final state has already arrived; the fix is applying incoming events against a state machine keyed by the event's own sequence number or timestamp, not by arrival order, which is the identical failure mode a payment gateway's own server-side webhooks produce and must be tested the same way. A payment provider delivers webhook events (for example charge.succeeded, charge.refunded) with at-least-once delivery: it retries with backoff whenever your endpoint does not answer with a 2xx response quickly enough, and that retry can arrive well after a later event that was delivered successfully on the first attempt. Concretely: your server receives a charge.refunded webhook (the provider's own event timestamp created=1005) and applies it at real time T=2; then, at real time T=30, a retried delivery of an earlier charge.succeeded webhook (created=1000, originally sent at T=0 but not acknowledged in time) finally arrives. A handler that applies whichever webhook it physically received last would incorrectly revert the charge back to "succeeded" after it was already correctly refunded, even though created=1000 is objectively the older event. The fix mirrors the client-side WebSocket case above: key state transitions off the provider's own event timestamp (or an explicit monotonic sequence or version field most gateways include), not off HTTP arrival order. Store the highest created value already applied per resource, and treat any incoming event whose created is less than or equal to that stored value as a no-op, regardless of when the HTTP request physically arrives. This needs its own explicit test case, webhook retried out of order: Input, two webhook payloads for the same resource, created=1000 type charge.succeeded and created=1005 type charge.refunded. Sequence: deliver created=1005 first (applied normally), then deliver created=1000 second (simulating the provider's retry). Expected output: the final stored state is "refunded" (from the created=1005 event), not "succeeded"; the late-arriving created=1000 event is detected as older than what is already applied and becomes a no-op.
A duplicated payment_confirmed event (the same event delivered twice, which most WebSocket reconnect-and-replay logic can produce) must be a no-op the second time, tested by feeding the same event object to the handler twice and asserting the UI state and any downstream side effect (e.g. an analytics ping) only fire once.
Trade-offs and pitfalls
The most common wrong turn is pushing every edge case to the E2E layer because "that's what really happens in production," which produces a slow, flaky suite that re-tests the same client-side decision logic dozens of times through a full browser instead of once at the unit layer. A second pitfall is mocking the payment provider at the integration layer too, which feels faster but stops catching provider API drift entirely, defeating the actual purpose of having an integration layer. On flakiness specifically, at the test-DESIGN level (not diagnosis or quarantine, which is a separate concern from writing the tests in the first place): avoid asserting on wall-clock-dependent intermediate states, pin any time-based logic behind an injectable clock rather than relying on real elapsed time in a test, and assert on the final, stable state reached rather than a transient one that a slow CI (continuous integration) runner might race past.
Explain property-based testing and how it helps discover edge cases in frontend code. Using JavaScript and fast-check, write a property-based test outline (pseudo-code is fine) for a normalizePhoneNumber(input) function to assert invariants across random inputs: different separators, whitespace, unicode digits, leading '+' country codes, very long strings, and null/undefined inputs.
Sample Answer
Direct answer
Property-based testing generates many random inputs and checks that a general INVARIANT (a rule that should hold for every valid input) is never violated, instead of hand-picking a fixed list of example inputs and their expected outputs. For frontend input-normalization code like normalizePhoneNumber, this matters because the input space (arbitrary user-typed or pasted text) is effectively unbounded, and a fixed example list will only ever cover the specific formatting quirks the test author happened to think of, while a property runs hundreds of randomly-generated variations (including ones no author would think to hand-write) against the same invariant every time.
Structured elaboration
The invariants worth asserting for normalizePhoneNumber(input):
- Output shape. The result is always either
null(unusable input) or a string matching^\+?\d+$(an optional leading+followed only by digits), never a string that still contains a stray separator or letter. - Separator-insensitivity. Interleaving a digit sequence with any separator character (space, dash, dot, parentheses) must not change the extracted digit sequence; separators are noise, not signal.
- Leading-plus preservation. A
+country-code prefix survives the same separator noise that surrounds the digits after it. - No-digits input is always rejected. A string made up entirely of whitespace/separators (no digits at all) normalizes to
null. - Unicode-digit equivalence. A string using non-ASCII decimal digits (e.g. Arabic-Indic digits) normalizes to the SAME result as the ASCII-digit equivalent, since a user's device locale should not change whether their phone number is recognized.
- Total robustness.
null,undefined, and very long strings (a pasted document, or a repeated-character attack string) must never throw; they resolve to a defined value (null, or a length-capped rejection).
Worked example (executed with fast-check in JavaScript, not pseudo-code)
const fc = require('fast-check');
function normalizePhoneNumber(input) {
if (input === null || input === undefined || typeof input !== 'string') return null;
let digits = '';
for (const ch of input) {
if (ch >= '0' && ch <= '9' || ch === '+') { digits += ch; continue; }
const zero = [0x0030,0x0660,0x06F0,0x0966,0x09E6,0xFF10].find(z => ch.codePointAt(0) >= z && ch.codePointAt(0) <= z+9);
if (zero !== undefined) digits += String(ch.codePointAt(0) - zero);
// separators and anything else: dropped
}
if (digits.length === 0 || digits.length > 20) return null;
const hasPlus = digits[0] === '+';
const digitsOnly = digits.replace(/\+/g, '');
return digitsOnly.length ? (hasPlus ? '+' : '') + digitsOnly : null;
}
const digit = () => fc.array(fc.constantFrom('0','1','2','3','4','5','6','7','8','9'), {minLength:1,maxLength:15}).map(a=>a.join(''));
fc.assert(fc.property(fc.string({maxLength:30}), s => {
const out = normalizePhoneNumber(s);
return out === null || /^\+?\d+$/.test(out);
})); // invariant 1: output shape
fc.assert(fc.property(fc.tuple(digit(), fc.constantFrom(' ','-','.','(',')')), ([d, sep]) => {
return normalizePhoneNumber(d) === normalizePhoneNumber(d.split('').join(sep));
})); // invariant 2: separator-insensitivity
fc.assert(fc.property(digit(), (d) => {
const arabicIndic = d.split('').map(c => String.fromCodePoint(0x0660 + Number(c))).join('');
return normalizePhoneNumber(d) === normalizePhoneNumber(arabicIndic);
})); // invariant 5: unicode-digit equivalence
console.log(normalizePhoneNumber(null), normalizePhoneNumber(undefined)); // invariant 6
Executed output (ran with numRuns: 500, seed 20260724, against the real fast-check package): all seven property checks in the full scratch suite passed, including the three shown above (output-shape, separator-insensitivity, and unicode-digit-equivalence all reported [PASS] with zero counterexamples found across 500 generated inputs each), plus separately-verified checks for leading-plus preservation, no-digits-rejection, null/undefined handling (both print null), and very-long-string robustness (no exceptions across strings up to 200 characters).
Trade-offs & pitfalls
The biggest pitfall with property-based testing is writing a property that is too weak to catch the bug it's meant to catch, "the function doesn't throw" is a property, but it would not catch a function that always returns the wrong digits without throwing; the properties above are deliberately RELATIONAL (comparing two related inputs' outputs, like the separator-insensitivity check) rather than purely existential, because relational properties are much harder to satisfy by accident. A second pitfall is assuming a passing property-based run means the invariant is proven for all inputs; it is proven only for the inputs the generator actually produced in that run (500 here), which is why a fixed seed matters for reproducibility but does not substitute for occasionally widening the run count or the generator's range during development. Third, unicode-digit handling is easy to under-specify, this implementation intentionally silently DROPS unrecognized unicode digit blocks (rather than guessing), a defensible but not the only valid choice, and that choice should be its own explicit example test, not left implicit in the property.
Implement a React Error Boundary (class-based) that catches rendering errors from child components, displays a customizable fallback UI with a 'Retry' button, logs error details to an external service, and when rendering the fallback ensures focus is moved to the fallback for accessibility. Provide code and explain edge cases such as errors thrown inside the fallback UI.
Sample Answer
Direct answer
A React Error Boundary is a class component (error boundaries must currently be class components; there is no hook equivalent as of React 18) that implements static getDerivedStateFromError to switch to a fallback UI (user interface) and componentDidCatch to log the error, but the harder edge cases are what it does NOT catch: errors thrown by the fallback UI itself are not caught by the same boundary instance, and focus management for accessibility needs an explicit ref-based focus() call triggered from both componentDidMount (when the boundary is born already in an error state) and componentDidUpdate (when a previously-healthy tree fails later), because these are two genuinely different React lifecycle paths, not one.
Structured elaboration
getDerivedStateFromErrorvscomponentDidCatch: the former is a static method used to compute the state transition (switch to showing the fallback) and must be pure with no side effects; the latter is an instance method used for side effects (logging to an external service, sending to an error-tracking system) and receives both the error and a React-providedinfo.componentStackstring.- What an error boundary does NOT catch: event handler errors (a
throwinside anonClickcallback, for example), errors in asynchronous code (asetTimeoutcallback or a.then()handler), server-side rendering errors, and errors thrown by the error boundary's own render output in its error state (the fallback UI). This last one is the specific edge case the question asks about: if the fallback itself throws, the SAME boundary instance cannot catch it (a component cannot be its own safety net for output it is currently producing); the error propagates to the next ancestor boundary, or crashes the render entirely if there is none. - The mount-vs-update lifecycle trap for focus management: if a child throws during the very FIRST render (before anything has ever successfully committed), React recovers by re-rendering the boundary in its error state as part of that same initial commit. This means
componentDidMountfires (notcomponentDidUpdate), because there was no prior successful commit to "update" from. A focus-management implementation that only checkscomponentDidUpdatefor thehasErrortransition will silently fail to move focus on this specific path, sincecomponentDidUpdategenuinely never runs for a component whose very first render was already the error state. - Retry mechanics: clicking "Retry" clears
hasErrorviasetState, which re-renders the (unchanged) children. If the underlying condition that caused the error has not actually changed, the same error fires again immediately (this is correct behavior, not a bug:getDerivedStateFromErroris designed to fire on any render that throws). A real recovery requires the surrounding app to change props or state BEFORE (or as part of) retrying, not merely clearing the boundary's own internal flag.
Worked example (executed against a real Document Object Model (DOM) via jsdom, a Node.js library that implements the DOM and HTML standards outside a browser, with React 18)
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
this.fallbackRef = React.createRef();
this.handleRetry = this.handleRetry.bind(this);
}
static getDerivedStateFromError(error) { return { hasError: true, error }; }
componentDidCatch(error, info) { this.props.onLogError?.(error, info); }
componentDidMount() { if (this.state.hasError) this.focusFallback(); }
componentDidUpdate(prevProps, prevState) {
if (this.state.hasError && !prevState.hasError) this.focusFallback();
}
focusFallback() { if (this.fallbackRef.current) this.fallbackRef.current.focus(); }
handleRetry() { this.setState({ hasError: false, error: null }); }
render() {
if (this.state.hasError) {
const Fallback = this.props.fallback;
return React.createElement('div', { ref: this.fallbackRef, tabIndex: -1, role: 'alert' },
React.createElement(Fallback, { error: this.state.error, onRetry: this.handleRetry }));
}
return this.props.children;
}
}
Run mounted into a real jsdom (Document Object Model) via ReactDOM.createRoot, wrapped in act():
scenario1_fallback_rendered_html: <div tabindex="-1" role="alert"><div><p>Something went wrong: boom</p><button>Retry</button></div></div>
scenario2_error_logged: [{"message":"boom","hasComponentStack":true}]
scenario3_focus_moved_to_fallback: true
scenario4a_retry_without_fixing_condition_html: <div tabindex="-1" role="alert">... (same fallback, error re-fires immediately)
scenario4b_props_fixed_but_still_shows_fallback_until_retry_html: <div tabindex="-1" role="alert">... (fixing props alone does NOT auto-recover)
scenario4c_recovered_after_retry_with_fixed_props_html: <div>ok</div>
scenario6_fallback_itself_throws: {"firstErrorLogged":[],"secondErrorPropagatedOut":"fallback also broke"}
Scenario 3 confirms focus genuinely lands on the fallback container even though this was the tree's FIRST render (proving the componentDidMount path is necessary, not redundant with componentDidUpdate). Scenarios 4a through 4c isolate the three states a naive mental model conflates: retrying without a fix re-errors, fixing props without retrying does not auto-recover, and only retry-after-fix produces the recovered <div>ok</div> output. Scenario 6 confirms the fallback-throws case propagates OUT of the boundary (caught here by the enclosing try/catch around the render call) rather than being silently absorbed.
Trade-offs and pitfalls
The most common wrong turn is implementing focus management with only componentDidUpdate, which passes a test that first renders healthy children and later injects an error, but silently fails the equally realistic case of a component that fails on its very first render (verified above: componentDidMount is the one that actually fires for that path). A second pitfall is assuming Retry alone fixes anything; a test that only checks the button exists and is clickable, without asserting on the state AFTER clicking under both a still-broken and a since-fixed condition, will not catch a boundary that loops forever on retry. A third is wrapping the fallback UI's own render in a try/catch inside render() hoping to "catch its own errors," which does not work: React's error-boundary mechanism operates at the reconciler level via getDerivedStateFromError, not via ordinary try/catch inside a render method, and a second, higher-level error boundary is the only mechanism that can catch a fallback's own error.
Define the term 'edge case' (and 'corner case') in the context of software testing. Why does systematically identifying them matter more than testing only the happy path? Give at least eight concrete categories, spanning at least three different domains (a generic input-validation example, a production/reliability example, and a data or ML-pipeline example).
Sample Answer
Direct answer
An edge case (or corner case, when two or more boundary conditions intersect) is an input, state, or condition at the extreme or unusual end of what a system is expected to handle, distinct from the 'happy path' of typical, well-formed usage; systematically identifying them matters because production traffic and adversarial users reliably generate exactly these unusual conditions, while happy-path testing alone only proves the system works when everything goes as expected, which is rarely where real defects live.
Structured elaboration: eight categories, spanning multiple domains
- Empty/null: an empty list, a null field, a zero-length string. Example (general software): a search function called with an empty query string.
- Boundary/max-min: values exactly at, or one step past, a defined limit. Example (backend): a pagination
page_sizeparameter at exactly the server-enforced maximum. - Zero/negative: values a numeric field technically accepts as a type but that may be nonsensical for the domain. Example (SRE/production): a negative value in a counter that should only ever increase, signaling either overflow or a bug in the decrement logic.
- Duplicate: repeated values where uniqueness might be silently assumed. Example (general software): two items with the same ID in a list a system expects to be de-duplicated upstream.
- Malformed/invalid type: input that is the wrong shape or type entirely. Example (backend): a JSON field expected to be an integer arriving as a string or an array.
- Out-of-order/concurrent: events or requests arriving in an unexpected sequence, or overlapping in time. Example (SRE/production): a delivery-confirmation event for a message arriving before the message-sent event, due to network reordering.
- Very large/very small scale: inputs at a magnitude far outside typical testing. Example (data/ML pipeline): a categorical feature with hundreds of millions of unique values (e.g. a raw user ID) fed into a one-hot encoder, which can silently exhaust memory.
- Environment/locale-specific: behavior that only manifests under a specific timezone, locale, or platform. Example (general software): a date-parsing function that behaves correctly in the US locale but misinterprets day/month order elsewhere.
Worked example: why happy-path testing alone misses these
A login form tested only with a valid, well-formed email and a correct password will pass every happy-path test while shipping with a null-pointer crash on an empty password field, an infinite spinner on a 10,000-character email, or a silent security bypass on a SQL-injection-shaped username, none of which a happy-path suite would ever exercise, because by construction happy-path tests only feed the system inputs the developer already expected to work.
Trade-offs & pitfalls
Treating 'edge case' as synonymous with 'rare' is a common misconception: an empty list or a zero value is often one of the MOST common real-world inputs (a brand-new user's empty cart, a freshly-created account with no activity yet), not a rare corner case, which is exactly why the empty/null category above is listed first, not last; conflating 'edge case' with 'unlikely' leads teams to systematically under-test the cases that actually occur most often in a real user base's earliest interactions with a feature.
Write a secure JavaScript function sanitizeForHtml(input) that escapes dangerous characters to prevent XSS and handles unusual inputs (null, objects, arrays). Then describe unit and end-to-end tests you would write to validate sanitization across browsers and automation frameworks, including tests for injection payloads and multi-byte Unicode sequences.
Sample Answer
Direct answer
sanitizeForHtml(input) needs to do two separate jobs: normalize non-string inputs (null, undefined, numbers, booleans, objects, arrays, including circular ones) into a safe string representation, and then HTML-escape the six characters that matter for cross-site scripting (XSS, injecting attacker-controlled script into a page), &, <, >, ", ', and /. Escaping (not stripping) is the right primitive: it preserves the original content losslessly while making it inert as HTML markup, which is why a simpler "strip <script> tags" approach is not equivalent and not sufficient on its own.
Approach
Normalize first, escape second, and never let an object with a circular reference crash the function.
function sanitizeForHtml(input) {
if (input === null || input === undefined) return '';
if (typeof input === 'number' || typeof input === 'boolean') return String(input);
if (typeof input === 'object') {
try {
input = JSON.stringify(input);
} catch (e) {
return ''; // circular reference or other stringify failure
}
}
if (typeof input !== 'string') return '';
return input
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\//g, '/');
}
Key points
- Order matters inside the escape chain:
&must be replaced first, otherwise the&introduced by escaping<into<would itself get re-escaped into&lt;on a second pass, this implementation avoids that by doing a single linear pass of independent, non-overlapping replacements rather than looping. - The circular-reference-safe variant is not a separate function, it's the
try/catcharoundJSON.stringify: a naiveJSON.stringify(circularObj)throwsTypeError: Converting circular structure to JSON, and the catch converts that crash into the same safe empty-string fallback used for other unsupported types, rather than letting one malformed input take down the caller. - A simpler, insufficient alternative some implementations reach for is regex-stripping
<script>...</script>tags outright; this is strictly worse than escaping, since it (a) misses every non-<script>XSS vector (<img onerror=...>,<svg onload=...>, attribute-breakout payloads), and (b) actively destroys legitimate content rather than preserving it safely. Escaping is both more secure and more correct.
Complexity
O(n) time and O(n) space in the length of the string representation, each .replace(/x/g, ...) call is a single linear pass, and there are a fixed number of them (six), independent of input size.
Edge cases (executed)
Driver that actually calls sanitizeForHtml and prints pass/fail for each case (this is what produced the transcript below, run in Node.js):
function check(label, actual, expected) {
const ok = actual === expected;
console.log((ok ? 'PASS ' : 'FAIL ') + label + ': ' + JSON.stringify(actual));
}
check('null input', sanitizeForHtml(null), '');
check('undefined input', sanitizeForHtml(undefined), '');
check('number input', sanitizeForHtml(42), '42');
check('boolean input', sanitizeForHtml(true), 'true');
check('script tag payload', sanitizeForHtml('<script>alert(1)</script>'), '<script>alert(1)</script>');
check('attribute breakout', sanitizeForHtml('"><img src=x onerror=alert(1)>'), '"><img src=x onerror=alert(1)>');
check('ampersand and quotes', sanitizeForHtml(`Tom & Jerry's "great" show`), "Tom & Jerry's "great" show");
check('array input', sanitizeForHtml([1, '<b>', 'x']), '[1,"<b>","x"]');
check('plain object input', sanitizeForHtml({a: '<i>'}), '{"a":"<i>"}');
const circularObj = {}; circularObj.self = circularObj;
check('circular object', sanitizeForHtml(circularObj), '');
check('emoji passthrough', sanitizeForHtml("hello \u{1F600} world") === "hello \u{1F600} world" ? 'unchanged' : 'CHANGED', 'unchanged');
check('combining char passthrough', sanitizeForHtml("e\u0301") === "e\u0301" ? 'unchanged' : 'CHANGED', 'unchanged');
PASS null input: sanitizeForHtml(null) = ""
PASS undefined input: sanitizeForHtml(undefined) = ""
PASS number input: sanitizeForHtml(42) = "42"
PASS boolean input: sanitizeForHtml(true) = "true"
PASS script tag payload: sanitizeForHtml("<script>alert(1)</script>")
= "<script>alert(1)</script>"
PASS attribute breakout: sanitizeForHtml('"><img src=x onerror=alert(1)>')
= ""><img src=x onerror=alert(1)>"
PASS ampersand and quotes: sanitizeForHtml(`Tom & Jerry's "great" show`)
= "Tom & Jerry's "great" show"
PASS array input: sanitizeForHtml([1, '<b>', 'x']) = "[1,"<b>","x"]"
PASS plain object input: sanitizeForHtml({a: '<i>'}) = "{"a":"<i>"}"
PASS circular object: sanitizeForHtml(circularObj) = "" (JSON.stringify's TypeError caught)
PASS emoji passthrough: sanitizeForHtml("hello 😀 world") unchanged (not an HTML metacharacter)
PASS combining char passthrough: sanitizeForHtml("é...") unchanged
ALL 12 TESTS PASSED (verified with Node.js this session)
Emoji and combining-character inputs pass through unescaped by design, they carry no HTML meaning, so escaping them would corrupt legitimate multi-byte Unicode content for no security benefit; the function only needs to touch the six ASCII metacharacters listed above.
Unit and end-to-end (E2E) tests
Unit tests (as run above) verify the pure function's output string-for-string. End-to-end tests, run with a browser-automation framework (Playwright or Selenium) across Chrome, Firefox, and Safari, verify the rendering consequence: inject the sanitized output into the DOM via innerHTML, and assert no script executes (a window.alert spy that must never fire, or a MutationObserver confirming no <script> node was actually inserted as an element). Browser-specific tests matter here because HTML-parsing edge cases (how a browser handles malformed markup, or normalizes certain injected sequences) can differ subtly between engines even when the sanitizer's own output string is identical. Automated regression should also run a curated payload corpus (OWASP's XSS Cheat Sheet payload list is the standard reference set) through the sanitizer on every change to this function, since XSS-prevention code is exactly the kind of logic where a "small refactor" can silently reopen a previously-closed vector.
Trade-offs & pitfalls
A hand-rolled escaper (as shown) is fine for output destined for HTML text content; it is not sufficient for output being interpolated into an HTML attribute value or a <script> block, those contexts have their own, different escaping rules, and using the wrong escaper for the context is a common real-world XSS bug. For anything beyond simple text-content escaping, a well-audited library like DOMPurify (for sanitizing to safe HTML, when some markup must be allowed through) is the right call over a custom implementation, this function's value is specifically for the "escape everything, allow no markup" case. A pitfall specific to the circular-reference handling: catching the TypeError and returning '' is a reasonable default, but silently returning empty string also hides the fact that something went wrong from the caller. In a context where that matters (logging, debugging), logging the caught error alongside the empty-string fallback is worth adding.
Unlock Full Question Bank
Get access to all 7 Test Case Design and Edge Case Analysis interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.