In a Minitest subscription-renewal suite, why can stubbing Time.now or rand still give wrong or flaky results, and when is an injected hand-written fake better?
answer
- Date.today does not call Time.now
- rand is private Kernel, called on self
- class-method stubs are process-wide
- mocks are not thread-safe
- inject clock: and random:
basics
~20 sTime.stub(:now) misses clocks that do not go through Time.now, and Kernel.stub(:rand) misses rand called on self; both stubs are process-wide and signature-blind. Injecting a clock and a Random, with a small hand-written fake, removes those gaps.
solid answer
~40 s`Time.stub(:now, t)` replaces only `Time.now`: `Date.today`, `Process.clock_gettime` and a timestamp captured before the block all keep the real clock, so a renewal test can pass for the wrong reason or fail around midnight. `rand` is a **private `Kernel` method** called on `self`, so `Kernel.stub(:rand, 7)` does nothing to code that just writes `rand(100)`; you must stub it on the object under test. Class-method stubs are also **process-wide** while the block runs, which interferes with tests running in other threads, and minitest-mock's own README says mocks do not support multi-threading. The durable fix is dependency injection: `RenewalService.new(clock: Time, random: Random.new)` in production, and in tests a hand-written `FakeClock` with `now` and `advance`, plus `Random.new(42)` for repeatable jitter. The fake is explicit and thread-local, and it breaks loudly if the interface changes.
code
ruby · 10 linesdef test_subscription_lapses_after_grace_period
clock = FakeClock.new(Time.new(2026, 10, 1))
service = RenewalService.new(clock: clock, random: Random.new(42))
sub = Subscription.new(renews_at: Time.new(2026, 10, 1))
assert service.due?(sub)
clock.advance(3 * 86_400)
assert service.lapsed?(sub)
assert_equal service.retry_delay, RenewalService.new(clock: clock, random: Random.new(42)).retry_delay
endgo deeper
Recall that Time.stub(:now) only changes Time.now, and that passing a clock into an object makes tests about time easier.
Explain why rand called on self ignores Kernel.stub, which clocks bypass Time.now, and how a seeded Random gives repeatable values.
Diagnose a renewal test that fails at midnight or only under concurrency, trace it to a global stub or a second clock, and refactor to injected fakes.
Weigh a codebase-wide convention of injected clocks and randomness against the churn of retrofitting constructors, and decide where stubs stay acceptable.
## The scenario A subscription-renewal job decides whether a subscription is due, and adds random jitter to the retry delay after a declined card: ```ruby class RenewalService def due?(sub) = sub.renews_at <= Time.now def retry_delay = 3_600 + rand(600) end ``` Tests that pin the clock with `Time.stub(:now, t)` and the jitter with a stub on `rand` look deterministic. Several gaps make them less so than they appear. ## Gap 1: not every clock is `Time.now` `Time.stub(:now, t)` replaces exactly one method on the `Time` class. Other ways of reading the time are untouched: - `Date.today` asks the operating system directly (the date extension calls C's `time()`), so a renewal rule written with dates sees the real day. - `Process.clock_gettime(Process::CLOCK_MONOTONIC)` is a separate clock for measuring durations. - A value captured **before** the block, in a constant, a memoised variable or `setup`, keeps the real time. The symptom is a test that passes on most days and fails near midnight, month end or a daylight-saving change. ## Gap 2: `rand` is not where you think In Ruby, `rand` is a private instance method of `Kernel`, and code that writes `rand(600)` calls it on `self`. So: | stub | effect on `service.retry_delay` | |---|---| | `Kernel.stub(:rand, 7)` | none: that replaces the module-level `Kernel.rand` only | | `Random.stub(:rand, 7)` | none: `Random.rand` is a different method | | `service.stub(:rand, 7)` | works: the call resolves on that object's singleton class | Stubbing on the object under test works but couples the test to an implementation detail: switching the code to `Random.rand` or `SecureRandom` silently bypasses the stub. ## Gap 3: process-wide and signature-blind - A stub on a **class method** (`Time.now`) is visible to every thread during the block. If tests run concurrently, one test's frozen clock shows up in another test's code. - minitest-mock's README states that its mocks **do not support multi-threading**. - `stub` does not check the real method's signature. If `Time.now(in: "UTC")` or a renamed method is involved, the stub can accept calls the real code would not make. ## The alternative: inject, then fake Make the dependencies arguments with production defaults, and write the smallest fake that satisfies them: ```ruby class RenewalService def initialize(clock: Time, random: Random.new) @clock, @random = clock, random end def due?(sub) = sub.renews_at <= @clock.now def retry_delay = 3_600 + @random.rand(600) end class FakeClock attr_reader :now def initialize(now) @now = now end def advance(seconds) @now += seconds end end ``` What this buys: 1. **Explicit dependencies.** The constructor lists the clock and randomness, so reviewers see them. 2. **No global state.** Each test builds its own `FakeClock`; nothing is patched, so concurrent tests cannot interfere. 3. **Time travel.** `clock.advance(86_400 * 3)` walks a subscription through its grace period in one test. 4. **Repeatable randomness.** `Random.new(42)` produces the same sequence every run, without stubbing anything. 5. **Loud drift.** If the service starts calling `@clock.today`, the fake raises `NoMethodError` instead of silently using the real clock. ## A checklist for a time-dependent test - Grep the code under test for every clock it reads: `Time.now`, `Time.new` with no arguments, `Date.today`, `Process.clock_gettime`. - Look for times captured at load time in constants or memoised variables. - Choose fixed instants that avoid midnight, month ends and daylight-saving changes unless the test is about them, and then name that in the test. - Replace `rand` calls in owned code with an injected `Random`, seeded in tests. - Run the test once with a different `TZ` environment variable to catch rules that silently depend on the machine's local time zone. ## When stubbing is still fine `Time.stub(:now)` remains reasonable at edges you do not own, such as a third-party helper that reads `Time.now`, and in short, single-threaded tests. The judgement is where the dependency lives: if you own the code, inject it; if you do not, stub narrowly and keep the block small.
- In Ruby, why does Kernel.stub(:rand, 7) leave rand(600) inside a service method unchanged?`rand` there is a private instance method inherited from `Kernel` and called on `self`, the service object. `Kernel.stub(:rand, 7)` replaces `Kernel.rand`, the module function on `Kernel`'s singleton class, which that call never reaches. Stubbing `service.stub(:rand, 7)` works, but injecting a `Random` is sturdier.
- With minitest-mock, why can Time.stub(:now, t) make a parallel test suite flaky?It replaces `now` on `Time`'s singleton class, which is shared by every thread in the process. While one test's block runs, another test running concurrently also sees the frozen time. An injected fake clock belongs to one test object, so it cannot leak.
saying these in an interview costs you the question
- Stubbing Time.now also freezes Date.today.
- Kernel.stub(:rand, 7) controls every rand call in the program.
- A Time.now stub affects only the thread that created it.
- Hand-written fakes are always worse because they need maintenance.
- Seeding Random requires stubbing rand.