-
Notifications
You must be signed in to change notification settings - Fork 15
Add Clock Abstraction for Deterministic Testing #169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
@aaronbrethorst could you review this PR and share your thoughts |
|
@Satyam709 will do, this is really cool! |
|
I’ve fixed a few more tests failing for the same reason |
aaronbrethorst
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for putting this together! This is a well-motivated PR that addresses a real pain point with flaky tests caused by time-dependent logic. The Clock interface design is clean, the documentation in the PR description is thorough, and the test coverage for the clock package is comprehensive.
I found a few issues we'll need to address before merging.
Issues to Fix
1. MockClock is not thread-safe
In internal/clock/clock.go, the MockClock struct has a public CurrentTime field and the Set/Advance methods don't use any synchronization:
type MockClock struct {
CurrentTime time.Time
}
func (m *MockClock) Set(t time.Time) {
m.CurrentTime = t
}
func (m *MockClock) Advance(d time.Duration) {
m.CurrentTime = m.CurrentTime.Add(d)
}What needs to change:
- If MockClock is used in concurrent tests (e.g., parallel subtests), this could cause data races
- Consider either adding a mutex, or documenting that MockClock is not safe for concurrent use
- Since the field is public, users could also access it directly causing races with
Now()
2. Custom trimWhitespace instead of strings.TrimSpace
In internal/clock/clock.go, there's a custom implementation:
func trimWhitespace(s string) string {
// Simple trim for newlines and spaces
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t' || s[0] == '\n' || s[0] == '\r') {
s = s[1:]
}
// ...
}What needs to change:
- Replace with
strings.TrimSpace(s)which is more robust and handles all Unicode whitespace - This reduces code to maintain and follows Go conventions
3. Potential semantic change in routes_for_location_handler.go
In internal/restapi/routes_for_location_handler.go, the behavior has changed:
// Before:
stops := api.GtfsManager.GetStopsForLocation(ctx, ..., time.Time{})
// After:
stops := api.GtfsManager.GetStopsForLocation(ctx, ..., api.Clock.Now())What needs to change:
- Verify this is intentional - passing a zero
time.Time{}vs actual current time may have different semantics inGetStopsForLocation - If the zero value meant "don't filter by time", this could change behavior
- If intentional, this is fine but worth confirming
4. EnvironmentClock mutex may not be necessary
In internal/clock/clock.go, the mutex protects os.Getenv and os.ReadFile:
func (e *EnvironmentClock) syncFromEnvVar() (time.Time, error) {
e.mutex.Lock()
defer e.mutex.Unlock()
// os.Getenv is already thread-safe
timeStr := os.Getenv(e.envVar)
// ...
}Suggestion:
os.Getenvandos.ReadFileare already thread-safe- The mutex could be removed unless there's a specific reason for serializing calls
- If keeping it, consider whether holding the lock during file I/O could cause unnecessary contention
5. EnvironmentClock doesn't handle nil location
In internal/clock/clock.go, if location is nil, parseTime will panic:
func (e *EnvironmentClock) parseTime(s string) (time.Time, error) {
// ...
if t, err := time.ParseInLocation(format, s, e.location); err == nil { // panics if e.location is nil
return t, nil
}
}What needs to change:
- Either document that
locationmust not be nil, or - Default to
time.Localortime.UTCif nil is passed
Suggestions (Non-blocking)
Consider making MockClock.CurrentTime private
Making the field private and only exposing it via methods would give you more control over thread-safety and prevent direct field access:
type MockClock struct {
currentTime time.Time
}Add a test verifying the specific date fix
Since the PR specifically mentions fixing TestStopsForLocationHandlerEndToEnd failing on Dec 7, 14, 21, 25, consider adding a comment in the test explaining why the chosen date (2025-12-26) was selected, to help future maintainers understand the context.
This is a valuable addition to the codebase! The core design is solid. Once the thread-safety and minor issues are addressed, this will be ready to merge. The change to enable deterministic testing is well worth the refactoring effort across the handlers.
Add Clock Abstraction for Deterministic Testing
Introduces a new Clock API for deterministic logic testing and validation.
Problem
Several endpoints and calculations in the codebase are time-dependent e.g. active stops filtering, arrivals/departures, schedule lookups, trip details, etc.
The challenge comes in testing this logic deterministically. Tests that rely on GTFS schedule data behave differently depending on when they run passing on weekdays but failing on weekends or holidays when transit services don't operate.
Example
TestStopsForLocationHandlerEndToEndfails on several days (e.g., Dec 7, 14, 21, 25) while passing on others. The test queries stops near a location, but stops are filtered based on whether they have active service on the current date:This PR fixes this for
TestStopsForLocationEndToEndand provides the foundation to fix similar issues across the codebase.Solution
Clock Interface
Implementations
RealClocktime.Now()MockClockEnvironmentClockUsage
RealClock (Production)
MockClock (Unit Tests)
Test Example:
EnvironmentClock (Integration Tests)
Reads time from external sources, useful for integration tests where time can be controlled by environment without code changes.
In Handlers
In Response Models
Advantages