[Jest] グローバル変数を扱うモジュールの単体テストをしたい

2023.02.11

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、CX事業本部 Delivery部の若槻です。

前回のエントリで、再利用したいデータをAWSLambdaのグローバル変数でキャッシュするhandlerコードを紹介しました。

その後そのhandlerコードの単体テストをJestで作成しようとしたのですが、テスト対象モジュールの外にあるグローバル変数をモックさせたい場合には工夫が必要だったため、方法を書き残しておきます。

グローバル変数をモックしない場合、テストが期待通り動かない

前回のhandlerコードを少し改変した以下のコードのテストを作成してみます。処理内容は同じですが、テストのしやすさためにAWS SDKによるGetParameter処理を別関数に分けています。

lib/cdk-sample-app.nyaoFunc.ts

import { SSMClient, GetParameterCommand, Parameter } from '@aws-sdk/client-ssm';

export let HOGE_CACHE: string | undefined;

export const handler = async (): Promise<string> => {
  if (HOGE_CACHE !== undefined) {
    return HOGE_CACHE;
  }

  const parameter = await getParameter();

  if (parameter !== undefined && parameter!.Value !== undefined) {
    const hoge = parameter.Value;

    HOGE_CACHE = hoge;

    return hoge;
  }

  HOGE_CACHE = undefined;

  throw new Error('Parameter value is invalid.');
};

const ssmClient = new SSMClient({
  region: 'ap-northeast-1',
});

export const getParameter = async (): Promise<Parameter | undefined> => {
  const response = await ssmClient.send(
    new GetParameterCommand({ Name: 'hoge' })
  );

  return response.Parameter;
};

上記コードが期待通りの動作となるのかを確認するために、Jestで次のような3つの単体テストケースを作成してみました。

test/cdk-sample-app.nyaoFunc.test.ts

import { handler, getParameter, HOGE_CACHE } from '../lib/cdk-sample-app.nyaoFunc';

describe('キャッシュされていない場合', (): void => {
  describe('パラメーターがParameter Storeから取得できた場合', (): void => {
    test('グローバル変数にキャッシュされ、リターンされる', async (): Promise<void> => {
      (getParameter as jest.Mock) = jest
        .fn()
        .mockReturnValue({ Value: 'あああ' });

      expect(HOGE_CACHE).toBeUndefined();

      const response = await handler();

      expect(getParameter).toBeCalledTimes(1);

      expect(response).toBe('あああ');

      expect(HOGE_CACHE).toBe('あああ');
    });
  });

  describe('パラメーターがParameter Storeから取得できなかった場合', (): void => {
    test('グローバル変数からキャッシュが削除され、エラー終了する', async (): Promise<void> => {
      (getParameter as jest.Mock) = jest
        .fn()
        .mockReturnValue({ Value: undefined });

      expect(HOGE_CACHE).toBeUndefined();

      await expect(() => handler()).rejects.toThrow(
        'Parameter value is invalid.'
      );

      expect(getParameter).toBeCalledTimes(1);

      expect(HOGE_CACHE).toBeUndefined();
    });
  });
});

describe('キャッシュされている場合', (): void => {
  test('パラメーターがグローバル変数から取得される', async (): Promise<void> => {
    (getParameter as jest.Mock) = jest
      .fn()
      .mockReturnValue({ Value: 'あああ' });

    const response = await handler();

    expect(getParameter).toBeCalledTimes(0);

    expect(response).toBe('いいい');

    expect(HOGE_CACHE).toBe('いいい');
  });
});

しかしテストを実行してみると、テスト毎にグローバル変数HOGEの値がundefinedにリセットされて欲しかったのですが、1つ目のテストケースでグローバル変数に格納されたあああが後続のテストケースで使い回されてしまっています。

$ npx jest test/cdk-sample-app.nyaoFunc.test.ts
FAIL test/cdk-sample-app.nyaoFunc.test.ts
  キャッシュされていない場合
    パラメーターがParameter Storeから取得できた場合
      ✓ グローバル変数にキャッシュされ、リターンされる (1 ms)
    パラメーターがParameter Storeから取得できなかった場合
      ✕ グローバル変数からキャッシュが削除され、エラー終了する
  キャッシュされている場合
    ✕ パラメーターがグローバル変数から取得される (2 ms)

  ● キャッシュされていない場合 › パラメーターがParameter Storeから取得できなかった場合 › グローバル変数からキャッシュが削除され、エラー終了する

    expect(received).toBeUndefined()

    Received: "あああ"

      30 |         .mockReturnValue({ Value: undefined });
      31 |
    > 32 |       expect(HOGE_CACHE).toBeUndefined();
         |                          ^
      33 |
      34 |       await expect(() => handler()).rejects.toThrow(
      35 |         'Parameter value is invalid.'

      at Object.<anonymous> (test/cdk-sample-app.nyaoFunc.test.ts:32:26)

  ● キャッシュされている場合 › パラメーターがグローバル変数から取得される

    expect(received).toBe(expected) // Object.is equality

    Expected: "いいい"
    Received: "あああ"

      53 |     expect(getParameter).toBeCalledTimes(0);
      54 |
    > 55 |     expect(response).toBe('いいい');
         |                      ^
      56 |
      57 |     expect(HOGE_CACHE).toBe('いいい');
      58 |   });

      at Object.<anonymous> (test/cdk-sample-app.nyaoFunc.test.ts:55:22)

Test Suites: 1 failed, 1 total
Tests:       2 failed, 1 passed, 3 total
Snapshots:   0 total
Time:        2.761 s, estimated 3 s
Ran all test suites matching /test\/cdk-sample-app.nyaoFunc.test.ts/i.

グローバル変数を操作する関数を設けてモックした場合、テストが期待通り動いた

そこでhandlerコードにグローバル変数から値を取得および設定する関数getHogeCachesetHogeCacheを設けて、handler内ではその関数を使用してキャッシュを操作するようにします。

lib/cdk-sample-app.nyaoFunc.ts

import { SSMClient, GetParameterCommand, Parameter } from '@aws-sdk/client-ssm';

export let HOGE_CACHE: string | undefined;
export const getHogeCache = (): string | undefined => HOGE_CACHE;
export const setHogeCache = (newParameter: string | undefined): void => {
  HOGE_CACHE = newParameter;
};

export const handler = async (): Promise<string> => {
  const hogeCache = getHogeCache();

  if (hogeCache !== undefined) {
    return hogeCache;
  }

  const parameter = await getParameter();

  if (parameter !== undefined && parameter!.Value !== undefined) {
    const hoge = parameter.Value;

    setHogeCache(hoge);

    return hoge;
  }

  setHogeCache(undefined);

  throw new Error('Parameter value is invalid.');
};

const ssmClient = new SSMClient({
  region: 'ap-northeast-1',
});

export const getParameter = async (): Promise<Parameter | undefined> => {
  const response = await ssmClient.send(
    new GetParameterCommand({ Name: 'hoge' })
  );

  return response.Parameter;
};

テストコード側ではgetHogeCachesetHogeCacheをモックします。

test/cdk-sample-app.nyaoFunc.test.ts

import {
  handler,
  getParameter,
  getHogeCache,
  setHogeCache,
} from '../lib/cdk-sample-app.nyaoFunc';

describe('キャッシュされていない場合', (): void => {
  describe('パラメーターがParameter Storeから取得できた場合', (): void => {
    test('グローバル変数にキャッシュされ、リターンされる', async (): Promise<void> => {
      (getParameter as jest.Mock) = jest
        .fn()
        .mockReturnValue({ Value: 'あああ' });
      (getHogeCache as jest.Mock) = jest.fn().mockReturnValue(undefined);
      (setHogeCache as jest.Mock) = jest.fn().mockReturnValue(undefined);

      const response = await handler();

      expect(getParameter).toBeCalledTimes(1);

      expect(response).toBe('あああ');

      expect(setHogeCache).toBeCalledTimes(1);
      expect(setHogeCache).toBeCalledWith('あああ');
    });
  });

  describe('パラメーターがParameter Storeから取得できなかった場合', (): void => {
    test('グローバル変数からキャッシュが削除され、エラー終了する', async (): Promise<void> => {
      (getParameter as jest.Mock) = jest
        .fn()
        .mockReturnValue({ Value: undefined });
      (getHogeCache as jest.Mock) = jest.fn().mockReturnValue(undefined);
      (setHogeCache as jest.Mock) = jest.fn().mockReturnValue(undefined);

      await expect(() => handler()).rejects.toThrow(
        'Parameter value is invalid.'
      );

      expect(getParameter).toBeCalledTimes(1);

      expect(setHogeCache).toBeCalledTimes(1);
      expect(setHogeCache).toBeCalledWith(undefined);
    });
  });
});

describe('キャッシュされている場合', (): void => {
  test('パラメーターがグローバル変数から取得される', async (): Promise<void> => {
    (getParameter as jest.Mock) = jest
      .fn()
      .mockReturnValue({ Value: 'あああ' });
    (getHogeCache as jest.Mock) = jest.fn().mockReturnValue('いいい');
    (setHogeCache as jest.Mock) = jest.fn().mockReturnValue(undefined);

    const response = await handler();

    expect(getParameter).toBeCalledTimes(0);

    expect(response).toBe('いいい');

    expect(setHogeCache).toBeCalledTimes(0);
  });
});

テストを実行すると、すべてPASSさせることができました。ちゃんとモックさせられているようです!

$ npx jest test/cdk-sample-app.nyaoFunc.test.ts
 PASS  test/cdk-sample-app.nyaoFunc.test.ts
  キャッシュされていない場合
    パラメーターがParameter Storeから取得できた場合
      ✓ グローバル変数にキャッシュされ、リターンされる (2 ms)
    パラメーターがParameter Storeから取得できなかった場合
      ✓ グローバル変数からキャッシュが削除され、エラー終了する (5 ms)
  キャッシュされている場合
    ✓ パラメーターがグローバル変数から取得される

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        2.984 s, estimated 3 s
Ran all test suites matching /test\/cdk-sample-app.nyaoFunc.test.ts/i.

念の為、修正後のhandlerコードをAWS Lambdaにデプロイして、前回作成したE2Eテストのコードを動かしてみると、こちらもPASSしました。ウォームスタート時に上手くキャッシュが効いているようです!

$ npx jest test/cdk-sample-app.nyaoFunc.e2e.test.ts
 PASS  test/cdk-sample-app.nyaoFunc.e2e.test.ts (10.813 s)
  ✓ コールドスタート (4204 ms)
  ✓ ウォームスタート (3130 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        10.875 s, estimated 11 s
Ran all test suites matching /test\/cdk-sample-app.nyaoFunc.e2e.test.ts/i.

おわりに

Jestでグローバル変数を扱うモジュールの単体テストをする方法を確認しました。

結論としては、グローバル変数を操作する関数を設けてモックするようにしましょう。

以上