Slack 次世代プラットフォーム機能を少しずつ試す - ユニットテスト編

こんにちは、Slack の[公式 SDK 開発](https://github.com/slackapi)と日本の Developer Relations を担当している瀬良 (@seratch) と申します :wave:

この記事は [Slack の次世代プラットフォーム機能を少しずつ試しながら、ゆっくりと理解していくシリーズ](https://qiita.com/seratch/items/b1449132add003e61de6)の記事です。

「次世代プラットフォーム機能って何？」という方は、以下の記事で詳しく解説しましたので、まずはそちらをお読みください。

https://qiita.com/seratch/items/ecc16b845483c9d6f9e1

前回の記事では、外部通信を行うファンクションを実装しました。

https://qiita.com/seratch/items/229e05506a22b07b2599

今回の記事では、カスタムファンクションのユニットテストの書き方をご紹介していきます。

### ブランクプロジェクトを作成

いつものようにブランクプロジェクトを作成してゼロからコードを足していきましょう。`slack create` コマンドを実行して、選択肢から「Blank Project」を選択してください。作成したプロジェクトの構成は以下の通りです。

```bash
$ tree
.
├── LICENSE
├── README.md
├── assets
│   └── default_new_app_icon.png
├── deno.jsonc
├── import_map.json
├── manifest.ts
└── slack.json
```

今回の記事では、以下の三つの例を順に紹介していきます。

* inputs を少し操作して返すだけのファンクション（`echo.ts`）
* Slack の chat.postMessage API を呼び出すファンクション（`my_send_message.ts`）
* 外部の API を呼び出すファンクション（`translate.ts`）

これでは順に見ていきましょう。

### inputs を少し操作して返すだけのファンクションのテスト

このパターンは、ほとんどシンプルな Deno アプリケーションコードのテストとなります。まずは、以下に示すテスト対象のファンクションを `echo.ts` として保存してください。

```typescript
import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";

export const def = DefineFunction({
  callback_id: "echo",
  title: "Echo inputs",
  source_file: "echo.ts",
  input_parameters: {
    properties: {
      text: { type: Schema.types.string },
      capitalize: { type: Schema.types.boolean },
    },
    required: ["text"],
  },
  output_parameters: {
    properties: { text: { type: Schema.types.string } },
    required: ["text"],
  },
});

export default SlackFunction(def, ({ inputs }) => {
  const { text, capitalize } = inputs;
  if (capitalize) {
    return { outputs: { text: text.toUpperCase() } };
  } else {
    return { outputs: { text } };
  }
});
```

これに対するテストを書いていきましょう。テストコードは `*_test.ts` という名前で配置します。今回は `echo_test.ts` として保存します。

`createContext` を使用している以外は、ごく普通のテストコードであることがわかるかと思います。

```typescript
// Deno の標準テストモジュール
import { assertEquals } from "https://deno.land/std@0.153.0/testing/asserts.ts";

// Run-on-Slack アプリのアプリ用のテストユーティリティ
import { SlackFunctionTester } from "deno-slack-sdk/mod.ts";
// ファンクションに渡す inputs/env のまとまりを互換性のある型に変換するためのユーティリティ
const { createContext } = SlackFunctionTester("my-function");

// テスト対象のコードは SlackFunction() を default export したもの
import handler from "./echo.ts";

// テストパターンを Deno.test(label, async () => { ... }) を使って列挙
// VS Code の Deno エクステンションをインストールしていれば一つずつ実行可能
Deno.test("Return the input text as-is", async () => {
  // このように inputs, env などは自由に生成できる
  const inputs = { text: "Hi there!" };
  const { outputs } = await handler(createContext({ inputs }));
  assertEquals(outputs?.text, "Hi there!");
});

Deno.test("Return the capitalized input text as-is when capitalize: true", async () => {
  const inputs = { text: "Hi there!", capitalize: true };
  const { outputs } = await handler(createContext({ inputs }));
  assertEquals(outputs?.text, "HI THERE!");
});
```

テストの実行は `slack test`・・ではなく、`deno test` を実行します。上のコードそのままであれば、以下の通り全て pass します。

```bash
$ deno test
Check file:///~/recursing-anteater-962/echo_test.ts
Check file:///~/recursing-anteater-962/my_send_message_test.ts
running 2 tests from ./echo_test.ts
Return the input text as-is ... ok (5ms)
Return the capitalized input text as-is when capitalize: true ... ok (4ms)
running 0 tests from ./my_send_message_test.ts

ok | 2 passed | 0 failed (73ms)
```

テストコードの一部を書き換えて失敗させてみましょう。以下のようにエラーが表示されます。

```bash
running 2 tests from ./echo_test.ts
Return the input text as-is ... ok (7ms)
Return the capitalized input text as-is when capitalize: true ... FAILED (6ms)

 ERRORS

Return the capitalized input text as-is when capitalize: true => ./echo_test.ts:13:6
error: AssertionError: Values are not equal:

    [Diff] Actual / Expected

-   HI THERE!
+   HI THERE!!

  throw new AssertionError(message);
        ^
    at assertEquals (https://deno.land/std@0.153.0/testing/asserts.ts:183:9)
    at file:///~/recursing-anteater-962/echo_test.ts:16:3

 FAILURES

Return the capitalized input text as-is when capitalize: true => ./echo_test.ts:13:6

FAILED | 1 passed | 1 failed (67ms)

error: Test failed
```

これで、テストの基本的な書き方は理解できました。次は、API コールを含むファンクションにモックを使ったテストを書いてみましょう。

### Slack の chat.postMessage API を呼び出すファンクション

以下の記事で書いたファンクションをテストしてみましょう。

https://qiita.com/seratch/items/d3c2d2d9f44551473fb5

コードをシンプルにしたものが以下です。`my_send_message.ts` として保存してください。`functions/my_send_message.ts` として配置しても構いませんが、その場合は `source_file` の相対パスを `functions/my_send_message.ts` に書き換えてください。

```typescript
import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";

export const def = DefineFunction({
  callback_id: "my-send-message",
  title: "My SendMessage",
  source_file: "my_send_message.ts",
  input_parameters: {
    properties: {
      channel_id: { type: Schema.slack.types.channel_id },
      message: { type: Schema.types.string },
    },
    required: ["channel_id", "message"],
  },
  output_parameters: {
    properties: { ts: { type: Schema.types.string } },
    required: ["ts"],
  },
});

import { SlackAPI } from "deno-slack-api/mod.ts";

export default SlackFunction(def, async ({ inputs, token }) => {
  const client = SlackAPI(token);
  const response = await client.chat.postMessage({
    channel: inputs.channel_id,
    text: inputs.message,
  });
  const ts = response.ts;
  return { outputs: { ts } };
});
```

テストファイルを `my_send_message_test.ts` として配置します。シンプルなテストは以下の通りです。

ポイントは [`mock_fetch` モジュール](https://deno.land/x/mock_fetch)を使うようになった点です。ファンクション内の全ての `fetch` 関数の呼び出しをこれによって差し替えることが簡単にできるようになります。

なお、現時点ではこのライブラリをおすすめしておりますが、将来、他にも良いライブラリが出てくるかもしれません。その場合は、別のものを使っても全く問題ありません。

```typescript
import * as mf from "https://deno.land/x/mock_fetch@0.3.0/mod.ts";
import { SlackFunctionTester } from "deno-slack-sdk/mod.ts";
import { assertEquals } from "https://deno.land/std@0.153.0/testing/asserts.ts";
import handler from "./my_send_message.ts";

// ここから globalThis.fetch をモックに置き換える
mf.install();

mf.mock("POST@/api/chat.postMessage", () => {
  return new Response(JSON.stringify({ ok: true, ts: "1111.2222" }), {
    status: 200,
  });
});

const { createContext } = SlackFunctionTester("my-function");

Deno.test("Send a message successfully", async () => {
  const inputs = { channel_id: "C111", message: "Hi there!" };
  const token = "xoxb-valid";
  const { outputs } = await handler(createContext({ inputs, token }));
  assertEquals(outputs, { ts: "1111.2222" });
});
```

さて、正常系の確認ができるようになったので、これはこれでよいのですが、テストを書いているうちにエラーパターンが気になり始めました（私だけ？）。

試しに [`chat.postMessage` API](https://api.slack.com/methods/chat.postMessage) がエラーコードを応答するパターンのテストを書いてみましょう。テストコードのモックを以下のように書き換えてみます。

```typescript
mf.mock("POST@/api/chat.postMessage", async (args) => {
  const body = await args.formData();
  const authHeader = args.headers.get("Authorization");
  if (authHeader !== "Bearer xoxb-valid") {
    return new Response(JSON.stringify({ ok: false, error: "invalid_auth" }), {
      status: 200,
    });
  }
  if (body.get("channel") !== "C111") {
    return new Response(
      JSON.stringify({ ok: false, error: "channel_not_found" }),
      {
        status: 200,
      },
    );
  }
  return new Response(JSON.stringify({ ok: true, ts: "1111.2222" }), {
    status: 200,
  });
});
```

そして、新しいテストパターンを追加します。しかし、メッセージは投稿されないはずなので `outputs.ts` が `"1111.2222"` として応答されることはないでしょう。この場合、このファンクションはどう動作するべきでしょうか？

```typescript
Deno.test("Fail to send a message with invalid token", async () => {
  const inputs = { channel_id: "C111", message: "Hi there!" };
  const token = "xoxb-invalid";
  const { outputs } = await handler(createContext({ inputs, token }));
  // TODO: ここをどうする？？
  assertEquals(outputs, { ts: "1111.2222" });
});
```

あるべき姿はどのようにファンクションが利用されるかを想定するかによって異なります。

「メッセージが投稿されなかったら、ワークフロー全体の実行を異常終了として中断したい」ということであれば、`outputs` の代わりに `error` 含めたオブジェクトを返します。

```typescript
return { error: "ここに何らか事象と原因がわかる情報を含める" };
```

なお、ファンクションの中で例外を throw した場合は `event_dispatch_failed` となり、リトライが発生しますので、 `{ error }` を return することをおすすめします。

一方で、「後続のファンクションで何らかの対応を行いたい」という場合であれば `outputs` の中で `ts` を返す代わりに `error` を返すようにしても OK です。

今回は　`outputs` の中に `error` 情報を含める設計にしてみましょう。

テスト対象のファンクションのコードは、以下のように変わります。API の応答に `error` が含まれていたら、それを `outputs` に含めて返します。またこれによって `ts` が必ず返されるという取り決めは成り立たなくなりますので、定義の方の `output_parameters.required` から `ts` を削除します。

```typescript
import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";

export const def = DefineFunction({
  callback_id: "my-send-message",
  title: "My SendMessage",
  source_file: "my_send_message.ts",
  input_parameters: {
    properties: {
      channel_id: { type: Schema.slack.types.channel_id },
      message: { type: Schema.types.string },
    },
    required: ["channel_id", "message"],
  },
  output_parameters: {
    properties: {
      ts: { type: Schema.types.string },
      error: { type: Schema.types.string },
    },
    required: [],
  },
});

import { SlackAPI } from "deno-slack-api/mod.ts";

export default SlackFunction(def, async ({ inputs, token }) => {
  const client = SlackAPI(token);
  const response = await client.chat.postMessage({
    channel: inputs.channel_id,
    text: inputs.message,
  });
  if (response.error) {
    return { outputs: { error: response.error } };
  }
  const ts = response.ts;
  return { outputs: { ts } };
});
```

このファンクションをテストする完全なコードは以下のようになります。

```typescript
import * as mf from "https://deno.land/x/mock_fetch@0.3.0/mod.ts";
import { SlackFunctionTester } from "deno-slack-sdk/mod.ts";
import { assertEquals } from "https://deno.land/std@0.153.0/testing/asserts.ts";
import handler from "./my_send_message.ts";

// ここから globalThis.fetch をモックに置き換える
mf.install();

mf.mock("POST@/api/chat.postMessage", async (args) => {
  const body = await args.formData();
  const authHeader = args.headers.get("Authorization");
  if (authHeader !== "Bearer xoxb-valid") {
    return new Response(JSON.stringify({ ok: false, error: "invalid_auth" }), {
      status: 200,
    });
  }
  if (body.get("channel") !== "C111") {
    return new Response(
      JSON.stringify({ ok: false, error: "channel_not_found" }),
      {
        status: 200,
      },
    );
  }
  return new Response(JSON.stringify({ ok: true, ts: "1111.2222" }), {
    status: 200,
  });
});

const { createContext } = SlackFunctionTester("my-function");

Deno.test("Send a message successfully", async () => {
  const inputs = { channel_id: "C111", message: "Hi there!" };
  const token = "xoxb-valid";
  const { outputs } = await handler(createContext({ inputs, token }));
  assertEquals(outputs, { ts: "1111.2222" });
});

Deno.test("Fail to send a message with invalid token", async () => {
  const inputs = { channel_id: "C111", message: "Hi there!" };
  const token = "xoxb-invalid";
  const { outputs } = await handler(createContext({ inputs, token }));
  assertEquals(outputs, { error: "invalid_auth" });
});

Deno.test("Fail to send a message to an unknown channel", async () => {
  const inputs = { channel_id: "D111", message: "Hi there!" };
  const token = "xoxb-valid";
  const { outputs } = await handler(createContext({ inputs, token }));
  assertEquals(outputs, { error: "channel_not_found" });
});
```

`deno test` で実行してみてください。全て成功するはずです。

### 外部の API を呼び出すファンクション

次に以下の記事でご紹介した DeepL の API を呼び出すファンクションのテストを書いてみましょう。

https://qiita.com/seratch/items/229e05506a22b07b2599

シンプルにしたコードは以下の通りです。

```typescript
import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";

export const def = DefineFunction({
  callback_id: "translator",
  title: "Translator",
  source_file: "translate.ts",
  input_parameters: {
    properties: {
      text: { type: Schema.types.string },
      source_lang: { type: Schema.types.string },
      target_lang: { type: Schema.types.string },
    },
    required: ["text", "target_lang"],
  },
  output_parameters: {
    properties: {
      translated_text: { type: Schema.types.string },
    },
    required: ["translated_text"],
  },
});

export default SlackFunction(def, async ({
  inputs,
  env,
}) => {
  const authKey = env.DEEPL_AUTH_KEY;
  if (!authKey) {
    throw new Error("DEEPL_AUTH_KEY env value is missing!");
  }
  const apiSubdomain = authKey.endsWith(":fx") ? "api-free" : "api";
  const apiUrl = `https://${apiSubdomain}.deepl.com/v2/translate`;
  const body = new URLSearchParams();
  body.append("auth_key", authKey);
  body.append("text", inputs.text);
  if (inputs.source_lang) {
    body.append("source_lang", inputs.source_lang.toUpperCase());
  }
  body.append("target_lang", inputs.target_lang.toUpperCase());
  const requestHeaders = {
    "content-type": "application/x-www-form-urlencoded;charset=utf-8",
  };
  const apiResponse = await fetch(apiUrl, {
    method: "POST",
    headers: requestHeaders,
    body,
  });
  const apiResponseStatus = apiResponse.status;
  if (apiResponseStatus != 200) {
    const body = await apiResponse.text();
    console.log(`apiResponse: ${apiResponse}, body: ${body}`);
    const error =
      `DeepL API call failed - status: ${apiResponseStatus}, body: ${body}`;
    throw new Error(error);
  }
  const translationResult = await apiResponse.json();
  if (!translationResult || translationResult.translations.length === 0) {
    const error = `Translation failed for some reason: ${
      JSON.stringify(translationResult)
    }`;
    console.log(error);
    throw new Error(error);
  }
  return {
    outputs: {
      translated_text: translationResult.translations[0].text,
    },
  };
});
```

このファンクションのテストを `translate_test.ts` として以下の通り、保存します。ご覧の通り、同じようにモックを設定することができます。

```typescript
import * as mf from "https://deno.land/x/mock_fetch@0.3.0/mod.ts";
import { SlackFunctionTester } from "deno-slack-sdk/mod.ts";
import { assertEquals } from "https://deno.land/std@0.153.0/testing/asserts.ts";
import handler from "./translate.ts";

// ここから globalThis.fetch をモックに置き換える
mf.install();

mf.mock("POST@/v2/translate", () => {
  return new Response(
    JSON.stringify({
      translations: [{ detected_source_language: "EN", text: "こんにちは！" }],
    }),
    {
      status: 200,
    },
  );
});

const { createContext } = SlackFunctionTester("my-function");

Deno.test("Translate text successfully", async () => {
  const inputs = { text: "Hello!", target_lang: "ja" };
  const env = { DEEPL_AUTH_KEY: "valid-token" };
  const { outputs } = await handler(createContext({ inputs, env }));
  assertEquals(outputs, { translated_text: "こんにちは！" });
});
```

なお、この記事投稿時点で、（私が調べた限りでは）このライブラリはドメイン名までを `mf.mock` の第一引数にうまく設定できないようです。ただ、第二引数の関数の引数に URL が入っていますので、呼び出す API のパスが重複した場合は、その値に含まれるドメインを見て応答内容を決める必要があるでしょう。

```typescript
mf.mock("POST@/v2/translate", (args) => {
  console.log(args.url);
```

### いくつかの注意点

この記事投稿時点で、テストを書く上での注意点がいくつかあります。課題として認識しているものも多く、今後 SDK 側の改善によって解決したり、やりやすくなる可能性があります。

まず、上の API エンドポイントのモックの挙動のカスタマイズですが、より網羅的なテストを書きたくなった場合、それなりに複雑になってくるかと思います。そのような場合、いっそテストファイルを複数ファイルに分割してしまう方が楽になるかもしれません。

次に（今後公開する予定の）高度なモーダルを使ったインタラクションのファンクションでは、現状、網羅的なテストを簡単に書くことができません。モーダルを開くところまでは、以下の例のように書くことはできますが、

https://github.com/slack-samples/deno-message-translator/blob/main/functions/configure_test.ts

その場合も、このコードがやっているように `interactivity` オブジェクトを丸ごと渡すのではなく `interactivity.interactivity_pointer` だけを渡すなどの工夫が必要になったりします。この辺は将来的に何らかの指針を示せるよう、引き続き検討を進めてまいります。

また、ファンクション単位ではなくワークフローのテストもできるようになるとよいのですが、現時点ではそのようなサポートモジュールは提供されていません。ワークフロー全体としての挙動の確認は、実際に Slack ワークスペース内でマニュアルでテストをする必要があります。

### 終わりに

いかがだったでしょうか？ファンクションのテストコードは、ぜひプロジェクトの資産として運用してみてください。

例えば、ファンクションの挙動を見直したくなったときにテストコードを書き換えつつ設計を見直したり、CI で自動実行することで基本的なレベルのリグレッションバグを検知するために役立つはずです。

それでは！