6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SlackAdvent Calendar 2022

Day 11

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

Last updated at Posted at 2022-12-12

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

この記事は Slack の次世代プラットフォーム機能を少しずつ試しながら、ゆっくりと理解していくシリーズの記事です。

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

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

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

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

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

$ 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 として保存してください。

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 を使用している以外は、ごく普通のテストコードであることがわかるかと思います。

// 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 します。

$ 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)

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

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 を呼び出すファンクション

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

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

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 モジュールを使うようになった点です。ファンクション内の全ての fetch 関数の呼び出しをこれによって差し替えることが簡単にできるようになります。

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

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 がエラーコードを応答するパターンのテストを書いてみましょう。テストコードのモックを以下のように書き換えてみます。

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" として応答されることはないでしょう。この場合、このファンクションはどう動作するべきでしょうか?

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 含めたオブジェクトを返します。

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

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

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

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

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

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 } };
});

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

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 を呼び出すファンクションのテストを書いてみましょう。

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

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 として以下の通り、保存します。ご覧の通り、同じようにモックを設定することができます。

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 のパスが重複した場合は、その値に含まれるドメインを見て応答内容を決める必要があるでしょう。

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

いくつかの注意点

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

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

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

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

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

終わりに

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

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

それでは!

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?