破壊的メソッドを使用した処理を含む関数を Vitest でテストする際にハマった話

2024.02.24

こんにちは、CX 事業本部製造ビジネステクノロジー部の若槻です。

今回は、破壊的メソッドを使用した処理を含む関数を、テスティングフレームワークの Vitest でテストする際にハマった話について紹介します。

環境

$ npm ls vitest typescript
cdk_sample_app@0.1.0 /Users/wakatsuki.ryuta/projects/cm-rwakatsuki/cdk_sample_app
├── typescript@5.3.3
└── vitest@1.2.2

事象

ソースコード

次のような引数に応じてレスポンスするオブジェクトが変更される関数をテストします。

src/get-data.ts

export const getData = (flag?: boolean): any => {
  if (flag) {
    return responseOk({
      'Content-Type': 'application/json',
    });
  }
  return responseOk();
};

interface HttpResponseHeader {
  [key: string]: string;
}

const DEFAULT_HEADERS = {
  'Access-Control-Allow-Headers': 'Content-Type,Authorization',
  'Access-Control-Allow-Methods': 'OPTIONS,POST,PUT,GET,DELETE',
  'Access-Control-Allow-Origin': '*',
};

const responseOk = (headers?: HttpResponseHeader) => {
  return {
    statusCode: 200,
    headers: Object.assign(DEFAULT_HEADERS, headers), // ヘッダーのマージ
  };
};

テストコード

次のようにテストコードを記述します。フラグを指定した場合と指定しない場合のレスポンスをテストします。

src/get-data.test.ts

import { describe, it, expect } from 'vitest';

import { getData } from './get-data';

describe('getData', () => {
  it('should return a 200 response', () => {
    const result = getData(true);
    expect(result).toEqual({
      statusCode: 200,
      headers: {
        'Access-Control-Allow-Headers': 'Content-Type,Authorization',
        'Access-Control-Allow-Methods': 'OPTIONS,POST,PUT,GET,DELETE',
        'Access-Control-Allow-Origin': '*',
        'Content-Type': 'application/json',
      },
    });
  });

  it('should return a 200 response with default headers', () => {
    const result = getData();
    expect(result).toEqual({
      statusCode: 200,
      headers: {
        'Access-Control-Allow-Headers': 'Content-Type,Authorization',
        'Access-Control-Allow-Methods': 'OPTIONS,POST,PUT,GET,DELETE',
        'Access-Control-Allow-Origin': '*',
      },
    });
  });
});

テストが失敗する

次のようにテストを実行すると、フラグを指定しない場合のテストが失敗します。なぜかフラグを指定した場合のレスポンスが返却されてしまいます。

$ npx vitest run --dir ./src

 RUN  v1.2.2 /Users/wakatsuki.ryuta/projects/cm-rwakatsuki/cdk_sample_app

 ❯ src/get-data.test.ts (2)
   ❯ getData (2)
     ✓ should return a 200 response
     × should return a 200 response with default headers

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/get-data.test.ts > getData > should return a 200 response with default headers
AssertionError: expected { statusCode: 200, headers: { …(4) } } to deeply equal { statusCode: 200, headers: { …(3) } }

- Expected
+ Received

  Object {
    "headers": Object {
      "Access-Control-Allow-Headers": "Content-Type,Authorization",
      "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE",
      "Access-Control-Allow-Origin": "*",
+     "Content-Type": "application/json",
    },
    "statusCode": 200,
  }

 ❯ src/get-data.test.ts:21:20
     19|   it('should return a 200 response with default headers', () => {
     20|     const result = getData();
     21|     expect(result).toEqual({
       |                    ^
     22|       statusCode: 200,
     23|       headers: {

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)
   Start at  02:15:38
   Duration  230ms (transform 57ms, setup 0ms, collect 46ms, tests 5ms, environment 0ms, prepare 45ms)

調査

テストコードを一時的に修正して、2 つ目のフラグを指定しないテストのみを実行するようにします。

src/get-data.test.ts

import { describe, it, expect } from 'vitest';

import { getData } from './get-data';

describe('getData', () => {
  it('should return a 200 response', () => {
    const result = getData(true);
    expect(result).toEqual({
      statusCode: 200,
      headers: {
        'Access-Control-Allow-Headers': 'Content-Type,Authorization',
        'Access-Control-Allow-Methods': 'OPTIONS,POST,PUT,GET,DELETE',
        'Access-Control-Allow-Origin': '*',
        'Content-Type': 'application/json',
      },
    });
  });

  it.only('should return a 200 response with default headers', () => {
    const result = getData();
    expect(result).toEqual({
      statusCode: 200,
      headers: {
        'Access-Control-Allow-Headers': 'Content-Type,Authorization',
        'Access-Control-Allow-Methods': 'OPTIONS,POST,PUT,GET,DELETE',
        'Access-Control-Allow-Origin': '*',
      },
    });
  });
});

するとはじめは失敗したテストが今度は成功するようになりました。

$ npx vitest run --dir ./src

 RUN  v1.2.2 /Users/wakatsuki.ryuta/projects/cm-rwakatsuki/cdk_sample_app

 ✓ src/get-data.test.ts (2)
   ✓ getData (2)
     ↓ should return a 200 response [skipped]
     ✓ should return a 200 response with default headers

 Test Files  1 passed (1)
      Tests  1 passed | 1 skipped (2)
   Start at  02:16:23
   Duration  209ms (transform 62ms, setup 0ms, collect 54ms, tests 2ms, environment 0ms, prepare 44ms)

原因、解決

原因は、返却されるテスト対象となるオブジェクトの作成で、破壊的メソッド(Object.assign)を使用していためでした。Vitest の挙動として関数の再利用が内部的に行われているようです。

そこでソースコードの responseOk 関数を修正し、スプレッド構文を使用してオブジェクトをマージするように変更します。

src/get-data.ts

export const getData = (flag?: boolean): any => {
  if (flag) {
    return responseOk({
      'Content-Type': 'application/json',
    });
  }
  return responseOk();
};

interface HttpResponseHeader {
  [key: string]: string;
}

const DEFAULT_HEADERS = {
  'Access-Control-Allow-Headers': 'Content-Type,Authorization',
  'Access-Control-Allow-Methods': 'OPTIONS,POST,PUT,GET,DELETE',
  'Access-Control-Allow-Origin': '*',
};

const responseOk = (headers?: HttpResponseHeader) => {
  return {
    statusCode: 200,
    headers: { ...DEFAULT_HEADERS, ...headers }, // ヘッダーのマージ方法をスプレッド構文に変更
  };
};

再度テストを実行すると、すべてのテストが成功するようになりました。

$ npx vitest run --dir ./src

 RUN  v1.2.2 /Users/wakatsuki.ryuta/projects/cm-rwakatsuki/cdk_sample_app

 ✓ src/get-data.test.ts (2)
   ✓ getData (2)
     ✓ should return a 200 response
     ✓ should return a 200 response with default headers

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  02:17:48
   Duration  279ms (transform 57ms, setup 0ms, collect 47ms, tests 1ms, environment 0ms, prepare 45ms)

おわりに

破壊的メソッドを使用した処理を含む関数を Vitest でテストする際にハマった話でした。

破壊的メソッドを安易に使用すると、同じ関数やオブジェクトが再利用された際に予期せぬ挙動を引き起こす可能性があるため、注意が必要です。場合によっては今回紹介したように非破壊な方法に修正をするようにしましょう。

以上