【JavaScript】Date オブジェクトのタイムゾーンを差し替えてテストする

## Date が絡んだコードのテスト

JavaScript の [Date](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Date) オブジェクトは [getFullYear()](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Date/getFullYear) や [getHours()](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Date/getHours) など、地方時に基づいた値を返すメソッドを持っています。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Date

このようなメソッドを利用した処理のテストは少々厄介です。

次のような Date をフォーマットする関数について考えてみます。

```javascript
/**
 * 日付を YYYY-MM-DDTHH:mm:ssZ の形式でフォーマットした文字列を返す。
 * @param {Date} date フォーマットする日時
 * @returns {string} フォーマットされた文字列
 */
function formatTimezone(date) {
  const year = date.getFullYear().toString().padStart(4, "0");
  const month = (date.getMonth() + 1).toString().padStart(2, "0");
  const day = date.getDate().toString().padStart(2, "0");
  const hour = date.getHours().toString().padStart(2, "0");
  const minute = date.getMinutes().toString().padStart(2, "0");
  const second = date.getSeconds().toString().padStart(2, "0");

  const tz = (() => {
    const offset = date.getTimezoneOffset();
    const tzHour = Math.floor(Math.abs(offset) / 60)
      .toString()
      .padStart(2, "0");
    const tzMinute = (Math.abs(offset) % 60).toString().padStart(2, "0");
    const tzSign = offset > 0 ? "-" : "+";
    return `${tzSign}${tzHour}:${tzMinute}`;
  })();

  return `${year}-${month}-${day}T${hour}:${minute}:${second}${tz}`;
}
```

この関数が複数のタイムゾーンにおいて正しく動作することをテストしたい場合、どのようにテストを書けばよいでしょうか。

例えば [Jest](https://jestjs.io/) の場合、テストコード中で環境変数 `TZ` を変更するようなアプローチではうまくいきません。

```javascript
/**
 * 環境変数 TZ を一時的に上書きする。
 * @param {string} tz 上書きする値
 * @returns {function(): void} 上書きした変数を元に戻す関数
 */
function setTZ(tz) {
  const tmp = process.env.TZ;
  process.env.TZ = tz;
  return () => {
    process.env.TZ = tmp;
  };
}

describe("formatTimezone", () => {
  test("UTC", () => {
    const restore = setTZ("UTC");

    const date = new Date(Date.UTC(2022, 2, 5, 12, 0, 0));
    expect(formatTimezone(date)).toEqual("2022-03-05T12:00:00+00:00");

    restore();
  });

  test("JST", () => {
    const restore = setTZ("Asia/Tokyo");

    const date = new Date(Date.UTC(2022, 2, 5, 12, 0, 0));
    expect(formatTimezone(date)).toEqual("2022-03-05T12:00:00+00:00");

    restore();
  });
});
```

これは、Jest にはテストから `process.env` を変更することができないという仕様があることに起因しています。

https://github.com/facebook/jest/issues/9264

他のテストフレームワークの場合はうまくいくこともあるかもしれませんが、そもそも環境変数 `TZ` を変更するというアプローチ自体が Node.js のバージョンや OS によってはうまくいかないことがあるようなので避けた方が無難でしょう。
このあたりについては次の記事がとてもわかりやすいです。

https://zenn.dev/dora1998/articles/node-process-env-tz

## Date を mock する

[Jest](https://jestjs.io/) の場合は [jest.spyOn()](https://jestjs.io/docs/jest-object#jestspyonobject-methodname) を使うことで Date オブジェクトのメソッドを mock することができます。

```javascript
describe("formatTimezone", () => {
  test("UTC", () => {
    const mockGetFullYear = jest.spyOn(Date.prototype, "getFullYear");
    mockGetFullYear.mockImplementation(() => 2022);
    const mockGetMonth = jest.spyOn(Date.prototype, "getMonth");
    mockGetMonth.mockImplementation(() => 2);
    const mockGetDate = jest.spyOn(Date.prototype, "getDate");
    mockGetDate.mockImplementation(() => 5);
    const mockGetHours = jest.spyOn(Date.prototype, "getHours");
    mockGetHours.mockImplementation(() => 12);
    const mockGetMinutes = jest.spyOn(Date.prototype, "getMinutes");
    mockGetMinutes.mockImplementation(() => 0);
    const mockGetSeconds = jest.spyOn(Date.prototype, "getSeconds");
    mockGetSeconds.mockImplementation(() => 0);
    const mockGetTimezoneOffset = jest.spyOn(
      Date.prototype,
      "getTimezoneOffset"
    );
    mockGetTimezoneOffset.mockImplementation(() => 0);

    const date = new Date(Date.UTC(2022, 2, 5, 12, 0, 0));
    expect(formatTimezone(date)).toEqual("2022-03-05T12:00:00+00:00");

    mockGetFullYear.mockRestore();
    mockGetMonth.mockRestore();
    mockGetDate.mockRestore();
    mockGetHours.mockRestore();
    mockGetMinutes.mockRestore();
    mockGetSeconds.mockRestore();
    mockGetTimezoneOffset.mockRestore();
  });

  test("JST", () => {
    // ...
  });
});
```

ただ、見ての通りかなりしんどい感じのコードになります。
また、Date オブジェクトを生で扱っている場合はまだ良いのですが、[Day.js](https://day.js.org/en/) のようなライブラリを使用している場合は実際にどのメソッドが使用されているかを簡単に知ることができません。

```javascript
/**
 * 日付を YYYY-MM-DDTHH:mm:ssZ の形式でフォーマットした文字列を返す。
 * @param {Date} date フォーマットする日時
 * @returns {string} フォーマットされた文字列
 */
function formatTimezone(date) {
  // 何を mock したらいいか分からない
  return dayjs(date).format("YYYY-MM-DDTHH:mm:ssZ");
}
```

## timezone-mock を使用する

[timezone-mock](https://github.com/Jimbly/timezone-mock) は Date オブジェクトを mock に差し替え、ローカルタイムゾーンを自由に変更できるようにするライブラリです。

https://github.com/Jimbly/timezone-mock

これにより次のようにテストを書くことができます。

```javascript
const timezoneMock = require("timezone-mock");

describe("formatTimezone", () => {
  test("UTC", () => {
    timezoneMock.register("UTC");

    const date = new Date(Date.UTC(2022, 2, 5, 12, 0, 0));
    expect(formatTimezone(date)).toEqual("2022-03-05T12:00:00+00:00");

    timezoneMock.unregister();
  });

  test("JST", () => {
    // JST には対応していないので GMT オフセットを利用する。
    timezoneMock.register("Etc/GMT-9");

    const date = new Date(Date.UTC(2022, 2, 5, 12, 0, 0));
    expect(formatTimezone(date)).toEqual("2022-03-05T21:00:00+09:00");

    timezoneMock.unregister();
  });
});
```

[Day.js](https://day.js.org/en/) のような Date オブジェクトを利用したライブラリを使用している場合でも同様にしてテスト可能です。

ただしコード中のコメントにもあるように [timezone-mock](https://github.com/Jimbly/timezone-mock) が持っているタイムゾーン情報は完全ではないため、対応していないタイムゾーンについては GMT オフセットを利用する必要があります。
これにより特に古い日付を扱おうとした場合などに、実際の Date オブジェクトとは異なる挙動になることがあります。

また、[timezone-mock](https://github.com/Jimbly/timezone-mock) が提供する Date オブジェクトの mock は残念ながら Date オブジェクトと 100% の互換性があるわけではなく、Date オブジェクトがサポートしている文字列フォーマットを parse できないことがあります。

```javascript
const timezoneMock = require("timezone-mock");

timezoneMock.register("Etc/GMT-9");
new Date("Fri, 26 Jul 2019 10:32:24 GMT");
// => AssertionError [ERR_ASSERTION]: Unhandled date format passed to MockDate constructor: Fri, 26 Jul 2019 10:32:24 GMT
```

https://github.com/Jimbly/timezone-mock/issues/24

そのためこのようなフォーマットを扱うテストで [timezone-mock](https://github.com/Jimbly/timezone-mock) を使うことはできません。
ただし、意図せぬところでこのエラーが発生してしまう場合は、次のようにエラー発生時に通常の Date オブジェクトにフォールバックするようにすることで回避できることがあります。

```javascript
const timezoneMock = require("timezone-mock");
const { _Date } = require("timezone-mock");

timezoneMock.options({ fallbackFn: (date) => new _Date(date) });

timezoneMock.register("Etc/GMT-9");
new Date("Fri, 26 Jul 2019 10:32:24 GMT");
// => エラーは発生しない
// (ただし通常の Date にフォールバックしているのでタイムゾーンは変更されない)
```

## Date オブジェクトを使うのをやめる

ECMAScript の proposal である [Temporal](https://tc39.es/proposal-temporal/docs/) であれば、時刻データにタイムゾーン情報を含めることができるためこのようなことで悩まなくなるかもしれません。

https://tc39.es/proposal-temporal/docs/#Temporal-ZonedDateTime