Technote

by sizuhiko

@swc-node/jest を使ってテストを高速化する(v1.5対応版)

少し前のブログで @swc-node/jest を使ってテストを高速化する という記事を書いたのですが、最後のまとめで以下のように紹介していました。

ちなみに 1.5系からは tsconfig を読み込むように変更されているのですが、現時点私たちのプロジェクトではビルドが失敗するので、まだ 1.4 系を利用しています。 問題はすでに issue TypeScript path mapping is not working. になっており、 パスエイリアスを使っているときに、うまくファイルが import できないところなのですが、これが解決されれば transformer の設定も不要になるので、 とても便利になるはずです。

そこから他の issue を調べたりしているうちに解決策がわかったので、今回の記事で追記していきます。

@swc-node/jest の最新版(現時点では 1.5.2 )を使います。

パスエイリアスの指定方法

1.5系からは tsconfig を読み込むので、 jest.config.jstransform は以下のように記述すれば良いことになっています。

/** @type {import('@jest/types').Config.InitialOptions} */
const config = {
  moduleFileExtensions: ['ts', 'js'],
  transform: {
    '^.+\\.ts$': ['@swc-node/jest']
  },
  testMatch: ['**/*.test.ts'],
  moduleNameMapper: {
    '@/(.*)$': '<rootDir>/$1',
  },
};

module.exports = config;

パスエイリアスは moduleNameMapper に記述されているとおり、テストコードからは @/domain/entities/user-entity みたいに @ をルートにしてパスエイリアスを使えるようにしています。

で、このままだとパスエイリアスがすべて解決されません、というのが前回までの内容でした。

今回は調べるなかで、以下のように指定することでパスエイリアスがうまく利用できるようになったので、紹介します。

/** @type {import('@jest/types').Config.InitialOptions} */
const config = {
  moduleFileExtensions: ['ts', 'js'],
  transform: {
    '^.+\\.ts$': [
      '@swc-node/jest',
      // ここから
      {
        paths: {
          '@/*': [`${__dirname}/*`],
        }
      }
      // ここまで
    ]
  },
  testMatch: ['**/*.test.ts'],
  moduleNameMapper: {
    '@/(.*)$': '<rootDir>/$1',
  },
};

module.exports = config;

transformpaths の指定をするとパスエイリアスが使えるようになります。 tsconfig にも paths の指定が以下のようになっています

  "paths": {
    "@/*": ["./*"]
  },

で、tsconfig に記述しているとおりコードが書き変わると、そのまま相対パスになってしまい、 インポートパスが見つからないということになっていました。 TypeScript は tsconfig でパスエイリアスを指定するとき ./* のように書くとプロジェクトルートからの解釈をしてくれるのですが、 swc ではそのように解釈はしてくれません。 そのため jest.config.jstransform で絶対パスに上書きするように記述します。 jest.config.js はJavaScriptコードなので、 __dirname を使って絶対パスに変更することができるようになります。

これで安心して @swc-node/jest も最新版に追従できるようになりました。 もし同じような問題になっている人の手助けになる記事になっていれば幸いです。

Rails 7の採用提案で注目を集め始めた Import maps の過去、現在、そして未来について...

import maps については 2019年ごろに度々当ブログにて紹介してきました。 で、それからもずっと issue なんかを追いかけてはきたのですが、昨年 Rails 7 に import maps が入るっていう驚きがあったのと、 社内で発表する機会もあったので、少しスライドにまとめて発表しました。

で、これを発表したのが 2021/11/29 (いい肉の日だった)のですが、それからすぐ 2021/12/15 Rails 7 がリリースされました。

Rails 7 における import maps

Rails 7.0: Fulfilling a vision

で、import maps に関連する部分として Importmap for Rails というライブラリができました。 Rails本体の Gemfile に入っているので Rails 7+ をインストールすると、自動的に入ります。 Railsのアプリケーションジェネレータを使うとき、 javascript オプション( -j )のデフォルトも importmap のようです。 /railties/lib/rails/generators/rails/app/app_generator.rb#L26

ドキュメントにも書いてあるとおり、現時点は Chrome/Edge 89+ だけが import maps をサポートしているので、es-module-shimsを使っているようです。

ライブラリの中では importmaptagshelper.rb で import maps 関連のタグを出力できるヘルパー関数を用意しています。 ドキュメントによると、設定は config/importmap.rb にするようです。

公式ドキュメントによると、以下のように記述するようです。

# config/importmap.rb
pin "react", to: "https://ga.jspm.io/npm:react@17.0.2/index.js"

もしくはコマンド実行すると、最新のバージョンで config/importmap.rb に追記してくれるようです。 Using npm packages via JavaScript CDNs

./bin/importmap pin react react-dom

なるほど、といいたいところですが、ここで一つ疑問がおきます。 これって npm outdatednpm audit てきなことや、 dependabot での脆弱性チェックってどうなっちゃうのかしら?と。

パッケージの最新追従はどうなるのか?

さきほどの importmap コマンドですが、 unpin という削除するオプションはあるようですが、アップデート系は書いてないので無いのかな? あとは import maps の json を出力する json オプションがあるだけみたいだ(執筆時点のソースコードを確認した)。

脆弱性があったパッケージのアップデートとか気にしないのかな?気にするよね? 少し package-lock.json から config/importmap.rb に変換するライブラリとかないかな?と調べてみたりもしたのですが、 見つかりませんでした。

まぁそれより、ちゃんと issue 見るか、と思っていたらちゃんとありました。 /bin/importmap outdated

で、上記に対するPRもありました。 Add outdated and audit commands

こんな感じで出力されるようです。

Audit

+-------------+----------+---------------------+--------------------------------------------------------+
| Package     | Severity | Vulnerable versions | Vulnerability                                          |
+-------------+----------+---------------------+--------------------------------------------------------+
| glob-parent | high     | <5.1.2              | Regular expression denial of service                   |
| is-svg      | high     | >=2.1.0 <4.3.0      | ReDOS in IS-SVG                                        |
| is-svg      | high     | >=2.1.0 <4.2.2      | Regular Expression Denial of Service (ReDoS)           |
| lodash      | critical | <4.17.12            | Prototype Pollution in lodash                          |
| lodash      | high     | <4.17.21            | Command Injection in lodash                            |
| lodash      | high     | <4.17.19            | Prototype Pollution in lodash                          |
| lodash      | high     | <4.17.11            | Prototype Pollution in lodash                          |
| lodash      | low      | <4.17.5             | Prototype Pollution in lodash                          |
| lodash      | moderate | <4.17.21            | Regular Expression Denial of Service (ReDoS) in lodash |
| lodash      | moderate | <4.17.11            | Prototype pollution in lodash                          |
| nth-check   | moderate | <2.0.1              | Inefficient Regular Expression Complexity in nth-check |
+-------------+----------+---------------------+--------------------------------------------------------+
  11 vulnerabilities found: 6 high, 3 moderate, 1 low, 1 critical

Outdated


+-----------------+---------------+---------------+
| Package         | Current       | Latest        |
+-----------------+---------------+---------------+
| @jspm/core      | 2.0.0-beta.18 | 2.0.0-beta.19 |
| @jspm/core      | 2.0.0-beta.2  | 2.0.0-beta.19 |
| aaaasssstimulus | 2.0.0         | Not found     |
| glob-parent     | 3.1.0         | 6.0.2         |
| is-glob         | 3.1.0         | 4.0.3         |
| is-svg          | 3.0.0         | 4.3.2         |
| lodash          | 4.17.1        | 4.17.21       |
| nth-check       | 1.0.0         | 2.0.1         |
| react           | 16.0.0        | 17.0.2        |
| stimulus        | 2.0.0         | 3.0.1         |
+-----------------+---------------+---------------+
  10 outdated packages found

CI で dependabot みたいなのがどう検知するかといった課題はあるかもですが、いったんコマンド結果が出力できるので、それをシェルでゴニョゴニョすれば アップデートの検知や脆弱性の発見はできるようになりそうですね。

合わせて読みたい

さいごに Zenn にも良い記事があったので、紹介しておきます。 Rails 7.0 で標準になった importmap-rails とは何なのか?

Middleman を 3系から 4系にアップグレードした

GWどこ行っても混んでそうなんで、月-金はブログを書くと決めた今年。 ここ2年コロナでコミュニティ活動やらもできず、ブログを書くのもモチベ下がりまくりだったけど、良いきっかけになりました。 さて、久々にブログやるのだから、せっかくなんで執筆環境もアップデートしようということで、確認すると Middleman は 4系にアップデートされていました。

Middleman公式のアップデートガイドv4 へのアップグレードをまずはチェック。

ふむふむ、 config には影響ありそうだけど、それ以外は外部ライブラリやテンプレート関係っぽいので、まずはビルドしてみてかな、という感じ。 そういえば Middleman で使ってる Ruby のバージョンも古いなーと思って、 Middleman v4 で対応している Ruby バージョンは何だろう?と調べました。

特に記述がないので Ruby 3系でも動くのかなーと思っていたら、Cannot start server ( or use cli) with Ruby 3.0 and Middleman 4なんて issue がありました。

Which 4.4.0 tried to add some support for Ruby 3, I would not suggest relying on it. It is far too much work to support projects as old as 2008 and a language released in 2020 at the same time.

Patches welcome or if you want to live dangerously the master branch and 5.0x series will be Ruby 3.1+ only

とのこと。Middleman 5系になると Ruby 3.1以上のみになるんですね。まぁそれまでは Ruby 2系かぁということで、とはいえ2系も古いバージョンが手元に入っていたので、これを最新にすることにしました。

Ruby 2.7.6 へのアップデート

Ruby のバージョン管理には rbenv + ruby-build を使っているので、まずはそれ自体のアップデートを。 brew 使っても良いのですが、僕は git から clone して使っているので、 pull して最新に。

$ rbenv install 2.7.6
Downloading openssl-...

BUILD FAILED 

あれ?なんかopensslダウンロードしようとして、失敗するな…

rbenv brew openssl でググるとありました。rbenv installがopensslで失敗する、そして解決策をみて「あーこれ昔もハマった記憶があるような、ないような」という気分に。

ということで、以下のコマンドを実行して無事アップデートできました。

RUBY_CONFIGURE_OPTS="--with-openssl-dir=/opt/local/bin/openssl" rbenv install 2.7.6

Middleman 4.4.2 へのアップデート

まず bundle outdated で、アップデート可能なバージョンがあるか確認します。 Gemfileは以下のようになっていました。

# the following line to use "https"
source 'http://rubygems.org'

gem "middleman", "~> 3.2.2"
gem "middleman-blog", "~> 3.5.1"
gem "middleman-deploy"

# For feed.xml.builder
gem "builder", "~> 3.0"
gem "middleman-syntax"
gem "redcarpet"

すると、 middlemanmiddleman-blog には、ともに 4系のアップデートがあったので、 Gemfile のバージョンを更新して bundle update を実行しました。

middleman-deploy の更新

ライブラリのバージョンも無事アップデートできたので、 bundle exec middleman server を実行してみたところ、 middleman-deploy でエラーが出ます。 このライブラリは outdated で調べたとき更新がなかったので、何でだろう?と思い、まずは issue を調べてみることに。 そうしたら、ありました。 Error with Middleman 4.2 and middleman-deploy 1.0

なるほど、まだステーブルではない 2.0.0.pre.alpha バージョンを利用しないといけないようです。これだと outdated には出ないですね。 それに伴い condfig も変更になっていました。

activate :deploy do |deploy|
  deploy.build_before = true # default: false
  # deploy.method = :git 1系の指定方法
  deploy.deploy_method = :git # 2系の指定方法

しかし、alpha から 7年経過…。Middleman も一時期は流行ったのですが、SSG(Static Site Generator)の移り変わりも速いですね。 今は何が流行っているのだろうか?

さいごに

これらを修正した commit はこちらになります。

実は古い記事の demo コードもブログに混ぜていたのですが、これを入れると何故か UTF-8 じゃないファイルがある、みたいなエラーになってしまいました。 エラー箇所は、バイナリー判定してバイナリーでないファイルだったら、みたいなコードもあったりするので、 html が置いてあるといけないのかな。なんでとりあえず demo はディレクトリごと削除することに。 まぁ古い jQuery 関係の demo なんで、そのうちそれらはライブラリ側の github pages に移そうと思います(もう需要ないだろうけど)。

あと middleman コマンド実行すると

Deprecation warning: Expected string default value for '--instrument'; got false (boolean).
This will be rejected in the future unless you explicitly pass the options `check_default_type: false` or call `allow_incompatible_default_type!` in your code
You can silence deprecations warning by setting the environment variable THOR_SILENCE_DEPRECATION.

みたいなワーニングが出ているので、時間があったらこちらも調査したいなと思っています。

uvu での Test Double

uvu は軽量で高速なJavaScript/TypeScriptのテスティングランナーです。 SveltteKit でも利用されており、pong-swoosh でも採用しています。

現時点の uvu は assert 機能がメインで、依存関係をモックして置き換えるような Test Double には対応していません。 ただ、すべてが DI で解決できるわけではなく、依存パッケージについてはモックしたい場合もあります。

そこで pong-swoosh では、以下の2つのライブラリを使って、 uvu で Test Double を実現しています。

snoop

Easy breezy test spies fo sheezy. というキャッチです(よくわからんw)。

やれることは Jest でいうところの Jest.fn() だと思っていれば大丈夫です。 モック関数を作れます。公式の Example でも uvu をランナーに使っていることから、親和性も高いです。

import { snoop } from 'snoop';
import { test } from 'uvu';
import * as assert from 'uvu/assert';

const add = (a, b) => a + b;

test('add', () => {
  const addFn = snoop(add);
  const anotherAddFn = snoop(add);

  addFn.fn(1, 1);
  anotherAddFn.fn(1, 1);
  addFn.fn(2, 2);
  anotherAddFn.fn(2, 2);

  assert.ok(addFn.called);
  assert.not(addFn.notCalled);
  assert.not(addFn.calledOnce);
  assert.is(addFn.callCount, 2);
  assert.equal(addFn.calls[0].arguments, [1, 1]);
  assert.equal(addFn.calls[1].arguments, [2, 2]);
  assert.is(addFn.calls[0].result, 2);
  assert.is(addFn.calls[1].result, 4);
  assert.not(addFn.firstCall.error);
  assert.not(addFn.lastCall.error);
  assert.ok(addFn.calledBefore(anotherAddFn));
  assert.ok(addFn.calledAfter(anotherAddFn));
  assert.ok(addFn.calledImmediatelyBefore(anotherAddFn));
  assert.not(addFn.calledImmediatelyAfter(anotherAddFn));
});

test.run();

proxyquire

Node.js の require モジュールをプロキシすることができるライブラリです。 Jest でいうところの Jest.mock('ライブラリ名', {/** モック */}) だと思っていれば大丈夫です。

公式サンプルを引用すると、以下のような感じです。

get.js:

var get    = require('simple-get');
var assert = require('assert');

module.exports = function fetch (callback) {
  get('https://api/users', callback);
};

get.test.js:

var proxyquire = require('proxyquire').noCallThru();
var assert = require('assert');

var fetch = proxyquire('./get', {
  'simple-get': function (url, callback) {
    process.nextTick(function () {
      callback(null, { statusCode: 200 })
    })
  }
});

fetch(function (err, res) {
  assert(res.statusCode, 200)
});

pong-swoosh での使い方

この2つを組み合わせて、依存モジュールを proxyquire で置き換え、置き換える関数は snoop で作っています。

create-channel.jsのテストコードを例にします。 create-channel は、ブラウザからのチャンネル作成リクエストを受け付けたあと、実際にチャンネルを作成する処理です。

test('まだ作成されていないチャンネルの場合は、チャンネル追加されてソケットにオーナー登録されること', (context) => {
  const getChannelMock = snoop(() => false); // 存在しないチャンネル
  const addChannelMock = snoop(() => {});
  const channelId = faker.datatype.uuid();
  const createChannel = proxyquire('../create-channel', {
    './channel': {
      getChannel: getChannelMock.fn,
      addChannel: addChannelMock.fn,
    },
    'uuid': {
      v4: () => channelId,
    },
  });
  const result = createChannel(context.socket, 'test', 'test.ch');

  assert.ok(addChannelMock.called);
  assert.is(addChannelMock.callCount, 1);
  assert.equal(addChannelMock.calls[0].arguments, ['test', channelId, 'test.ch']);

create-channel の実装では以下のように channel モジュールに依存しているので、そこをモックしていきます。

const channel = require('./channel');
const { v4: uuidv4 } = require('uuid');

module.exports = (socket, userId, channelName, channelId) => {
  const exists = channel.getChannel(userId, channelName);

まず、Test Double を下準備します。

  const getChannelMock = snoop(() => false); // 存在しないチャンネル
  const addChannelMock = snoop(() => {});
  const channelId = faker.datatype.uuid();
  const createChannel = proxyquire('../create-channel', {
    './channel': {
      getChannel: getChannelMock.fn,
      addChannel: addChannelMock.fn,
    },
    'uuid': {
      v4: () => channelId,
    },
  });

snoop でモック関数を2つ作り、それを proxyquire を使って、 ./channelrequire モジュールを置き換えます。 uuid モジュールも同様に snoop でモックしていることがわかるでしょう。

で、実際に const result = createChannel(context.socket, 'test', 'test.ch'); でテスト対象のモジュールを呼び出したら getChannelfalse になると addChannel が呼ばれるのが正しい動作なので、以下のように検証しています。

  assert.ok(addChannelMock.called);
  assert.is(addChannelMock.callCount, 1);
  assert.equal(addChannelMock.calls[0].arguments, ['test', channelId, 'test.ch']);
  • 1行目は、 addChannel が呼ばれたかどうか
  • 2行目は、 addChannel が何回呼ばれたか
  • 3行目は、 addChannel がどのような引数で呼び出されたか

を検証することができます。Jest で Test Double を書いたことがあれば、イメージはつきやすいと思います。 このように Test Double の機能が標準では備わってない uvu でも、どうしてもモックしたいテストも記述できるようになっています。

GitHub Enterprise の Actions で依存関係を S3 にキャッシュする

GitHub の Actions で npm などの依存関係パッケージをキャッシュするのは、公式ドキュメント 依存関係をキャッシュしてワークフローのスピードを上げる にも書いてあるように、 CIを高速化するのに重要です。

公式 Example だと以下のとおりです。

name: Caching with npm

on: push

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Cache node modules
        uses: actions/cache@v3
        env:
          cache-name: cache-node-modules
        with:
          # npm cache files are stored in `~/.npm` on Linux/macOS
          path: ~/.npm
          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-build-${{ env.cache-name }}-
            ${{ runner.os }}-build-
            ${{ runner.os }}-

      - name: Install Dependencies
        run: npm install

      - name: Build
        run: npm build

      - name: Test
        run: npm test

actions/cache を利用すれば問題なく動くのですが、Enterprise 版の場合は runs-on にセルフホストランナーを利用するので、キャッシュがホストランナーのディスクに依存してしまいます。 これだとホストランナーのディスク管理とか大変になるので、少し管理で楽をしたいな、と思ったところ S3 にキャッシュできるものがあり、現在もこれを利用しています。

actions-s3-cache

actions-s3-cache は名前のとおり依存関係のキャッシュをS3に保持してくれます。 これならディスク容量を気にすることもないし、管理が楽になりますね。

公式 Example だと、こんな感じです。

steps:
  - uses: actions/checkout@v2
  - uses: shonansurvivors/actions-s3-cache@v1.0.1
    env:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      AWS_DEFAULT_REGION: us-east-1
    with:
      s3-bucket: your-s3-bucket-name # required
      cache-key: npm-v1-${{ hashFiles('laravel/package-lock.json') }} # required ('.zip' is unnecessary)
      paths: node_modules # required 
      command: npm ci # required
      zip-option: -ryq # optional (default: -ryq)
      unzip-option: -n # optional (default: -n)
      working-directory: laravel # optional (default: ./)

僕らの運用では

  • S3バケットは複数リポジトリで共有
  • キー名にリポジトリ名を入れる

みたいな決め事を作ったので、こんな感じにしてみました。

  - uses: actions/checkout@v2
  - id: get-repository-name
    run: |
      IFS=/
      REPO=${{ github.repository }}
      set -- ${REPO}
      echo "::set-output name=name::$2"
  - uses: shonansurvivors/actions-s3-cache@v1.0.1
    env:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      AWS_DEFAULT_REGION: ap-northeast-1
    with:
      s3-bucket: ci-actions-cache
      cache-key: ${{steps.get-repository-name.outputs.name}}-cache-${{ hashFiles('package-lock.json') }}
      paths: node_modules
      command: npm ci

get-repository-name でリポジトリ名を抜き出してきて、 cache-key で使っています。 cache-key に一致するものがあれば、S3からダウンロードしてzipを展開、一致するものがなければ command を実行して paths にあるファイルを zip アーカイブして S3 に保持といったことをやってくれるので、便利ですね。

あとは S3 のバケットにファイルの有効期限を設定しておくようにすれば、古いキャッシュがいつまでも残ることもなく、とても便利です。