Date が絡んだコードのテスト
JavaScript の Date オブジェクトは getFullYear() や getHours() など、地方時に基づいた値を返すメソッドを持っています。
このようなメソッドを利用した処理のテストは少々厄介です。
次のような Date をフォーマットする関数について考えてみます。
/**
* 日付を 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 の場合、テストコード中で環境変数 TZ
を変更するようなアプローチではうまくいきません。
/**
* 環境変数 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
を変更することができないという仕様があることに起因しています。
他のテストフレームワークの場合はうまくいくこともあるかもしれませんが、そもそも環境変数 TZ
を変更するというアプローチ自体が Node.js のバージョンや OS によってはうまくいかないことがあるようなので避けた方が無難でしょう。
このあたりについては次の記事がとてもわかりやすいです。
Date を mock する
Jest の場合は jest.spyOn() を使うことで Date オブジェクトのメソッドを mock することができます。
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 のようなライブラリを使用している場合は実際にどのメソッドが使用されているかを簡単に知ることができません。
/**
* 日付を 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 は Date オブジェクトを mock に差し替え、ローカルタイムゾーンを自由に変更できるようにするライブラリです。
これにより次のようにテストを書くことができます。
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 のような Date オブジェクトを利用したライブラリを使用している場合でも同様にしてテスト可能です。
ただしコード中のコメントにもあるように timezone-mock が持っているタイムゾーン情報は完全ではないため、対応していないタイムゾーンについては GMT オフセットを利用する必要があります。
これにより特に古い日付を扱おうとした場合などに、実際の Date オブジェクトとは異なる挙動になることがあります。
また、timezone-mock が提供する Date オブジェクトの mock は残念ながら Date オブジェクトと 100% の互換性があるわけではなく、Date オブジェクトがサポートしている文字列フォーマットを parse できないことがあります。
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
そのためこのようなフォーマットを扱うテストで timezone-mock を使うことはできません。
ただし、意図せぬところでこのエラーが発生してしまう場合は、次のようにエラー発生時に通常の Date オブジェクトにフォールバックするようにすることで回避できることがあります。
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 であれば、時刻データにタイムゾーン情報を含めることができるためこのようなことで悩まなくなるかもしれません。