Slack Next-gen Platform – Unit Testing

This is translation of  Slack Next-gen Platform – Unit Testing” written by Kaz (SDK Engineering & DevRel at Slack), I do not include any personal opinion here except guessing when meaning of original text is not clear for me)

이 글은 슬랙의 SDK Engineering / DevRel 인 Kaz 의 글을 번역한것입니다. 저의 개인 의견은 들어가지 않으며, 일부 원문의 의미가 애매한 경우에만 부연 설명을 달았습니다.


이번 튜토리얼 에서는, Slack 의 차세대 플랫폼에서 앱 펑션의 유닛 테스트를 하는 방법을 배워볼 것입니다.

Prerequisites

차세대 플랫폼을 접하는 것이 처음이시라면, 이전의 튜토리얼인 “The Simplest Hello World” 부터 읽어주세요. 요약하면 유료 Slack 워크스페이스와 해당 워크스페이스에서 “beta feature” 를 사용할 수 있는 권한이 필요합니다. 가지고 계시다면 Slack CLI 와 워크스페이스를 연결하기만 하면 됩니다.

준비되었다면 앱을 한번 만들어보도록 하죠. 시작해보겠습니다.

Create a Blank Project

slack create 명령어를 사용하여, 새로운 프로젝트를 시작할 수 있습니다. 이 튜토리얼에서는 아무것도 없는 상태에서 앱을 만들어보겠습니다. “Blank Project” 를 선택하여 주십시요.

$ slack create
? Select a template to build from:

  Hello World
  A simple workflow that sends a greeting

  Scaffolded project
  A solid foundational project that uses a Slack datastore

> Blank project
  A, well.. blank project

  To see all available samples, visit github.com/slack-samples.

프로젝트가 생성되면, slack run 명령어가 정상적으로 동작하는지 확인해보세요. 이 명령어는 “dev” 버전의 앱을 워크스페이스에 설치합니다. 이 앱의 봇 유저가 생성되고, 이 봇은 API 호출을 위한 봇 토큰 값을 가지고 있습니다.

$ cd affectionate-panther-654
$ slack run
? Choose a workspace  seratch  T03E94MJU
   App is not installed to this workspace

Updating dev app install for workspace "Acme Corp"

  Outgoing domains
   No allowed outgoing domains are configured
   If your function makes network requests, you will need to allow the outgoing domains
   Learn more about upcoming changes to outgoing domains: https://api.slack.com/future/changelog
  seratch of Acme Corp
Connected, awaiting events

Connected, awaiting events 로그 메세지를 보신다면, 이 앱이 성공적으로 워크스페이스에 연결된 것입니다. “Ctrl +C” 를 눌러 로컬 앱 프로세스를 종료합니다.

What You’ll Do

다음 3개의 펑션을 추가하고 테스트를 하는 코드를 작성해보겠습니다.

  • 입력받은 텍스트를 변환하고, 결과물을 리턴하는 echo.ts
  • Slack 채널에 메세지를 보내는 my_send_message.ts
  • DeepL 의 API 를 사용하어 주어진 텍스트를 다른 언어로 변환하는 translate.ts

보통엔, 워크플로와 트리거를 앱에 추가합니다만, 이번 튜토리얼에서는 펑션의 유닛 테스트에 집중햅도록 하겠습니다. 따라서, 이번에는 워크플로와 트리거를 앱에 꼭 추가할 필요는 없습니다.

만약 연결된 Slack 워크스페이스에서 펑션이 작동하는 것을 확인하고 싶다면, 워크플로를 추가하고 실행하면 됩니다. 이전 튜토리얼에서 배워보았음으로 참고 하시기 바랍니다.

만약 커스텀 펑션이 아직 익숙하지 않다면 다음 내용들을 진행하기 전에 커스텀 펑션에 대한 튜토리얼을 일독해보실 것을 권합니다.

Add echo.ts and its Tests

echo 펑션부터 시작해보겠습니다. 입력된 텍스트를 outputs 으로 리턴하는 역할을 합니다. 펑션 호출자가 inputs 내의 calipalize: true 를 전달하면 이 펑션은 텍스트를 변환 합니다.

import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";
import { FunctionSourceFile } from "https://deno.land/x/deno_slack_source_file_resolver@0.1.5/mod.ts";

export const def = DefineFunction({
  callback_id: "echo",
  title: "Echo inputs",
  source_file: FunctionSourceFile(import.meta.url),
  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 } };
  }
});

Having Aliases in import_map.json

코드 토픽을 테스트하기전에, Deno 코딩의 흥미로운 기술을 하나 소개하고자 합니다. 코드내에 외부 디펜던시가 있는 경우, import 소스는 풀패키지 호스팅 URL 이어야만 합니다. 예를 들어, 위의 코드를 보면 deno_slack_source_file_resolver 모듈이 0.1.5 버전으로 설정되어있습니다.

import { FunctionSourceFile } from
  "https://deno.land/x/deno_slack_source_file_resolver@0.1.5/mod.ts";

Deno VS 코드 확장을 설치했다면, 위와 같은 부분을 많이 보실 수 있을 꺼고, 문제될 부분은 없습니다. 그러나 같은 모듈내에 버전이 포함된 Full URL 을 가진 수많은 임포터가 있는 것을 보셨다면, 이걸 매번 반복해야되나? 라는 생각도 하실 수 있을 겁니다. 디펜던시 버전을 관리하기 위해서 import_map.json 을 설정하실 수 있는데요, deno.jsonc 내에 반드시 “importMap”: “import_map.json” 가 있어야 합니다. (slack create 명령어가 대신 해주기도 합니다.)

암튼 소스코드내에서 https://deno.land/x/deno_slack_source_file_resolver 모듈과 버전을 매번 임포트를 하는 피하기 위해서 import_map.json 을 추가해보도록 하겠습니다.

{
  "imports": {
    "deno-slack-sdk/": "https://deno.land/x/deno_slack_sdk@1.4.3/",
    "deno-slack-api/": "https://deno.land/x/deno_slack_api@1.5.0/",
    "deno-slack-source-file-resolver/": "https://deno.land/x/deno_slack_source_file_resolver@0.1.5/"
  }

요렇게 해두시고 나면, 임포터에서 URL 을 다 적는 노가다를 피할 수 있습니다. (모듈의 풀 URL 대신에 내가 alias 로 만든 이름 사용)

// Add "deno-slack-source-file-resolver/": "https://deno.land/x/deno_slack_source_file_resolver@0.1.5/"
// to "imports" in ./import_map.json
import { FunctionSourceFile } from "deno-slack-source-file-resolver/mod.ts";

이 튜토리얼에서는 위 방법을 사용하여 몇가지 alias 를 만들어두고 사용하도록 하겠습니다. 다음과 같은 내용이 됩니다.

{
  "imports": {
    "deno-slack-sdk/": "https://deno.land/x/deno_slack_sdk@1.4.3/",
    "deno-slack-api/": "https://deno.land/x/deno_slack_api@1.5.0/",
    "deno-slack-source-file-resolver/": "https://deno.land/x/deno_slack_source_file_resolver@0.1.5/",
    "mock-fetch/": "https://deno.land/x/mock_fetch@0.3.0/",
    "std/": "https://deno.land/std@0.170.0/"
  }
}

Slack 의 차세대 플랫폼의 템플릿에서는 개발자분들이 하이픈으로 구분된 alias 를 사용하는 것을 권고하고 있습니다. 그래서 이번 튜토리얼에서도 이러한 네이밍 법칙을 사용하겠습니다. 그렇지만 만약 하이픈(-)이 아닌 언더바(_) 를 사용하시더라도 문제될 것은 없습니다.

Write Your First Deno Test Code

아 이제 테스트할 대상이 준비되었습니다. 먼저 첫번째 Deno 테스트 코드를 써보겠습니다. echo_test.ts 라는 파일을 만들고 다음으로 채워넣습니다.

// Add "std/": "https://deno.land/std@0.170.0/" to "imports" in ./import_map.json
import { assertEquals } from "std/testing/asserts.ts";
import { SlackFunctionTester } from "deno-slack-sdk/mod.ts";
// `createContext` utility helps you build valid arguments for functions
const { createContext } = SlackFunctionTester("my-function");

import handler from "./echo.ts";

// Define a test pattern using Deno.test utility
// The method accept label and test function
// When you install VS Code Deno extension, you can run each test pattern on the editor UI
Deno.test("Return the input text as-is", async () => {
  const inputs = { text: "Hi there!" };
  const { outputs } = await handler(createContext({ inputs }));
  assertEquals(outputs?.text, "Hi there!");
});

이 코드에서 Slack 의 차세대플랫폼과 연관된 부분은 SlackFunctionTestercreateContext 유틸리티 입니다. 이 유틸리티들은 펑션을 위한 유효한 인자값들을 만드는데 도움이 되는데요. 작고 간단하기 때문에, 다양한 테스트 패턴을 만드는데 크게 방해되지 않습니다. inputs, env, token 과 그외에 각종 테스트 데이터를 전달할 수 있습니다.

Deno 테스팅은 너무 간단한데요. 별도의 다른 설정파일을 만들 필요가 없습니다. 그리고 deno test 명령어를 통해서 테스트를 수행할 수 있습니다.

그러나, 만드신 코드가 파일 시스템 리드,네트워크 접근, 환경 변수 접근등의 별도 권한이 필요하다면 deno test 커맨드 수행시에 추가해주셔야 합니다. 특히 테스트 대상이 FunctionSourceFile 와 같은 파일 시스템 접근권한을 필요로 한다면 테스트 명령어는 deno test –allow-read 가 될 것입니다. 똑같은 방법으로 코드가 네트워크 접근을 해아한다면 deno test –allow-net 을 붙여주면 됩니다.

이런 세부 옵션들을 하나하나 기억하고 싶으신 않을테니까, deno.jsoncdeno task 라인을 추가해두셔도 됩니다. 다음와 같이 해두시면 deno task test 명령어를 수행했을 때 deno test –alow-read 가 수행됩니다.

{
  "importMap": "import_map.json",
  "tasks": {
    "test": "deno test --allow-read"
  }
}

마지막으로 팁을 드리자면, 만약 한개 펑션에 대해서만 테스트를 수행하고 싶으시다면 뒤에 파일의 경로를 적어주시면 됩니다. echo_test.ts 펑션을 테스트하고 싶다면 deno test –allow-read echo_test.ts 로 적어주시면 되는거죠.

$ deno test --allow-read echo_test.ts
running 1 test from ./echo_test.ts
Return the input text as-is ... ok (6ms)

ok | 1 passed | 0 failed (43ms)

성공적으로 패스 했습니다.

새로운 테스트 패턴을 추가하실 때, 추가적으로 하셔야 되는 일은 같은 파일내에 Deno.test 부분을 추가하는 것입니다. 다음 테스트 패턴은 capitalize 옵션이 예상한대로 동작하는지 검증 합니다.

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

똑같은 테스트 코드를 실행해보겠습니다. 결과물이 다른 것을 보실 수 있습니다.

$ deno test --allow-read echo_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)

ok | 2 passed | 0 failed (48ms)

예상한대로 다시 패스 하였습니다. 패스를 못하게 되면 어떻게 될지 한번 볼까요? 두번째 테스트의 마지막 줄을 약간 바꿔보겠습니다. (뭐가 다른지 해깔리실 수 있는데 느낌표가 한개에서 두개로 바꾼겁니다.)

assertEquals(outputs?.text, "HI THERE!!");

그리고 다시 실행.. 실패한것을 볼 수 있습니다.

$ deno test --allow-read echo_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 ... FAILED (7ms)

 ERRORS

Return the capitalized input text as-is when capitalize: true => ./echo_test.ts:17: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.170.0/testing/asserts.ts:190:9)
    at file:///path-to-project/echo_test.ts:20:3

 FAILURES

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

FAILED | 1 passed | 1 failed (51ms)

error: Test failed

이 섹션에서는 Deno 테스팅의 기본에 대한 부분을 배웠습니다. 이 부분에 대해 좀 더 깊게 공부해보고 싶으시다면, Deno 매뉴얼 페이지를 참조하세요.

Add my_send_message.ts and its Tests

다음 테스트 대상은 약간 어렵습니다. Slack 의 API 엔드포인트로 HTTP 요청을 보냅니다. 이 유닛 테스팅 코드에서는, API로의 연결도 같이 구현해야 합니다.

그렇지만 걱정 안하셔도 됩니다. 좋은 서티파티 패키지가 있기 때문입니다. mock_fetch library 가 테스트 코드를 빨리 작성하는데 도움이 될겁니다.

자 프로젝트에 테스트 대상을 추가하는 것부터 시작해보겠습니다. my_send_sessage.ts 파일을 만들고 다음 내용을 저장합니다. 커스텀 펑션에 대한 이전 튜토리얼을 보셨다면 익숙하신 코드 일겁니다.

import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";
// Add "deno-slack-source-file-resolver/": "https://deno.land/x/deno_slack_source_file_resolver@0.1.5/" to "imports" in ./import_map.json
import { FunctionSourceFile } from "deno-slack-source-file-resolver/mod.ts";

export const def = DefineFunction({
  callback_id: "my_send_message",
  title: "My SendMessage",
  source_file: FunctionSourceFile(import.meta.url),
  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"],
  },
});

export default SlackFunction(def, async ({ inputs, client }) => {
  const response = await client.chat.postMessage({
    channel: inputs.channel_id,
    text: inputs.message,
  });
  console.log(`chat.postMessage result: ${JSON.stringify(response, null, 2)}`);
  if (response.error) {
    const error = `Failed to post a message due to ${response.error}`;
    return { error };
  }
  return { outputs: { ts: response.ts } };
});

mocks 없이 어떻게 동작하는지 한번 볼까요. my_send_message_test.ts 파일을 만듭시다.

import { SlackFunctionTester } from "deno-slack-sdk/mod.ts";
// Add "std/": "https://deno.land/std@0.170.0/" to "imports" in ./import_map.json
import { assertEquals } from "std/testing/asserts.ts";
import handler from "./my_send_message.ts";

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, error } = await handler(createContext({ inputs, token }));
  assertEquals(error, undefined);
  assertEquals(outputs, { ts: "1111.2222" });
});

테스트를 해보면 다음과 같이 실패하는 것을 볼 수 있습니다.

$ deno test --allow-read my_send_message_test.ts
running 1 test from ./my_send_message_test.ts
Send a message successfully ... FAILED (7ms)

 ERRORS

Send a message successfully => ./my_send_message_test.ts:41:6
error: PermissionDenied: Requires net access to "slack.com", run again with the --allow-net flag
    const resp = await fetch(url, {
                       ^
    at opFetch (deno:ext/fetch/26_fetch.js:73:16)
    at mainFetch (deno:ext/fetch/26_fetch.js:225:61)
    at deno:ext/fetch/26_fetch.js:470:11
    at new Promise (<anonymous>)
    at fetch (deno:ext/fetch/26_fetch.js:433:20)
    at BaseSlackAPIClient.apiCall (https://deno.land/x/deno_slack_api@1.5.0/base-client.ts:38:24)
    at apiCallHandler (https://deno.land/x/deno_slack_api@1.5.0/api-proxy.ts:11:23)
    at Proxy.APIProxy.objectToProxy (https://deno.land/x/deno_slack_api@1.5.0/api-proxy.ts:43:14)
    at AsyncFunction.<anonymous> (file:///path-to-project/my_send_message.ts:23:38)
    at handlerModule (https://deno.land/x/deno_slack_sdk@1.4.3/functions/slack-function.ts:44:28)

 FAILURES

Send a message successfully => ./my_send_message_test.ts:41:6

FAILED | 0 passed | 1 failed (44ms)

error: Test failed

보신바와 같이 이 코드는 slack.com 엔드포인트로 HTTP 요청을 보냅니다. 따라서 테스트 수행시에 –-allow-net 옵션이 필요합니다.

error: PermissionDenied: Requires net access to "slack.com", run again with the --allow-net flag 

지금은 –allow-net 옵션이 어떻게 동작하는지 보는게 목적은 아닙니다만, 일단은 한번 보도록 하시죠.

$ deno test --allow-read --allow-net my_send_message_test.ts
running 1 test from ./my_send_message_test.ts
Send a message successfully ...
------- output -------
chat.postMessage result: {
  "ok": false,
  "error": "invalid_auth"
}
----- output end -----
Send a message successfully ... FAILED (293ms)

 ERRORS

Send a message successfully => ./my_send_message_test.ts:41:6
error: AssertionError: Values are not equal:

    [Diff] Actual / Expected

-   "Failed to post a message due to invalid_auth"
+   undefined

  throw new AssertionError(message);
        ^
    at assertEquals (https://deno.land/std@0.170.0/testing/asserts.ts:190:9)
    at file:///path-to-project/my_send_message_test.ts:45:3

 FAILURES

Send a message successfully => ./my_send_message_test.ts:41:6

FAILED | 0 passed | 1 failed (333ms)

error: Test failed

API 호출을 위한 유효한 토근을 설정하지 않았기 때문에(테스트 코드상에서는 xoxb-valid 로 되어있습니다. 잘못됐죠), chat.postMessage API 호출이 실패하는 것을 볼 수 있습니다.

테스트를 성공적으로 하기 위해서, 실제 HTTP 요청이 아닌 stub/mock 이 필요합니다. 이전에 말씀드린것처럼 mock_fetch library 를 사용해보도록 하죠. mf.install() 호출뒤에 mock 핸들러를 다음과 같이 정의 합니다. mf.mock(“POST@/api/chat.postMessage”, handler) 테스트간의 fetch 펑션에 대해서 동작할 것입니다. 물론 이러한 전역 객체 교체는 프로덕션 코드에는 영향을 미치지 않습니다.

// Add "mock-fetch/": "https://deno.land/x/mock_fetch@0.3.0/" to "imports" in ./import_map.json
import * as mf from "mock-fetch/mod.ts";
import { SlackFunctionTester } from "deno-slack-sdk/mod.ts";
// Add "std/": "https://deno.land/std@0.170.0/" to "imports" in ./import_map.json
import { assertEquals } from "std/testing/asserts.ts";
import handler from "./my_send_message.ts";

// After this method call,
// all `globalThis.fetch` calls will be replaced with mock behaviors
mf.install();

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

// Utility for generating valid arguments
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, error } = await handler(createContext({ inputs, token }));
  assertEquals(error, undefined);
  assertEquals(outputs, { ts: "1111.2222" });
});

다시 테스트를 돌려보면, 다음과 같이 성공하는 것을 볼 수 있습니다.

$ deno test --allow-read --allow-net my_send_message_test.ts
running 1 test from ./my_send_message_test.ts
Send a message successfully ...
------- output -------
Bearer xoxb-valid
chat.postMessage result: {
  "ok": true,
  "ts": "1111.2222"
}
----- output end -----
Send a message successfully ... ok (9ms)

ok | 1 passed | 0 failed (47ms)

테스트 패턴을 두개 더 추가해보겠습니다. 잘 동작할 것입니다.

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, error } = await handler(createContext({ inputs, token }));
  assertEquals(error, "Failed to post a message due to invalid_auth");
  assertEquals(outputs, undefined);
});

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, error } = await handler(createContext({ inputs, token }));
  assertEquals(error, "Failed to post a message due to channel_not_found");
  assertEquals(outputs, undefined);
});

다음과 같이 나옵니다.

$ deno test --allow-read my_send_message_test.ts
running 3 tests from ./my_send_message_test.ts
Send a message successfully ...
------- output -------
chat.postMessage result: {
  "ok": true,
  "ts": "1111.2222"
}
----- output end -----
Send a message successfully ... ok (10ms)
Fail to send a message with invalid token ...
------- output -------
chat.postMessage result: {
  "ok": false,
  "error": "invalid_auth"
}
----- output end -----
Fail to send a message with invalid token ... ok (6ms)
Fail to send a message to an unknown channel ...
------- output -------
chat.postMessage result: {
  "ok": false,
  "error": "channel_not_found"
}
----- output end -----
Fail to send a message to an unknown channel ... ok (5ms)

ok | 3 passed | 0 failed (59ms)

Want to remove console.log()?

Slack의 공식 예제들은 로그기록을 위해서 console.log() 를 사용하는 것을 권합니다만, ——– output ——- 부분이 테스트 결과에서 나오는것이 싫으시다면, Deno 의 표준 로거 로 바꾸시고, env 내에 로그레벨을 전달하셔도 됩니다.

예제 입니다. logger.ts 파일을 만드세요

// Add "std/": "https://deno.land/std@0.170.0/" to "imports" in ./import_map.json
import * as log from "std/log/mod.ts";

// Simple logger using std modules
export const Logger = function (
  level?: string,
): log.Logger {
  const logLevel: log.LevelName = level === undefined
    ? "DEBUG" // the default log level
    : level as log.LevelName;
  // Note that this method call make global effects
  log.setup({
    handlers: {
      console: new log.handlers.ConsoleHandler(logLevel),
    },
    loggers: {
      default: {
        level: logLevel,
        handlers: ["console"],
      },
    },
  });
  return log.getLogger();
};

그리고 my_send_messages.ts 내에 console.log() 부분을 교체 하시면 됩니다.

import { Logger } from "./logger.ts";

export default SlackFunction(def, async ({ inputs, client, env }) => {
  const logger = Logger(env.LOG_LEVEL);
  const response = await client.chat.postMessage({
    channel: inputs.channel_id,
    text: inputs.message,
  });
  // Replace console.log() here
  logger.debug(`chat.postMessage result: ${JSON.stringify(response, null, 2)}`);
});

테스트 코드도 조금 바꿔보죠.

Deno.test("Send a message successfully", async () => {
  const inputs = { channel_id: "C111", message: "Hi there!" };
  const token = "xoxb-valid";
  // Pass the env to set log level
  const env = { LOG_LEVEL: "INFO" };
  const { outputs, error } = await handler(
    createContext({ inputs, env, token }),
  );
  assertEquals(error, undefined);
  assertEquals(outputs, { ts: "1111.2222" });
});

다시 테스트를 수행해보시면 stdout 에서 console.log() 부분 아웃풋이 없어졌을 것입니다.

향후에는 Deno SDK 에서 좀더 나은 커스텀 로깅 방법을 제공할수도 있겠지만, 그때까지는 이러한 방식으로 하셔야 할 겁니다.

Add translate.ts and its Tests

마지막 테스트 대상은 외부 API 호출을 하는 펑션입니다. 두번째 코드와 크게 다를 것은 없습니다.

translate.ts 파일을 만들고 다음 내용을 넣습니다. 만약 External API Calls 튜토리얼을 해보셨다면 친숙한 코드일 것입니다.

import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";
// Add "deno-slack-source-file-resolver/": "https://deno.land/x/deno_slack_source_file_resolver@0.1.5/" to "imports" in ./import_map.json
import { FunctionSourceFile } from "deno-slack-source-file-resolver/mod.ts";

// The metadata definition for the translator function
export const def = DefineFunction({
  callback_id: "translate",
  title: "Translate",
  description: "Translate text using DeepL's API",
  // This example code uses a 3rd party module "deno_slack_source_file_resolver"
  // to automatically resolve the relative path of this source file.
  source_file: FunctionSourceFile(import.meta.url),
  input_parameters: {
    properties: {
      text: { type: Schema.types.string },
      source_lang: { type: Schema.types.string }, // optional
      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 }) => {
  // When running a dev version of your app,
  // placing `.env` file with variables is the way to configure `env`.
  // As for the deployed prod one, you need to run
  // `slack env add DEEPL_AUTH_KEY (value)` before running the app's workflow.
  const authKey = env.DEEPL_AUTH_KEY;
  if (!authKey) {
    // Since it's impossible to continue in this case, this function returns an error.
    const error =
      "DEEPL_AUTH_KEY env value is missing! Please add `.env` file for `slack run`. If you've already deployed this app, `slack env add DEEPL_AUTH_KEY (value)` command configures the env variable for you.";
    return { error };
  }
  // Build an HTTP request towards DeepL's text translation API
  const subdomain = authKey.endsWith(":fx") ? "api-free" : "api";
  const deeplApiUrl = `https://${subdomain}.deepl.com/v2/translate`;
  const body = new URLSearchParams();
  body.append("auth_key", authKey);
  body.append("text", inputs.text);
  if (inputs.source_lang) { // this input is optional
    body.append("source_lang", inputs.source_lang.toUpperCase());
  }
  body.append("target_lang", inputs.target_lang.toUpperCase());
  // When there is no Deno library to perform external API calls,
  // simply using the built-in `fetch` function is recommended.
  const response = await fetch(deeplApiUrl, {
    method: "POST",
    headers: {
      "content-type": "application/x-www-form-urlencoded;charset=utf-8",
    },
    body,
  });
  const status = response.status;
  if (status != 200) {
    const body = await response.text();
    const error = `DeepL API error (status: ${status}, body: ${body})`;
    return { error };
  }
  // When the translation succeeds, the response body is JSON data.
  const result = await response.json();
  if (!result || result.translations.length === 0) {
    const error = `Translation failed: ${JSON.stringify(result)}`;
    return { error };
  }
  // When it's successful, the outputs must include "translated_text" as it's required.
  return { outputs: { translated_text: result.translations[0].text } };
});

다음은 테스트 코드 입니다.

// Add "mock-fetch/": "https://deno.land/x/mock_fetch@0.3.0/" to "imports" in ./import_map.json
import * as mf from "mock-fetch/mod.ts";
import { SlackFunctionTester } from "deno-slack-sdk/mod.ts";
// Add "std/": "https://deno.land/std@0.170.0/" to "imports" in ./import_map.json
import { assertEquals } from "std/testing/asserts.ts";
import handler from "./translate.ts";

// After this method call,
// all `globalThis.fetch` calls will be replaced with mock behaviors
mf.install();

// Handles DeepL's text translation API calls
mf.mock("POST@/v2/translate", async (args) => {
  const params = await args.formData();
  if (params.get("auth_key") !== "valid-token") {
    // Invalid auth_key
    return new Response("Unauthorized", { status: 401 });
  }
  // Successful pattern
  const body = JSON.stringify({
    translations: [{ detected_source_language: "EN", text: "こんにちは!" }],
  });
  return new Response(body, { 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: "こんにちは!" });
});

Deno.test("Fail to continue if DEEPL_AUTH_KEY is missing", async () => {
  const inputs = { text: "Hello!", target_lang: "ja" };
  //intentionally empty
  const env = {};
  const { outputs, error } = await handler(createContext({ inputs, env }));
  assertEquals(
    error,
    "DEEPL_AUTH_KEY env value is missing! Please add `.env` file for `slack run`. If you've already deployed this app, `slack env add DEEPL_AUTH_KEY (value)` command configures the env variable for you.",
  );
  assertEquals(outputs, undefined);
});

Deno.test("Fail to traslate text with an invalid token", async () => {
  const inputs = { text: "Hello!", target_lang: "ja" };
  const env = { DEEPL_AUTH_KEY: "invalid-token" };
  const { outputs, error } = await handler(createContext({ inputs, env }));
  assertEquals(error, "DeepL API error (status: 401, body: Unauthorized)");
  assertEquals(outputs, undefined);
});

이전 코드와 다른점은 mf.mock(“POST@/v2/translate”, handler) 부분 입니다.

mf.mock() 에 엔드포인트 도메인을 설정해도 되지 않나 라고 생각하실 수 있는데, 제가 알기로는 mock_fetch library 에서 아직 지원하지 않습니다. 그래서 만약 몇개의 도메인 경로와 충돌이 발생한다면 url 인자를 확인해보심이 좋겠습니다.

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

Want to Write Tests for Interactions?

모달/버튼을 통한 테스트 수행을 하고 싶으실 수도 있겠지만, 현재는 불가능 합니다.

새로 모달 창을 오픈하는 테스트코드를 올려두었으니 한번 확인해보세요. https://github.com/slack-samples/deno-message-translator/blob/main/functions/configure_test.ts

그러나 모달을 오픈하고 난뒤에 view_submission/view_closed/block_actions 요청 패턴을 테스트 하는 방법에 대해서는 아직 저도 최선의 방법을 찾고 있는 중입니다.

이러한 패턴들을 위한 가장 좋은 방법을 찾거나 (또는 Deno SDK 에서 제공해주거나) 한다면, https://api.slack.com/future 내의 문서를 업데이트 해두거나 이 페이지를 업데이트 하도록 하겠습니다.

Wrapping Up

이번 튜토리얼을 통해서 배운것은 다음과 같습니다.

  • 펑션 테스트를 위한 Deno 테스트 코드 작성
  • 테스트를 위한 inputs, env 준비
  • fetch 펑션 호출을 위한 mock 객체 사용
  • 모듈 경로의 alias 를 위한 import_map.json 편집
  • –allow-read 등의 deno 명령어 옵션 사용법

이 프로젝트는 다음 경로에서 확인하실 수 있습니다.

https://github.com/seratch/slack-next-generation-platform-tutorials/tree/main/04_Unit_Testing

이번 튜토리얼도 즐거우셨으면 좋겠네요. 마찬가지로 피드백이나 코멘트가 있으시다면 트위터 (@seratch) 로 연락주시거나, 여기 남겨주세요.

Happy hacking with Slack’s next-generation platform 🚀

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다