moment.js mock local() so unit test runs consistently
Asked Answered
B

3

6

I want to test the following piece of code. I am wondering if there is a way to mock moment.js or force it to think my current location is America/New_York so that my unit test doesn't fail in gitlab.ci runner which may be in various geographical locations?

  const centralTimeStartOfDay = moment.tz('America/Chicago').startOf('day');
  const startHour = centralTimeStartOfDay
    .hour(7)
    .local()
    .hour();
    

Basically I want to hard code my timezone to be America/New_York and want this function to behave consistently.

Edit:

I tried:

  1. Date.now = () => new Date("2020-06-21T12:21:27-04:00")
  2. moment.tz.setDefault('America/New_York')

And still, I get the same result. I want to mock the current time so startHour returns a consistent value.

Belletrist answered 19/6, 2020 at 18:59 Comment(4)
I have tried moment.tz.setDefault('America/New_York');, it doesn't workBelletrist
Maybe How to mock moment.utc() for unit tests? can help you.Rainband
Is this a CRA project?Sabotage
Did my answer solve your problem?Copperhead
E
13

The problem

So there is no one line answer to this question. The problem is a fundamental one to javascript, where you can see dates in one of two ways:

  1. UTC (getUTCHours(), getUTCMinutes() etc.)
  2. local (i.e. system, getHours(), getMinutes() etc.)

And there is no specified way to set the effective system timezone, or even the UTC offset for that matter.

(Scan through the mdn Date reference or checkout the spec to get a feeling for just how unhelpful this all is.)

"But wait!" we cry, "isn't that why moment-timezone exists??"

Not exactly. moment and moment-timezone give much better / easier control over managing times in javascript, but even they have no way to know what the local timezone Date is using, and use other mechanisms to learn that. And this is a problem as follows.

Once you've got your head round the code you'll see that the moment .local() method (prototype declaration and implementation of setOffsetToLocal) of moment effectively does the following:

  • sets the UTC offset of the moment to 0
  • disables "UTC mode" by setting _isUTC to false.

The effect of disabling "UTC mode" is to mean that the majority of accessor methods are forwarded to the underlying Date object. E.g. .hours() eventually calls moment/get-set.js get() which looks like this:

export function get(mom, unit) {
    return mom.isValid()
        ? mom._d['get' + (mom._isUTC ? 'UTC' : '') + unit]()
        : NaN;
}

_d is the Date object that the moment (mom) is wrapping. So effectively for a non-UTC mode moment, moment.hours() is a passthrough to Date.prototype.getHours(). It doesn't matter what you've set with moment.tz.setDefault(), or if you've overridden Date.now(). Neither of those things are used.

Another thing...

You said:

Basically I want to hard code my time to be America/New_York and want this function behaves consistently

But actually, that is not generally possible. You are using Chicago, which I imagine has offset shifts in sync with New York, but e.g. the UK shifts at a different time from the US, so there are going to be weeks in the year where your test would fail if you were converting from a US timezone to a UK timezone.

The solutions.

But this is still frustrating, because I don't want my devs in Poland and the west coast of America to have breaking local tests because my CI server is running in UTC. So what can we do about it?

The first solution is a not-a-solution: find a different way of doing the thing you're doing! Generally the use cases for using .local() are quite limited, and are to display to a user the time in their current offset. It's not even their timezone because the local Date methods will only look at the current offset. So most of the time you'd only want to use it for the current time, or if you don't mind if it's wrong for half of the Date objects you use it for (for timezones using daylight savings). It could well be better to learn the timezone the user wants through other means, and not use .local() at all.

The second solution is also a not-a-solution: don't worry about your tests so much! The main thing with displaying a local time is that it works, you don't really care what it is exactly. Verify manually that it's displaying the correct time, and in your tests just verify that it returns a reasonable looking thing, without checking the specific time.

If you still want to proceed, this last solution at least makes your case work and a few others, and it's obvious what you need to do if you find you need to extend it. However, it's a complicated area and I make no guarantees that this will not have some unintended side-effects!

In your test setup file:

[
  'Date',
  'Day',
  'FullYear',
  'Hours',
  'Minutes',
  'Month',
  'Seconds',
].forEach(
  (prop) => {
    Date.prototype[`get${prop}`] = function () {
      return new Date(
        this.getTime()
        + moment(this.getTime()).utcOffset() * 60000
      )[`getUTC${prop}`]();
    };
  }
);

You should now be able to use moment.tz.setDefault() and using .local() should allow you to access the properties of the datetime as though it thought the local timezone was as configured in moment-timezone.

I thought about trying to patch moment instead, but it is a much more complicated beast than Date, and patching Date should be robust since it is the primitive.

Endurance answered 27/6, 2020 at 10:13 Comment(1)
Amazing answer. This could be a blog post! Thank you. I decided to go with the second solution: "Test only that it is returning a reasonable thing, not the exact time".Platus
A
0

try

// package.json
{
  "scripts": {
    "test": "TZ=EST jest"
  }
}
Alethiaaletta answered 16/6, 2021 at 21:20 Comment(0)
U
0

Brilliant daphtdazz - thank you! To clarify for those who follow, this is the full solution I used to control the current date, timezone, and with daphtdazz's help - the local() behavior in moment:

import MockDate from 'mockdate';

const date = new Date('2000-01-01T02:00:00.000+02:00');
MockDate.set(date);

[
    'Date',
    'Day',
    'FullYear',
    'Hours',
    'Minutes',
    'Month',
    'Seconds',
].forEach(
    (prop) => {
        Date.prototype[`get${prop}`] = function () {
            return new Date(
                this.getTime()
                + moment(this.getTime()).utcOffset() * 60000
            )[`getUTC${prop}`]();
        };
    }
);

const moment = require.requireActual('moment-timezone');
jest.doMock('moment', () => {
    moment.tz.setDefault('Africa/Maputo');
    return moment;
});
Umpire answered 7/2, 2023 at 3:48 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.