Let the fake times roll...
In my $dayjob at GetResponse I have to deal constantly with time dependent features. For example this email marketing platform allows you to use something called 'Time Travel', which is sending messages to your contacts at desired hour in their time zones. So people around the world can get email at 8:00, when they start their work and chance for those messages message being read are highest. No matter where they live.
But even such simple feature has more pitfalls that you can imagine. For example user has three contacts living in Europe/Warsaw, America/Phoenix and Australia/Sydney time zones.
The obvious validation is to exclude nonexistent days, for example user cannot select 2017-02-29 because 2017 is not a leap year. But what if he wants to send message at 2017-03-26 02:30:00? For America/Phoenix this is piece of cake - just 7 hours difference from UTC (or unix time). For Australia/Sydney things are bit more complicated because they use daylight saving time and this is their summer so additional time shift must be calculated. And for Europe/Warsaw this will fail miserably because they are just changing to summer time from 01:59:00 to 03:00:00 and 02:30 simply does not exist therefore some fallback algorithm should be used.
So for one date and time there are 3 different algorithms that have to be tested!
Unfortunately most of the time dependent code does not expose any interface to pass current time to emulate all edge cases, methods usually call time( ) or DateTime.now( ) internally. So let's test such blackbox - it takes desired date, time and time zone and it returns how many seconds are left before message should be sent.
package Timers; use DateTime; sub seconds_till_send { my $when = DateTime->new( @_ )->epoch( ); my $now = time( ); return ( $when > $now ) ? $when - $now : 0; }
Output of this method changes in time. To test it in consistent manner we must override system time( ) call:
#!/usr/bin/env perl use strict; use warnings; BEGIN { *CORE::GLOBAL::time = sub () { $::time_mock // CORE::time }; } use Timers; use Test::More; # 2017-03-22 00:00:00 UTC $::time_mock = 1490140800; is Timers::seconds_till_send( 'year' => 2017, 'month' => 3, 'day' => 26, 'hour' => 2, 'minute' =>30, 'time_zone' => 'America/Phoenix' ), 379800, 'America/Phoenix time zone';
Works like a charm! We have consistent test that pretends our program is ran at 2017-03-22 00:00:00 UTC and that means there are 4 days, 9 hours and 30 minutes till 2017-03-26 02:30:00 in America Phoenix.
We can also test DST case in Australia.
# 2017-03-25 16:00:00 UTC $::time_mock = 1490457600; is Timers::seconds_till_send( 'year' => 2017, 'month' => 3, 'day' => 26, 'hour' => 2, 'minute' =>30, 'time_zone' => 'Australia/Sydney' ), 0, 'America/Phoenix time zone';
Because during DST Sydney has +11 hours from UTC instead of 10 that means when we run our program at 2017-03-25 16:00:00 UTC requested hour already passed there and message should be sent instantly. Great!
But what about nonexistent hour in Europe/Warsaw? We need to fix this method to return some useful values in DWIM-ness spirit instead of crashing. And I haven't told you whole, scarry truth yet, because we have to solve two issues at once here. First is nonexistent hour - in this case we want to calculate seconds to nearest possible hour after requested one - so 03:00 Europe/Warsaw should be used if 02:30 Europe/Warsaw does not exist. Second is ambiguous hour that happens when clocks are moved backwards and for example 2017-10-29 02:30 Europe/Warsaw occurs twice during this day - in this case first hour occurrence should be taken - so if 02:30 Europe/Warsaw is both at 00:30 UTC and 01:30 UTC seconds are calculated to the former one. Yuck...
For simplicity let's assume user cannot schedule message more than one year ahead, so only one time change related to DST will take place. With that assumption fix may look like this:
sub seconds_till_send { my %params = @_; my $when; # expect ambiguous hour during summer to winter time change if (DateTime->now( 'time_zone' => $params{'time_zone'} )->is_dst) { # attempt to create ambiguous hour is safe # and will always point to latest hour $when = DateTime->new( %params ); # was the same hour one hour ago? my $tmp = $when->clone; $tmp->subtract( 'hours' => 1 ); # if so, correct to earliest hour if ($when->hms eq $tmp->hms) { $when = $when->epoch - 3600; } else { $when = $when->epoch; } } # expect nonexistent hour during winter to summer time change else { do { # attempt to create nonexistent hour will die $when = eval { DateTime->new( %params )->epoch( ) }; # try next minute maybe... if ( ++$params{'minute'} > 59 ) { $params{'minute'} = 0; $params{'hour'}++; } } until defined $when; } my $now = time( ); return ( $when > $now ) ? $when - $now : 0; }
If your eyes are bleeding here is TL;DR. First we have to determine which case we may encounter by checking if there is currently DST in requested time zone or not. For nonexistent hour we try to brute force it into next possible time by adding one minute periods and adjusting hours when minutes overflow. There is no need to adjust days because DST never happens on date change. For ambiguous hour we check if by subtracting one hour we get the same hour (yep). If so we have to correct unix timestamp to get earliest one.
But what about our tests? Can we still write it in deterministic and reproducible way? Luckily it occurs that DateTime->now( ) uses time( ) internally so no additional hacks are needed.
# 2017-03-26 00:00:00 UTC $::time_mock = 1490486400; is Timers::seconds_till_send( 'year' => 2017, 'month' => 3, 'day' => 26, 'hour' => 2, 'minute' => 30, 'time_zone' => 'Europe/Warsaw' ), 3600, 'Europe/Warsaw time zone nonexistent hour';
Which is expected result, 02:30 is not available in Europe/Warsaw so 03:00 is taken that is already in DST season and 2 hours ahead of UTC.
Now let's solve leap seconds issue where because of Moon slowing down Earth and causing it to run on irregular orbit you may encounter 23:59:60 hour every few years. OK, OK, I'm just kidding :) However in good tests you should also take leap seconds into account if needed!
I hope you learned from this post how to fake time in tests to cover weird edge cases.
Before you leave I have 3 more things to share:
- Dave Rolsky, maintainer of DateTime module does tremendous job. This module is a life saver. Thanks!
- Overwrite CORE::time before loading any module that calls time( ). If you do it this way
use DateTime; BEGIN { *CORE::GLOBAL::time = sub () { $::time_mock // CORE::time }; } $::time_mock = 123; say DateTime->now( );
it won't have any effect due to sub folding.
- Remember to include empty time signature
BEGIN { # no empty signature *CORE::GLOBAL::time = sub { $::time_mock // CORE::time }; } $::time_mock = 123; # parsed as time( + 10 ) # $y = 123, not what you expected ! my $y = time + 10;
because you cannot guarantee someone always used parenthesis when calling time( ).
Leave a comment