277 JavaScript Frontend Developer Interview Questions & Answers

133 top • 30 Amazon • 15 Apple • 29 Google • 18 Meta • 21 Microsoft • 20 Netflix • 11 NVIDIA

JavaScript Frontend Developer icon

Questions with Detailed ExplanationsWith Detailed Explanations

(Last Updated: August 28, 2026)

91. How would you prove the retaining path of a complex detached-DOM memory leak?PerformanceHard

Question Details

After closing a configurable workspace repeatedly, heap snapshots show thousands of detached nodes, but the application also holds legitimate cached templates. Describe how you would establish a stable reproduction, force comparable garbage-collection checkpoints, compare snapshots, inspect dominators and retaining paths, and separate expected caches from leaked listeners, closures, observers, or maps. Define a fix-validation protocol that covers repeated open/close cycles and avoids using total heap size alone.

Short Interview Answer (30-60 seconds)

I would prove the leak by repeating the same workspace open and close sequence, forcing comparable garbage collection checkpoints, and comparing heap snapshots taken at the same points. I would focus on detached DOM trees that keep growing, inspect their dominators and retaining paths, and trace each suspicious node back to a live garbage collection root such as Window. Then I would separate intentional template caches from accidental listeners, closures, observers, maps, sets, timers, or global references. After removing the exact bad reference, I would repeat the same cycles and confirm that detached node counts and retained size stay stable. I would not use total heap size alone because legitimate caches and normal browser memory behavior can make it noisy.

Detailed Explanation

The goal is to prove why old workspace elements stay in memory after the workspace is closed. I would first make the same open and close action happen in a predictable way. Then I would take matching memory pictures at the same points and compare what remains. I would look for old page elements whose count keeps increasing, then follow the chain of things that still point to them. Some saved items are expected, such as reusable templates, so I would separate those from accidental references and repeat the same test after changing the cleanup logic.

Useful Questions to Ask the Interviewer
  1. Can I reproduce the issue with one fixed workspace configuration and the same open and close sequence?
  2. Are there known template caches or other objects that are intentionally kept for the lifetime of the page?
  3. Which browsers must the fix be validated in?
  4. Is there an existing automated workspace lifecycle test that I can extend for memory regression checks?
How would you prove the retaining path of a complex detached-DOM memory leak? diagram
How to Explain It in an Interview

I would start by defining the symptom precisely. The symptom is not simply that total heap size rises. The stronger symptom is that detached DOM nodes from closed workspaces remain reachable after garbage collection and their count or retained size keeps growing across repeated cycles.

Next I would create a stable reproduction. I would use the same browser version, application build, workspace configuration, data, cache state, and lifecycle sequence. I would disable unrelated noisy features when practical. I would open the configurable workspace, close it, run the normal teardown hooks, and repeat that exact sequence a fixed number of times such as twenty to fifty cycles.

I would then create comparable garbage collection checkpoints. In the browser Memory tools, I would collect garbage and take a baseline heap snapshot at a defined idle state. I would run the chosen number of workspace cycles, return to the same idle state, collect garbage again, and take another heap snapshot. If the browser was launched with explicit garbage collection support, a test harness could use that capability, but I would not assume that ordinary page code can always call it. The important rule is that every snapshot represents the same lifecycle point.

I would compare the snapshots and focus on detached DOM trees and the objects associated with them. I would inspect counts and retained size and look for types that consistently grow across repeated cycles. I would also inspect closures, event listeners, observers, Maps, Sets, timers, and long lived application objects. One snapshot can contain normal noise, so repeated growth under the same workload is stronger evidence than one large value.

For a suspicious detached node, I would inspect the dominator tree and the retainers view. A dominator is an object whose continued reachability controls whether another group of objects can be collected. The retaining path shows the chain of strong references that keeps the detached object reachable. I would trace the shortest useful retaining path until I reach a garbage collection root such as Window.

A concrete path consistent with the investigation could be a detached workspace element retained by an event listener, which is retained through a closure, which is stored as a value in a Map, which is owned by a long lived module singleton, which is reachable from Window. Any strong reference in that chain is enough to keep the detached DOM subtree alive. I would record the path, the retainer type, the relevant object types, the counts before and after the cycles, and the exact reproduction steps so the evidence can be repeated.

I would not automatically label every retained detached node as a leak. The application intentionally keeps cached templates. I would classify those separately. An expected cache should have a clear owner, an intentional purpose, and bounded or stable growth across repeated workspace cycles. Reusable DocumentFragment templates can be legitimate. A bounded Map can also be legitimate. WeakMap or WeakRef may be appropriate for some ownership models, but they do not replace correct lifecycle cleanup.

A suspicious path is different. Examples include an event listener that is never removed, a closure that captures a workspace node, a MutationObserver or ResizeObserver that is never disconnected, a Map or Set that keeps entries for closed workspaces, a timer that still holds workspace state, a third party callback that remains registered, or a global or singleton object that keeps a reference longer than intended. If the retained size or object count grows with each repeated workspace cycle, that makes the case stronger.

Once I prove the exact bad edge, I would remove that ownership relationship rather than applying a broad memory workaround. I might remove the listener during teardown, disconnect the observer, cancel the timer, delete the Map or Set entry, clear a closure reference, abort an obsolete supported operation, or remove the reference held by the singleton. The change should follow directly from the retaining path that was measured.

Then I would validate the fix with the same measurement boundary. I would repeat the same twenty to fifty workspace open and close cycles, use the same garbage collection checkpoints, and capture fresh heap snapshots. I would compare the same object types again. Success means the detached DOM node count returns near the stable baseline, the retained size of the suspicious objects does not keep growing, the old retaining path no longer reaches the closed workspace through the leaking application objects, listener and observer cleanup is visible, and Map or Set ownership remains stable.

I would also verify that the cleanup did not break behavior. Listeners that belong to an active workspace must still work. Observers that are needed while the workspace is open must still run. Timers and callbacks must stop only when their owner is actually closed. Visible behavior, keyboard use, focus behavior, error handling, and accessibility must remain correct.

The main measurement limitation is that developer tools can add noise and can sometimes keep inspected objects reachable. Heap size also changes because of legitimate caches, code, browser internals, and memory fragmentation. That is why I would not use total heap size as the primary success metric. I would track the specific detached nodes, their retained size, their dominators, and their retaining paths.

For regression protection, I would add an automated memory smoke test around the same workspace lifecycle. It would repeat the open and close sequence and flag an unbounded growth trend in the targeted retained objects. That automated check is a guardrail rather than final proof. If it fails, I would return to heap snapshots and retaining path analysis to identify the exact reference.

Technical Approach
  1. Define the symptom as detached workspace DOM nodes that remain reachable after close and continue growing across equivalent cycles.
  2. Freeze the test conditions by using the same browser version, application build, workspace configuration, data, cache state, lifecycle sequence, and idle checkpoint.
  3. Collect garbage and take a baseline heap snapshot.
  4. Open and close the configurable workspace repeatedly using the same actions and normal teardown path.
  5. Return to the same idle state, collect garbage again, and take the comparison snapshot.
  6. Compare detached DOM tree counts, retained size, and related closures, listeners, observers, Maps, Sets, timers, and long lived objects.
  7. Select a suspicious detached node and inspect its dominator and retaining path until the path reaches a garbage collection root such as Window.
  8. Record the shortest useful path, retainer type, object types, counts before and after, and reproduction steps.
  9. Classify the retainer as intentional or accidental. Keep bounded template caches. Treat unexpected listeners, closures, observers, maps, timers, third party references, or global references as leak candidates.
  10. Remove the exact strong reference identified by the retaining path.
  11. Repeat the same twenty to fifty cycles with the same garbage collection checkpoints and capture fresh snapshots.
  12. Confirm that detached node counts and retained size remain stable and that the previous leaking path no longer exists.
  13. Verify workspace behavior, keyboard behavior, focus behavior, accessibility, errors, and cleanup correctness.
  14. Add an automated memory regression guard for the same workspace lifecycle.
Practical Insights

The main cost is investigation time and memory used by the profiling tools. Heap snapshots can pause the page and use a large amount of memory because the browser records many objects and references. Comparing large snapshots can also take time. Repeating twenty to fifty workspace cycles makes the test slower, but it gives stronger evidence. The cleanup itself can add maintenance work because listeners, observers, timers, Maps, Sets, and closures need clear ownership rules. Automated memory tests are also slower and noisier than ordinary functional tests, so they need stable conditions and sensible growth thresholds.

Why Interviewers Ask This

Interviewers ask this to see whether I can prove a browser memory leak with repeatable evidence instead of guessing from total memory. They want to know whether I can create a controlled reproduction, compare equivalent heap snapshots, follow object ownership back to a live garbage collection root, distinguish intentional caches from accidental references, choose the right browser memory tools, and verify that cleanup really works across many repeated workspace cycles.

Common interview mistakes

Common mistakes include treating total heap size as proof of a leak, taking snapshots at different lifecycle points, comparing different workspace configurations, naming a detached node as a leak without tracing its retaining path, assuming every cache is wrong, ignoring legitimate bounded template caches, looking only at object count and ignoring retained size, stopping at the first retainer instead of tracing to a garbage collection root, forgetting to remove event listeners, forgetting to disconnect observers, leaving closed workspace entries in Maps or Sets, keeping DOM nodes inside long lived closures, failing to cancel timers, assuming WeakMap automatically repairs bad lifecycle ownership, trusting one snapshot, and validating the fix with a different workload. Another mistake is forgetting that developer tools themselves can add measurement noise or retain inspected objects.

Interview tip

Explain the investigation as a proof chain. Start with a repeatable workspace lifecycle. Take comparable snapshots after garbage collection. Find the detached objects that grow. Trace one retaining path to its live root. Separate legitimate bounded caches from accidental retention. Remove the exact bad reference. Repeat the same test and show that the targeted counts and retained size stay stable and that the leaking path is gone.

Interviewer may ask next
What if total heap size still grows after the retaining path is removed?

I would not treat total heap growth alone as proof that the workspace leak still exists. For the same repeated workspace open and close workload, I would compare detached DOM node counts, retained size for the previously suspicious object types, dominators, and retaining paths after equivalent garbage collection checkpoints. Total heap can still vary because of legitimate template caches, code, browser internals, allocation behavior, or fragmentation. If the targeted detached nodes stay stable and the previous application retaining path to Window is gone, I would investigate the remaining heap growth as a separate problem.

How would you protect against this leak returning when heap snapshots are too expensive to collect continuously?

I would keep heap snapshots as the controlled proof tool and add a cheaper automated regression guard around the same workspace open and close lifecycle. The test would repeat a fixed number of cycles and watch targeted memory signals such as detached node count or retained size when the test environment supports them. I would look for a sustained growth trend rather than total heap alone. The tradeoff is that automated memory checks can be noisy and browser dependent, so a failing guard should trigger a controlled heap snapshot investigation instead of being treated as final proof by itself.

92. How would you decide whether to move computation to a Web Worker?PerformanceHard

Question Details

A visualization transforms 120,000 records and currently blocks the main thread for 450 ms before rendering. Compare optimizing the algorithm on the main thread, chunking with cooperative yielding, and moving pure transformation to a dedicated worker. Account for structured-clone or transferable costs, initialization, cancellation, stale responses, error propagation, memory duplication, and browsers without the required worker capability. Define end-to-end interaction and correctness measurements rather than comparing computation time alone.

Short Interview Answer (30-60 seconds)

I would measure the full interaction first, not only the transform function. The visualization blocks the main thread for about 450 ms while transforming 120,000 records, so I would confirm that the delay is pure JavaScript work with a browser performance trace. I would optimize the algorithm first, then compare cooperative chunking with a dedicated Web Worker. I would choose the worker only if the full path, including startup, cloning or transfer, computation, result handling, rendering, memory, cancellation, and errors, gives better INP and fewer long tasks while keeping results correct.

Detailed Explanation

The page has to transform 120,000 records before it can show the visualization. Right now that work keeps the browser busy for about 450 ms, so the person may feel a pause before seeing the result. The decision is whether to make the work cheaper on the main thread, split it into smaller pieces so the browser can respond between pieces, or move the pure calculation to a separate worker. The best choice is the one that makes the whole interaction faster and smoother while keeping the final result correct, memory use reasonable, and fallback behavior safe.

Useful Questions to Ask the Interviewer
  1. Is the 450 ms block measured on a representative production device and browser?
  2. Does the transformation touch the DOM, style, layout, canvas state, or any other main thread only API?
  3. What INP and time to first useful visual result should this interaction meet?
  4. Are the 120,000 records plain serializable data, or can large buffers be transferred instead of copied?
  5. Can a newer request replace an older request while work is still running?
  6. Which browsers must support this feature, and what fallback behavior is required?
How would you decide whether to move computation to a Web Worker? diagram
How to Explain It in an Interview

I would treat this as an interaction performance decision. The baseline is the same user action that transforms 120,000 records and blocks the main thread for about 450 ms before the useful visual result appears.

First, I would reproduce the action with a production build on representative devices and supported browsers. I would keep the input size, build, cache state, browser, device class, and interaction the same for every comparison. In browser Performance tools I would confirm that the delay is JavaScript execution on the main thread rather than network waiting, style calculation, layout, paint, or compositing. I would also collect INP, long task duration, animation or scrolling smoothness, time to first useful visual result, total interaction time, memory behavior, and result correctness. Lab traces help me reproduce and inspect the cause. Field telemetry tells me whether real users see the same problem.

Then I would compare three choices. First, I would keep the work on the main thread and improve the algorithm, data structures, repeated lookups, and unnecessary allocations. This is the simplest option because it avoids worker startup and message costs. If the optimized work becomes short enough that the interaction is responsive, I would keep it on the main thread.

Second, I would test cooperative chunking. I would split the transformation into small pieces and yield between pieces with a scheduler such as scheduler.postTask when available, or setTimeout as a fallback. requestAnimationFrame can be used when I specifically want to schedule a small piece of work before a visual update, but it does not make expensive work free. Chunking avoids worker communication costs, but the CPU work still runs on the main thread, so large or frequent chunks can still cause jank.

Third, I would test a dedicated Web Worker if the expensive part is pure computation. A worker cannot directly access the page DOM, style, or layout. The main thread sends a job id and input data. The browser uses structured clone for ordinary serializable values, or ownership transfer for supported transferable buffers when that is safe. The worker performs the pure transformation and posts the result back with the same job id. The main thread accepts only the newest valid result, applies it, and renders the visualization.

I would measure the full worker path. That includes worker initialization when it is not already ready, request creation, serialization or transfer, worker computation, result transfer, main thread result application, and the next visible update. A worker is useful only when this complete interaction is better than the optimized main thread or chunked version. Raw worker compute time alone is not enough.

I would also handle production correctness. Each request gets a job id. If a newer request starts, the older job becomes obsolete. An AbortController can represent cancellation in the application, but it does not automatically stop worker CPU work. The main thread must send a cancel message or terminate and replace the worker when appropriate, and long worker computations should check a cancellation flag between safe chunks. When any result returns, the main thread ignores it if its job id is no longer current. This prevents stale responses from replacing newer state.

Errors must cross the worker boundary clearly. I would listen for worker error and message error events, return useful error details for expected failures, and keep the UI in a safe state. I would watch memory because structured cloning large data can temporarily duplicate it. Transferable ArrayBuffer based data can reduce copying, but transfer changes ownership, so the sender must not expect to keep using the transferred buffer. SharedArrayBuffer with Atomics is another specialized option for shared memory, but it adds browser security requirements and synchronization complexity, so I would use it only when measurement shows that simpler transfer is not enough.

For browsers without the required worker capability, I would use a checked main thread fallback with the same transformation logic. That fallback can use the optimized algorithm and cooperative chunking so the result remains correct even if responsiveness is lower.

Finally, I would rerun the same representative scenario for all three choices. I would compare INP, long tasks, FPS or visible jank, time to first useful visual result, total interaction time, memory, and correctness. I would also verify cancellation, stale result handling, error states, supported browsers, and that the transformed output matches the expected result. For this 450 ms pure transformation, a dedicated worker is a strong candidate if algorithm improvements and chunking are not good enough and the communication and memory costs remain acceptable.

Technical Approach
  1. Define the baseline as the same interaction that transforms 120,000 records and blocks the main thread for about 450 ms before the useful visual result.
  2. Reproduce it with the same production build, browser, device class, input, cache state, and measurement window.
  3. Use browser Performance tools to confirm that the long delay is JavaScript execution on the main thread and not network waiting or rendering work.
  4. Measure INP, long tasks, FPS or visible jank, time to first useful visual result, total interaction time, memory, and correctness.
  5. Optimize the algorithm, data shape, repeated lookups, and unnecessary allocations first. Measure again with the same workload.
  6. If the work is still expensive, test cooperative chunking with small pieces that yield through scheduler.postTask or setTimeout. Use requestAnimationFrame only for small work that should run before a visual update.
  7. If the expensive part is pure and the data is serializable or transferable, test a dedicated Web Worker. Measure initialization, request setup, structured clone or transfer cost, worker computation, result return, main thread application, and rendering.
  8. Give each request a job id. Mark older work obsolete when a newer request starts and ignore stale results.
  9. Use AbortController as the application cancellation signal, then send a cancel message or terminate the worker when appropriate. Let long worker work check cancellation between safe chunks.
  10. Propagate worker errors to the main thread. Watch memory duplication and use transferable buffers when ownership transfer is safe and useful.
  11. Use an optimized main thread fallback when the required worker capability is unavailable.
  12. Compare all options under the same workload and choose the simplest one that meets interaction, memory, and correctness goals.
Practical Insights

Moving the transformation to a worker does not automatically reduce the amount of logical computation. If the algorithm still visits the same 120,000 records, the big order of growth may stay the same. The main benefit is that suitable CPU work no longer blocks input and rendering on the main thread. A worker adds initialization, message handling, serialization or transfer, error handling, cancellation logic, and maintenance cost. Structured cloning can copy large data and temporarily increase memory use. Transferable buffers can reduce copy cost, but ownership moves to the receiver. Cooperative chunking avoids worker startup and transfer costs, but the CPU work still consumes main thread time.

Why Interviewers Ask This

Interviewers ask this to see whether I measure the user experience before choosing a concurrency tool. They want to know whether I can prove that JavaScript execution is blocking the main thread, compare simpler options first, understand worker communication and memory costs, and preserve correctness when requests are cancelled, replaced, or fail.

Common interview mistakes

Common mistakes are moving work to a worker before proving that JavaScript execution is the bottleneck, comparing only worker compute time instead of the full interaction, and assuming a Promise moves CPU work off the main thread. Another mistake is sending very large copied objects without measuring structured clone and memory cost. Teams also forget worker initialization, cancellation, stale responses, error propagation, browser fallback behavior, and ownership changes for transferred buffers. Chunking can also fail when each piece is still too large. Before and after tests are misleading when they use different devices, inputs, builds, or cache states. It is also a mistake to ignore correctness after the optimization.

Interview tip

Explain the decision in a clear order. Start with the 450 ms user visible block. Prove that pure JavaScript work is causing it. Optimize the algorithm first. Then compare cooperative chunking with a dedicated worker. Include initialization, data movement, memory, cancellation, stale results, errors, and fallback behavior. Finish by saying that the winning choice is the simplest one that improves the full interaction and preserves correctness.

Interviewer may ask next
What if the worker compute time is much faster but the interaction still feels just as slow?

I would not call the worker a success. For the same 120,000 record interaction, I would measure from the user action through worker initialization when relevant, request setup, structured clone or transfer, worker computation, result return, main thread application, and the next visible update. If INP, long tasks, FPS, or time to first useful visual result do not improve, the bottleneck may have moved to data movement, rendering, or result handling. I would profile that full path and compare it again with the optimized main thread and chunked versions.

How would you roll out the worker path if memory use increases for large inputs?

I would keep the same 120,000 record workload and compare memory together with INP, long tasks, visible result time, and correctness. I would check whether structured cloning creates large temporary copies and test transferable buffers when ownership transfer is safe. I would release the worker path gradually behind a controlled switch, monitor field interaction metrics, memory signals, and errors, and keep the optimized main thread fallback available. If memory pressure or failures exceed the budget, I would disable the worker path and investigate a smaller data shape or a safer transfer strategy.

93. How do unit, integration, and end-to-end frontend tests differ?TestingEasy

Question Details

Use a sign-in form that renders email and password fields, validates input, submits asynchronously, and navigates after success. Compare what one unit test, one DOM integration test, and one browser end-to-end test should verify. For each level, name the user interaction, visible or accessible result, timing boundary, fixture data, browser requirement, and which dependencies are real versus mocked. Explain the tradeoff among speed, isolation, confidence, and diagnostic value.

Short Interview Answer (30-60 seconds)

I would use a unit test for isolated validation logic, a DOM integration test for the real sign in component working with routing and a controlled network response, and an end to end test for the complete journey in a real browser. Unit tests are fastest and easiest to diagnose. Integration tests give stronger component confidence. End to end tests give the highest confidence in the complete user journey, but they are slower, less isolated, and failures can be harder to localize.

Detailed Explanation

A sign in form has several kinds of behavior, so I would not check everything with one large test. First, I would check the small input rules by themselves. Next, I would check what a person sees after typing into the form and pressing Sign in. Finally, I would check the whole successful journey in a real browser, from entering the details to reaching the dashboard. Each level gives more confidence about the complete experience, but it also needs more setup, takes more time, and depends on more moving parts.

Useful Questions to Ask the Interviewer
  1. Should the successful browser test use a dedicated test environment with seeded users?
  2. Should form validation finish before the sign in request is sent?
  3. Should the DOM integration test include real application routing behavior?
How do unit, integration, and end-to-end frontend tests differ? diagram
How to Explain It in an Interview

The unit test checks only the validation function. It calls validate with plain JavaScript values, such as an empty email and a password value. The expected result is an email field error. The call is synchronous, no browser is required, and there is no UI, router, network, storage, or browser dependency inside this test boundary. Because those dependencies are not used by the validation function, no mocks are needed. This test is very fast, highly isolated, and easy to diagnose. Its limitation is that it does not prove the form, request, routing, or real browser journey works.

The DOM integration test renders the real SignInForm and interacts with it the way a user would. The user types into the email and password fields and clicks the Sign in button with Testing Library userEvent. The UI and routing behavior remain real within the frontend test environment. The POST request to /api/login is controlled with Mock Service Worker so the response is deterministic and does not depend on a remote production system. The test checks accessible visible results, such as an email validation message or the dashboard state after a successful request. Asynchronous changes are awaited with findByRole, findByText, waitFor, or another bounded Testing Library utility. There are no fixed sleep calls and no fake timers because timer behavior is not part of this example. The test runs with a simulated DOM such as jsdom, so it gives useful component confidence but does not prove real browser navigation or browser compatibility.

The end to end test runs the real application in a real browser. The user opens the sign in page, fills the email and password fields, and clicks Sign in. The test waits for the application to complete the request and navigation, then checks that the URL is /dashboard and that the dashboard heading is visible. The browser, application, and routing are real. The test uses a real or dedicated test backend with seeded test data rather than production user data. This gives the highest confidence in the complete user journey. The tradeoff is that the test is slower, has lower isolation, needs more environment setup, and a failure can be harder to localize because more parts are involved.

The practical balance is to use many fast unit tests for small logic, DOM integration tests for important component behavior and controlled boundaries, and a smaller number of end to end tests for critical user journeys. That gives fast feedback while still checking that the complete sign in flow works in a real browser.

Technical Approach
  1. Define the behavior. The sign in form renders email and password fields, validates input, submits asynchronously, and navigates after success.
  2. Choose the smallest useful boundary for each behavior.
  3. For the unit test, call validate directly with plain values and assert the returned field errors.
  4. For the DOM integration test, render the real SignInForm, keep the frontend behavior real, control POST /api/login with Mock Service Worker, interact through accessible controls, and wait for visible results.
  5. For the end to end test, open the real application in a real browser, fill the form, submit it, and wait for the dashboard URL and visible dashboard content.
  6. Keep fixture data explicit. Use plain values for the unit test, a controlled network response for the DOM test, and a seeded test user for the browser test.
  7. Reset network handlers and other changed test state so tests remain independent.
  8. Run the fast focused tests often and keep the slower browser suite focused on critical journeys in CI.
Practical Insights

Traditional algorithmic complexity does not meaningfully apply here. The important cost is test runtime, setup, maintenance, and CI time. The unit test is fastest because it calls one function with plain values and no browser. The DOM integration test has a moderate cost because it renders the component, processes realistic user events, and waits for a controlled network response and DOM update. The end to end test is slowest because it drives a real browser, runs the application, uses seeded test data, performs network work, and waits for navigation. Large browser suites therefore cost more to run and maintain.

Why Interviewers Ask This

Interviewers ask this question to see whether I can choose the right test boundary for different kinds of frontend behavior. They want to know whether I understand isolation, realistic user interaction, asynchronous behavior, controlled dependencies, browser confidence, and useful failure diagnosis. They also want to see whether I know when a fast focused test is enough and when a complete real browser journey is worth the extra runtime, setup, and maintenance cost.

Common interview mistakes

Common mistakes include testing the complete sign in journey at every level, mocking dependencies that the isolated validation function does not use, replacing too much of the real component in the DOM integration test, calling production services from tests, using production user data, using fixed sleep calls for asynchronous work, adding fake timers when no timer behavior is being tested, checking private component state instead of visible behavior, sharing mutable fixture state between tests, forgetting to reset Mock Service Worker handlers, depending on test execution order, and treating a simulated DOM test as proof that the real browser journey works.

Interview tip

Explain the boundaries from smallest to largest. For each level, say what the user does, what result you assert, what data you use, what environment is required, what stays real, what is controlled, and what the test cannot prove. Finish with the tradeoff: unit tests give fast isolation, DOM integration tests give component confidence, and a smaller number of end to end tests give confidence in critical real browser journeys.

Interviewer may ask next
How would you test a failed sign in request without making the test depend on a real remote service?

I would keep the DOM integration boundary and change the Mock Service Worker handler for POST /api/login to return the supported failure response. The real SignInForm and frontend routing behavior would stay in the test. I would submit the form with userEvent and wait for the visible accessible error result. This matters because the test checks the frontend failure behavior with a deterministic network boundary. I would reset the handler afterward so that this failure fixture cannot affect another test.

Why not put every sign in test in the real browser if end to end tests give the highest confidence?

I would keep the three level strategy because each boundary has a different job. The end to end boundary is best for the complete browser journey, but it is slower, less isolated, and harder to diagnose when something fails. Validation logic is faster and clearer in the unit boundary. Component behavior and controlled request handling are cheaper to verify in the DOM integration boundary. I would therefore keep only the most important complete journeys in the browser suite so CI gets strong confidence without making every feedback cycle depend on the full application environment.

94. What are mocks, stubs, and spies used for in frontend tests?TestingEasy

Question Details

Use a notification preference component that reads initial settings, calls a save dependency, and records a telemetry event. Explain how a stubbed return value, a mocked dependency, and a spy on calls differ. Define the rendered behavior and DOM interaction under test, asynchronous completion, accessible feedback, fixture values, browser boundary, and which collaborators should remain real so the test does not only verify its own mocks.

Short Interview Answer (30-60 seconds)

I would use a component test and keep the real component, DOM behavior, accessible queries, and user events. I would stub loadPreferences so the component starts with known settings, mock savePreferences so I can control the save result without a real external call, and spy on trackEvent so I can verify the telemetry event and payload. I would await the user actions and the visible success message. This gives fast and reliable component confidence, but it does not prove the real network, telemetry service, or complete browser journey works.

Detailed Explanation

See the Code while reading this explanation.

This question asks how to test a notification settings screen without depending on outside systems. The screen first shows saved choices, lets a person change them, saves the new choices, and records that the save happened. A good test should start from known values, perform the same clicks a person would make, wait until saving finishes, and check the message the person can see. It should also check that the right information reached the save and tracking helpers, while keeping the real screen behavior, controls, and user interaction inside the test.

Useful Questions to Ask the Interviewer
  1. Should the component test cover only a successful save, or does the real component also define a visible save error state?
  2. Are loadPreferences and savePreferences imported functions, component props, or values supplied through context in the real project?
  3. Should browser specific behavior such as focus, storage, or navigation be covered by this test or by separate browser integration tests?
What are mocks, stubs, and spies used for in frontend tests? diagram
How to Explain It in an Interview

The main goal is to test the behavior that the user can observe in Notification Preferences. I would render the real component and keep its state updates, DOM behavior, accessible controls, and user interaction real. I would replace only the collaborators outside the component boundary.

A stub gives controlled data back to the code under test. In this example, loadPreferences is stubbed to resolve with a small fixture. The fixture has emailUpdates set to true and pushNotifications set to false. This makes the starting screen predictable and lets the test verify that the component renders known settings.

A mock replaces a dependency whose behavior the test needs to control. Here, savePreferences is mocked. For the success test it resolves successfully instead of performing a real external save. The test can then verify that it received the exact preference values produced by the user action. A separate failure test could make this mock reject if the real component defines visible error behavior.

A spy records calls to a function. Here, vi.spyOn watches telemetry.trackEvent. The test does not need telemetry to perform a real analytics operation. It needs to verify that the component records the preferences_saved event with the correct payload after a successful save.

The test flow follows the diagram. First, arrange the controlled collaborators and fixture. Next, render Notification Preferences. Then use userEvent to change the Push notifications switch and click the Save button through accessible queries. Await each user action because userEvent can perform asynchronous work.

After saving, wait for an observable result instead of sleeping for a fixed amount of time. findByRole can wait for the status element. Then verify that its text says Preferences saved. The test should also verify that the switch reflects the changed state, savePreferences received the expected values, and telemetry.trackEvent received the expected event and payload.

The fixture should stay small and explicit. Each test should begin with fresh values so tests do not depend on execution order or shared mutable state. Mocks and spies should be reset or restored after each test. Timers, storage, network handlers, or other global browser state should also be restored whenever a test changes them.

The confidence boundary is important. This component test proves that the real component behaves correctly when its external collaborators behave in controlled ways. It does not prove that the real save service, telemetry transport, or browser integration works. When actual browser behavior such as focus, navigation, storage, compatibility, service workers, or a complete user journey matters, add browser integration or end to end coverage in a real browser.

The important collaborators inside this component test should remain real. Do not mock React rendering, Testing Library queries, userEvent, accessible DOM behavior, or the component state logic. Routing, context, storage, and network behavior should also remain real when they are part of the behavior being tested. If the test intentionally moves the network boundary outward, Mock Service Worker can control requests at that boundary instead of mocking fetch directly.

The practical tradeoff is speed against integration confidence. Stubs, mocks, and spies make component tests fast, deterministic, and easy to debug. Too many replacements can create a test that only verifies its own test doubles. That is why I would replace only clear external boundaries and use separate browser or integration tests for risks that the component test cannot prove.

Key Insight / Why This Solution Works
  1. Define the behavior. Notification Preferences should load known settings, let the user change a switch, save the new values, record telemetry, and show accessible success feedback.
  2. Choose the test level. Use a component test because the main confidence needed is rendered behavior and DOM interaction.
  3. Arrange the boundary. Keep the component, DOM behavior, accessible queries, and user events real. Stub loadPreferences, mock savePreferences, and spy on telemetry.trackEvent.
  4. Create fresh fixture data. Start with emailUpdates true and pushNotifications false.
  5. Render the component and wait for the initial settings to appear.
  6. Use userEvent to click the Push notifications switch and then the Save button.
  7. Await asynchronous completion with findByRole or waitFor. Do not use a fixed sleep.
  8. Assert the visible success message and final switch state.
  9. Assert that savePreferences received the expected preference object and trackEvent received preferences_saved with the expected payload.
  10. Reset or restore test doubles and any changed global state so the next test starts cleanly.
Code
import React, { useEffect, useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/vitest';

// These functions represent the external collaborators shown in the diagram.
const loadPreferences = vi.fn();
const savePreferences = vi.fn();

const telemetry = {
  trackEvent() {},
};

function NotificationPreferences() {
  const [preferences, setPreferences] = useState(null);
  const [saved, setSaved] = useState(false);

  useEffect(() => {
    // Read the initial settings from the controlled load dependency.
    loadPreferences().then(setPreferences);
  }, []);

  if (!preferences) {
    return <p>Loading</p>;
  }

  async function handleSave() {
    // Wait until the mocked save dependency completes successfully.
    await savePreferences(preferences);

    // Record telemetry only after the save has completed.
    telemetry.trackEvent('preferences_saved', preferences);
    setSaved(true);
  }

  return (
    <div>
      <label>
        Email updates
        <input
          type="checkbox"
          role="switch"
          checked={preferences.emailUpdates}
          onChange={(event) =>
            setPreferences((current) => ({
              ...current,
              emailUpdates: event.target.checked,
            }))
          }
        />
      </label>

      <label>
        Push notifications
        <input
          type="checkbox"
          role="switch"
          checked={preferences.pushNotifications}
          onChange={(event) =>
            setPreferences((current) => ({
              ...current,
              pushNotifications: event.target.checked,
            }))
          }
        />
      </label>

      <button type="button" onClick={handleSave}>
        Save
      </button>

      {saved ? <p role="status">Preferences saved</p> : null}
    </div>
  );
}

afterEach(() => {
  // Restore spies and reset standalone mocks so tests stay independent.
  vi.restoreAllMocks();
  vi.resetAllMocks();
});

describe('NotificationPreferences', () => {
  it('saves changed preferences and records telemetry', async () => {
    // Use a small explicit fixture so the initial UI is deterministic.
    const preferencesFixture = {
      emailUpdates: true,
      pushNotifications: false,
    };

    // Stub the load return value with the controlled fixture.
    loadPreferences.mockResolvedValue(preferencesFixture);

    // Mock the save dependency so this test performs no real external save.
    savePreferences.mockResolvedValue(undefined);

    // Spy on telemetry because the important behavior is the call and payload.
    const trackEventSpy = vi.spyOn(telemetry, 'trackEvent');

    // Render the real component while controlling only its external collaborators.
    render(<NotificationPreferences />);

    const user = userEvent.setup();

    // Wait for the loaded fixture, then change the Push notifications setting.
    const pushSwitch = await screen.findByRole('switch', {
      name: /push notifications/i,
    });
    await user.click(pushSwitch);

    // Use the same accessible Save action a user would use.
    await user.click(screen.getByRole('button', { name: /save/i }));

    // Wait for observable asynchronous feedback instead of using a fixed sleep.
    const status = await screen.findByRole('status');
    expect(status).toHaveTextContent(/preferences saved/i);

    // Verify the visible control reflects the saved user choice.
    expect(pushSwitch).toBeChecked();

    // Verify the stubbed load dependency was used for the initial settings.
    expect(loadPreferences).toHaveBeenCalledTimes(1);

    // Verify the mocked save dependency received the exact changed values.
    expect(savePreferences).toHaveBeenCalledWith({
      emailUpdates: true,
      pushNotifications: true,
    });

    // Verify the telemetry spy recorded the expected event and payload.
    expect(trackEventSpy).toHaveBeenCalledWith('preferences_saved', {
      emailUpdates: true,
      pushNotifications: true,
    });
  });
});
Why Interviewers Ask This

Interviewers ask this to see whether I understand how to isolate a frontend component without replacing everything around it. They want to know whether I can choose the right test double, keep user visible behavior real, control external effects, verify important calls, handle asynchronous work correctly, and understand what a mocked component test can and cannot prove.

Common interview mistakes

Common mistakes include mocking the component itself, replacing every collaborator, asserting private component state instead of visible behavior, using a fixed sleep for asynchronous work, sharing mutable fixtures between tests, depending on test order, forgetting to reset mocks or restore spies, and checking only that a function was called without checking the important payload. Another mistake is treating stubs, mocks, and spies as identical. A stub mainly supplies controlled data. A mock controls a dependency and supports interaction expectations. A spy observes how a callable function was used. A mocked component test also must not be presented as proof that the real network or remote service works.

Interview tip

Use the notification component as one simple story. Say that the stub gives known starting data, the mock controls the save dependency, and the spy records the telemetry call. Then explain the user action, the awaited success message, the payload assertions, and cleanup. Finish by stating that the test proves component behavior with controlled boundaries, not the real external systems.

Interviewer may ask next
How would you test the component if saving fails?

I would keep the same component test boundary and change only the savePreferences mock so it rejects, but only if the real component defines visible error behavior. I would perform the same accessible user action, await the error feedback, verify that success feedback is not shown, and verify that telemetry is not recorded if telemetry is supposed to run only after a successful save. This matters because the rejected dependency is still inside the controlled save boundary. The limitation is that this tests the component response to rejection, not a real network failure.

When should this test move to a real browser or a wider integration boundary?

I would keep this component test for the normal rendered behavior and add a real browser test when confidence depends on actual browser behavior such as focus, navigation, storage, compatibility, service workers, or a complete user journey. The boundary then expands beyond the isolated component and its controlled collaborators. This matters because mocks cannot prove those integrations. The tradeoff is that real browser tests usually take longer to run, require more setup, and can add more CI maintenance.

95. When should a frontend test wait for an asynchronous UI state?TestingEasy

Question Details

A save button changes to Saving…, sends a request, then displays either Saved or an alert. Describe how a DOM integration test should trigger the action, observe the immediate state, wait for the later state without arbitrary sleep delays, and fail if the state never appears. Include accessible naming, a success and error fixture, real versus mocked clock and network choices, cleanup, and what still requires a real-browser test.

Short Interview Answer (30-60 seconds)

I wait only when the UI result is expected to appear after asynchronous work. For this save flow, I await the user click, check the disabled Saving button immediately, then use a bounded findBy query for the later Saved status or error alert. I control the request with Mock Service Worker and use real timers by default. Fake timers are only useful when the component itself has timer behavior. If the expected UI never appears, the bounded query fails the test.

Detailed Explanation

See the Code while reading this explanation.

The test should copy what a user sees after pressing Save. First, the button should quickly show Saving and become unavailable. Then the request finishes. The page should show either Saved or a clear error message. The test should wait for that later message instead of waiting for a fixed amount of time. It should check both a successful request and a failed request. If the expected result never appears, the test should fail instead of continuing silently.

Useful Questions to Ask the Interviewer
  1. Should the save request be controlled inside the test instead of calling a real service?
  2. Does the component use timer behavior such as debounce?
  3. Should both the success state and the error alert be covered by this test?
When should a frontend test wait for an asynchronous UI state? diagram
How to Explain It in an Interview

I would use a DOM integration test because the important behavior is visible component behavior plus a controlled network boundary. I would render the real SaveItemForm and use Testing Library userEvent to interact with it through the same button a user sees.

The network is replaced only at the request boundary with Mock Service Worker. One handler returns a successful response. Another handler returns an error response. This keeps the test deterministic while still exercising the component request code and response handling.

After await user.click(...), I check the immediate state synchronously. The button should have the accessible name Saving and be disabled. I do not add a sleep because this state should already be visible after the awaited user action completes.

For the later state, I use a bounded findByRole query. On success, I wait for the status element and then check that its text contains Saved. On failure, I wait for the alert element and then check that its text contains Failed to save. I do not query the status or alert with { name: ... } unless that element truly has an accessible name. Visible status or alert text is not automatically the accessible name of that role.

A findBy query waits only until its timeout. If the expected status or alert never appears, the returned Promise rejects and the test fails. This is better than a fixed sleep because the test waits for the actual user visible condition and can finish as soon as that condition appears.

I use real timers by default. Fake timers are appropriate only when the component itself uses setTimeout, setInterval, or debounce behavior that the test must advance in a controlled way. Fake timers should not be used merely to make the mocked request resolve.

Each test starts with known network handlers. After each test, I reset handlers that were changed and clean up the rendered DOM. After the suite, I close the Mock Service Worker server. If fake timers were enabled for a timer specific test, I would restore real timers as part of cleanup. This prevents one test from changing another test.

This DOM integration test gives confidence in the component behavior, accessible interaction, request handling, immediate Saving state, later success state, and later error state. It does not prove real browser navigation, real cookies or storage behavior, service worker behavior, downloads, native dialogs, permissions, visual layout, animation, full page keyboard and focus behavior, or browser compatibility. Those areas still need a real browser test.

Technical Approach
  1. Define the visible behavior. The button starts as Save, changes to Saving after the click, and later reaches either Saved or an error alert.
  1. Choose a DOM integration test. Render the real component and keep the user interaction real inside the simulated DOM.
  1. Control only the network boundary with Mock Service Worker. Provide one success fixture and one error fixture.
  1. Create a user with userEvent and find the Save button by its accessible role and name.
  1. Await the user click because userEvent can perform asynchronous work.
  1. Immediately assert that the button now has the accessible name Saving and is disabled.
  1. For success, wait with findByRole('status'), then assert that the status text contains Saved.
  1. For failure, replace the network handler with the error fixture, wait with findByRole('alert'), then assert that the alert text contains Failed to save.
  1. Do not use a fixed sleep. Let the bounded query reject if the expected state does not appear before its timeout.
  1. Use real timers unless the component itself contains timer behavior that must be advanced deterministically.
  1. Reset changed network handlers and clean up the DOM after every test. Close the test server after the suite.
  1. Keep real browser checks for behavior that the simulated DOM cannot prove.
Practical Insights

Algorithmic time and space complexity are not important for this test. The practical cost comes from rendering the component, running user events, handling controlled requests, and waiting for observable UI results. A DOM integration test is usually faster and cheaper than a complete real browser journey. Mock Service Worker adds some setup and fixture maintenance, but it makes success and error behavior deterministic. A bounded wait can add time when a test fails because the query waits until its timeout. Small fixtures and isolated tests help the suite remain fast and predictable in continuous integration.

Code
import React from 'react';
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import SaveItemForm from './SaveItemForm';

// The default network fixture represents a successful save request.
const server = setupServer(
  rest.post('/api/items', async (req, res, ctx) => {
    return res(ctx.status(200), ctx.json({ id: '1' }));
  })
);

// Start the controlled network boundary before this test suite runs.
beforeAll(() => {
  server.listen({ onUnhandledRequest: 'error' });
});

// Reset changed handlers and remove rendered DOM after each test so tests stay isolated.
afterEach(() => {
  server.resetHandlers();
  cleanup();
});

// Close the Mock Service Worker server after all tests finish.
afterAll(() => {
  server.close();
});

describe('SaveItemForm', () => {
  test('shows Saving immediately and Saved after a successful request', async () => {
    // Render the real component and create a realistic user interaction helper.
    render(<SaveItemForm />);
    const user = userEvent.setup();

    // Query the button by the accessible role and name that a user experiences.
    const saveButton = screen.getByRole('button', { name: /save/i });

    // Await the user action because userEvent can perform asynchronous work.
    await user.click(saveButton);

    // Check the immediate observable loading state without any arbitrary sleep.
    expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled();

    // Wait for the later success role. The bounded query rejects if it never appears.
    const status = await screen.findByRole('status', {}, { timeout: 3000 });

    // Check the visible success text separately from the role query.
    expect(status).toHaveTextContent(/saved/i);
  });

  test('shows Saving immediately and an alert after a failed request', async () => {
    // Replace only the network boundary for this test with the error fixture.
    server.use(
      rest.post('/api/items', async (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ error: 'fail' }));
      })
    );

    // Render a fresh component instance so the failure test is independent.
    render(<SaveItemForm />);
    const user = userEvent.setup();

    // Trigger the same accessible user action used in the success test.
    await user.click(screen.getByRole('button', { name: /save/i }));

    // Check the immediate loading state without adding an arbitrary sleep.
    expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled();

    // Wait for the later alert role. The bounded query rejects if it never appears.
    const alert = await screen.findByRole('alert', {}, { timeout: 3000 });

    // Check the visible error text separately from the role query.
    expect(alert).toHaveTextContent(/failed to save/i);
  });
});
Why Interviewers Ask This

Interviewers want to see whether I can separate an immediate UI change from a later asynchronous result. They also want to know whether I can choose the right DOM integration boundary, control the network without hiding real component behavior, use accessible queries, avoid unreliable sleep calls, and clean up test state. This shows whether I can write tests that stay predictable in continuous integration and are easy to debug when they fail.

Common interview mistakes

A common mistake is using setTimeout or another fixed sleep before checking the final UI. That makes the test slower and can still be flaky. Another mistake is checking internal component state instead of the visible Saving, Saved, or alert behavior. It is also incorrect to assume that visible text inside a status or alert automatically becomes that element's accessible name. Other mistakes include mocking the whole component or request logic, using fake timers when no timer behavior needs control, calling a real production service, sharing changed handlers between tests, forgetting cleanup, testing only success, or using selectors that do not reflect accessible user behavior.

Interview tip

Explain the states in time order. Say what should be visible immediately after the click, what appears only after the request finishes, and which assertion must wait. Then explain that the network is controlled with Mock Service Worker, real timers stay real unless timer behavior matters, and a bounded query fails naturally if the expected UI never appears. Finish by naming what still needs a real browser test.

Interviewer may ask next
What would you do if this test sometimes passes and sometimes times out in continuous integration?

I would keep the same DOM integration boundary and first find which observable state is unstable. I would confirm that every request is handled by Mock Service Worker, each test resets changed handlers, the rendered DOM is cleaned up, and no shared state leaks between tests. I would not add a fixed sleep. If the component truly needs more time, I can adjust the bounded query timeout with a clear reason, but I would first remove the source of nondeterminism. This matters because a flaky test reduces trust in the whole suite.

When would you add a real browser test for this save scenario?

I would add a real browser test when the confidence boundary expands beyond the component and controlled request behavior. Examples include navigation, browser storage, service workers, downloads, native dialogs, permissions, visual layout, animation, focus across the page, or browser compatibility. I would keep the DOM integration test because it is faster and gives focused feedback. The main tradeoff is that the real browser test gives broader confidence but costs more runtime and maintenance in continuous integration.

96. What is frontend testing?TestingEasy

Question Details

Define frontend testing as checking that browser-visible behavior and its supporting units work correctly under expected, boundary, failure, and accessibility conditions. Explain unit, component, integration, contract, visual, and end-to-end tests; arrange-act-assert; deterministic setup; user-observable assertions; test doubles; and the role of a balanced test strategy.

Short Interview Answer (30-60 seconds)

Frontend testing checks that browser visible behavior and the supporting frontend units work correctly. I use unit tests for small logic, component tests for user interface behavior, integration tests for parts that work together, contract tests for API shapes, visual and accessibility tests for the interface, and end to end tests for important browser journeys. I arrange a controlled state, perform an action, and assert what the user can observe. The tradeoff is that broader tests give more confidence about real user behavior, but they are slower and cost more to maintain.

Detailed Explanation

Frontend testing means checking that the parts of a web page work the way a user expects. We test small pieces, complete screen parts, connections between parts, and important journeys through the browser. We also check unusual input, errors, accessibility, and unwanted visual changes. Good tests start from a known state, perform an action, and check a visible result. Some outside parts can be replaced with controlled versions so the test stays stable. No single test can prove everything, so we combine different kinds of tests for useful confidence.

Useful Questions to Ask the Interviewer
  1. Which frontend framework and test tools does the project already use?
  2. Which user journeys are most important to protect?
  3. Are accessibility and visual checks part of the normal test process?
What is frontend testing? diagram
How to Explain It in an Interview

The first goal is to define the behavior that should give us confidence. For frontend code, this usually means something the user can see, do, or experience in the browser, plus the smaller units that support that behavior.

A unit test checks a small function, utility, or state change in isolation when browser behavior is not required. It is usually fast and easy to debug.

A component test renders the real user interface component needed by the behavior. The test interacts with it through actions such as clicking, typing, or submitting. It then checks rendering, state, text, accessible roles, or other results the user can observe.

An integration test checks whether several frontend parts work together. This can include components, routing, state, storage, browser APIs, or a controlled network boundary. It gives more confidence about collaboration than a small isolated test, but it normally requires more setup and takes longer to run.

A contract test checks that frontend requests and response parsing match a documented remote API contract. It can verify supported request shapes, response shapes, fields, and status behavior without claiming that the real remote system is available or correct.

A visual test checks for unintended changes in the rendered interface. An accessibility test checks semantics, names, roles, focus, keyboard behavior, announcements, and automated accessibility rules. Automated accessibility checks are useful, but they do not replace manual testing with assistive technology.

An end to end test runs an important user journey in a real browser against a controlled deployed environment. It provides broad confidence that major frontend parts work together, but it is slower and more expensive than smaller tests.

A useful structure for each test is Arrange, Act, Assert. Arrange means creating a deterministic starting state and controlled inputs. Act means performing the behavior being tested. Assert means checking the result that the user or another public boundary can observe.

Test doubles are used only when a clear dependency boundary should be controlled. A stub returns controlled data. A spy records calls. A mock controls a dependency and sets an expected interaction. A fake provides a lightweight working implementation. For frontend network behavior, a controlled request handler can replace the remote boundary while the frontend request behavior remains realistic. A controlled test does not prove that the real network or remote service works.

Reliable tests avoid random shared state. Time, randomness, storage, network handlers, and other global changes should be controlled only when needed and restored after the test. Tests should not depend on test order. Async actions and results should be awaited instead of using fixed delays.

Assertions should focus on user observable behavior instead of private component methods or incidental implementation details. Tests should cover expected behavior, boundary values, failure behavior, and accessibility conditions when those cases matter.

A balanced strategy uses many fast unit and component tests, enough integration and contract tests to check important collaboration, and a smaller number of end to end tests for critical journeys. Visual and accessibility tests protect interface quality where needed. As tests cover more real user behavior, their runtime, setup cost, and maintenance cost usually increase while feedback becomes slower.

Frontend testing has a clear boundary. It can check what users see, user actions, frontend state and logic, controlled API requests and responses, and accessibility behavior. It does not by itself prove backend services, databases, authentication providers, external notification or payment systems, or large scale performance are correct. Those areas need their own testing.

The main limitation is that every test proves only what exists inside its boundary. An isolated component test cannot prove that the real backend works. A contract test cannot prove that a remote service is available. An end to end test gives broader confidence, but it still cannot cover every browser, device, data combination, or failure. That is why several test levels work together.

Technical Approach
  1. Define the browser visible behavior that should be protected.
  2. Choose the smallest test level that can prove that behavior with enough confidence.
  3. Arrange deterministic data, state, and dependency boundaries.
  4. Act through the public interface, preferably with realistic user actions when browser behavior matters.
  5. Assert visible results, accessible state, and only important public interactions.
  6. Add expected, boundary, failure, and accessibility cases when they matter.
  7. Clean up rendered state, test doubles, handlers, timers, storage, and global changes.
  8. Run every test independently in local development and continuous integration.
  9. Keep a balanced mix of fast small tests and fewer broad browser tests.
Practical Insights

Traditional algorithmic complexity is not the main concern for this question. The practical cost comes from test runtime, setup, isolation, maintenance, and continuous integration time. Unit tests are usually fastest and cheapest. Component and integration tests require more rendering and dependency setup. Contract, visual, accessibility, and end to end tests may require network handlers, browser environments, snapshots, or deployed test environments. Broader tests cover more real user behavior, but they normally run more slowly and cost more to maintain. A balanced strategy keeps most feedback fast while using broader tests where their extra confidence is valuable.

Why Interviewers Ask This

Interviewers ask this to see whether you understand what frontend tests should prove, how to choose the right test boundary, and how to keep tests reliable. They also want to know whether you can separate user visible behavior from implementation details, choose when to replace dependencies, and balance fast isolated tests with broader browser tests.

Common interview mistakes

Common mistakes include testing implementation details instead of user observable behavior, replacing the wrong dependency boundary, using too many test doubles, confusing stubs, spies, mocks, and fakes, sharing mutable fixtures between tests, depending on test order, calling production remote services from automated frontend tests, leaving async work or timers running, using fixed sleep calls, writing weak assertions, ignoring boundary and failure cases, treating code coverage as proof of quality, and claiming that a controlled test proves the real browser, network, backend, or external service works.

Interview tip

Start with the browser visible behavior you want confidence in. Then explain the test level, what remains real, what is controlled, what you assert, and what that test cannot prove. Finish by explaining why a balanced mix of many fast tests and fewer broad browser tests gives useful confidence without making feedback too slow.

Interviewer may ask next
How would you handle a frontend test that becomes flaky because it depends on time or a network response?

I would keep the same frontend behavior boundary but control the unstable dependency. For time based behavior, I would use the test runner's controlled clock only when time is part of the behavior and restore the real clock afterward. For network behavior, I would use a controlled request handler with explicit responses instead of a live remote service. I would still interact with the frontend normally and assert user visible results. This matters because the test should fail when frontend behavior is wrong, not because time or the network changed. The tradeoff is that the controlled test does not prove the real remote integration works.

When should an important frontend behavior use an end to end test instead of only a component test?

I would add an end to end boundary when the behavior depends on a complete real browser journey that a component test cannot prove. Examples include full navigation, browser specific behavior, focus across screens, downloads, or a critical workflow across several application areas. The test should run in a real browser against a controlled deployed environment. This matters because it checks more of the real user journey. The tradeoff is that end to end tests are slower and more expensive to maintain, so I would keep detailed behavior in smaller tests and reserve broad tests for important journeys.

97. Why should frontend tests prefer observable behavior over implementation details?TestingEasy

Question Details

Consider a collapsible disclosure that renders a button and a hidden content region, then reveals the region when activated. Describe assertions based on accessible name, expanded state, focus, and visible content rather than private variables or internal method calls. State the unit or integration boundary, how keyboard and pointer interactions are performed, whether any timer is real, what fixture text is used, and why the test should remain valid after an internal refactor.

Short Interview Answer (30-60 seconds)

I would test what the user can observe. For this disclosure, I render the real component in a simulated DOM and find its button by accessible name. I activate it through pointer or keyboard input, then check its expanded state, focus, and visible content. I do not inspect private state or call internal methods. No fake timers are needed because there is no time based behavior. This gives strong component behavior confidence and stays useful after internal code changes, but it does not prove real browser rendering or browser engine behavior.

Detailed Explanation

See the Code while reading this explanation.

A strong test checks what a person can actually see and do. In this example, the page has a button called Shipping details. The shipping message starts hidden and appears after the button is used. The test should behave like a person by clicking the button or reaching it with the keyboard. It should check the button name, whether the section is open, whether the button still has keyboard attention, and whether the shipping message can be seen. It should not depend on hidden program details that a user never sees.

Useful Questions to Ask the Interviewer
  1. Should the disclosure keep its hidden content in the page or remove it until opened?
  2. Do you want both pointer and keyboard activation covered in this component test?
  3. Is a simulated DOM enough for this question, or is separate real browser coverage expected?
Why should frontend tests prefer observable behavior over implementation details? diagram
How to Explain It in an Interview

The behavior under test is the public contract of the disclosure. A user can find a button named Shipping details, activate it, observe whether it is expanded, keep focus on the button, and see the shipping message. Those are stable outcomes even if the component changes its hooks, helper functions, state shape, or markup structure.

The chosen boundary is a component integration test. I render the real Disclosure component with Testing Library in jsdom. jsdom provides a simulated DOM. It is not a real browser. I do not replace the component, network, clock, or browser APIs because this example does not need those boundaries. There is no network request and no time based behavior, so no fake timers or Mock Service Worker handlers are needed.

The fixture is small and explicit. The accessible button name is Shipping details. The hidden content says that it contains shipping information and that delivery arrives in 3 to 5 business days. The test finds the button with getByRole and its accessible name instead of relying on a class name, element nesting, private variable, hook, or internal method.

For the pointer path, I create a userEvent instance and await user.click on the button. For the keyboard path, I start from a fresh render, use user.tab to move focus to the button, assert that the button has focus, and then await user.keyboard with Enter. These are separate activation paths, so one path does not depend on state created by the other.

Before activation, I assert that the button has aria expanded set to false. The content may either be missing from the DOM or present but hidden. If the content node exists, it must not be visible. This keeps the assertion focused on what the user experiences instead of requiring one rendering strategy.

After activation, I assert that the button has aria expanded set to true. I find the shipping text and assert that it is visible. I also assert that focus remains on the button after activation. These checks describe the behavior contract without depending on a private state variable or internal toggle function.

Testing Library cleanup removes the rendered DOM after each test in the environment shown by the diagram, so no additional teardown is needed. There are no fake timers, network handlers, storage overrides, or global mocks to restore.

Useful failure cases are observable failures. The button might stay collapsed after a click. Pressing Enter might fail to expand it. The shipping text might remain hidden. The button might report the wrong expanded state. Keyboard focus might not reach or remain on the button. Each failure points to behavior that a user can experience.

This test is reliable in continuous integration because it has no remote service, shared mutable fixture, random value, timer dependency, or fixed sleep. Each activation path gets a fresh render. The important limitation is that jsdom does not prove real browser rendering or browser engine behavior. If layout, browser compatibility, or browser specific focus behavior matters, I would add a real browser test rather than claim this component test provides that confidence.

The main tradeoff is intentional. A behavior focused test knows less about the internal implementation. That makes it less useful for checking private functions directly, but much more stable when those private details change. As long as the user visible contract remains the same, the test should continue to pass after an internal refactor.

Key Insight / Why This Solution Works
  1. Define the behavior. The disclosure button is named Shipping details. It starts collapsed and reveals the shipping content after activation.
  2. Choose the boundary. Render the real component with Testing Library in jsdom as a component integration test.
  3. Arrange the fixture. Use the explicit button name and shipping content. Do not add mocks, network handlers, or fake timers because this component does not need them.
  4. Assert the starting behavior. Check that the button reports a collapsed state. If the content node already exists, check that it is not visible.
  5. Run the pointer path in its own test. Await a user click on the button.
  6. Assert the observable result. Check the expanded state, visible shipping content, and focus on the button.
  7. Run the keyboard path in a separate test with a fresh render. Tab to the button, confirm focus, press Enter, and assert the same observable result.
  8. Let Testing Library cleanup remove the rendered DOM after each test in the configured environment.
  9. Run each test independently in continuous integration. Do not depend on test order or shared mutable state.
Code
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/vitest';
import Disclosure from './Disclosure';

const buttonName = /shipping details/i;
const contentText = 'Content about shipping... Arrives in 3–5 business days.';

function renderDisclosure() {
  // Render the real component. No mock or fake dependency is needed for this behavior.
  render(<Disclosure />);

  // Query the control through its role and accessible name, which are observable to users.
  const button = screen.getByRole('button', { name: buttonName });

  return { button };
}

function expectCollapsed(button) {
  // Assert the public expanded state instead of reading private component state.
  expect(button).toHaveAttribute('aria-expanded', 'false');

  // Allow either valid implementation: the content can be absent or mounted but hidden.
  const content = screen.queryByText(contentText);
  if (content) {
    expect(content).not.toBeVisible();
  }
}

function expectExpanded(button) {
  // Assert the accessible expanded state after activation.
  expect(button).toHaveAttribute('aria-expanded', 'true');

  // Assert the exact fixture content is visible to the user.
  expect(screen.getByText(contentText)).toBeVisible();

  // The approved behavior keeps focus on the disclosure button after activation.
  expect(button).toHaveFocus();
}

describe('Disclosure observable behavior', () => {
  it('expands through pointer activation', async () => {
    // Create realistic pointer interaction behavior.
    const user = userEvent.setup();
    const { button } = renderDisclosure();

    // Verify the initial observable state.
    expectCollapsed(button);

    // Activate the disclosure through the pointer path.
    await user.click(button);

    // Verify only outcomes that the user can observe.
    expectExpanded(button);
  });

  it('expands through keyboard activation', async () => {
    // Use a fresh render so this path is independent of the pointer test.
    const user = userEvent.setup();
    const { button } = renderDisclosure();

    // Move focus through normal keyboard navigation before activation.
    await user.tab();
    expect(button).toHaveFocus();

    // Activate the focused disclosure button with Enter.
    await user.keyboard('{Enter}');

    // Verify the same observable behavior contract as the pointer path.
    expectExpanded(button);
  });
});
Why Interviewers Ask This

Interviewers ask this to see whether I can test the behavior a user depends on instead of coupling tests to private code. They want to see whether I choose the right test boundary, use realistic user actions, make useful accessibility assertions, and understand why a test should survive an internal refactor. It also shows whether I understand the confidence and limitations of a simulated DOM test.

Common interview mistakes

A common mistake is checking a private state variable such as open instead of checking the expanded state users can observe. Another is spying on an internal toggle function or hook. That makes the test fail after a refactor even when the user experience is unchanged. Tests also become fragile when they assert class names, exact element nesting, or other incidental DOM structure. Another mistake is forcing collapsed content to be absent from the DOM when a valid implementation may keep it mounted but hidden. Keyboard tests should move focus naturally before pressing Enter. Fixed sleeps, unnecessary fake timers, and unnecessary mocks add complexity without helping this example.

Interview tip

Explain the behavior contract first. Say what the user can find, do, and observe. Then name the component integration boundary, explain that jsdom is simulated rather than a real browser, and show how pointer and keyboard paths produce the same visible result. Finish by explaining that private state and internal methods can change without breaking the test.

Interviewer may ask next
What would you change if the keyboard test sometimes failed because the button did not receive focus?

I would first keep the same component integration boundary and inspect the keyboard path. The test should use user.tab to move focus naturally, then assert that the Shipping details button has focus before pressing Enter. If focus does not reach the button, that is observable behavior to investigate rather than something to bypass with a private method call. I would not add a fixed sleep. If the failure depends on real browser focus behavior that jsdom cannot model reliably, I would add a focused real browser test while keeping the component test for the basic contract.

When would you move this disclosure test from jsdom to a real browser?

I would add a real browser boundary when the behavior depends on browser rendering, browser engine focus behavior, layout, browser compatibility, or a complete user journey. The existing component integration test should still cover the basic accessible contract because it is fast and easy to run in continuous integration. The tradeoff is that a real browser gives stronger browser confidence but costs more runtime and setup. I would use each level for the confidence it can actually provide.

98. How should a test choose DOM elements by role and accessible name?TestingEasy

Question Details

A form renders a labeled search field, a Search button, a live status message, and result links after an asynchronous request. Explain a query priority that reflects how users and assistive technology identify these elements. Specify the integration boundary, typed interaction, expected accessible and visible states, response fixture, real DOM environment, and mocked network boundary. Contrast semantic queries with brittle class-name or DOM-position selectors.

Short Interview Answer (30-60 seconds)

I would choose elements the way users and assistive technology identify them. For interactive controls, I prefer role plus accessible name. For a labeled form control, label text is also a strong query. A role alone is fine when that role already identifies the element clearly, such as a status message. I would render the real component in a DOM test environment, type and click with userEvent, mock only the HTTP boundary with Mock Service Worker, and assert the visible status and async result link. Test ids, classes, and DOM positions are last resort choices because they are more tied to implementation.

Detailed Explanation

See the Code while reading this explanation.

The test should find each part of the search page in the same way a person would. The search field should be identified from its label or accessible name. The Search button should be identified from its role and name. The loading message should be identified from its status role. The result links should be identified from their visible names. The test should type, click, wait for the result, and check what appears. The outside network reply should be controlled so the test stays repeatable and does not depend on a live service.

Useful Questions to Ask the Interviewer
  1. Should this test cover only the successful search flow, or also empty and failed responses?
  2. Is this component expected to run in a DOM test environment with Mock Service Worker, with separate real browser coverage for complete journeys?
How should a test choose DOM elements by role and accessible name? diagram
How to Explain It in an Interview

The goal is to test the search form through behavior a user can observe. This is a component integration test. The real SearchForm, rendered DOM tree, event handling, request code, and DOM updates stay real. The HTTP boundary is replaced with Mock Service Worker so the response is deterministic.

For the Search button, I would use screen.getByRole('button', { name: /search/i }). The role tells us what the element does, and the accessible name tells us which button it is. For the labeled search field, screen.getByLabelText(/search/i) is a good choice. If the textbox has the accessible name Search, screen.getByRole('textbox', { name: /search/i }) is also appropriate. For the live message, screen.getByRole('status') works because the element has role status. For an async result link, screen.findByRole('link', { name: /docs: testing library/i }) waits for that user visible result to appear.

The query order should reflect user meaning, not private markup. Role plus accessible name is the preferred choice for interactive elements. Label text is strong for labeled form controls. A role alone is useful when that role is already sufficient to identify the element, such as a single status region. Visible text can be used for noninteractive content. A test id is a last resort when there is no useful user facing way to identify the element.

The test renders SearchForm in the configured DOM environment. Testing Library userEvent types testing library into the search field and clicks Search. Mock Service Worker intercepts GET /api/search and returns the fixed response fixture shown in the diagram. That fixture contains the result links Docs: Testing Library, MDN: ARIA: Roles, and WAI: Accessible Name. No fake timers or fixed sleeps are needed.

After the click, the test checks the status element for the visible Searching message. It then awaits the result link with findByRole and verifies that the link is visible. After each test, Mock Service Worker handlers and the rendered DOM are reset so later tests do not inherit state.

This approach is more resilient than selecting .btn primary, using container.querySelector('button'), or choosing getAllByRole('link')[0]. Those selectors depend on CSS, DOM structure, or ordering instead of user meaning. A test id can still be useful when no semantic query can identify the element clearly, but it should not be the first choice here.

The limitation is important. This test gives confidence that the component behaves correctly with a controlled HTTP response. It does not prove that the real remote service is available, that the real service contract has not changed, or that a complete real browser journey works. Those concerns need separate contract or browser tests when they matter.

Technical Approach
  1. Define the behavior. A user enters a search, presses Search, sees a live status message, and then sees result links.
  2. Choose the test level. Use a component integration test because DOM behavior, user interaction, and the network boundary matter.
  3. Keep the component real. Render SearchForm in the configured DOM environment.
  4. Control only the network boundary. Use Mock Service Worker for GET /api/search and return the fixed result fixture.
  5. Find controls semantically. Prefer role plus accessible name for the button and textbox, label text for the labeled field, and role alone for the status element when it is sufficient.
  6. Act like the user. Type with userEvent and click the Search button.
  7. Assert the accessible state. Check screen.getByRole('status') for the Searching message.
  8. Assert the async visible result. Await screen.findByRole('link', { name: /docs: testing library/i }) and verify that it is visible.
  9. Clean up. Reset the network handlers and rendered DOM.
  10. Keep the test isolated. Do not use a production service, fixed sleeps, shared mutable state, class selectors, or DOM position selectors.
Practical Insights

Algorithmic Big O complexity is not very useful for this test. The practical cost comes from rendering the component, simulating the user action, intercepting the request, and waiting for the DOM update. The fixture is small, so memory and setup cost are low. This kind of DOM integration test is usually faster and cheaper in CI than a complete real browser journey. Its main maintenance cost is keeping the fixture and accessible expectations aligned with intended behavior.

Code
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { SearchForm } from './SearchForm';

// Use one small deterministic response fixture for every run.
const fixture = {
  results: [
    { title: 'Docs: Testing Library', url: '/docs' },
    { title: 'MDN: ARIA: Roles', url: '/aria/roles' },
    { title: 'WAI: Accessible Name', url: '/accname' },
  ],
};

// Replace only the HTTP boundary. The component and DOM behavior stay real.
const server = setupServer(
  rest.get('/api/search', (_req, res, ctx) => {
    return res(ctx.status(200), ctx.json(fixture));
  })
);

beforeAll(() => {
  // Start request interception before any component test runs.
  server.listen({ onUnhandledRequest: 'error' });
});

afterEach(() => {
  // Reset network overrides and the rendered DOM so tests remain isolated.
  server.resetHandlers();
  cleanup();
});

afterAll(() => {
  // Stop the mock network server when this test suite finishes.
  server.close();
});

describe('SearchForm', () => {
  it('finds controls semantically and shows accessible search results', async () => {
    // userEvent performs the same typed and click interactions a user makes.
    const user = userEvent.setup();

    // Render the real component in the configured DOM test environment.
    render(<SearchForm />);

    // Find the textbox by role and accessible name instead of class or position.
    await user.type(screen.getByRole('textbox', { name: /search/i }), 'testing library');

    // Find the button by its semantic role and accessible name.
    await user.click(screen.getByRole('button', { name: /search/i }));

    // Assert the live accessible status while the request is in progress.
    expect(screen.getByRole('status')).toHaveTextContent(/searching/i);

    // Await the async result by link role and accessible name, then check visibility.
    expect(
      await screen.findByRole('link', {
        name: /docs: testing library/i,
      })
    ).toBeVisible();
  });
});
Why Interviewers Ask This

Interviewers ask this to see whether a candidate tests behavior that users and assistive technology can observe instead of depending on private page structure. They also want to see good judgment about semantic queries, async behavior, network isolation, and the confidence provided by a component integration test.

Common interview mistakes

A common mistake is choosing elements by class name, CSS selector, DOM position, or array index when the user already has a meaningful role, label, or visible name. Another mistake is querying a Search button only by its text when role plus accessible name expresses the intent more clearly. Tests also become weak when they mock the component itself, call a production service, use fixed sleeps for async work, forget to reset Mock Service Worker handlers, or treat a mocked HTTP test as proof that the real remote integration works.

Interview tip

Start with one rule: query the page the way users and assistive technology identify it. Then walk through the exact search flow. Keep the component and DOM behavior real, use userEvent for typing and clicking, mock only the HTTP boundary with Mock Service Worker, assert the status and async result link, and clean up afterward. End by explaining that semantic queries survive many harmless markup changes while class and DOM position selectors are brittle.

Interviewer may ask next
How would you test a failed search request without making this test flaky?

I would keep the same component integration boundary and replace only the Mock Service Worker handler for that test so GET /api/search returns the intended failure response. I would perform the same userEvent actions and then await the user visible error state through its semantic role or accessible text. The handler would be reset after the test. This matters because the failure stays controlled and repeatable while the real component and DOM behavior still run. The tradeoff is that the test still does not prove how the real remote service behaves.

When should this check also run as a real browser test?

I would keep this DOM component integration test for fast semantic behavior checks and add a real browser test when the risk depends on browser behavior such as focus, keyboard navigation, routing, layout, browser compatibility, service workers, or a complete user journey. The boundary then changes from a DOM test environment with a mocked HTTP response to a real browser environment. That gives broader confidence, but it costs more CI time and is usually slower to debug.

99. Design tests for an autocomplete that must ignore stale responses.TestingMedium

Question Details

The search field waits 200 ms after typing, requests suggestions, and displays only results for the latest query even when responses arrive out of order. Define unit coverage for the request-selection logic, a DOM integration test that types ca then cat, controlled response fixtures that resolve in reverse order, and a browser test for focus and keyboard selection. Specify loading, empty, error, and selected states; accessible combobox semantics and announcements; timer and network mocks; cancellation or stale-result behavior; and cleanup on unmount.

Short Interview Answer (30-60 seconds)

I would make the latest query rule the main invariant. A unit test proves that only the newest request id can update suggestions. A DOM test renders the real autocomplete, controls the 200 millisecond debounce with fake timers, and controls the network so the cat response arrives before the older ca response. The UI must keep the cat results after ca finishes. I would separately cover loading, empty, error, selected, cancellation, accessibility, and unmount cleanup. Then I would use a small real browser test for focus, ArrowDown, Enter, and selection. The tradeoff is speed versus browser confidence.

Detailed Explanation

See the Code while reading this explanation.

The search box waits briefly before asking for suggestions. The important rule is that an old answer must never replace a newer answer. The tests should make this difficult timing case happen on purpose, every time. They should also check what the person sees while waiting, when nothing is found, when a request fails, and after a suggestion is chosen. The plan should check keyboard use, focus, spoken updates for assistive tools, and safe cleanup when the component disappears. This gives confidence without depending on unpredictable real network timing.

Useful Questions to Ask the Interviewer
  1. Should a new query abort the previous request, or is ignoring an old response enough?
  2. What should the live status announce for loading, result count, empty, and error states?
  3. Should the browser test use controlled test data or a deployed test service?
Design tests for an autocomplete that must ignore stale responses. diagram
How to Explain It in an Interview

The core invariant is simple: only the latest query may change the suggestion list. I would give each request an increasing request id. When a response returns, its id is compared with the active id. A matching id may update the state. An older id is stale and is ignored.

First, I would unit test only that request selection rule. Start a request for ca, then a newer request for cat. Resolve cat first with cat facts. Then resolve ca with older data. The stored suggestions must still contain cat facts. I would also cover rejection and reset behavior so an older success cannot replace the state that belongs to a newer request.

Next, I would render the real autocomplete in a DOM test. I would use fake timers only for the 200 millisecond debounce. I would use Mock Service Worker at the network boundary with controlled response promises. The test types ca and advances the debounce so its request starts. It then types the final t, which changes the input from ca to cat, and advances the debounce again. The cat response is released first. The ca response is released last. The visible list must continue showing cat results after the old response finishes.

The visible states should have focused tests. Loading exposes a status such as Searching. Empty shows No results. Error shows the supported failure message. Selected puts the chosen suggestion into the input and closes the list. These tests should use accessible queries instead of private component fields.

The input should expose the combobox role, aria expanded, aria controls, and aria activedescendant when an option is active. The popup should use the listbox role. Suggestions should use the option role, and the active option should expose aria selected. A live status should announce useful changes such as the number of results. An automated axe check can be added as a supplemental check, but it does not replace direct tests for semantics, focus, keyboard behavior, and announcements.

For cancellation, I would abort the previous fetch with AbortController when a newer request starts, but I would still keep the request id guard. Aborting reduces unnecessary work. The id guard protects correctness if cancellation races with completion or an old result still reaches the component. An AbortError caused by a newer search should not become a user facing error.

Cleanup matters for reliability. When the component unmounts, it should clear a pending debounce timer, abort an active request, remove listeners that it created, and prevent later work from updating state. The test suite should restore real timers and reset Mock Service Worker handlers after every test.

Finally, I would use Playwright for the behavior that needs a real browser. Focus the combobox, enter cat, wait for the suggestion list, press ArrowDown, press Enter, verify the selected value, verify that the list closes, and verify that focus remains on the input. I would not use fixed sleep calls. I would wait for visible browser conditions.

In CI, unit and DOM tests provide fast coverage for many timing and state cases. A smaller browser suite gives confidence in focus and keyboard behavior. Controlled network tests do not prove that a production service is available or that its real contract is unchanged. That requires a separate contract or deployed integration check.

Key Insight / Why This Solution Works
  1. Define the invariant that only the latest query can update suggestions.
  2. Unit test the request selection logic with increasing request ids.
  3. Start ca, then start cat, and release their results in reverse order.
  4. Assert that the cat result stays selected after the older ca result arrives.
  5. Render the real autocomplete for the DOM boundary.
  6. Use fake timers only to control the 200 millisecond debounce.
  7. Use Mock Service Worker with controlled response promises to control network order.
  8. Test loading, empty, error, selected, aborted, and stale outcomes with focused cases.
  9. Assert combobox, listbox, option, active option, and live status behavior with accessible queries.
  10. Test timer and request cleanup when the component unmounts.
  11. Use Playwright for real browser focus and keyboard selection.
  12. Reset handlers, restore timers, and remove test state after every test.
Code
// selector.test.js
import { describe, expect, test } from 'vitest';
import { createRequestSelector } from './selector';

describe('request selection', () => {
  test('keeps the latest result when an older response arrives last', () => {
    // Start two searches so the second request becomes the active request.
    const selector = createRequestSelector();
    const caId = selector.search('ca');
    const catId = selector.search('cat');

    // Apply the newest response first.
    selector.response(catId, ['cat facts']);
    expect(selector.getSuggestions()).toEqual(['cat facts']);

    // Deliver the older response last and prove that it cannot replace current data.
    selector.response(caId, ['ca facts']);
    expect(selector.getSuggestions()).toEqual(['cat facts']);
  });
});

// Autocomplete.test.jsx
import React from 'react';
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { Autocomplete } from './Autocomplete';

// Create an explicit response gate so the test decides when a request completes.
function deferred() {
  let resolve;
  const promise = new Promise((next) => {
    resolve = next;
  });
  return { promise, resolve };
}

const server = setupServer();

beforeAll(() => {
  // Reject unexpected requests so missing fixtures fail loudly.
  server.listen({ onUnhandledRequest: 'error' });
});

afterEach(() => {
  // Remove rendered UI and restore every shared test boundary.
  cleanup();
  server.resetHandlers();
  vi.useRealTimers();
  vi.restoreAllMocks();
});

afterAll(() => {
  server.close();
});

describe('Autocomplete', () => {
  test('shows cat results when the older ca response resolves last', async () => {
    const caResponse = deferred();
    const catResponse = deferred();

    // Hold both network responses until the test releases them.
    server.use(
      http.get('/api/suggest', async ({ request }) => {
        const query = new URL(request.url).searchParams.get('q');

        if (query === 'ca') {
          const body = await caResponse.promise;
          return HttpResponse.json(body);
        }

        if (query === 'cat') {
          const body = await catResponse.promise;
          return HttpResponse.json(body);
        }

        return HttpResponse.json([]);
      })
    );

    // Fake time only while exercising the two debounce periods.
    vi.useFakeTimers();
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

    render(<Autocomplete />);
    const input = screen.getByRole('combobox');

    // Enter ca and let its 200 ms debounce start request one.
    await user.type(input, 'ca');
    await vi.advanceTimersByTimeAsync(200);

    // Add t so the query becomes cat, then start request two.
    await user.type(input, 't');
    await vi.advanceTimersByTimeAsync(200);

    // Restore real time before waiting for asynchronous network and DOM work.
    vi.useRealTimers();

    // Release the latest response first and verify the current result.
    catResponse.resolve(['cat facts', 'catalog', 'cataract']);
    expect(await screen.findByRole('option', { name: 'cat facts' })).toBeInTheDocument();

    // Release the stale response last and verify that it is ignored.
    caResponse.resolve(['ca facts']);
    expect(await screen.findByRole('option', { name: 'cat facts' })).toBeInTheDocument();
    expect(screen.queryByRole('option', { name: 'ca facts' })).not.toBeInTheDocument();
  });

  test('shows loading and then the empty state', async () => {
    const response = deferred();

    server.use(
      http.get('/api/suggest', async () => {
        const body = await response.promise;
        return HttpResponse.json(body);
      })
    );

    vi.useFakeTimers();
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

    render(<Autocomplete />);
    const input = screen.getByRole('combobox');

    // Start the request and check the visible loading announcement while it is pending.
    await user.type(input, 'none');
    await vi.advanceTimersByTimeAsync(200);
    vi.useRealTimers();
    expect(screen.getByRole('status')).toHaveTextContent(/searching/i);

    // Resolve with no suggestions and verify the empty state.
    response.resolve([]);
    expect(await screen.findByText(/no results/i)).toBeInTheDocument();
  });

  test('shows the error state when the latest request fails', async () => {
    const response = deferred();

    server.use(
      http.get('/api/suggest', async () => {
        await response.promise;
        return HttpResponse.error();
      })
    );

    vi.useFakeTimers();
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

    render(<Autocomplete />);
    const input = screen.getByRole('combobox');

    // Start the latest request, then release a controlled network failure.
    await user.type(input, 'fail');
    await vi.advanceTimersByTimeAsync(200);
    vi.useRealTimers();
    response.resolve();

    expect(await screen.findByText(/try again|failed/i)).toBeInTheDocument();
  });

  test('moves a selected suggestion into the input and closes the list', async () => {
    server.use(
      http.get('/api/suggest', () => HttpResponse.json(['cat facts', 'catalog', 'cataract']))
    );

    vi.useFakeTimers();
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

    render(<Autocomplete />);
    const input = screen.getByRole('combobox');

    // Load suggestions for cat.
    await user.type(input, 'cat');
    await vi.advanceTimersByTimeAsync(200);
    vi.useRealTimers();

    const option = await screen.findByRole('option', { name: 'cat facts' });

    // Select through the visible option instead of calling component internals.
    await user.click(option);
    expect(input).toHaveValue('cat facts');
    expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
  });

  test('exposes combobox semantics, active option state, and announcements', async () => {
    server.use(
      http.get('/api/suggest', () => HttpResponse.json(['cat facts', 'catalog', 'cataract']))
    );

    vi.useFakeTimers();
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

    render(<Autocomplete />);
    const input = screen.getByRole('combobox');

    // The closed input exposes its combobox relationship before results appear.
    expect(input).toHaveAttribute('aria-expanded', 'false');
    expect(input).toHaveAttribute('aria-controls');

    await user.type(input, 'cat');
    await vi.advanceTimersByTimeAsync(200);
    vi.useRealTimers();

    // Results use listbox and option roles and announce their count.
    expect(await screen.findByRole('listbox')).toBeInTheDocument();
    expect(input).toHaveAttribute('aria-expanded', 'true');
    expect(screen.getAllByRole('option')).toHaveLength(3);
    expect(screen.getByRole('status')).toHaveTextContent(/3 results/i);

    // Keyboard movement keeps DOM focus on the input and identifies the active option.
    await user.keyboard('{ArrowDown}');
    expect(input).toHaveFocus();
    expect(input).toHaveAttribute('aria-activedescendant');
    expect(screen.getByRole('option', { name: 'cat facts' })).toHaveAttribute(
      'aria-selected',
      'true'
    );
  });

  test('clears a pending debounce timer when unmounted', async () => {
    vi.useFakeTimers();
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

    const { unmount } = render(<Autocomplete />);
    const input = screen.getByRole('combobox');

    // Typing creates a pending debounce timer before any request is sent.
    await user.type(input, 'ca');
    expect(vi.getTimerCount()).toBeGreaterThan(0);

    // Unmount must remove the pending timer so later work cannot run.
    unmount();
    expect(vi.getTimerCount()).toBe(0);
  });

  test('aborts an active request when unmounted', async () => {
    const response = deferred();
    const abortSpy = vi.spyOn(AbortController.prototype, 'abort');

    server.use(
      http.get('/api/suggest', async () => {
        const body = await response.promise;
        return HttpResponse.json(body);
      })
    );

    vi.useFakeTimers();
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

    const { unmount } = render(<Autocomplete />);
    const input = screen.getByRole('combobox');

    // Let the debounce finish so a request is active before unmount.
    await user.type(input, 'cat');
    await vi.advanceTimersByTimeAsync(200);

    // Unmount must cancel the active request through the browser abort boundary.
    unmount();
    expect(abortSpy).toHaveBeenCalled();

    // Release the controlled handler so no test work remains pending.
    response.resolve(['cat facts']);
  });
});

// autocomplete.spec.js
import { expect, test } from '@playwright/test';

test('supports focus and keyboard selection in a real browser', async ({ page }) => {
  // Control only the suggestion request while keeping real browser focus and keyboard behavior.
  await page.route('**/api/suggest?*', async (route) => {
    const query = new URL(route.request().url()).searchParams.get('q');

    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(query === 'cat' ? ['cat facts', 'catalog', 'cataract'] : []),
    });
  });

  await page.goto('/');
  const combo = page.getByRole('combobox');

  // Use observable browser conditions instead of a fixed sleep.
  await combo.fill('cat');
  await expect(page.getByRole('listbox')).toBeVisible();

  // Move to the first option and select it with the keyboard.
  await combo.press('ArrowDown');
  await combo.press('Enter');

  // Selection updates the input, closes the popup, and keeps focus on the combobox.
  await expect(combo).toHaveValue('cat facts');
  await expect(page.getByRole('listbox')).toBeHidden();
  await expect(combo).toBeFocused();
});
Why Interviewers Ask This

Interviewers ask this question to see whether you can separate unit, DOM, and real browser confidence. They want to know whether you can control time and network order, reproduce a race reliably, assert visible behavior, test accessibility, handle cancellation safely, and clean up timers and requests so one test cannot affect another.

Common interview mistakes

Common mistakes are using fixed sleep calls, resolving requests only in normal order, checking private component state instead of visible behavior, mocking the entire component instead of the timer or network boundary, forgetting to restore fake timers, sharing mutable handlers across tests, treating AbortError as a user failure, relying only on cancellation without a stale request guard, skipping loading and failure states, ignoring combobox semantics, and claiming a controlled network test proves that the real remote service works.

Interview tip

Start with the invariant that only the newest query can update the list. Then explain the unit race test, the DOM test with fake time and reversed responses, the visible state and accessibility checks, cleanup on unmount, and the small real browser test for focus and keyboard selection.

Interviewer may ask next
What if aborting the ca request races with completion and its response still reaches the component?

I would keep the request id check at the component state boundary even when AbortController is used. Cancellation reduces wasted work, but the id comparison is the correctness rule. The test starts ca, starts cat, lets cat update the list, and then delivers the old ca result anyway. The visible list must remain the cat list. This matters because cancellation and completion can happen close together. The tradeoff is a small amount of extra state logic for stronger protection.

Would you move all reverse response cases into Playwright for more confidence?

No. I would keep the detailed reverse response cases at the DOM network boundary and use Playwright for the smaller real browser boundary. The DOM tests are faster and make request order easy to control, so they are better for stale, empty, error, and cancellation cases. Playwright should focus on behavior that needs a real browser, especially focus and keyboard selection. The tradeoff is that more browser tests add stronger browser confidence but also increase CI time and debugging cost.

100. Design an integration test for an accessible modal dialog.TestingMedium

Question Details

A Delete project button opens a confirmation dialog rendered into a document-level portal. The dialog must receive focus, contain Tab navigation, close on Escape or Cancel, return focus to the trigger, and show a progress state while deletion runs. Define fixture content, pointer and keyboard interactions, synchronous and asynchronous assertions, accessibility checks, portal cleanup, DOM-environment limitations, mocked delete responses, and the real-browser cases needed for focus and scroll behavior.

Short Interview Answer (30-60 seconds)

I would render the real project settings UI with a document level portal root and control only the delete request with Mock Service Worker. I would open the dialog through the Delete project button, verify that focus moves into the dialog, check Tab and Shift Tab navigation, then test Escape and Cancel separately and confirm that focus returns to the trigger. For deletion, I would keep the request pending long enough to assert the progress state, then resolve it and verify that the dialog closes. I would also run accessibility checks, clean up the portal and handlers, and cover true focus order and page scroll behavior in a real browser because a simulated document cannot prove those behaviors.

Detailed Explanation

This test checks what a person experiences when deleting a project. The page starts with a Delete project button. Pressing it should open a confirmation box and move attention inside it. Keyboard movement must stay inside the box. Escape and Cancel must close it and return attention to the original button. When the person confirms deletion, the box must show that work is happening. After success, it should disappear. The test also checks that temporary page content is removed and that important behavior is tested again in a real browser where needed.

Useful Questions to Ask the Interviewer
  1. Should Cancel receive focus first when the dialog opens, or should another control receive the initial focus?
  2. What visible message should appear when the delete request fails?
  3. Does opening the dialog intentionally lock page scrolling in the production component?
Design an integration test for an accessible modal dialog. diagram
How to Explain It in an Interview

I would treat this as a frontend integration test because several real pieces work together: the project settings component, the document level portal, focus management, keyboard handling, visible progress state, and the delete request boundary. I would keep those frontend pieces real and replace only the remote delete response with Mock Service Worker.

The fixture is small. It contains one project, a visible Delete project button, and the portal target used by the component. Before the action, the dialog should not exist and the trigger can be the active element.

I would use Testing Library user events instead of calling component methods. First I click Delete project. I then query the dialog by its role and accessible name, such as Delete project?. The first synchronous checks are that the dialog exists, has the expected modal semantics, and focus is now inside the dialog. In the approved flow, Cancel is the first focusable control.

Next I test keyboard containment. Pressing Tab moves from Cancel to Delete. Another Tab wraps back to Cancel if those are the only focusable controls. Shift Tab moves in the opposite direction. I do not prove this by checking private focus management code. I check document.activeElement or the visible focused control.

Escape is one closing path. I press Escape, verify that the dialog is removed, and verify that focus returns to the Delete project trigger. I reopen the dialog and test Cancel separately because a pointer or keyboard activation of Cancel is another required user path. It should also remove the dialog and restore focus to the trigger.

For the delete path, I reopen the dialog and click Delete. Mock Service Worker controls the DELETE request at the request boundary shown in the approved design. While that response is pending, the dialog should show a visible progress message such as Deleting project..., expose the busy state when the component uses it, and prevent another delete submission. I wait for observable state instead of using a fixed delay. After the mocked success response resolves, I wait until the dialog is removed and verify the successful visible result expected by the component.

I also provide a failure response with Mock Service Worker. The failure test should verify the product behavior that the component actually defines, such as keeping the dialog open and showing Cannot delete. The important point is that the failure is controlled at the same network boundary and is not produced by changing private component state.

Accessibility checks combine targeted assertions with an automated axe scan. I verify the dialog role, accessible name, modal semantics, accessible names for Cancel and Delete, focus movement, keyboard behavior, and the progress announcement exposed by the component. An axe result is useful for detectable rule violations, but it does not prove that focus trapping, announcements, or assistive technology behavior works correctly in every browser.

Cleanup matters because the dialog is rendered into document level state. After each test, the rendered component is removed, the portal must not contain a leftover dialog, Mock Service Worker handlers are reset, and any changed mocks, timers, body styles, or global values are restored according to their lifetime. Tests must not depend on another test leaving the page in a certain state.

A simulated DOM is useful for fast integration checks, but it does not provide full browser layout, scrolling, or native focus behavior. I would therefore add a small real browser suite with Playwright or the project's existing browser runner. That suite should verify real Tab and Shift Tab order, focus restoration after Escape and Cancel, page scroll locking while the dialog is open, restoration of scrolling after close, visible focus styling, and important viewport behavior. Manual assistive technology checks are still needed for screen reader announcements.

The main tradeoff is speed versus browser confidence. The simulated integration tests are fast and precise, so they can cover most states on every change. The real browser tests are slower, so I would keep them focused on behavior that the simulated environment cannot faithfully prove.

Technical Approach
  1. Create a small fixture with one project, the Delete project trigger, and the portal target used by the real component.
  2. Start with no dialog and record the trigger as the expected focus return target.
  3. Click Delete project with a realistic user event.
  4. Find the dialog by role and accessible name, then assert its modal semantics and initial focus inside the dialog.
  5. Press Tab and Shift Tab to verify that keyboard navigation stays within the dialog and wraps between the available controls.
  6. Press Escape, verify that the dialog is removed, and verify that focus returns to the Delete project trigger.
  7. Reopen the dialog and activate Cancel. Verify the same close and focus return behavior.
  8. Reopen the dialog and confirm deletion. Use Mock Service Worker to control the DELETE response.
  9. While the response is pending, assert the visible progress state, busy state when exposed by the component, and protection against repeated deletion.
  10. Resolve the success response and wait for the dialog to disappear and the visible success result to appear.
  11. Override the Mock Service Worker handler with the approved failure response and verify the component's visible failure behavior without changing private state.
  12. Run automated axe checks together with direct assertions for role, accessible name, focus, keyboard behavior, and progress announcement.
  13. Remove rendered content, reset Mock Service Worker handlers, and restore any global state changed by the test.
  14. Run focused real browser cases for native focus order, focus return, page scroll lock, visible focus styling, and viewport behavior.
Practical Insights

Algorithmic time and space complexity are not the important measure for this test. The practical cost comes from how many user states and environments are exercised. The simulated integration tests are relatively fast because the page is rendered locally and the delete request is controlled. Mock Service Worker adds a small setup and maintenance cost but gives a realistic request boundary. Automated accessibility checks add some runtime but are still suitable for normal CI. Real browser tests cost more because a browser must start and execute real focus and scroll behavior, so I would keep that suite small and focused. Maintenance cost is lowest when tests use accessible user behavior instead of private DOM structure.

Why Interviewers Ask This

Interviewers ask this question to see whether I can test behavior that crosses several frontend boundaries without confusing a simulated document with a real browser. They want to see whether I can choose realistic user actions, verify focus and keyboard behavior through accessible queries, control the delete request, wait correctly for asynchronous state changes, clean up portal state, and explain which focus and scroll checks still need a real browser.

Common interview mistakes

Common mistakes are checking private component state instead of visible behavior, calling event handlers directly instead of using realistic user actions, mocking the focus manager instead of testing the real focus behavior, replacing too much of the component, and treating a mocked request as proof that the real backend works. Other mistakes include checking only that the dialog appears while ignoring focus, forgetting Shift Tab and Escape, testing Escape but not Cancel, failing to verify focus return, using fixed sleeps for deletion, forgetting the pending progress state, skipping the rejected response, leaving portal content or request handlers behind, and assuming a simulated DOM proves real scrolling or browser focus behavior. Another mistake is relying only on axe and treating zero automated violations as complete accessibility proof.

Interview tip

Start with the user behavior and the confidence boundary. Say what stays real, what is controlled, and what the simulated test can prove. Then walk through open, focus, keyboard navigation, close and focus return, pending deletion, success and failure, accessibility, cleanup, and finally the small set of checks that need a real browser.

Interviewer may ask next
How would you test a failed delete request without making the test flaky?

I would change only the Mock Service Worker delete handler for that test and return the approved failure response. The frontend component, portal, focus behavior, and user interactions stay real. I would click Delete and wait for the visible failure result instead of using a fixed delay. I would also verify that the dialog remains in the correct state and that another test cannot inherit the failure handler because the handlers are reset afterward. This boundary matters because it gives deterministic failure coverage without pretending that the mocked response proves the real remote service works.

Which parts would you move to a real browser test, and why?

I would keep most state and request cases in the fast integration suite, but I would verify native Tab order, Shift Tab wrapping, focus restoration, page scroll locking, visible focus styling, and important viewport behavior in a real browser. The boundary changes from a simulated document to an actual browser environment while the user flow remains the same. This matters because simulated DOM environments do not reproduce layout, scrolling, or every focus detail. The tradeoff is slower CI, so the real browser suite should stay small and target only behavior that needs browser level confidence.

More questions load as you scroll

Disclaimer: This interview guide is for educational and informational purposes only. It is designed to help readers prepare, but it does not guarantee any interview result, hiring decision, offer, or outcome. Interview questions, hiring criteria, and preferred answers can vary by employer, interviewer, industry, location, and time. The examples and explanations reflect the authors' research and judgment, are provided without warranties of any kind, and should not be treated as the only correct approach. Diagrams are simplified illustrations intended to highlight the main components and their interactions; actual systems and implementations may be more complex. Alternative approaches may be equally valid or better suited to a particular question, context, or interviewer. To the fullest extent permitted by applicable law, the author, contributors, and publisher are not liable for decisions made, actions taken, or losses incurred based on this guide.