
Beyond travel_to: The Block-Scoped State Pattern Hidden in Rails Testing
August 12, 2026
Rails developers often use travel_to without thinking much about how it works.
travel_to Time.zone.parse("2026-08-12 10:00") do # Test code runs as if it were 10:00end
The API is simple: change the perceived time, run some code, and automatically return to the original state.
But travel_to is an example of a broader pattern that appears throughout Rails testing:
Temporarily change some state, execute a block, and guarantee that the original state is restored.
Once you recognize that pattern, several Rails testing APIs become easier to understand—and it also becomes a useful pattern for designing your own test helpers.
The basic pattern
At its simplest, the implementation looks like this:
def with_something(value) old_value = current_value change_value(value) yieldensure restore_value(old_value)end
The important part is not yield itself.
It is the combination of:
- Capturing the existing state.
- Applying temporary state.
- Executing the block.
- Using ensure to restore the previous state.
The ensure clause is critical because restoration must happen even when the test raises an exception.
Without it, one test could leave global or shared state modified and cause subsequent tests to fail in confusing ways.
travel_to: scoped time
The most familiar example is ActiveSupport::Testing::TimeHelpers.
travel_to Time.zone.parse("2026-08-12 10:00") do assert_equal "2026-08-12 10:00", Time.current.to_send
Inside the block, Rails replaces the behavior used to obtain the current time.
When the block finishes, Rails restores the previous time behavior.
This makes tests deterministic without requiring application code to know that it is being tested.
For example:
travel_to Time.zone.parse("2026-12-31 23:59") do assert user.subscription.expired?end
The test describes the condition it cares about instead of manipulating clocks manually.
travel: the relative version
travel provides the same scoped mechanism but expresses the change relative to the current time.
travel 2.days do assert user.subscription.expired?end
Conceptually, it is a convenience around travel_to.
Instead of saying:
travel_to Time.current + 2.days do # ...end
you can say:
travel 2.days do # ...end
The important observation is that these APIs are not independent implementations of time manipulation.
They form a small abstraction hierarchy around the same underlying mechanism.
freeze_time: another interface to the same idea
freeze_time is useful when the test needs time to remain constant.
freeze_time do created_at = Time.current perform_operation assert_equal created_at, Time.currentend
The clock does not advance while the block executes.
Again, the interesting part is the scope:
before ↓freeze time ↓execute test ↓restore time ↓after
The block defines the lifetime of the altered state.
travel_back: temporarily removing the alteration
travel_back is slightly different.
Suppose time has already been changed:
travel_to some_time do # Time is mocked here travel_back do # Real time is available here end # The previous travel_to state is restored hereend
This is another variation of the same scoped-state idea.
It temporarily changes the testing environment again, executes a block, and restores the previous testing state afterward.
The more interesting example: stub_const
The pattern becomes more obvious when we leave time manipulation behind.
Rails also provides ActiveSupport::Testing::ConstantStubbing.
For example:
stub_const(MyService, :TIMEOUT, 1) do assert_equal 1, MyService::TIMEOUTend
The constant is temporarily replaced for the duration of the block.
Afterward, the original constant is restored.
Conceptually:
old_value = MyService::TIMEOUTMyService::TIMEOUT = 1begin yieldensure MyService::TIMEOUT = old_valueend
This is remarkably similar to the mental model behind travel_to.
The state being modified is different, but the lifecycle is the same:
save ↓modify ↓yield ↓ensure ↓restore
This is arguably the more interesting connection between the APIs.
Blocks are also used differently in assertions
Not every Rails testing helper that accepts a block is doing temporary state management.
Consider:
assert_difference("User.count") do User.create!end
Here the block represents the operation being tested.
Rails evaluates the expression before the block, executes the block, and evaluates the expression again afterward.
The purpose is not to establish a temporary context.
It is to observe the effect of an operation.
The conceptual model is therefore:
measure ↓execute ↓measure again ↓assert difference
This distinction is important.
Both APIs use blocks, but they use them for different reasons.
Scoped state
travel_to(...)stub_const(...)
The block defines the lifetime of temporary state.
Observation
assert_difference(...)assert_changes(...)
The block defines the operation whose effects are being measured.
Notifications use the same block-oriented testing style
Rails’ notification assertions provide another example.
For instance:
assert_notification("user.created") do UserCreator.call(user)end
The block represents the code whose notifications Rails should observe.
Internally, the notification capture itself is scoped to the block.
This gives us another useful pattern:
subscribe ↓yield ↓unsubscribe ↓assert
So Rails uses blocks for several related forms of test scoping.
Three patterns hiding behind Rails test helpers
Looking at these APIs together reveals three useful categories.
1. Temporary state
travel_to(...)stub_const(...)
The helper changes the environment and restores it afterward.
save → modify → yield → restore
2. Observation
assert_difference(...)assert_changes(...)
The helper measures what happens around the block.
measure → yield → measure → assert
3. Capture
capture_notifications(...)
The helper captures events generated while the block executes.
start capture → yield → stop capture → return result
The common denominator is the block.
The block becomes a scope boundary.
Why ensure matters
When implementing this pattern yourself, the most important Ruby feature is not yield.
It is ensure.
Consider this naive implementation:
def temporarily_change(value) old_value = current_value change_value(value) yield restore_value(old_value)end
This fails if the block raises:
temporarily_change(:test) do raise "boom"end
restore_value is never reached.
The safer implementation is:
def temporarily_change(value) old_value = current_value change_value(value) yieldensure restore_value(old_value)end
Now restoration occurs whether the block succeeds or raises.
That makes the temporary state truly scoped to the block.
Building your own helper
This pattern is useful beyond Rails internals.
Suppose an application has a global feature mode that needs to be temporarily changed during a test.
A custom helper could follow the same structure:
def with_feature_mode(mode) previous_mode = Feature.mode Feature.mode = mode yieldensure Feature.mode = previous_modeend
The test becomes:
with_feature_mode(:beta) do assert feature_available?end
The caller does not need to remember cleanup.
That is the important API-design benefit.
Instead of this:
Feature.mode = :beta# testFeature.mode = :normal
the scope is encoded directly into the API:
with_feature_mode(:beta) do # testend
Why this is a good testing abstraction
A well-designed block-scoped helper gives the caller three guarantees:
Isolation
The temporary state exists only inside the block.
Automatic cleanup
The caller does not have to remember to restore state.
Exception safety
Cleanup happens even when the block raises.
That combination makes block-scoped APIs particularly well suited to testing.
The larger Ruby lesson
travel_to looks like a specialized time-testing API.
It is.
But it is also an example of a much more general Ruby technique:
Use a block to define the lifetime of temporary state.
Rails applies this idea to clocks, constants, assertions, and notification capture.
Once you see the pattern, many Rails testing APIs stop looking like unrelated conveniences.
They become variations on a common design:
setup temporary contextbegin yieldensure restore contexten
And that is perhaps the most reusable lesson hidden behind travel_to.
Final takeaway
The interesting thing about travel_to isn’t that Rails can fake the current time.
It is that Rails provides a clean API for scoping a temporary change to a block.
travel_to, travel, freeze_time, travel_back, and stub_const demonstrate this particularly well.
The pattern is small, but powerful:
old_state = statechange_stateyieldensure restore_state(old_state)
If you are writing Rails tests or designing your own testing helpers this is a pattern worth recognizing.
The block is not merely syntax.
The block is the boundary of the temporary state.