【Javascript】JestでFileオブジェクトのテストをする方法

# はじめに
**Jestを使ったユニットテストで、Fileオブジェクトを利用したメソッドのテスト**を実施したい。
FileオブジェクトはHTMLの`<input　type="file">`要素から得られる。

しかし、Jestのユニットテストでは、HTMLを介さずローカルのファイルからFileオブジェクトを生成したい。

ローカルファイルからFileオブジェクトを生成するにあたってBufferオブジェクトを扱う必要があるのですが、**小さいサイズのBufferは共有メモリで扱われる**ということを知らず詰まってしまったしまいました。

この記事ではFileオブジェクトのテスト方法と注意すべき点を解説します。

※最終的なローカルファイルからFileオブジェクト生成のコードは[こちらに](#おわりに)記載しています

# テスト方法
以下にJestでのFileオブジェクトのテストの手順を示す。
## テスト対象のメソッド
テスト対象のメソッドは以下のコード`files.ts`である。
処理内容としては、引数にテキストファイルのFileオブジェクト受け取り、改行で区切りnumberのArrayにするというものである。

```typescript:files.ts
export function readCsvToArray(file: File, maxRow: number): Promise<number[]> {
  return new Promise((resolve, reject) => {
    const fr = new FileReader();
    fr.onload = function(event) {
      const res = (event.target?.result as string)
        .split('\n')
        .map(value => parseInt(value, 10))
        .filter(v => !!v);
      if (res.length > maxRow) {
        reject(`アップロードできるファイルは${maxRow}行以下です`);
      }
      resolve(res);
    };
    fr.onerror = function() {
      reject('ファイルの読み込みに失敗しました');
    };

    fr.readAsText(file);
  });
}
```

このメソッドは本来であれば、HTMLの`<input>`要素から受け取ったFileオブジェクトを引数とする。
しかしJestではローカルのファイルからFileオブジェクトを生成したい。以下に手順を示す。

## ローカルファイルからのFileオブジェクト生成
### Fileコンストラクタ
まず、Fileオブジェクトを生成するために[コンストラクタのドキュメント](https://developer.mozilla.org/ja/docs/Web/API/File/File)を見てみる。
コンストラクタの第一引数には**DOMString, Blob,　ArrayBuffer**のいずれか、もしくはこれらのArrayを指定する必要がある。

それぞれの型について調べてみる。
DOMStringはDOMで記述された文字列。
BlobはFileの継承元で、コンストラクタにはArrayBuffer、Blob、USVStringのArrayが必要。USVStringはUnicodeの文字列。

**ArrayBuffer**はバイナリデータのバッファを示すために使用するデータタイプ。Bufferオブジェクトのbufferプロパティを呼び出すと得られる。
Bufferオブジェクトを取得する方法として[FileStreamのcreateFileSyncメソッド](https://nodejs.org/api/fs.html#fs_fs_readfilesync_path_options)がある。**createFileSync**メソッドは第一引数にファイルパスを受け取り、Bufferオブジェクトを返す。つまり、次の手順でローカルファイルからFileオブジェクトを生成できそうである。

#### ローカルファイルからFileオブジェクトの生成手順
1. FileStreamの**createFileSyncメソッド**にファイルパス（文字列）を渡し、**Bufferオブジェクト**を取得
2. Bufferオブジェクトのbufferプロパティを呼び出し、**ArrayBufferオブジェクト**を取得
3. ArrayBufferをFileコンストラクタに渡し、**Fileオブジェクト**を取得

## テストコード作成
[ローカルファイルからFileオブジェクトの生成手順](#ローカルファイルからFileオブジェクトの生成手順)に沿って以下のテストコード`files.spec.ts`を作成。

テストコード内の`getUploadedCsvBuffer`メソッドを呼び、ArrayBufferを取得。これをFileコンストラクタの第一引数に渡す。また、第2引数にはファイル名、第３引数はファイル属性のオプションを渡している。

テストデータ`test.csv`も作成。**ファイルサイズは6バイト**である。（このファイルサイズが後に問題が起こす...）

テスト対象のコード`files.ts`、テストコード`files.spec.ts`、テストデータ`test.csv`をすべて同じディレクトリに保存する。


```typescript:files.spec.ts
import { readCsvToArray } from './file';
import fs from 'fs';

const getUploadedCsvBuffer = (): ArrayBuffer => {
  const path = `${__dirname}/test.csv`; // テスト対象のコード
  const buffer = fs.readFileSync(path);  // Buffer
  return buffer.buffer // ArrayBuffer
};

describe('readCsvToArray', () => {
  // 正常系
  it('return number of array', async () => {
    const file = new File([getUploadedCsvBuffer()], 'test.csv', {
      type: 'text/csv',
    });
    const result = await readCsvToArray(file, 100);
    expect(result).toEqual([1,2,3]);
  });
  // エラー
  it('throw error of over max row', async () => {　
    const file = new File([getUploadedCsvBuffer()], 'test.csv', {
      type: 'text/csv',
    });
    try {
      await readCsvToArray(file, 1);
    } catch (e) {
      expect(e).toEqual('アップロードできるファイルは1行以下です');
    }
  });
});

```

```text:test.csv
1
2
3
```

## いざテスト
テストの準備が出来たので実行。

**実行結果**
![スクリーンショット 2020-09-04 16.17.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133979/a18a05fb-993d-81de-9c89-459ef2d39ae7.png)

なぜか、テストデータに記載していないはずの`0`が含まれている。

# 原因調査
記載していないデータが含まれているので、バッファ周りが怪しいと思い、Bufferオブジェクトについて調査してみた。
すると[buf.buffer](https://nodejs.org/dist/latest-v12.x/docs/api/buffer.html#buffer_buf_byteoffset)に`buf.bufferで得られるArrayBufferは、必ずしも元のBufferとは対応しない。詳しくはbuf.byteOffsetに記述。`とあった。

そこで[buf.byteOffset](https://nodejs.org/api/buffer.html#buffer_buf_byteoffset)をみると、`Bufferのpoolsizeより小さいバッファはスライスとオフセットを使用しないと別のバッファからのデータを含む`とある。

小さいサイズのバッファは共有メモリとして扱われてしまうため、buf.bufferでアクセスしたときに意図しないデータが含まれてしまうということである。

[Buffer.poolsize](https://nodejs.org/dist/latest-v12.x/docs/api/buffer.html#buffer_class_property_buffer_poolsize)はデフォルトで**8192バイト**である。今回のテストデータはこのpoolsizeを下回るため、意図しないデータが含まれてしまった。今回のテストで生成されたブッファのサイズを見てみると8192バイトであり、テストデータの12バイトとは異なる。

バッファから任意のデータと取得するためには、**bufferオブジェクトのsliceメソッドに、取得したブッファのオフセットと長さを指定**することで取得が可能である。

## テストコード変更

ArrayBufferを返すメソッド`getUploadedCsvBuffer`の返り値をスライスするように変更。

```diff:files.spec.ts
const getUploadedCsvBuffer = (): ArrayBuffer => {
  const path = `${__dirname}/test.csv`; // テスト対象のコード
  const buffer = fs.readFileSync(path);  // Buffer
- return buffer.buffer // ArrayBuffer
+ return buffer.buffer.slice(
+   buffer.byteOffset,
+   buffer.byteOffset + buffer.byteLength
+ ); 
   
};
```

**実行結果**
![スクリーンショット 2020-09-04 18.01.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133979/14dac093-3671-87b8-d20b-ab1f6a2c45a0.png)

無事にテストを実行することができた。

# おわりに

ローカルファイルからFileオブジェクトの生成手順は以下のような流れである。
1. FileStreamの**createFileSyncメソッド**にファイルパス（文字列）を渡し、**Bufferオブジェクト**を取得
2. Bufferオブジェクトのbufferプロパティを呼び出し、**ArrayBufferオブジェクト**を取得
3. ArrayBufferをFileコンストラクタに渡し、**Fileオブジェクト**を取得


BufferオブジェクトのbufferプロパティでArrayBufferを取得する際には注意が必要である。バッファサイズが8192Byte以下のときは、バッファは共有メモリで扱われるため明示的に位置を指定する必要がある。

ローカルファイルからFileオブジェクト生成のコードを示します。

```typescript:files.spec.ts
const getArrayBuffer = (): ArrayBuffer => {
  const path = `${__dirname}/test.csv`; // テスト対象のコード
  const buffer = fs.readFileSync(path);  // Buffer
  return buffer.buffer.slice(
  buffer.byteOffset,
  buffer.byteOffset + buffer.byteLength
  );  
};

const file = new File([getArrayBuffer()], 'test.csv', {
  type: 'text/csv',
});
```