- 投稿日:
本記事では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の管理下に起き、自由に書き換えられるようにするのが本記事のアプローチです。以前と異なり、呼び出している関数の実装の詳細を見ることはできなくなりましたが、これは単体テストの観点としては正しいため、ある意味本来の単体テストになったとも取れると考えています。(モックに元の処理を注入することで今まで通り実装の詳細を確認することも出来る可能性がありますが、確認してません)
- 投稿日:
ググっても意外と出てこないレア情報な気がする
replace()
'foo bar'.replace(/(?<first>.+?) (?<last>.+)/, '$<first>-$<last>');
match()
const mat = 'foo bar'.match(/(?<first>.+?) (?<last>.+)/);
console.log(mat.groups.first, mat.groups.last);
参考
- グループと後方参照
- 引数としての文字列の指定
replace()
側はこっちに書いてる
- 投稿日:
割とハマってだるいので今回はサンプル程度にPRの一覧を取得して個別にSlackに投げるものを作ってみます
API トークンの入手
まずはSlack APIを叩くためのトークンをゲットします
- Create an appからアプリを作成
- 左のメニューからFeatures -> OAuth & Permissions
- Scopesを設定
- 今回はBot Token Scopesを
chat:write
とします
- 今回はBot Token Scopesを
- 左のメニューからSettings -> Install App to Your Teamでアプリをインストール
- トークンが吐き出されるのでメモする
GitHub Actions Workflowsの作成
SECRETの設定
- Slack APIトークンをリポジトリのSecretsに突っ込んでおきます
- 名前は一旦
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もあります
- 投稿日:
TSでprocess.exit()
をラップしたカスタムexitを作った時に静的解析のフローが壊れたので、その対策。
因みにこの制御フローのことをControl Flow Analysisと呼ぶらしい。TS系の文書ではCFAと略されていることが多い模様。
問題事例
通常のprocess.exit()
例の7-8行目のように次の行から先がデッドコードになって欲しいが、上手くいかないので、これを上手くいくようにする。
カスタムexitのコード例 | 通常のprocess.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);
関連記事
- TypeScriptで戻り値型がneverな関数を作る
- オブジェクトに包まれていない、裸の関数に対して同様のことを行うアプローチを書いている
- 投稿日:
レスポンシブにdisplay
を切り替えるCSSを書いててハマったので備忘録。
今回は例として画面幅に応じて表示する項目数を変動させるCSSを書いてみます。media queryとnth-childを使って表示数を切り替えていく感じです。
イメージとしてはこんな感じ。
SP View | PC View |
---|---|
![]() |
![]() |
上手くいかないケース
このコードでは画面幅を変えても項目数に変化がありません。理由としては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;
}
}