Beyond travel_to: The Block-Scoped State Pattern Hidden in Rails Testing

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:00
end

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.


Tokyo Topographic Map
Built for Ruby on Rails

Build Maps Without
Google APIs

Generate beautiful production-ready maps directly from your Rails backend. Fast rendering, zero external dependencies, full control.

✓ No API fees ✓ Self-hosted ✓ Rails Native ✓ Fast Rendering
Why developers switch
Replace expensive map stacks.

Stop relying on third-party map billing and bloated JS libraries. Render static or dynamic maps directly in Ruby.

Try It Now
Tokyo MapView Demo

The basic pattern

At its simplest, the implementation looks like this:

def with_something(value)
old_value = current_value
change_value(value)
yield
ensure
restore_value(old_value)
end

The important part is not yield itself.

It is the combination of:

  1. Capturing the existing state.
  2. Applying temporary state.
  3. Executing the block.
  4. 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_s
end

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.current
end

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 here
end

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::TIMEOUT
end

The constant is temporarily replaced for the duration of the block.

Afterward, the original constant is restored.

Conceptually:

old_value = MyService::TIMEOUT
MyService::TIMEOUT = 1
begin
yield
ensure
MyService::TIMEOUT = old_value
end

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)
yield
ensure
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
yield
ensure
Feature.mode = previous_mode
end

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
# test
Feature.mode = :normal

the scope is encoded directly into the API:

with_feature_mode(:beta) do
# test
end

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 context
begin
yield
ensure
restore context
en

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 = state
change_state
yield
ensure
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.

Article content

Leave a comment