[Jest] 複数回呼び出される関数のn回目呼び出し時の引数をテストする(nthCalledWith)

2022.01.31

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

今回は、JavaScriptのテストフレームワークであるJestで、複数回呼び出される関数のn回目呼び出し時の引数をテストする実装をしてみました。

nthCalledWith()

Jestでテストコードを実装する際に、複数回呼び出される関数のテストを行いたい時がありました。

そこで当初はマッチャーとしてtoBeCalledWith()toHaveBeenLastCalledWith()を組み合わせることにより誤魔化して対処しようとしましたが、ドキュメントをちゃんと調べたところnthCalledWith()なるマッチャーを見つけました。

nthCalledWith()は、テスト内で複数回呼び出された関数のn回目の引数をテストすることができるマッチャーです。nthCalledWith()toHaveBeenNthCalledWith()の別名となりどちらを使用してもOKです。

使い方としては、下記のように第一引数に何回目の呼び出しかを、第二引数以降に関数の引数を指定します。これによりモックした関数のn回目の引数をテストすることができます。

test('drink は n 回目の呼び出しで期待した値を返す', () => {
  const beverage1 = {name: 'La Croix (レモン)'};
  const beverage2 = {name: 'La Croix (オレンジ)'};
  const drink = jest.fn(beverage => beverage.name);

  drink(beverage1);
  drink(beverage2);

  expect(drink).toHaveNthReturnedWith(1, 'La Croix (レモン)');
  expect(drink).toHaveNthReturnedWith(2, 'La Croix (オレンジ)');
});

使ってみた

テスト対象コード

次のようなsendBulkTemplatedEmailによりAmazon SESでメール一斉送信を行うコードをテスト対象としてみます。

sendBulkTemplatedEmailでは一回のAPI実行で指定可能な最大宛先数が50であるため、指定された宛先リストを50毎のチャンクに分割して、sendBulkTemplatedEmailを複数回叩くようにしています。

このチャンク分割の処理が想定通りに機能するか確認するために、sendBulkTemplatedEmailがn回目に叩かれた時の引数をテストしたいですね。

src/lambda/handlers/sendBulkMail.ts

import { SES } from 'aws-sdk';
import { v4 as uuidv4 } from 'uuid';

const SES_API_VERSION = '2012-10-08';
const REGION = 'ap-northeast-1';

export const sesClient = new SES({
  region: REGION,
  apiVersion: SES_API_VERSION,
});

interface SendBulkEmailRequestParameter {
  SubjectPart: string;
  TextPart: string;
  toAddresses: string[];
  fromAddress: string;
}

export const sendBulkEmail = async (
  sendBulkEmailRequestParameter: SendBulkEmailRequestParameter
): Promise<void> => {
  //メールテンプレート名をUUIDで作成(重複回避のため)
  const templateName = uuidv4();

  const destinations = sendBulkEmailRequestParameter.toAddresses.map((d) => ({
    Destination: {
      ToAddresses: [d],
    },
  }));

  //sendBulkTemplatedEmailを最大宛先数50毎のチャンクに分割
  const destinationChunks = arrayChunk(destinations, 50);

  //一斉送信用のメールテンプレート作成
  await sesClient
    .createTemplate({
      Template: {
        TemplateName: templateName,
        SubjectPart: sendBulkEmailRequestParameter.SubjectPart,
        TextPart: sendBulkEmailRequestParameter.TextPart,
      },
    })
    .promise();

  //チャンク毎にメールを一斉送信
  for (let i = 0; i < destinationChunks.length; i++) {
    await sesClient
      .sendBulkTemplatedEmail({
        Source: sendBulkEmailRequestParameter.fromAddress,
        Template: templateName,
        Destinations: destinationChunks[i],
        DefaultTemplateData: JSON.stringify({}),
      })
      .promise();
  }

  //一斉送信用のメールテンプレート削除
  await sesClient
    .deleteTemplate({
      TemplateName: templateName,
    })
    .promise();
};

//宛先リストを50毎のチャンクに分割する
const arrayChunk = (array: SES.BulkEmailDestinationList, size: number) => {
  const chunks = [];

  while (0 < array.length) {
    chunks.push(array.splice(0, size));
  }

  return chunks;
};

※ちなみにこのコードはこちらを参考にさせて頂きました!

テストコード

そこでこんな感じのテストコードを作成してみました。宛先が50個以下の場合(チャンク分割なし)と、宛先が50より多い場合(チャンク分割あり)の2パターンのテストをしています。

このうち宛先が50より多い場合のテストで、sendBulkTemplatedEmailの1回目の実行では最初の50個の宛先に対する一括送信、2回目の実行では残りの宛先に対する一括送信が行われていることをnthCalledWith()を使用して確認しています。(ハイライト部分)

test/sampleHandler.test.ts

import { v4 as uuidv4 } from 'uuid';

import { sesClient, sendBulkEmail } from '../src/lambda/handlers/sendBulkMail';

jest.mock('uuid');

describe('sendBulkEmail', () => {
  (sesClient.sendBulkTemplatedEmail as jest.Mock) = jest.fn().mockReturnValue({
    promise: jest.fn().mockResolvedValue(undefined),
  });
  (sesClient.createTemplate as jest.Mock) = jest.fn().mockReturnValue({
    promise: jest.fn().mockResolvedValue(undefined),
  });
  (sesClient.deleteTemplate as jest.Mock) = jest.fn().mockReturnValue({
    promise: jest.fn().mockResolvedValue(undefined),
  });
  (uuidv4 as jest.Mock).mockReturnValue('uuid-123');

  afterEach(() => {
    jest.clearAllMocks();
  });

  test('宛先が50個以下の場合', async (): Promise<void> => {
    const toAddresses = [];
    const destinations = [];
    for (let i = 0; i < 50; i++) {
      toAddresses.push(`to${i}@example.com`);
      destinations.push({
        Destination: {
          ToAddresses: [`to${i}@example.com`],
        },
      });
    }
    const response = await sendBulkEmail({
      SubjectPart: '抽選結果のご案内',
      TextPart: '抽選の結果、チケットをご用意することができませんでした。',
      toAddresses: toAddresses,
      fromAddress: 'system@example.com',
    });

    expect(toAddresses.length).toBe(50);

    expect(sesClient.createTemplate).toBeCalledTimes(1);
    expect(sesClient.createTemplate).toBeCalledWith({
      Template: {
        TemplateName: 'uuid-123',
        SubjectPart: '抽選結果のご案内',
        TextPart: '抽選の結果、チケットをご用意することができませんでした。',
      },
    });

    expect(sesClient.sendBulkTemplatedEmail).toBeCalledTimes(1);
    expect(sesClient.sendBulkTemplatedEmail).toBeCalledWith({
      Source: 'system@example.com',
      Template: 'uuid-123',
      Destinations: destinations,
      DefaultTemplateData: JSON.stringify({}),
    });

    expect(sesClient.createTemplate).toBeCalledTimes(1);
    expect(sesClient.deleteTemplate).toBeCalledWith({
      TemplateName: 'uuid-123',
    });

    expect(response).toBeUndefined();
  });

  test('宛先が50個より多い場合', async (): Promise<void> => {
    const toAddresses = [];
    const destinations = [];
    for (let i = 0; i < 51; i++) {
      toAddresses.push(`to${i}@example.com`);
      destinations.push({
        Destination: {
          ToAddresses: [`to${i}@example.com`],
        },
      });
    }

    const response = await sendBulkEmail({
      SubjectPart: '抽選結果のご案内',
      TextPart: '抽選の結果、チケットをご用意することができませんでした。',
      toAddresses: toAddresses,
      fromAddress: 'system@example.com',
    });

    expect(toAddresses.length).toBe(51);

    expect(sesClient.createTemplate).toBeCalledTimes(1);
    expect(sesClient.createTemplate).toBeCalledWith({
      Template: {
        TemplateName: 'uuid-123',
        SubjectPart: '抽選結果のご案内',
        TextPart: '抽選の結果、チケットをご用意することができませんでした。',
      },
    });

    expect(sesClient.sendBulkTemplatedEmail).toBeCalledTimes(2);
    destinations.pop();
    expect(sesClient.sendBulkTemplatedEmail).nthCalledWith(1, {
      Source: 'system@example.com',
      Template: 'uuid-123',
      Destinations: destinations,
      DefaultTemplateData: JSON.stringify({}),
    });
    expect(sesClient.sendBulkTemplatedEmail).nthCalledWith(2, {
      Source: 'system@example.com',
      Template: 'uuid-123',
      Destinations: [{ Destination: { ToAddresses: ['to50@example.com'] } }],
      DefaultTemplateData: JSON.stringify({}),
    });

    expect(sesClient.createTemplate).toBeCalledTimes(1);
    expect(sesClient.deleteTemplate).toBeCalledWith({
      TemplateName: 'uuid-123',
    });

    expect(response).toBeUndefined();
  });
});

テストを実行すると、ちゃんといずれのテストもパスしました!

$ npx jest
 PASS  test/sampleHandler.test.ts (6.712 s)
  sendBulkEmail
    ✓ 宛先が50個以下の場合 (5 ms)
    ✓ 宛先が50個より多い場合 (2 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        6.778 s, estimated 9 s
Ran all test suites.

おわりに

Jestで複数回呼び出される関数のn回目呼び出し時の引数をテストする実装をしてみました。

nthCalledWith()によりテストをきれいに実装出来てよかったです。やはり公式ドキュメントはちゃんと読み込むべきですね。

参考

以上