ブックマークレットをテスト駆動開発する

前回、TypeScriptとGulpで無駄にモダンなブックマークレット開発環境を作ってみました。せっかくそこまで環境を作ったので、今回はテスト駆動開発できるようにしてみました。

(前回の記事はこちら)。
GulpとTypeScriptでブックマークレット開発環境を作ってみた - Trial and Spiral

テスト環境構築

テストフレームワークとして、Jestを使います。また、ブックマークレットである以上
1. 動かすためのページに行く
2. ブックマークレットのJavaScript(即時関数)を実行

という手順が必要になります。通常のユニットテストだけでは不十分なので今回はPuppeteerというヘッドレスブラウザも合わせて使います。要はコードからブラウザ操作を自動でやらせると思っていただければ。

関連ライブラリのインストール

ちょうど良く、jest-puppeteerというライブラリがあるのでそれを入れます。
jest-puppeteer puppeteer jest

READMEにあるように関連ライブラリも一緒に入れます。

$ yarn add -D jest-puppeteer puppeteer jest

さらにTSとGulpでやっているので必要なものを入れます。

$ yarn add -D gulp-jest ts-jest

そして関係する型も入れます

$ @types/expect-puppeteer @types/jest @types/jest-environment-puppeteer @types/puppeteer

設定ファイル

jestの設定はpackage.jsonに書いてもいいんですが、見通しを良くするため、今回はファイルを別に分けてみます。ファイルはルートディレクトリにjest.config.jsを作って以下を書きます。

また、jsonではなくjsファイルなのでコメントアウトやJS式が使える利点もありますね。
jest.config.js

module.exports = {
  preset: `jest-puppeteer`,
  moduleNameMapper: {
    '^#/(.+)': `<rootDir>/dist/$1`,
  },
  moduleFileExtensions: [`ts`, `tsx`, `js`],
  transform: {
    '^.+\\.(ts|tsx)$': `ts-jest`,
  },
  globals: {
    'ts-jest': {
      tsConfig: `tsconfig.json`,
    },
  },
  testMatch: [`**/__tests__/*.+(ts|tsx|js)`],
}

一番重要なのは最初のpreset: 'jest-puppeteer'globalsの指定ですね。それ以外はお好みです。

あとはオプショナルとして、puppeteerの設定も可能です。設定なしのデフォルトでも問題なく動いてくれますのでなくても大丈夫ですが、言及しておくと同じくルートディレクトリにjest-puppeteer.config.jsを作って設定できます、たとえば
jest-puppeteer.config.js

module.exports = {
  launch: {
    dumpio: true,
    headless: true,
  },
  browserContext: `default`,
}

みたいな感じです。特筆すべきはheadless: falseにするとヘッドレスモードをオフにできるので実際にGoogleChromeが動く様子がみれます。デバッグで使うこともありますね。

テストを書いてみる

設定ファイルに指定したようにテスト用のファイルは__tests__というディレクトリの中に作っていきます。前回にサンプルとして「見てるページのタイトルをプロンプトに表示する」というブックマークレットを作りました。まずはそのテストを書いてみます。

__tests__の中にsample.test.tsというファイルを作って以下のようにテストを書いてみます。
sample.test.ts

describe(`Google`, () => {
  beforeAll(async () => {
    await page.goto(`https://google.com`)
  })

  it(`should be titled "Google"`, async () => {
    await expect(page.title()).resolves.toMatch(`Google`)
  })

  it(`should be title in prompt`, async () => {
    let value
    await page.on(`dialog`, async dialog => {
      value = await dialog.defaultValue()
      await dialog.accept()
    })
    await page.addScriptTag({ path: './dist/sample.js' })
    expect(value).toMatch(`Google`)
  })
})

少し解説すると、beforeAllでサンプルとしてGoogleのトップに飛ぶようにしています。

最初のテストはブックマークレット関係なしでGoogleのサイトに飛べてるか、タイトルの値が取得できるかを見ています。このテストがパスしないのであれば、ブックマークレット以外の部分で問題があると考えたほうが良いですね。

2つめのテストが今回のテストです。ポイントはいくつかあります。

まず先にダイアログ(プロンプト)の表示を取得するように設定しておきます。具体的にはpage.on('dialog',でダイアログを待って、dialog.defaultValue()はダイアログが開いたときの入力欄の文字列を取得できます。取得したらその一応念のためにaccept()でダイアログを閉じておきます(OKを押したときの動作)。

その後でpage.addScriptTag({ path: './dist/sample.js' })でブックマークレットを動作させます。ちょっと強引ですがこの方法に落ちつきました。もしもっと良い方法があれば教わりたいです。
なお注意点としてはこのパスはテストを走らせるコマンドを叩くところからの相対パス(=ルートディレクトリから)で書く必要があります。

pathで指定したjsファイルをページに対してスクリプトとして挿入します。このJSはブックマークレット、すなわち即時関数であるためaddしたらすぐに実行される流れです。

そうしてあらかじめ待ち受けていたダイアログの文字列取得が動いて、valueに入るので、それをexpectでアサーションにかける、という流れですね。

さあ、ここまでできたら

$ jest

とテストを走らせてみましょう。2つのテストがパスすればOKです。

テストをGulpで扱う

せっかくビルド作業をGulpで扱っているのだからテストもGulpで扱うようにしてみます。というのもテストする対象がJSのなのでテスト前に都度都度ビルドする必要がある事情もありますので。

先にファイルから載せてしまうと
gulpfile.ts

import gulp from 'gulp'
import eslint from 'gulp-eslint'
import ts from 'gulp-typescript'
import uglify from 'gulp-uglify'
import replace from 'gulp-replace'
import jest from 'gulp-jest'
const tsProject = ts.createProject(`tsconfig.json`)
const srcDir = `src`
const destDir = `dist`
const testDir = `__tests__`

export const build = done => {
  gulp
    .src(`${srcDir}/*.ts`)
    .pipe(eslint({ useEslintrc: true }))
    .pipe(eslint.format())
    .pipe(eslint.failAfterError())
    .pipe(tsProject())
    .pipe(
      uglify({
        mangle: true,
        compress: true,
      })
    )
    .pipe(replace(/^(.*)$/, `javascript:$1`))
    .pipe(gulp.dest(destDir))
  done()
}
export const test = done => {
  gulp.src(`${testDir}/*.ts`).pipe(jest({}))
  done()
}
export const dev = done => {
  gulp.series(`build`, `test`)(done())
}
export default build

とこうなりました。前回から追加したものを順に説明します。

まずGulp内でJestを実行できるように読み込みます。

import jest from 'gulp-jest'

テスト用のディレクトリを定義しておきます。

const testDir = `__tests__`

testというタスクにjestのテストを割り合てます。

export const test = done => {
  gulp.src(`${testDir}/*.ts`).pipe(jest({}))
  done()
}

ビルドして続けてテストする一連を1つのタスクにします。

export const dev = done => {
  gulp.series(`build`, `test`)(done())
}

ちょいちょいdone()と出てきてますが、コールバックで終わりを明示しないとgulp.seriesで上手く扱えないようです。このへんJSらしくasync functionで扱えると良いんですが、v3からのやりかたもあり、最適解が良くわかりません。むむむ。

あとは、一応yarnやNPM経由でも実行しすいようにしておきましょうか。
package.json
package.json

{
  "scripts": {
    "build": "gulp build",
    "test": "gulp test",
    "dev": "gulp dev"
  },
}

を追記しておきましょう。

感想

やはり前回も言いましたが、たかだかブックマークレットでここまでやるのはオーバーキル気味ですね。個人的にはPuppeteer初めて触ってりしていろいろ楽しめた反面、ブックマークレットの動かし方がちょっとスマートじゃないのでモニョモニョしています。

またテスト環境的には本当は実際のページではなく用意したフィクスチャでやりたいところです。ネット環境が必須になってローカルだけで完結できないですし、テストに使ってる先に変更があったらテストが落ちてしまうので。

さらに、ブックマークレット内でCDNのjQueryを使う方法があって、そうするとちょっと楽にブックマークレット書けるんですが、その場合読み込み待ちをテスト内で実現できず上手くテストを書くことができませんでした。それでネイティブJSで実装しなおしたんですが、jQueryの読み込み待ちがなくなったことで高速化したので結果的にや良かったです。

まあ強引なテスト方法であることは否めないですが、やっぱりエンジニアたるものポチポチテストするよりはテストコードで動作を担保したいので、なかなか良いエクササイズでした。

最後にこの作成を環境をGitHubに上げたので気が向いたら参考にしてみてください。

AquiTCD/ts-bookmarklet-workbench: a workbench to build bookmarklets by typescript with jest

ブログシステムをVuePressに移行しましたGulpとTypeScriptでブックマークレット開発環境を作ってみた