deno標準テストモジュールの使い方

## 概要

denoの標準ライブラリであるdeno_stdにはtestingモジュールが含まれています。

今回はその使い方について紹介します。

deno及びdeno_stdについては、下記バージョンを想定しています。

- [deno@v0.26.0](https://github.com/denoland/deno/releases/tag/v0.26.0)
- [deno_std@v0.26.0](https://deno.land/std@v0.26.0/testing/README.md)

## テストの書き方

```typescript:sum.ts
export function sum(...numbers: number[]): number {
  return numbers.reduce((acc, x) => acc + x, 0);
}
```

```typescript:sum_test.ts
import { test, runIfMain } from 'https://deno.land/std@v0.26.0/testing/mod.ts';
import { assertStrictEq } from 'https://deno.land/std@v0.26.0/testing/asserts.ts';
import { sum } from './sum.ts';

test(function returnsSumOfNumbers() {
  const actual = sum(1, 2);
  const expected = 3;
  assertStrictEq(actual, expected);
});

test(function returnsZeroWhenCalledWithNoArgs() {
  const actual = sum();
  const expected = 0;
  assertStrictEq(actual, expected);
});

runIfMain(import.meta);
```

- テストファイルの名前にはサフィックスとして`_test`をつけます。([※後述](#テストを実行する))

## テストケースの記述

`testing/mod.ts`でエクスポートされている`test`関数を使用します。
下記いずれかの形式でテストを記述します。

### 関数形式

関数の`name`プロパティがテストの名前として扱われます。

```typescript
test(function returnsSumOfNumbers() {
  const actual = sum(1, 2);
  const expected = 3;
  assertStrictEq(actual, expected);
});
```

### オブジェクト形式

`name`プロパティでテストの名前、`fn`プロパティでテスト関数を指定します。

```typescript
test({
  name: 'sum() returns sum of numbers',
  fn() {
    const actual = sum(1, 2);
    const expected = 3;
    assertStrictEq(actual, expected);
  }
});
```

### 非同期処理のテスト

テスト関数で`Promise`を返却します。

返却した`Promise`がresolveされた場合は成功、rejectされると失敗とみなされます。

アサーションに失敗すると`AssertionError`が投げられるため、テスト関数を`async`関数として定義しておくと、期待どおりに動作してくれます。

```typescript
test(async function shouldWorkProperly() {
  const actual = await someAsyncFunc();
  const expected = { msg: 'hello' };
  assertEquals(actual, expected);
});
```

### コマンドラインからテストを実行する。

下記コマンドにより、ファイル名が`_test.ts`で終わるテストがまとめて実行されます。
(`--allow-net`オプションをつけないと、実行に失敗してしまうようです)

```sh
$ deno test --allow-net
```

#### `require is not defined`と表示され、テストが実行されない。

eslint等を使用している関係で`node_modules`ディレクトリが存在すると、テストが失敗してしまうことがあります。

その際は、-eオプションで除外対象のディレクトリまたはURLを指定できます。(複数指定したいときは、コンマで区切る)

```sh
$ deno test -e './node_modules' --allow-net
```

## アサーション
アサーション関数は`testing/asserts.ts`ファイルでexportされています。
それぞれの関数はアサーションに失敗すると、`AssertionError`を投げます。

各アサーション関数は、末尾の`msg`引数でアサーション失敗時の出力メッセージをカスタマイズできます。

### `assert(expr: boolean, msg = "")`

`expr`が`false`のとき失敗します。

### `assertEquals(actual: unknown, expected: unknown, msg?: string)`

`actual`と`expected`の深い比較をし、一致しなければ失敗します。
(`actual`と`expected`がオブジェクトであれば各プロパティを再帰的に比較、配列であれば各要素を再帰的に比較します。)

### `assertNotEquals(actual: unknown, expected: unknown, msg?: string)`

`assertEquals`の否定版
`actual`と`expected`が一致すると失敗します。

### `assertStrictEq(actual: unknown, expected: unknown, msg?: string)`

`actual`と`expected`を厳密に比較(`actual === expected`)し、一致しなければ失敗します。

### `assertStrContains(actual: string, expected: string, msg?: string)`

`expected`が`actual`の部分文字列であれば成功します。

### `assertMatch(actual: string, expected: RegExp, msg?: string)`

`actual`が`expected`で指定された正規表現にマッチすれば成功します。

### `assertArrayContains(actual: unknown[], expected: unknown[], msg?: string)`

`actual`が`expected`で指定されたすべての要素を含んでいれば成功します。(要素の順番は問いません)

### `assertThrows(fn: () => void, ErrorClass?: Constructor, msgIncludes = "", msg?: string)`

`fn`が`ErrorClass`で指定された型の例外を投げ、その`message`プロパティに`msgIncludes`で指定された文字列が含まれていれば成功します。

### `assertThrowsAsync(fn: () => Promise<void>, ErrorClass?: Constructor, msgIncludes = "", msg?: string)`

`fn`が`ErrorClass`で指定された例外を投げるまたはrejectし、その`message`プロパティに`msgIncludes`で指定された文字列が含まれていれば成功します。

## その他(Tips等)

### 特定のテストファイルのみを実行したい

```typescript:sum_test.ts
import { test, runIfMain } from 'https://deno.land/std@v0.26.0/testing/mod.ts';
import { assertStrictEq } from 'https://deno.land/std@v0.26.0/testing/asserts.ts';
import { sum } from './sum.ts';

test(function returnsSumOfNumbers() {
  const actual = sum(1, 2);
  const expected = 3;
  assertStrictEq(actual, expected);
});

runIfMain(import.meta);
```

テストファイルの末尾で`import.meta`を渡して`runIfMain`関数を実行します。

すると、下記コマンドにより、対象のテストファイルのみを実行できます。

```sh
$ deno run ./sum_test.ts
```

### テストファイルの配置場所について

このあたりはモジュールによって異なるようですが、現状下記のような方式をよく見かけます。

- テスト対象モジュールと同一ディレクトリに配置する。
- `tests`ディレクトリに配置する。

### タイムアウト

現状、タイムアウト機能はありませんが、下記のようなラッパーを用意すれば、対応できます。

```typescript
function testWithTimeout(fn: () => void | Promise<unknown>, timeout: number = 3000): void {
  test({
    name: fn.name,
    async fn() {
      const timeoutPromise = new Promise((resolve, reject) => setTimeout(() => reject(new Error('Timeout exceeded')), timeout));
      const testPromise = fn();
      await Promise.race([testPromise, timeoutPromise]);
    }
  });
}

testWithTimeout(async function shouldWorkProperly() {
  const actual = await someAsyncFunc();
  const expected = { msg: 'hello' };
  assertEquals(actual, expected);
}, 2000);
```

### サードパーティモジュールの管理について

現状、下記いずれかの手法を取ることが多いようです。

- `deps.ts`というファイルを用意し、すべてのモジュールを一括管理する。

```typescript:deps.ts
export { test, runIfMain } from 'https://deno.land/std@v0.26.0/testing/mod.ts';
export { assertStrictEq } from 'https://deno.land/std@v0.26.0/testing/asserts.ts';
```

```typescript
import { test } from './deps.ts';
```

- [dink](https://github.com/keroxp/dink)や[dem](https://github.com/syumai/dem)のような専用ツールを利用する。