Simple minds think alike

より多くの可能性を

nockでHTTP Request (axis)をモックしてjestでテストみた

Webアプリケーションのフロントエンドのテストを書く場合など、APIへのHTTPリクエストをモックしてテストしたい時 nock を使うとスッキリ書けて便利です。nock を読み込むと http.ClientRequestをオーバーライドしてくれて、リクエストに割り込み、特定のリクエストの場合モックしてくれるようになります。(ドキュメント)

axis を使ってHTTPリクエストを送るサンプルコードを紹介してみたいと思います。

サンプルコード

プロジェクトを0から作ってテストを書いて理解を深められるように書いていますが、Githubにサンプルのテストコードを上げているのでgit cloneして動かしてみても良いかもしれません。 github.com

git cloneしてテストを実行できます。(2つ目のテスト(index_sample2.test.js)はFAILします)

$ git clone git@github.com:moritamori/nock-sample.git
$ npm i
$ npm run test

プロジェクトのセットアップ

プロジェクトを作り、必要なライブラリをインストールします。

$ mkdir nock-sample && cd nock-sample && npm init -y
$ npm install nock axios jest --save-dev

ディレクトリ直下に index.js を追加します。

// index.js
const axios = require('axios')
module.exports.getData = function() {
  return axios.get("https://qiita.com/api/v2/items")
}

package.json にtestをjestを実行するように変えておきます。

{
  〜〜〜
  "scripts": {
    "test": "jest"
  },
  〜〜〜
}

nockで出てくる概念

まず nock には、スコープ(scope)インターセプター(intercepter)という2種類の概念があるのですが、モックするリクエストの情報を設定するためにどちらのオブジェクトの関数を呼ぶかを意識すると、うまくテストコードを書けると感じています。 nock v13の実装ベースで解説していこうと思います。

nock は1つのスコープオブジェクトとインターセプターのリストを作るコードを書くことで、テスト実行時にインターセプターのリストにマッチするリクエストがあると、実際にHTTP リクエストを送る代わりにモックオブジェクトを返してくれます。

例えば、以下のようなスコープ・インターセプターがある場合では、

  • インターセプター1
    • 2回までhttps://qiita.com/api/v2/items へのHTTPリクエストをモックし、レスポンス { items: [] }をステータス200で返す
  • インターセプター2
    • 3回までhttps://qiita.com/api/v2/items/1 へのHTTPリクエストをモックし、レスポンス { id: 1 }をステータス200で返す

という動きをします。

スコープ(scope)

スコープとは、ベースパス(プロトコル+ホスト名+ポート)やヘッダー情報といったリクエストの共通となる情報を持つオブジェクトです。

以下のように書くと、スコープオブジェクトが作られます。(参照した実装箇所)

// index.test.js
const scope = nock("https://qiita.com")

インターセプター(intercepter)

インターセプターをスコープに追加

スコープオブジェクトのGet, Post関数等を呼ぶことで、インターセプターのオブジェクトが作られます。また、インターセプターの reply 関数を呼ぶことで、スコープオブジェクトのインターセプターのリストに追加され、テスト実行時にリクエストにマッチするインターセプターを探します。

プロジェクトのディレクトリ直下にindex.test.js を追加して、テスト実行してみます。

/**
  * @jest-environment node
  */
const nock = require('nock')
const index = require('./index')
 
test('Quiita APIをモックしitems: []が返ってくること', async () => {
  // スコープオブジェクトを作成
  const scope = nock('https://qiita.com')
 
  // パス`api/v2/items`にマッチするリクエストがあれば、代わりに `item: []` を返す
  const intercepter = scope.get('/api/v2/items')
  intercepter.reply(200, { items: [] })
 
  // `/api/v2/items`にアクセスしてデータを取得
  const res = await index.getData()
 
  // `item: []` が返ってくることを確認
  expect(res.data).toEqual({ items: [] })
});
$ npm run test

 PASS  ./index.test.js
  ✓ Quiita APIをモックしitems: []が返ってくること (21 ms)

モックするとインターセプターが削除されることを確認

インターセプターによって該当のHTTPリクエストがモックされると、スコープオブジェクトのリストから削除されます。(参照した実装箇所)

なので、テストコードを以下のように変えて、同じリクエストを送ると2回目のリクエストはモックされず、テストはFAILします。

/**
  * @jest-environment node
  */
const nock = require('nock')
const index = require('./index')

test('Quiita APIの2回目のリクエストがエラーになること', async () => {
  // スコープオブジェクトを作成
  const scope = nock('https://qiita.com')
 
  // パス`api/v2/items`にマッチするリクエストがあれば、代わりに `item: []` を返す
  const intercepter = scope.get('/api/v2/items')
  intercepter.reply(200, { items: [] })
 
  // `item: []` が返ってくることを確認
  const res = await index.getData()
  expect(res.data).toEqual({ items: [] })
 
  // 2回目はインターセプターが動かないため、items: []が返らない
  const res2 = await index.getData()
  expect(res2.data).toEqual({ items: [] })
});
$ npm run test

 FAIL  ./index.test.js
  ✕ Quiita APIをモック (22 ms)

  ● Quiita APIをモック

    Nock: No match for request {
      "method": "GET",
      "url": "https://qiita.com/api/v2/items",
      "headers": {
        "accept": "application/json, text/plain, */*",
        "user-agent": "axios/0.19.2"
      }
    }

同じAPIに複数回リクエストを送るテストを実行する

同じAPIに複数回リクエストを送れるようにするには、以下のテストコードのように

  • スコープのpersist関数を呼ぶ(何回でもインターセプターが動くように)
  • インターセプターにモック回数(once, twice, thrice, times)(指定した回数インターセプターが動くように)

のどちらかを指定します。 両方指定した場合は、スコープの persist が優先され、何度でもインターセプターが動くようになります。(参照した実装箇所)

/**
  * @jest-environment node
  */
const nock = require('nock')
const index = require('./index')

test('Quiita APIをモックしitems: []が返ってくること', async () => {
  // スコープオブジェクトを作成
  const scope = nock('https://qiita.com')
    .persist() // 何度でもインタセープタが呼ばれるように

  // パス`api/v2/items`にマッチするリクエストがあれば、代わりに `item: []` を返す
  const intercepter = scope.get('/api/v2/items')
    .twice() // 2回までインタセプターを呼べるように (戻り値は、インターセプター)
    .thrice() // 3回までインタセプターを呼べるように (戻り値は、インターセプター)
    .times(5) // 5回までインタセプターを呼べるように (戻り値は、インターセプター)
  intercepter.reply(200, { 'items': [] });

  // `item: []` が返ってくることを確認
  const res = await index.getData();
  expect(res.data).toEqual({ 'items': [] });

  // 2回目もインターセプターが動くようになる
  const res2 = await index.getData();
  expect(res2.data).toEqual({ 'items': [] });
});

また、インターセプターonce, twice, thrice は内部的に times 関数を呼んでます。(参照した実装箇所)

インターセプターが動く回数毎に返すオブジェクトを変えたり、他のAPIをモックするには

インターセプターのreply 関数を呼ぶと、nock()で最初に作ったスコープオブジェクトが返ります。

なので、続けてGet, Post関数を呼ぶことでチェインして書けます。

/**
  * @jest-environment node
  */
const nock = require('nock')
const index = require('./index')

test('Quiita APIをモックすること', async () => {
  nock('https://qiita.com')
    .get('/api/v2/items')
    .once() // 1回目の`/api/v2/items`の呼び出しのみ `items: []`を返すように
    .reply(200, { 'items': [] }) // ここで、スコープオブジェクトが返る
    .get('/api/v2/items')
    .twice() //2,3回目の`/api/v2/items`の呼び出しのみ `items: [ { name: 'アイテム1' } ]`を返すように
    .reply(200, { 'items': [  { name: 'アイテム1' }  ] })  // ここで、スコープオブジェクトが返る
    .get('/api/v2/items/1') //`/api/v2/items/1`の呼び出しは `{ id: 1 }`を返すように
    .reply(200, { id: 1 })

  // `items: []` が返ってくることを確認
  const res = await index.getData();
  expect(res.data).toEqual({ 'items': [] });

  // 2回目は `items: [ { name: 'アイテム1' } ]`が返ってくることを確認
  const res2 = await index.getData();
  expect(res2.data).toEqual({ 'items': [  { name: 'アイテム1' }  ] })

  // 3回目は `items: [ { name: 'アイテム1' } ]`が返ってくることを確認
  const res3 = await index.getData();
  expect(res3.data).toEqual({ 'items': [  { name: 'アイテム1' }  ] })
});

他のリクエストの情報(ヘッダやクエリパラメータ、リクエストボディ、ディレイ)指定の仕方

ヘッダやクエリパラメータ、リクエストボディ、ディレイの指定に関しては、nockのREADMEを参考にしてみてください。

github.com

上記の記載したスコープ・インターセプターどちらのオブジェクトが返ってくるかを意識することで、複雑なテストケースに関してもうまく対応できるようになるかと思います。

実際のコードで、スコープインターセプターどちらにある関数なのか調べるのが確実です。

Web開発マシンをMacbook ProからThinkpad X1 Carbon(OS: Ubuntu)に変えてから2年が経ったので感想を書いてみる

2018年5月に仕事で使っている開発マシンをMacBook proからThinkpad X1 Carbon(OS: Ubuntu)に変更し、2年ちょっと経ちました。今の感想としては、MacBookの時と比べるとだいぶ快適に使えて、変えて本当に良かった!トラブルもない!という感じです。

MacからUbuntuへの移行の背景や移行に関して、Web開発に必要なアプリケーションがどう変わったか、良かった点・悪かった点・変わらない点を共有したいと思います。

Macをやめた理由

そもそもMacを使い始めた主な理由が、職場の人がほとんどがMacを使っていて、開発環境周りの構築で困ったことがあれば誰かに聞いて解決できそうというのがありました。しかし、ほぼググって問題を解決できたので、徐々にMacじゃなくても良さそうと感じていました。

そんな時にMacbookのイヤホンジャックが壊れてしまい、修理に出している間に仕事をするために代替機が必要となって、もしかしてMacbookは開発環境として可搬性が低いのでは。。と思ったというのがきっかけです。

あと、OS関係なくマシンのメモリ不足のせいかもしれませんが、重たく感じることが多く、ちょくちょく再起動していたりしました。

元々のLinuxの習熟度

学生の頃から自宅の自作PCのデスクトップ環境としてRedhat系のディストリビューションを使っていたりしたので、特に日々の運用で困ることは想定できなかったので、導入には抵抗感はなかったです。

ただ、Macは仕事で使うだけで

  • iTermでシェルコマンド実行
  • RubyMine(JetBrains IDE)やVSCode, Atomを使ってWeb開発
  • Chromeで色々調べる
  • その他、SlackやZoom等でコミュニケーションする
  • たまにKindleで本読む

くらいしか使っていなかったので、Ubuntuをインストールしてしまえば、必要な知識というかやることはあまり変わらないと感じています。

MacからUbuntuへどう移行したか

仕事の開発環境としてUbuntuを使ったことがなかったので、まずは自宅のPCに仕事で必要なアプリケーションを入れてみて、ちゃんと使えそう、と思えてから会社にPC(Thinkpad X1 Carbon)買ってもらいました。

Macで行っていた以下のようのことを、段階的にUbuntuで行うように置き換えていき、途中で問題が見つかれば一旦現状のMacでの作業に戻し、1つづつ問題を解決していって仕事に支障がないように移行しました。

  • SlackやZoomなど仕事上のコミュニケーション
  • Webブラウザでの調べ事
  • 開発周り(IDE・エディタ、シェルコンソール、Git、DB等ミドルウェア

Web開発マシンの選定

Web開発マシンとして採用したThinkpad X1 Carbonは以下の観点で採用しました

  • リスク面
  • ユーザビリティ
    • 家電量販店で、キーボードを試し打ちしたら、非常に打ちやすかったので個人的に気に入った
    • カーボンは軽くて頑丈なので持ち運びにも良い
  • コスト面
    • カスタマイズでちょっとスペックを上げたX1 Carbonでも20万円前後の金額で購入できるのは嬉しい
      • Macbook proだとスペックの割にちょっとお高めで気持ち的に買いづらい。。

Web開発用途でそこまで高いスペックは要求されないので、使っていたMacbookよりは良いスペックであれば良いくらいの感覚で選んでいました。

Thinkpad購入するとWindows OSがついてくるので、デュアルブートで社内で開発しているWindowsデスクトップアプリのテストにも使えるのも良いなと思いました。

仕事でよく使うアプリケーションの置き換え

よく使うアプリケーションは以下のように置き換えました。Macのアプリはよく出来ているアプリが多いのでUbuntuに変えてから全体的に少し使いづらくなった印象がありますが、致命的なものはないと感じています。

ターミナル

(Mac) iTerm => (Linux) Gnome Terminal

そんなに変わらない印象。

DBクライアント

(Mac) Sequel Pro (MySQL用) + PG Commander (Postgresql用) => (Linux) DBeaver Community

MacのDBクライアントはシンプルで使いやすかった分、DBeaverは複雑なので最初慣れるのに苦労した。が、慣れてしまえば普通に使える。

画像キャプチャ + 矢印や文字、ぼかし等を入れるツール

(Mac) Skitch => (LInux) Shutter

使い勝手はそんなに変わっていない。Evernoteには連携できなくなるので、使っている人は困るかも。

バックアップツール

(Mac) Time Machine => (Linux) Timeshift(ファイルシステム用) + Déjà Dup(ホームディレクトリ用)

Déjà DupはTime Machineのようにファイル毎に復元できないので、ちょっと不便かも。復元自体あまりしないので、そんなに困っていないけど。

ランチャー

(Mac) Alfred => (Linux) Albert

workflowが使えなくなったけど、もともとアプリケーション、Chromeブックマーク、Web検索しか使ってなかったので、あまり困っていない。

デザインツール

(Mac) Sketch => (Linux) Figma

最初、Inkspaceを使っていたけど、だいぶ使いづらく感じた。デザイナーではないのでFigmaだけあれば、Sketchは不要な感ある。

ER図作成

(Mac) SQLEditor => (Linux) MySQL Workbench

あまり使っていないけど、ちょっと使いにくくなった感ある

良かった点

  • 重くならなくならずいつでも快適(Android Studioエミュレータ起動しながら、Rubymineでデバッグ実行しつつも、Zoomで会話しててもスムーズ)
  • Docker使うのにパフォーマンス上の問題がなく、快適に使える
  • Railsの開発をするのにGemのインストール時にオプション指定が一切要らないのでハマらず、時間ロスしない

悪かった点

  • RubyMine(JetBrains IDE)のDocker連携がちゃんと動いていないっぽい
  • iOSアプリのビルドができない
    • 私はあまりビルドしないので、使う時だけMacbook出せば良いので支障はない

Macと変わらない点

  • 外部ディスプレイ2台付けてもちゃんと使える
  • UbuntuKindleアプリ使えなくなるかも!?と思っていたけどwineで使えた
  • SkypeやSlack、Zoom、Rubymine、Authy、VSCodeといったよく使われるアプリはLinux版も普通にある
  • Android StudioもあるのでAndroidアプリ開発も普通にできている。むしろ、マシンスペックがよくなった分、より快適になった
  • Googleドキュメント、スプレッドシート、スライドは、Macと同様に普通に使える
  • あまりキーマップは変えていなかったので、大きなところはgnome-tweak-toolでwinキーの動作を変えたくらいで済んでいる

Web開発マシン変更前後のスペック

マシンスペックはこんな感じです。

Thinkpad X1 Carbon (変更後)

プロセッサ メモリ ストレージ
Thinkpad X1 Carbon 6G Intel Core i7-8650U プロセッサ
1.90GHz, 8MB
16GB LPDDR3
2133MHz Soldered
512GB SSD

Macbook pro(変更前)

プロセッサ メモリ ストレージ
Macbook pro
2015 early
(13 inch)
Intel Core i5プロセッサ
2.9GHzデュアルコア
8GB 1,866MHz
LPDDR3オンボードメモリ
512GB PCIeベース
フラッシュストレージ

終わりに

今後もThinkpad + Ubuntuを使い続けると思います。

現状(2020年7月時点)ではコロナの影響で多くのエンジニアの方が自宅でリモートワークをする状況になってきていて、今後物理的なマシンのやり取りが手間になるかもしれません。オフィスに行くだと、マシンを用意する人とマシンを使う人の両方がオフィスに居る必要がありますから。

自作PCのマシンや古いWindows機にもインストールできるので、マシンを調達しやすいLinuxが人気でるかもしれませんね!

【SQL】limitを使わずグループ毎に先頭N件を取得する

例えば、データベースに以下のような書籍テーブル、書籍注文数テーブルがあって、売れている本をカテゴリ毎に1件ずつ取得したい、というようなことがあります。

【書籍テーブル(books)】

id 書籍名(name) カテゴリ(category)
1 でこぼこホットケーキ 世界文化社のワンダー絵本 絵本
2 1日10分でちずをおぼえる絵本 絵本
3 FACTFULNESS ビジネス書
4 1分で話せ ビジネス書
5 アフターコロナ 見えてきた7つのメガトレンド ビジネス書
6 HARD THINGS IT

【書籍注文数テーブル(books_orders)】

id 書籍id(book_id) 注文数(quantity)
1 1 2
2 1 3
3 2 7
4 3 1
5 3 8
6 3 4
7 4 3
8 4 5
9 5 9
10 5 3
11 6 4
12 6 1

【欲しい結果】

書籍id 書籍名 カテゴリ
2 1日10分でちずをおぼえる絵本 絵本
3 FACTFULNESS ビジネス書
6 HARD THINGS IT

欲しい結果を取得するSQLクエリにWindow関数(後述)が使えると楽なのですが、古いのバージョンのMySQLAmazon AuroraMySQL 5.7まで互換)だとWindow関数を使えないので、他の方法にする必要があります。最近のMySQLOracle, PostgreSQLデータベースだとWindow関数を使えます。

GROUP_CONCAT, FIND_IN_SETを使った書き方

GROUP_CONCATと, FIND_IN_SETを使った書き方です。( MySQL 5.7で確認しました。 )

まずは、書籍毎の注文数合計が分からないと並び替えられないので、以下のデータを取得するSQLクエリを作ります。

書籍id(book_id) 注文数合計(total_quantity)
1 5
2 7
3 13
4 8
5 12
6 5

書籍注文数テーブル(books_orders)を、書籍id(book_id)毎に集計すれば良いのでこんなSQLクエリになります。

select
  book_id,
  sum(quantity) as total_quantity
from
  book_orders
group by
  book_id

次にカテゴリ別に順位が並び変っている以下のデータを取得するSQLクエリを作ります。

カテゴリ(category) 書籍idリスト(book_id_list)
絵本 2,1
ビジネス書 3,5,4
IT 6

先程取得したデータに書籍テーブルを書籍idで結合して、カテゴリでgroup byしたうえで、GROUP_CONCATに book_id order by total_quantity descを入れると上の表のデータが取得できます。 SQLクエリはこんな感じになります。

select
  category,
  group_concat(book_orders_group_by_id.book_id order by total_quantity desc) as book_id_list
from
  books,
  (
    select
      book_id,
      sum(quantity) as total_quantity
    from
      book_orders
    group by
      book_id
  ) book_orders_group_by_id
where
  books.id = book_orders_group_by_id.book_id
group by
  books.category

次は、カテゴリ毎の順番を書籍テーブルにくっつけたデータを取得してみます。

書籍id 書籍名 カテゴリ カテゴリ内の順番(rank)
1 でこぼこホットケーキ 世界文化社のワンダー絵本 絵本 2
2 1日10分でちずをおぼえる絵本 絵本 1
3 FACTFULNESS ビジネス書 1
4 1分で話せ ビジネス書 3
5 アフターコロナ 見えてきた7つのメガトレンド ビジネス書 2
6 HARD THINGS IT 1

カテゴリ内での書籍の順位は分かっているので、FIND_IN_SETを使って該当書籍が何番目にあるのかを探すSQLクエリを書きます。 取得データに書籍テーブルをカテゴリで結合して、 FIND_IN_SET( books.id, book_id_list ) as rank のように

  • 第一引数に探したいもの(書籍id)
  • 第二引数に探す対象のリスト(上で取得したカテゴリ毎の順位のリスト)

を指定します。

SELECT
  books.id,
  books.name,
  books.category,
  FIND_IN_SET( books.id, book_id_list ) as rank
FROM
  books,
  (
    SELECT
      category,
      GROUP_CONCAT( book_orders_group_by_id.book_id
        order by total_quantity desc ) as book_id_list
    from
      books,
       (
        select
          book_id,
          sum(quantity) as total_quantity
        from
          book_orders
        group by
          book_id
      ) book_orders_group_by_id
    where
      books.id = book_orders_group_by_id.book_id
    group by
      books.category
  ) book_ids_ranked_by_category
where
   books.category = book_ids_ranked_by_category.category

最後に順位をカテゴリ内の順番(rank)が1のものに絞れば、欲しい結果が取得できます。

書籍id 書籍名 カテゴリ
2 1日10分でちずをおぼえる絵本 絵本
3 FACTFULNESS ビジネス書
6 HARD THINGS IT
select
  ranked_books.id,
  ranked_books.name,
  ranked_books.category
from
  (
    SELECT
      books.id,
      books.name,
      books.category,
      FIND_IN_SET( books.id, book_id_list ) as rank
    FROM
      books,
      (
        SELECT
          category,
          GROUP_CONCAT( book_orders_group_by_id.book_id
            order by total_quantity desc ) as book_id_list
        from
          books,
           (
            select
              book_id,
              sum(quantity) as total_quantity
            from
              book_orders
            group by
              book_id
          ) book_orders_group_by_id
        where
          books.id = book_orders_group_by_id.book_id
        group by
          books.category
      ) book_ids_ranked_by_category
    WHERE
      books.category = book_ids_ranked_by_category.category
  ) ranked_books
where
  rank = 1

GROUP_CONCATを使った書き方のデメリットとしては、GROUP_CONCAT関数の結果が切れる事象が発生することがあります。これを防ぐには、例えば、MySQLだと group_concat_max_len という設定値を変更すれば大丈夫です。

Window関数を使った書き方

Window関数を使った書き方にするとだいぶシンプルです。( PostgreSQL 10.12で確認しました。 )

select 
  *
from
  (
    select
      books.name,
      books.category
      row_number() over  
      (partition by books.category order by
        book_orders_group_by_id.total_quantity desc) as rank
    from
      books,
      (
        select
          book_id,
          sum( quantity ) as total_quantity
        from
          book_orders
        group by
          book_id
     ) book_orders_group_by_id
    where
      books.id = book_orders_group_by_id.book_id
  ) ranked_book_orders
where 
  rank = 1

【HTML5】1つのフォームに複数のSubmitボタンをつけるには(formaction, formmethod)

Webサービスを開発していると、たまに一覧画面に一括処理を追加したいという要望が出てきたりします。

例えば、以下のような画面のように「一括で書籍の予約を開始する」といった状態の変更、「一括削除」のようなことを頻繁に行う必要がある場合です。

1つのフォームに複数のSubmitボタンがついた画面

従来、実装の関係上で

  • HTMLのフォームを一つ用意
  • 複数のボタンをフォームに追加
  • それぞれのボタンにクリック時のJavascriptコールバックを付ける
  • Javascriptのコールバックの中で、サーバ側にPOST, GETする

というようなことをしていました。

最近では、HTML5<input type="submit"> に追加された formaction, formmethod プロパティを使うと綺麗に実装できます。各ブラウザの対応状況も良い感じです。

Mozillaのドキュメントを読むと従来のformのaction, methodがあった場合、それらを上書きしてくれるようです。

実装

以下のような感じになります。

<form>
  <input type="submit" value="予約を有効にする" formaction="/admin/book_reserve_acceptance" formmethod="POST"/>
  <input type="submit" value="削除" formaction="/admin/book_bulk_deletion" formmethod="POST"/>

  <table class="table table-bordered table-striped">
    <thead>
      <tr>
        <th></th>
        <th>書籍名</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><input type='checkbox' value="1"/></td>
        <td>新刊1書籍名</td>
      </tr>
      <tr>
        <td><input type='checkbox' value="2"/></td>
        <td>新刊2書籍名</td>
      </tr>
    </tbody>
  </table>
</form>

2つのボタン両方ともmethodがPOSTなので、

<form method="POST">
  <input type="submit" value="予約を有効にする" formaction="/admin/book_reserve_acceptance" />
  <input type="submit" value="削除" formaction="/admin/book_bulk_deletion" />

  <!-- テーブルの箇所 -->
</form>

のようにも書けます。

Webpackを使っているElectronアプリにSentry導入してみた

ElectronアプリにSentryを導入し、エラーの詳細を把握できるようにしてみました。

Webpackの設定手順が煩雑なので実際のプロジェクトでは electron-webpack (Doc) や electron-react-boilerplate (Doc)などのテンプレートプロジェクトを元にプロジェクトを作っていますが、今回は新規にアプリケーションを作ってSentryでエラーを補足できるまでの手順の詳細を書いてみます。

手順の詳細を把握することで仕組みが理解でき、既に動いているElectronアプリに関しても導入が容易になるかと思います。 Sentryでエラーを補足するためにsource mapを送る方法がいくつかあるのですが、一番手間が少ないのでWebpack Pluginを使った方法をご紹介します。

サンプルプロジェクト

今回作ったサンプルプロジェクトをgithubにコードを上げておいたので、よかったら参考にしてみてください。 github.com

環境

  • Ubuntu 18.04 LTS
  • sentry/electron@1.3.1
  • electron@9.0.5
  • @webpack-cli/init@0.2.2

webpack4 からは設定ファイル webpack.config.js が必要なくなったという理由から、 webpack-cli/init@0.3 では自動的に生成しなくなったので、バージョン 0.2.2 を使用しました。

ドキュメント

Sentry

導入するにあたって事前にSentryの以下のドキュメントを読み、オプション等を把握しておきました。

Electron crash-reporter

また、Sentry SDK( @sentry/electron )の内部で使っているElectronのcrash-reporterのドキュメントも合わせて読んでおいて

  • crash-reporterが元々どういう機能のもの
  • Sentry側がどこ設定を簡略化してくれるのか

を把握でき、何かトラブルが発生した時に対処できるようになると思いますので、一読しておくと良いと思います。

事前準備

Sentryのアカウントなければ、取得しておきます。

Sentryのプロジェクト準備

プラットフォームにElectronを選択し、Sentryのプロジェクトを作っておきます。

以下のように開発プロジェクト側の設定手順が画面が表示されるので、基本的には同じ事を設定すればOKです。最新(2020年7月現在)のバージョンでは注意点があるので、追って解説していきます。

プロジェクトの作成

設定ファイル(package.json, webpack.config.jsを作成)

プロジェクトをディレクトリを作ります。

$ mkdir electron-sentry
$ cd electron-sentry

package.json の初期ファイルを作ります。

$ npm init -y

Webpackの初期ファイルを作ります。

$ npm i @webpack-cli/init@0.2.2 --save-dev
$ npx webpack-cli init

# Electronはメインプロセスのエントリファイルが1つのためNoにする
? Will your application have multiple bundles? No

# mainプロセスのjsファイルをmainにしたいのでmainにする
? Which will be your application entry point? main

# Webpackビルド後のファイルの出力先をdistにする。outputとかフォルダ変えても良い。
? In which folder do you want to store your generated bundles? 'dist'

# Typescriptでも良いがES6にする。今回CSSは使わないけど、SASSにする。
? Will you use one of the below JS solutions? ES6
? Will you use one of the below CSS solutions? SASS
? If you want to bundle your CSS files, what will you name the bundle? (press en
ter to skip) main
 conflict package.json

# package.jsonを上書き
? Overwrite package.json? overwrite
    force package.json
   create .babelrc
   create main.js
   create README.md

Electron、sentry関連、HTMLバンドルのためのパッケージ追加

各種パッケージ追加

$ npm i electron @sentry/electron @sentry/webpack-plugin html-webpack-plugin --save-dev

メインプロセスのjs(main.js)、レンダラプロセスの画面(index.html)・js(renderer.js)を作る

シンプルになコードを作ってみました。(全てプロジェクト直下に追加し、既に存在するmain.jsは上書きしました。)

注意点は、以下の3点です。

  • Sentry SDKのオプションdsnには、上記のSentryのプロジェクト準備で表示されたURLを入れます。
  • Sentry SDK のinit関数をメインプロセスとレンダラプロセスで分けます
    • メインプロセスの場合 @sentry/electron/dist/main から読む
    • レンダラプロセスの場合は @sentry/electron/dist/renderer 読み込む
    • ※そうしないとTypeError: mod.require is not a functionというエラーが発生。参考issue
  • init関数はメインプロセスの場合、app.on("ready")より前、できるだけ早いタイミングで呼ぶと良いようです

【メインプロセスのjsファイル(main.js)】

# main.js
const { app, BrowserWindow } = require("electron")
import * as path from 'path';
import * as url from 'url';
import { init } from '@sentry/electron/dist/main'
import * as Sentry from '@sentry/electron'
let win;

// Sentry SDKのinit。この後メインプロセス内でエラーを補足するとSentryに通知。
init({dsn: 'https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@oxxxxxx.ingest.sentry.io/xxxxxxx'})

// 画面表示(レンダラプロセス起動)
function createWindow() {
  win = new BrowserWindow({ 
    webPreferences: {
      nodeIntegration: true
    }, 
    width: 800, 
    height: 600, 
    webSecurity: false 
  });
  win.loadURL(
    url.format({
      pathname: path.join(__dirname, 'index.html'),
      protocol: 'file:',
      slashes: true
    })
  );
  win.on("closed", () => { win = null; });
}

// readyのライフサイクルでレンダラプロセスを表示
app.on("ready", createWindow);

// ウィンドウを全部閉じたらappを終了
app.on("window-all-closed", () => {
  if (process.platform !== "darwin") {
    app.quit();
  }
});

// ウィンドウアクティブ時にwinオブジェクトがなければ画面表示
app.on("activate", () => {
  if (win === null) {
    createWindow();
  }
})

【レンダラプロセスの画面ファイル(index.html)】

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>electron-sentry | Electronでsentry通知するサンプル</title>
</head>
<body>
  <h1>レンダラプロセスの画面</h1>

  <!-- レンダラプロセスのJSを読み込む -->
  <script src="dist/renderer.js"></script>
</body>
</html>

【レンダラプロセスのjsファイル(renderer.js)】

import { remote } from 'electron'

const { app } = remote
import { init } from '@sentry/electron/dist/renderer'
import * as Sentry from '@sentry/electron'

// Sentry SDKのinit。この後レンダラプロセス内でエラーを補足するとSentryに通知。レンダラプロセスが複数ある場合、その全てでinitする必要があります。
init({dsn: 'https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@oxxxxxx.ingest.sentry.io/xxxxxxx'})

document.addEventListener("DOMContentLoaded", () => {
  // 定義していない関数を呼び出して、エラーを発生させる
  myUndefinedFunction()
})

Webpack設定ファイル webpack.config.js の編集

ポイントとしては

  • Webpackの出力先(output)
    • アプリケーション実行に必要なファイルがdist内のみに収まるようにHtmlWebpackPluginでレンダラ画面のhtmlもコピー
  • SentryCliPluginの引数
    • release: package.jsonのname + version(例: electron-sentry1.0.0)になるように
      • エラーがどのバージョンをで発生したのかを把握できるように
    • include

となるようにしてみました。

const SentryCliPlugin = require('@sentry/webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path');
const webpack = require('webpack')

const package = require('./package.json');
const sentryRelease = `${package.name}${package.version}`;

const main = {
  mode: 'development',
  devtool: 'source-map',
  target: 'electron-main',
  node: {
    __dirname: false,
    __filename: false,
  },
  entry: './main.js',
  output: {
    path: __dirname + '/dist',
    filename: 'main.js'
  },
  plugins: [
    new SentryCliPlugin({
      release: sentryRelease,
      include: './dist'
    })
  ] 
}

const renderer = {
  mode: 'development',
  devtool: 'source-map',
  target: 'electron-renderer',
  node: {
    __dirname: false,
    __filename: false,
  },
  entry: {
    renderer: './renderer.js',
  },
  output: {
    path: __dirname + '/dist',
    filename: 'renderer.js'
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, './index.html'),
    }),
    new SentryCliPlugin({
      release: sentryRelease,
      include: './dist'
    })
  ]
}

module.exports = [ main, renderer ]

npm設定ファイル package.json の編集

上記で生成したpackage.json のmainとscriptsを変更します。

electron がWebpackで生成した dist/main.js を実行するようにしておきます。

  // 〜〜〜
  "main": "dist/main.js",
  "scripts": {
    "start": "electron .",
  }
  // 〜〜〜

.envファイルを追加

SentryCliPluginはWebpackのバンドル時に、自動的に.env等の設定があればそれを参照し、distに出力されたファイルをsentryに送信してくれるので、.envファイルをプロジェクト配下に作っておきます。

# .env
SENTRY_DSN= https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@oxxxxxx.ingest.sentry.io/xxxxxxx
SENTRY_ORG=[org name]
SENTRY_PROJECT=electron-sentry
SENTRY_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

sentry cliの認証に関しての詳細は、以下のドキュメントに詳しく書いていました。 Configuration and Authentication | Sentry Documentation

また、AUTH_TOKENは、Sentryのアカウント毎に以下のURLから生成することができます。 https://sentry.io/settings/account/api/auth-tokens/

Sentryでエラーの捕捉を確認

Electronアプリ実行してエラーを確認する前に

  • npmパッケージインストール
  • distディレクトリに実行するコードをwebpackで生成
  • Sentryにsource mapを送る

をしておきます。 以下を実行してください

$ npm install
$ npx webpack

> Found 4 release files
> Analyzing 4 sources
> Rewriting sources
> Adding source map references
> Bundled 4 files for upload
> Uploading release files...

> Source Map Upload Report
>   Scripts
>     ~/main.js
>     ~/renderer.js
>   Source Maps
>     ~/main.js.map
>     ~/renderer.js.map
> Uploaded release files to Sentry
> File upload complete

Electronアプリを起動すると

$ npm start

画面表示後に、chrome developer toolsを開き(ショートカットはCtrl+Shift+i)、consoleにエラーが出ていることが確認できます。

Sentryのコンソールを開いてみても、エラーが発生していることが確認できます。ソースコードが読めているのでsource mapsもちゃんと送れていそうです。

また、Webpackでバンドルした際にSentryにsource mapが送られていることも確認しておきます。ReleasesのArtifactsから確認できます。

うまくソースコードが表示できない場合

エラーは通知されるのに、Sentry上でうまくソースコードが表示されない時は大体

  • 通知されたコードのパス
  • SentryのReleasesのArtifactsのファイルのパス

がずれていることが原因です。そういう時は、urlPrefixをつけて相対パスを指定してあげたりすれば、表示されるようになります。

new SentryCliPlugin({
  include: './js',
  release: sentryRelease,
  urlPrefix: '~/js'
}),

GitHub Actionsで実行中のworkflowを自動的にキャンセルする

私の周りのプロジェクトでは、最近Circle CIのテスト実行や自動デプロイをGithub Actionsに置き換える動きが盛んになっています。

しかし、どんどんと置き換えて、ワークフローを際限なく動かしていたら、気がつかない間に結構な費用負担になっていました。。。Github Actionsのドキュメントを見ると、時間単位で課金されるようだったので、新しいワークフローが実行された時、古いワークフローは自動キャンセルされるようにしてみました。

以下のように2回連続でworkflowを動くと、古い方は自動的にキャンセルされます。

Circle CI等他のCIサービスではデフォルトでキャンセルされるようになっていますが、Github Actionsはまだその機能はデフォルトではありません(2020/6/16時点)ので、自動キャンセル専用のアクション(workflow-run-cleanup-action)を追加します。

設定方法

.github/workflows/***.ymlworkflow-run-cleanup-actionを追加します。

on:
  push: []
  jobs:
    cleanup-runs:
      runs-on: ubuntu-latest
      steps:
      - uses: rokroskar/workflow-run-cleanup-action@master
        env:
          GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
    ...
    other-jobs:
      〜〜〜

今のところ ubunut-latestのみサポートされているようなので、今後 windows-latest, macos-latestといった他の環境もサポートもされると良いなぁと思っています。(そっちの方がお高いので。。)

補足

コミットに[skip ci]をつけて、workflowのジョブ自体動かないようにすると、よりworkflowの実行時間を削減できそうです。 2021/2/8に公式で[skip ci]がサポートされました。

github.blog

gh-build-cancellerというのもあるらしい

別のアクションでgh-build-cancelerというのもあるようなのですが、今のところスター数が少ないので、私は workflow-run-cleanup-actionの方を使うようにしています。今後こっちの方が人気が出てきたり、機能拡張の状況がよかったりしたら乗り換えるかもしれません。

GH actions stale run canceller · Actions · GitHub Marketplace · GitHub

参考

github actionsを使った electron アプリインストーラの自動ビルド (action-electron-builder)

Electronは、MacOSWindowsといったマルチプラットフォームのデスクトップアプリを簡単に作れますが、複数プラットフォームのアプリを作れる分、ビルド/署名といった作業が手間になっていました。Github Actionsを使ってタグ付けした時にGithub releasesにビルドしたインストーラを配置するようにした時の方法を書いてみたので、よかったら参考にしてみてください。

こんな感で dmg ファイルや exe ファイルが出来ます。

Github Actionsで簡単に実現するには、action-electron-builder actionを使うと楽です。最新の情報は公式のREADMEから取得するようにしてください。

github.com

action-electron-builder 導入

まずはelectron-builder (v22+) でビルドできるようにします。大体のプロジェクトは electron-builder 使っていると思うので導入方法は割愛します。(今回使った electron-builderのバージョンはV22.7.0)

.github/workflows/build.yml に以下のようなファイルを置き、Github Actionsでmacos, ubuntu, windowsのビルド/リリースが実行されるようにします。

name: 'Build and release electron installer'
on:
  push:
    tags:
      - 'v*'

jobs:
  release:
    runs-on: ${{ matrix.os }}

    strategy:
      matrix:
        os: [macos-latest, ubuntu-latest, windows-latest]

    steps:
      - name: Check out Git repository
        uses: actions/checkout@v1

      - name: Install Node.js, NPM and Yarn
        uses: actions/setup-node@v1
        with:
          node-version: 10

      - name: Build/release Electron app
        uses: samuelmeuli/action-electron-builder@v1
        with:
          github_token: ${{ secrets.github_token }}

          release: ${{ startsWith(github.ref, 'refs/tags/v') }}

vから始まるタグがつけられるとワークフローが動き

$ npm run build 
or
$ yarn run build

が実行されます。スクリプト名( build の部分)はオプションで変えられます。

自動でリリースが作られてインストーラが配置されるように

electron-builderのpublishの設定

electron-builderのpublishの設定( githubのどのリポジトリにどのようにリリースするか)を追加します。 package.json を以下のようにしてみました。

  "build": {
    ~~~
    "publish": {
      "provider": "github",
      "owner": "moritamori",
      "repo": "my-first-app",
      "releaseType": "release"
    }
    ~~~
  }

通常、環境変数Githubトークンの設定が必要になるのですが、action-electron-builderbuild.ymlgithub_token を自動的に使うようになっているので、トークンが漏れることがなくセキュリティ面で安全なのが良い感じです。

package.jsonのバージョン変更 + タグ付け

package.jsonのバージョンを変更し、タグ付けしてpushします。

{
  "version": "1.0.0"
}
$ git tag v1.0.0
$ git push && git push --tags

タグ付け時にGithub actions workflow の中でelectron-builder が実行される時に --publish alwaysフラグが渡されることで、自動的にリリースが作られてpublishされます。

署名

アプリケーションの署名も自動的にできます。

MacOSのアプリ署名に関しては、 mac_certsエンコードした証明書、 mac_certs_password にパスワードを指定します。 Windowsに関しても同様に windows_certswindows_certs_password に指定します。

https://github.com/samuelmeuli/action-electron-builder#code-signing

サンプルプロジェクト

サンプルを作ってみたので、よかったら参考にしてみてください。 github.com