- 投稿日:
TypeScriptやJavaScriptだとコマンド一発で見れるコードカバレッジですが、C#.NETの開発ではちょっと手こずったので導入方法を記録しておく。
確認環境
Env | Ver |
---|---|
Visual Studio 2022 Community | Version 17.5.1 |
.NET Framework | 6.0 |
導入手順
- C#.NETで開発用のプロジェクトを作成
- 作成したプロジェクトのソリューションにxUnitテストプロジェクトを追加
- 何かしらの実装と、それに対するテスト実装を作成
- Fine Code CoverageをVisual Studioにインストール
- ソリューションエクスプローラからxUnitテストプロジェクトを右クリックしてテストの実行
- 下部トレイにあるFine Code Coverageタブを選択
- 表示されていない場合は表示 → その他のウィンドウ → Fine Code Coverageで表示出来る
- 表示されていない場合は表示 → その他のウィンドウ → Fine Code Coverageで表示出来る
- コードカバレッジが表示される
疑問
Visual Studio 2022にもなって標準でコードカバレッジも取れないの?
今どきそんな事あるか?思って調べてみたところ、どうやらEnterpriseであればコードカバレッジを取る機能がついている模様。
まぁ無料版だから仕方ないねということで諦めるしかないでしょう。Professional版にもないけど…w。VisualStudioの便利機能は以前から有料機能に組み込まれる傾向があるので仕方がない気もしますが、取り敢えず今回は有志が便利なツールを作っていてくれて助かりました。
因みにコードカバレッジ自体は無料版でも取れます。ただこれはXMLのカバレッジレポートを吐くだけなので、可視化するには別途ここからHTMLを生成する必要があり面倒なので、なんかもっと楽な方法はないかなと思って見つけたのが今回のFine Code Coverageでした。
しかしFine Code Coverage便利なのにそこまで利用されているように見えないのは、やはりテストに関心がある人が少ないのか、公式にあるカバレッジHTMLを吐く方法で納得しているのか、その辺りが気になりました。自前でFine Code Coverageの様な物を作っている人もいるでしょうが、それは少数派だと思いますし。
この方法で取れるカバレッジは正しいものか?
GitHubのREADMEを読む限り、組み込みのカバレッジレポーターのAPIを叩いて取ってきたものを表示しているだけに見えるので、恐らく正しいデータが出ているのではないかと思います。
少なくとも単純なコードのUnit testingを書いた感じではテストケースを増減することでカバレッジも連動して増減していたのと、組み込みのレポーターがそのくらいはやってくれている筈で、これはその内容を表示しているだけなので恐らく大丈夫なのではないかと考えています。
- 投稿日:
この記事ではGitHub ActionsのCustom actionをJavaScriptで実装するJavaScript actionをTypeScriptとSWCを使って実装した方法を書いてます。
モチベーション
- GitHub ActionsのWorkflowsを共通化したい
- TypeScriptでロジックを書きたい
- SWCを使いたい(nccはtscを使うので避けたい
- github-scriptは一定以上のボリュームがあるものには向かない
- これを使いつつUTを書いたりするとなると結構面倒になると思う
今回作るもの
Custom actionのうちJavaScript actionを作成します。
実装コードはTypeScript、トランスパイラはSWC、バンドラはwebpackを利用します。
バンドラを利用するのは、node_modules/
をGitで管理したくないためです。
ビルド成果物であるdist/
は実行時に必要なため、Gitで管理します。
(CI上でビルドしてキャッシュさせておくことも出来ると思いますが、今回は扱いません)
確認環境
Env | Ver |
---|---|
@actions/core | 1.10.0 |
@actions/github | 5.1.1 |
@swc/cli | 0.1.57 |
@swc/core | 1.3.26 |
swc-loader | 0.2.3 |
typescript | 4.9.5 |
webpack | 5.75.0 |
webpack-cli | 5.0.1 |
サンプルコード
Custom Actions本体
Custom action本体のサンプルコードです。以下に一式があります。
https://github.com/Lycolia/typescript-code-examples/tree/main/swc-ts-custom-actions
ディレクトリ構成
dist/
配下を叩くため、ここはGit管理に含めます。バンドルするのでnode_modules/
はGit管理から外して問題ありません。
├─dist/
│ └─index.js # Custom Actionsとして実行するファイル本体
├─node_modules/
├─src/
│ └─index.ts # TypeScript実装
├─action.yaml # Custom Actionsの定義
├─package-lock.json
├─package.json
├─swcrc-base.js # SWCの設定
├─tsconfig.json # tscの設定
└─webpack.config.js # webpackの設定
swcrc-base.js
SWCの設定例。特にJavaScript actionのための設定はなく、CLI向けのトランスパイルが出来る設定ならおk。
ファイル名は何でも大丈夫ですが、この場では.swcrc
にしないことで、直接SWCで利用しないことを判りやすくするために違う名前にしています。
module.exports = {
module: {
type: 'commonjs',
},
jsc: {
target: 'es2020',
parser: {
syntax: 'typescript',
tsx: false,
decorators: false,
dynamicImport: false,
},
baseUrl: '.',
paths: {
'src/*': ['src/*'],
},
},
};
webpack.config.js
SWCを使って.ts
ファイルをバンドルするための設定。これがないとimport
の解決ができずにコケます。
node_modules/
配下をGit管理に含める場合は不要かもしれませんが、それをするのは微妙だと思います。
const path = require('path');
const swcrcBase = require(path.resolve(__dirname, 'swcrc-base'));
module.exports = {
// エントリポイント
entry: path.resolve(__dirname, 'src/index.ts'),
// 出力設定
output: {
// クリーンアップ後に出力
clean: true,
// 出力ファイル名
filename: 'index.js',
// 出力パス
path: path.resolve(__dirname, 'dist'),
},
// 設定必須なので何か指定しておく
mode: 'production',
// 指定してないとNode.jsのネイティブAPIが呼べない
target: ['node'],
module: {
// swc-loaderの設定
rules: [
{
test: /\.ts$/,
exclude: /(node_modules)/,
use: {
loader: 'swc-loader',
// swcrcの設定
options: {
...swcrcBase,
},
},
},
],
},
resolve: {
// import時のファイル拡張子を省略してる場合にパスを解決するための設定
extensions: ['', '.ts', '.js'],
},
};
src/index.ts
最低限これだけ確認できれば応用して実装できるだろうという程度のサンプルコード。
@actions/*
系の使い方は以下のリンクから確認できます。
actions/toolkit: The GitHub ToolKit for developing GitHub Actions.
import * as core from '@actions/core';
import * as github from '@actions/github';
const githubToken = core.getInput('GITHUB_TOKEN', { required: true });
const octokit = github.getOctokit(githubToken);
console.log('octokit', octokit);
console.log('context', github.context);
core.setOutput('RESULT_MESSAGE', 'test result message');
action.yaml
実装の参考例として引数と出力を定義してます。特に不要な場合は書かなくてもいいです。
Node.jsのバージョンを詳細に指定したい場合は、composite action にすれば可能だとは思いますが、試してない。
composite actionにしてnvmか何かでインストールしてやれば恐らく可能。
構文は以下のページで確認できます。
GitHub Actions のメタデータ構文 - GitHub Docs
name: example
description: custom actions example
inputs:
GITHUB_TOKEN:
description: 'Repogitory GITHUB_TOKEN'
required: true
outputs:
RESULT_MESSAGE:
description: 'Result message'
on:
workflow_call:
runs:
using: node16
main: dist/index.js
Custom actionを使う側
Custom actionを使うWorkflowのサンプルコードです。以下にソースがあります。
https://github.com/Lycolia/custom-actions-usage-example
.github/workflows/example.yaml
uses
のところにはリポジトリの組織名と、リポジトリ名、action.yaml
が配置されているディレクトリまでのパスを書きます。ルートディレクトリにある場合はパスを書かなくてOK
最後に@sha-hash
でコミットハッシュかタグを付けてやれば呼べるようになります。
動作確認中はハッシュが頻繁に変わるので、最新のハッシュを取得してきて設定されるようにしておくと便利かもしれません。
name: run example
on:
workflow_dispatch:
jobs:
example:
runs-on: ubuntu-latest
steps:
- name: run custom actions
id: test
uses: org-name/repo-name/path/to/file@sha-hash
with:
# Custom action側で定義されている引数(input)の設定
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: show custom actions output
# Custom action側で定義されている出力(output)の取得
run: echo ${{ steps.test.outputs.RESULT_MESSAGE }}
参考資料
- Getting Started – SWC
- swcrcの書き方やswc-loaderの使い方
- Concepts | webpack
- webpackの設定
- カスタム アクションについて - GitHub Docs
- GitHub Actions のメタデータ構文 - GitHub Docs
- プライベート リポジトリからのアクションとワークフローの共有 - GitHub Docs
- actions/toolkit: The GitHub ToolKit for developing GitHub Actions.
@actions/*
系のドキュメントなど
- 投稿日:
変数の文字列を文字列で置換
@ECHO OFF
SET text="hoge piyo fuga"
REM 置換文字を標準出力
ECHO %text:hoge=XXX%
REM 置換文字を変数に設定
SET replaced_text=%text:hoge=XXX%
ECHO %replaced_text%
@ECHO ON
変数の文字列を変数の文字列で置換
SET "var=value"
とSET var="value"
の差は後述のAppendixを参照
CALL
を付けているのは、これがないと変数名そのものが文字列として扱われるため
@ECHO OFF
SET "text2=foo bar baz"
SET "search_word=baz"
SET "replacer=ZZZ"
REM 置換文字を標準出力
CALL ECHO %%text2:%search_word%=%replacer%%%
REM 置換文字を変数に設定
CALL SET replaced_text2=%%text2:%search_word%=%replacer%%%
ECHO %replaced_text2%
REM 検索文字列を削除
CALL SET replaced_text3=%%text2:%search_word%=%%
ECHO %replaced_text3%
@ECHO ON
Appendix
SETコマンドの記述方法を変えた場合の出力結果の差について
SET "var=foo bar ZZZ" |
SET var="foo bar ZZZ" |
---|---|
foo bar ZZZ |
"foo bar ZZZ" |
参考
- 投稿日:
本記事では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()
側はこっちに書いてる