【JavaScript/TypeScript】jestで環境変数を差し替えてテストする方法

jestを使ってテストを書いていて、環境変数をケースごとに書き換えたくなった際にハマったので記事に残しておく。
# 前提

Lambdaで環境変数を受け取る際に、以下のようにhandlerの外側で変数を定義しているときの話である。

```typescript:例)
import { Context, SQSEvent, SQSHandler } from 'aws-lambda';
import * as log from 'lambda-log';

// ** こんな感じで環境変数を取得している場合 **
export const TABLE_NAME = process.env.TABLE_NAME || '';
export const PRIMARY_KEY = process.env.PRIMARY_KEY || '';

export const handler: SQSHandler = async (event: SQSEvent, context: Context) => {
    log.info('event row data', { event, context });

    const instance = DynamoDBDocumentClient.from(new DynamoDBClient({ region: REGION }));
    
    for (const record of event.Records) {
      const body = JSON.parse(record.body);
      log.info('event body', { body });
      
      const params: PutCommandInput = {
        TableName: TABLE_NAME,
        Item: {
          [PRIMARY_KEY]: messageId,
        },
      };

      await instance.send(new PutCommand(params));
    }
};

```

# tl;dr

- テスト対象のコードを動的インポートする。
- `beforEach`の中で`jest.resetModules()`を呼ぶ。
- 各テストの中で`process.env.XXX`にテスト用の値を代入する。

# 解説

`jest`でテストを記載するときは、以下のようにテスト対象のファイルをコードの上部でインポートすると思う。

```ts:handler.test.ts
import { handler } from '../src/handler.ts';

describe('handlerのテスト', () => {
  test('正常系', async () => {
    const testEvent = { ... }
    const result = await handler(testEvent);
    expect(result).toBeTrythy();
  });
});
```

しかし、このような書き方をすると、環境変数を変更したテストができない。

```ts:handler.test.ts
import { handler } from '../src/handler.ts';

describe('handlerのテスト', () => {
  test('正常系', async () => {
    const testEvent = { ... }
    // これでは書き換わらず、想定している動きにならない。
    process.env.TABLE_NAME = 'dummyValue';
    const result = await handler(testEvent);
    expect(result).toBeTrythy();
  });
});
```

## 読み込まれるタイミングと動的インポート

この場合、環境変数は`import`したタイミングで解決されてしまう。
このため、`handler.test.ts`を実行した最初のタイミングで環境変数が決まることになる。

ES Modulesの場合、[動的インポート](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/import#dynamic_imports)を使えば、テストケースの中で都度インポートできる。
```diff_typescript:handler.test.ts
- import { handler } from '../src/handler.ts';

describe('handlerのテスト', () => {
  test('正常系', async () => {
    const testEvent = { ... }
    process.env.TABLE_NAME = 'dummyValue';
-   const result = await handler(testEvent);
+   const module = await import('../src/handler.ts');
+   await module.handler(testEvent)

    expect(result).toBeTrythy();
  });
});
```

## ES Modulesの場合の`require.cache`の削除

上記でうまく動作するかと思いきや、一度目のインポートからはやはり環境変数が変更できないことに気づく。

```ts:handler.test.ts
describe('handlerのテスト', () => {
  test('正常系', async () => {
    const testEvent = { ... }
    process.env.TABLE_NAME = 'dummyValue';
    const module = await import('../src/handler.ts');
    await module.handler(testEvent)
    // この時点ではTABLE_NAME = 'dummyValue'
    expect(result).toBeTrythy();
  });
  
  test('正常系2', async () => {
    const testEvent = { ... }
    process.env.TABLE_NAME = 'fakeValue';
    const module = await import('../src/handler.ts');
    await module.handler(testEvent)
    // このタイミングでもTABLE_NAME = 'dummyValue'になってしまう
    expect(result).toBeTrythy();
  });
});
```

これは、ES Modulesの仕様上importがキャッシュされることに起因するらしい。

そこで以下のように、`beforeEach`などでキャッシュを削除するとうまく動作する。

```diff_typescript:handler.test.ts
describe('handlerのテスト', () => {
+ beforeEach(() => {
+   jest.resetModules();
+ });
  
  test('正常系', async () => {
    const testEvent = { ... }
    process.env.TABLE_NAME = 'dummyValue';
    const module = await import('../src/handler.ts');
    await module.handler(testEvent)
    // この時点ではTABLE_NAME = 'dummyValue'
    expect(result).toBeTrythy();
  });
  
  test('正常系2', async () => {
    const testEvent = { ... }
    process.env.TABLE_NAME = 'fakeValue';
    const module = await import('../src/handler.ts');
    await module.handler(testEvent)
    // このタイミングではちゃんとTABLE_NAME = 'fakeValue'になっている
    expect(result).toBeTrythy();
  });
});
```

## おまけ

環境変数のテストをする際は、`describe`のスコープ内で変数を退避しておいてあげるとよさそうだ。

```diff_typescript:handler.test.ts
describe('handlerのテスト', () => {
+ const env = proccess.env

  beforeEach(() => {
    jest.resetModules();
+   process.env = { ...env };
  });
  
  test('正常系', async () => {
    const testEvent = { ... }
    process.env.TABLE_NAME = 'dummyValue';
    const module = await import('../src/handler.ts');
    await module.handler(testEvent)
    expect(result).toBeTrythy();
  });
  
  test('正常系2', async () => {
    const testEvent = { ... }
    process.env.TABLE_NAME = 'fakeValue';
    const module = await import('../src/handler.ts');
    await module.handler(testEvent)
    expect(result).toBeTrythy();
  });
});
```