Summary
A developer attempted to write a unit/integration test to verify that a controller action correctly handles a Timeout::Error when calling an external API via HTTParty. Despite using Timeout.timeout in the test block, the expected rescue block in the controller was never triggered, and the binding.break breakpoint was not hit. The investigation revealed that the failure stems from a misunderstanding of how test doubles (stubs) interact with the Ruby call stack and the mechanics of exception raising in a testing environment.
Root Cause
The primary issue is a mismatch between the test’s execution flow and the application’s execution flow.
- Stubbing bypasses the logic: When using libraries like WebMock or Mocha to stub an HTTP request, the code inside the controller does not actually perform a network operation. It immediately returns a predefined response object.
- Timeout is external to the stub: Wrapping the test execution in
Timeout.timeout(1)in the spec file only monitors the time taken by the test runner to execute the block. It does not force the internal logic of the stubbed method to throw a specific error unless explicitly told to do so. - Control Flow Disconnect: The test was trying to “force” a timeout by sleeping, but because the network call was stubbed, the code never hit the
sleepor the actual network socket logic that would have triggered a timeout. The stub returned instantly, the timeout period was never exceeded, and the rescue block was bypassed.
Why This Happens in Real Systems
In production, timeouts are typically non-deterministic and driven by the kernel or the network stack (TCP handshake failures, packet loss, or slow responses). In testing, we attempt to simulate these non-deterministic failures using deterministic stubs.
The disconnect happens because engineers often confuse:
- Simulating a delay: Making a stub wait (which tests client-side timeouts).
- Simulating a failure: Making a stub raise an error (which tests error-handling logic).
If you stub a method to return a 200 OK, no amount of wrapping that call in a Timeout block in your test will make the stubmed method suddenly decide to throw a Timeout::Error.
Real-World Impact
- False Sense of Security: Tests pass (or fail silently), leading developers to believe their error-handling paths (like retrying jobs or notifying users) are robust when they are actually untested.
- Fragile Error Handling: If the
rescueblock is never exercised in CI, a minor change to the exception class (e.g., changingErrno::ECONNREFUSEDto a more specific error) will break production without triggering a test failure. - Unreliable Retries: In this specific case, the failure to hit the
rescueblock means the background retry logic (unsent_doc) is never verified, potentially leading to lost data in production during an outage.
Example or Code
To fix this, you must instruct the stub to explicitly raise the error you want to test.
# WRONG: Trying to force a timeout via the test wrapper
assert_raises(Timeout::Error) do
Timeout.timeout(1) do
# This just executes the stub instantly; no error is raised
get :create, params: { txdoc_id: this_txdoc.id }
end
end
# RIGHT: Telling the stub to raise the specific error
# Using WebMock as an example
stub_request(:post, "https://www.example.co/ae/api.php")
.to_raise(Timeout::Error)
# Now the controller's rescue block will be executed
get :create, params: { txdoc_id: this_txdoc.id }
assert_equal "API connection failed. Retrying in background...", flash[:alert]
# Verify that the error handling side-effect occurred
assert_equal 'timeout_error', doc.reload.status
How Senior Engineers Fix It
A senior engineer approaches this by separating behavioral testing from environmental simulation:
- Identify the Goal: Is the goal to test that the timeout works or that the rescue block works? Usually, it is the latter.
- Explicit Exception Injection: Instead of trying to “wait out” a timer, we inject the exact exception (
Timeout::ErrororErrno::ECONNREFUSED) into the stub. This ensures the code path is hit instantly and deterministically. - Verify Side Effects: We don’t just check that an error was raised; we check that the consequences of the error occurred (e.g., a database record was updated, a job was enqueued, or a flash message was set).
- Test the Full Spectrum: We write separate tests for
Timeout::Error(slow network) andErrno::ECONNREFUSED(down server) to ensure therescueclause handles both.
Why Juniors Miss It
- The “Black Box” Fallacy: Juniors often treat stubs as “real” connections that just happen to be faster, rather than realizing stubs are total replacements of the underlying logic.
- Focus on the Symptom, Not the Flow: They focus on the symptom (the error not being caught) by trying to manipulate the test environment (
Timeout.timeout) instead of manipulating the mocked dependency. - Testing the Tool, Not the Code: They spend time trying to make
Timeout.timeoutwork within the test suite rather than understanding that therescueblock is the unit of logic being tested.