お知らせ

現在サイトのリニューアル作業中のため、全体的にページの表示が乱れています。

本記事ではTypeScriptとSWC、Jestの組み合わせ環境でjest.spyOnを使う方法をexportパターン別に書いていきます。また、jest.spyOn対象の実装の詳細はテストせず、モック化したインターフェースのテストのみを行う前提で書いています。

確認環境

Env Ver
Node.js 18.14.0
typescript 4.9.5
@swc/cli 0.1.62
@swc/core 1.3.36
@swc/jest 0.2.24
jest 29.4.3

サンプルコード

全編は以下にあります
jest.spyOn pattern example for SWC + TypeScript

親関数から子関数を呼び出すパターンで、子関数のjest.spyOnをするシチュエーションで考えます。

default export編

以下のdefaultエクスポートをjest.spyOnする方法です。

child.ts
export const child = (param: string) => {
  return param;
};
parent.ts
import child from 'src/default/child';

const parent = (param: string) => {
  return child(param);
};

export const Parent = {
  parent,
};
parent.spec.ts

この形式の場合import側では書き換え不能なのでファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOnするのには向いていません。

import * as child from 'src/default/child';
import { Parent } from 'src/default/parent';

jest.mock('src/default/child');

describe('default', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(child, 'default');
    Parent.parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

named export編

以下の名前付きエクスポートをjest.spyOnする方法です。

child.ts
export const child = (param: string) => {
  return param;
};
parent.ts
import { child } from 'src/named-export/child';

export const parent = (param: string) => {
  return child(param);
};
parent.spec.ts

default exportと同じくimport側では書き換え不能なのでファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOnするのには向いていません。

import * as child from 'src/named-export/child';
import { parent } from 'src/named-export/parent';

jest.mock('src/named-export/child');

describe('function', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(child, 'child');
    parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

namespace export編

やり方はnamed export編と同じです。

child.ts
export namespace Child {
  export const child = (param: string) => {
    return param;
  };
}
parent.ts
import { Child } from 'src/namespace/child';

export namespace Parent {
  export const parent = (param: string) => {
    return Child.child(param);
  };
}
parent.spec.ts

namespaceの場合、トランスパイル後にクロージャになり、書き換え以前に関数にアクセスできなくなるため、ファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOnするのには向いていません。

import { Child } from 'src/namespace/child';
import { Parent } from 'src/namespace/parent';

jest.mock('src/namespace/child');

describe('namespace', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(Child, 'child');
    Parent.parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

module export編

これだけやり方が変わります。

child.ts
const child = (param: string) => {
  return param;
};

export const Child = {
  child,
};
parent.ts
import { Child } from 'src/module/child';

const parent = (param: string) => {
  return Child.child(param);
};

export const Parent = {
  parent,
};
parent.spec.ts

このケースの場合オブジェクトをexportしていて書き換えができるため、モック化せずにそのままjest.spyOnすることが出来ます。
このやり方であれば、ファイルのモックはしないため、同じファイルにある関数をjest.spyOnする事ができますが、裏を返せばオブジェクトの書き換えが可能であるため、実装方法によっては予期せぬ不具合が生まれる可能性があり、危険です。
何かしらの悪意ある攻撃を受けた場合にオブジェクトがすり替わるなどすると致命的だと思います。

import { Child } from 'src/module/child';
import { Parent } from 'src/module/parent';

describe('module', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(Child, 'child');
    Parent.parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

おまけ:それぞれのトランスパイル結果

.spec.jsは省いてます

default export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'default', {
  enumerable: true,
  get: () => _default,
});
const child = (param) => {
  return param;
};
const _default = child;
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Parent', {
  enumerable: true,
  get: () => Parent,
});
const _child = /*#__PURE__*/ _interopRequireDefault(require('./child'));
function _interopRequireDefault(obj) {
  return obj && obj.__esModule
    ? obj
    : {
        default: obj,
      };
}
const parent = (param) => {
  return (0, _child.default)(param);
};
const Parent = {
  parent,
};

named export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'child', {
  enumerable: true,
  get: () => child,
});
const child = (param) => {
  return param;
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'parent', {
  enumerable: true,
  get: () => parent,
});
const _child = require('./child');
const parent = (param) => {
  return (0, _child.child)(param);
};

namespace export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'child', {
  enumerable: true,
  get: () => child,
});
const child = (param) => {
  return param;
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Parent', {
  enumerable: true,
  get: () => Parent,
});
const _child = require('./child');
var Parent;
(function (Parent) {
  var parent = (Parent.parent = (param) => {
    return _child.Child.child(param);
  });
})(Parent || (Parent = {}));

module export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Child', {
  enumerable: true,
  get: () => Child,
});
const child = (param) => {
  return param;
};
const Child = {
  child,
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Parent', {
  enumerable: true,
  get: () => Parent,
});
const _child = require('./child');
const parent = (param) => {
  return _child.Child.child(param);
};
const Parent = {
  parent,
};

この記事を書いた切っ掛け

一言でいうとTSC(TypeScript Compiler) からSWCに移行した際にjest.spyOnを使ったら、上手く使えずにハマったためです。

まずTypeScript + JestではTypeScriptをCJS(CommonJS) にトランスパイルするためjest.spyOnが有効でした。しかし、SWCはTypeScriptをESM(ES Modules) にトランスパイルします。ESMではjest.spyOnが有効になりません。

これはCJSではexportしたObjectの書き換えが可能なのに対し、ESMでは出来ないためです。(jest.spyOnは実行時にオブジェクトを書き換えることで動いています)

この対策としてファイルそのものをモックに置き換えることで、jestの管理下に起き、自由に書き換えられるようにするのが本記事のアプローチです。以前と異なり、呼び出している関数の実装の詳細を見ることはできなくなりましたが、これは単体テストの観点としては正しいため、ある意味本来の単体テストになったとも取れると考えています。(モックに元の処理を注入することで今まで通り実装の詳細を確認することも出来る可能性がありますが、確認してません)

投稿日:
言語::JavaScript言語::正規表現

ググっても意外と出てこないレア情報な気がする

replace()

'foo bar'.replace(/(?<first>.+?) (?<last>.+)/, '$<first>-$<last>');

match()

const mat = 'foo bar'.match(/(?<first>.+?) (?<last>.+)/);
console.log(mat.groups.first, mat.groups.last);

参考

割とハマってだるいので今回はサンプル程度にPRの一覧を取得して個別にSlackに投げるものを作ってみます

PRが2個ある場合、出力イメージはこんな感じ。2投稿に分けて投稿します

GitHub ActionからのSlackへの投稿イメージ

API トークンの入手

まずはSlack APIを叩くためのトークンをゲットします

  1. Create an appからアプリを作成
  2. 左のメニューからFeatures -> OAuth & Permissions
  3. Scopesを設定
    1. 今回はBot Token Scopesをchat:writeとします
  4. 左のメニューからSettings -> Install App to Your Teamでアプリをインストール
  5. トークンが吐き出されるのでメモする

GitHub Actions Workflowsの作成

SECRETの設定

  1. Slack APIトークンをリポジトリのSecretsに突っ込んでおきます
  2. 名前は一旦SLACK_TOKENとします

Workflowsの作成

前提
  • PR一覧の取得には actions/github-script を利用します
    • GitHub内部の情報を抜いたり、JSで処理を組みたいときに重宝します
    • APIリファレンスが読みやすいので、使うのにはあんま苦労しないと思います
  • Slack APIを叩くのにはcurlを利用します
    • actions/github-scriptから叩くのは多分難しいです
ベースの作成

これに肉付けをしていきます

name: Post to slack example
on:
  workflow_dispatch:
jobs:
  post-slack:
    runs-on: ubuntu-latest
    steps:
PR一覧の取得

List pull requestsにある通りに進めていきます

- uses: actions/github-script@v6
  id: set-result
  with:
    result-encoding: string
    script: |
      const { data: respPulls } = await github.rest.pulls.list({
        owner: context.repo.owner,
        repo: context.repo.repo,
      });

      console.log(respPulls);
PR一覧の加工

こんなデータを取る感じで組んでいきます
API仕様は List pull requests を参照

type PullRequest = {
  id: number;
  reviewers: string[];
};

先ほど取得したrespPullsを上記の型付けになるように加工します

const getReviewersName = (requested_reviewers) => {
  return requested_reviewers.map((reviewers) => {
    return reviewers.login;
  });
};

const getPullRequests = (pulls) => {
  return pulls.map((pull) => {
    return {
      id: pull.number,
      reviewers: getReviewersName(pull.requested_reviewers),
    };
  });
};

const pulls = getPullRequests(respPulls);
Slackに投げるメッセージの作成

こんなメッセージをPRの数分組んでいきます

なお実際にSlackでメンションを作る場合はGitHubのスクリーン名とSlackのユーザー IDの突き合わせ処理が別途必要です。やり方は別途後述します

@foo @bar
https://example.com/pulls/1

やっていること

  • 上記のフォーマットでメッセージを作成
  • シェルスクリプトで配列として扱うためにBase64にエンコード
    • 改行コードが混ざっていると扱いづらいので
  • エンコードした文字列をスペース区切り文字列として連結
    • AAA BBB CCC ...みたいな
  • 最後にWorkflowsの戻り値として設定しています
const encodedMessages = pulls.reduce((messages, pull) => {
  const reviewersBuff = pull.reviewers
    .reduce((acc, cur) => {
      return `${acc}${cur} `;
    }, '')
    .replace(/ $/, '');

  const reviewers = reviewersBuff === '' ? 'レビュアー未設定' : reviewersBuff;

  const message = `${reviewers}\\nhttps://example.com/pulls/${pull.id}`;
  const encodedMessage = Buffer.from(message).toString('Base64');

  return `${messages}${encodedMessage} `;
}, '');

return encodedMessages;
curlを利用してSlack APIを叩く

やっていること

  • encodedMessages=(${{steps.set-result.outputs.result}})
    • 前項で作った文字列を配列として取得しています
  • for message in ${encodedMessages[@]}
    • foreach的なやつです
    • 改行コードがこの時点で存在すると上手くいきません
  • decoded_mes=$(echo ${message} | base64 -di)
    • ここでBase64エンコードをデコードします
  • postSlack "$decoded_mes"
    • 別引数にならないように""で固めます
  • curl叩いてるところ
    • -dの中をヒアドキュメントで展開するのが味噌です
    • 単純に文字列として扱うと変数展開が起きてJSONが壊れます
      - run: |
        postSlack() {
            local mes=$1

            curl -sS https://slack.com/api/chat.postMessage \
                -H 'Authorization: Bearer ${{ secrets.SLACK_TOKEN }}' \
                -H 'Content-Type: application/json; charset=UTF-8' \
                -d @- <<EOF
                {
                    token: "${{ secrets.SLACK_TOKEN }}",
                    channel: "#api-test",
                    text: "$mes"
                }
        EOF
        }

        encodedMessages=(${{steps.set-result.outputs.result}})

        for message in ${encodedMessages[@]}
        do
            decoded_mes=$(echo ${message} | base64 -di)
            postSlack "$decoded_mes"
        done
コード全体
name: Post to slack example
on:
  workflow_dispatch:
jobs:
  post-slack:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@v6
        id: set-result
        with:
          result-encoding: string
          script: |
            const getReviewersName = (requested_reviewers) => {
              return requested_reviewers.map((reviewers) => {
                return reviewers.login;
              });
            };

            const getPullRequests = (pulls) => {
              return pulls.map((pull) => {
                return {
                  id: pull.number,
                  reviewers: getReviewersName(pull.requested_reviewers),
                }
              });
            }

            const { data: respPulls } = await github.rest.pulls.list({
              owner: context.repo.owner,
              repo: context.repo.repo,
            });

            const pulls = getPullRequests(respPulls);

            const encodedMessages = pulls.reduce((messages, pull) => {
              const reviewersBuff = pull.reviewers.reduce((acc, cur) => {
                return `${acc}${cur} `
              }, '').replace(/ $/, '');

              const reviewers = reviewersBuff === '' ? 'レビュアー未設定' : reviewersBuff;

              const message = `${reviewers}\\nhttps://example.com/pulls/${pull.id}`;
              const encodedMessage = Buffer.from(message).toString('Base64');

              return `${messages}${encodedMessage} `
            }, '');


            return encodedMessages;
      - run: |
          postSlack() {
            local mes=$1

            curl -sS https://slack.com/api/chat.postMessage \
                -H 'Authorization: Bearer ${{ secrets.SLACK_TOKEN }}' \
                -H 'Content-Type: application/json; charset=UTF-8' \
                -d @- <<EOF
                {
                    token: "${{ secrets.SLACK_TOKEN }}",
                    channel: "#api-test",
                    text: "$mes"
                }
          EOF
          }

          encodedMessages=(${{steps.set-result.outputs.result}})

          for message in ${encodedMessages[@]}
          do
              decoded_mes=$(echo ${message} | base64 -di)
              postSlack "$decoded_mes"
          done

Appendix:Slackにメンションを投げる方法

Slackへ実際にメンションを投げるのはユーザーIDを指定する必要があります
参考:Formatting text for app surfaces

"text": "<@U024BE7LH> Hello"のようにすることでメンションを投げられます

ユーザーIDはSlackアプリから相手のプロフィールを開き、そこにあるハンバーガーメニューみたいなやつから取れます。一応取得用のAPIもあります

ユーザーIDをSlackアプリ画面から取得する方法

投稿日:
言語::TypeScriptNode.js

TSでprocess.exit()をラップしたカスタムexitを作った時に静的解析のフローが壊れたので、その対策。
因みにこの制御フローのことをControl Flow Analysisと呼ぶらしい。TS系の文書ではCFAと略されていることが多い模様。

問題事例

通常のprocess.exit()例の7-8行目のように次の行から先がデッドコードになって欲しいが、上手くいかないので、これを上手くいくようにする。

カスタムexitのコード例 通常のprocess.exit()例
カスタムexitのコード例
静的解析のエディタ表示例

確認環境

Env Ver
TypeScript 4.8.4

サンプルコード

オブジェクトでラップして、カスタムexit関数の戻り値をneverで指定してやると上手くいくようになる。
因みにこれTypeScriptの仕様らしく、上手くやる方法はあんまりなさそう。
実はアロー関数ではなくfunctionを使えば解決したりするが、それはなんか嫌なので…

example.ts

type ErrorPayload = {
  message: string;
  code: number;
};

type ExampleExit = {
  exit: (err: ErrorPayload) => never;
};

const exit = (err: ErrorPayload) => {
  process.exit(err.code);
};

export const Example: ExampleExit = { exit };

implements.ts

import { Example } from './example';

Example.exit({ message: 'exit', code: 1 });
console.log(1);

関連記事

投稿日:
言語::CSS

レスポンシブにdisplayを切り替えるCSSを書いててハマったので備忘録。

今回は例として画面幅に応じて表示する項目数を変動させるCSSを書いてみます。media queryとnth-childを使って表示数を切り替えていく感じです。

イメージとしてはこんな感じ。

SP View PC View
SPビュー
PCビュー

上手くいかないケース

このコードでは画面幅を変えても項目数に変化がありません。理由としてはnth-child(n + 4)display: none;が900pxでも保持されたままだからです。試しにdisplay: none;font-weight: bold;とかに変えてみると良くわかります。

デモページ

.example li:nth-child(n + 4) {
    display: none;
}

@media screen and (min-width: 900px) {
    .example li:nth-child(n + 6) {
        display: none;
    }
}

上手くいくケース

以下のコードではnth-childで非表示にしている部分を900pxの時に表示指定にすることで3項目表示と5項目表示が切り替わるようになっています。そりゃ指定してなければ表示されないよねっていう単純な話ですね…。

デモページ

.example li:nth-child(n + 4) {
    display: none;
}

@media screen and (min-width: 900px) {
    /** 明示的に表示させる */
    .example li:nth-child(n + 4) {
        display: list-item;
    }

    .example li:nth-child(n + 6) {
        display: none;
    }
}