AtomでJavaScriptのテストカバレッジを可視化するパッケージを作って公開した話

[個人開発 Advent Calendar 2017](https://qiita.com/advent-calendar/2017/private-development)の17日目の記事です。

## 作ったもの
JavaScriptのテストカバレッジをAtomで表示するパッケージを作りました。

[coverage-markers](https://atom.io/packages/coverage-markers)

<img width="1080" alt="coverage_markers_package.png" src="https://qiita-image-store.s3.amazonaws.com/0/38478/935e3ce7-9641-8b5f-a674-bcd43b69c0e7.png">


こちらが実際に動かした様子です。テストが網羅されている行は緑色、網羅されていない行には赤色のマーカーが表示されます。

パッケージには以下の特徴があります。

### テストカバレッジ表示はLCOV形式に対応
```bash
$ nyc --reporter=lcov --reporter=text npm run test
```
[nyc](https://github.com/istanbuljs/nyc)や[istanbul](https://github.com/istanbuljs/istanbuljs)などのカバレッジ計測ツールではLCOVフォーマットでのカバレッジ出力に対応しており、本パッケージではその出力されたファイル(`lcov.info`)を利用しています。

<img width="828" alt="coverage_nyc.png" src="https://qiita-image-store.s3.amazonaws.com/0/38478/a7888900-6551-9c6a-4491-4f9da9fc0c47.png">

もともとnycやistanbulには結果をHTMLで表示する機能があり、今回はその一部(Statement Coverage)をAtomで簡単に表示できるようにしました。

### カバレッジファイルを監視して変更の度に結果をエディタに反映
![usage.gif](https://qiita-image-store.s3.amazonaws.com/0/38478/5bc5f995-e26a-f632-0e8b-681a83b721a6.gif)

テストを実行してカバレッジファイルに変更があれば、データを読み直して結果をエディタに反映するようになっています。

## 開発のきっかけ
今年6月中旬にAWS Summit Tokyo 2017での[t_wada](https://twitter.com/t_wada)さんのTestable Lambdaの[講演動画](https://www.youtube.com/watch?v=C0zNc4bdWhY)を見ました。その中で紹介されていたemacs向けのカバレッジ可視化ツール「[coverlay.el](https://github.com/twada/coverlay.el)」が非常に魅力的に思えました。

なぜなら、私の所属するチームでは機能追加と共にユニットテストを書く文化があり、普段からテストを書く機会が多かったのでカバレッジを参考にしながらテストを書けたら便利だと考えたからです。

それを実現するために普段使っているAtomで使えるカバレッジ可視化ツールを作ることにしました。[^1]

[^1]: Atom向けのカバレッジ可視化ツールはすでに存在していましたが、開発が止まっていたり、上手く動かなかったので勉強も兼ねて作ることにしました。

## 動作の仕組み
実際にカバレッジを表示するために以下の処理を行っています。

1. **プロジェクトディレクトリからカバレッジファイル`lcov.info`を検索する**
2. カバレッジファイルからカバレッジデータを読み込む
3. **カバレッジデータをJSONに変換**
4. **各エディタのガターにマーカーをレンダリングする**
5. **カバレッジファイルに変更があれば、2.に戻って繰り返し**

本稿では特にメインの処理である、1、3、4、5について紹介します。

### ファイル検索
```js
async function findCoverageFilePath(projectPath) {
  try {
    // 検索対象外のディレクトリを指定しておく
    const ignore = [`${projectPath}/**/node_modules/**`];
    const matchPaths = await glob.globAsync(`${projectPath}/**/lcov.info`, { ignore, dot: false });

    // lcov.infoがヒットしたらファイルパスを返す
    if (matchPaths.length > 0) {
      return matchPaths[0];
    }

    throw new Error('coverage file path could not be found.');
  } catch (error) {
    throw new Error(error.message);
  }
}
```

nycやistanbulなどのカバレッジ計測ツールを使用すると、`lcov.info`というカバレッジデータを記録したファイルを出力します。本パッケージではプロジェクトディレクトリ内の`lcov.info`をファイル検索し読み込んで利用しています。この処理ではglobというファイル名のパターンマッチングができるモジュールを使用しています。

[isaacs/node-glob: glob functionality for node.js](https://github.com/isaacs/node-glob)

### カバレッジデータ変換
`lcov.info`に記録されたカバレッジデータを読み込んで、JSONに変換しパッケージ内で取り扱えるようにしています。この処理の実現にはlcov.jsというモジュールを使用しました。

```
TN:
SF:/test-project/index.js
DA:16,0
DA:17,0
DA:22,0
DA:29,0
DA:33,0
LF:7
LH:0
BRF:0
BRH:0
end_of_record
```
`lcov.info`のカバレッジデータ

```json
[
  {
    "sourceFile": "/test-project/index.js",
    "branches": {
      "found": 0,
      "hit": 0,
      "data": []
    },
    "functions": {
      "found": 0,
      "hit": 0,
      "data": []
    },
    "lines": {
      "found": 7,
      "hit": 0,
      "data": [
        {
          "lineNumber": 16,
          "executionCount": 0,
          "checksum": ""
        },
        {
          "lineNumber": 17,
          "executionCount": 0,
          "checksum": ""
        },
        {
          "lineNumber": 22,
          "executionCount": 0,
          "checksum": ""
        },
        {
          "lineNumber": 29,
          "executionCount": 0,
          "checksum": ""
        },
        {
          "lineNumber": 33,
          "executionCount": 0,
          "checksum": ""
        }
      ]
    }
  }
]
```
JSON変換後は各ファイルごとにカバレッジ情報がまとめられます。この情報をもとにエディタにマーカーをレンダリングします。

```js
function parseCoverageFile(coverageFile) {
  try {
    const report = Report.fromCoverage(coverageFile);
    const coverage = report.toJSON();
    return coverage.records;
  } catch (error) {
    throw new Error('coverage file could not be parsed.');
  }
}
```
こちらがカバレッジデータをJSONに変換している部分です。モジュールを使用することで数行書くだけで処理ができました。

[cedx/lcov.js: Parse and format LCOV coverage reports, in JavaScript.](https://github.com/cedx/lcov.js)

### マーカーのレンダリング
```js
import { Range } from 'atom';

export default class Marker {
  constructor(editor) {
    this.editor = editor;
  }

  // テストがカバーされていない行に適用
  addTestUncovered(line) {
    const range = new Range([line - 1, 0], [line - 1, 0]);
    const marker = this.editor.markBufferRange(range, { invalidate: 'never' });
    // CSSでクラスを当てることでマーカーを表示
    this.editor.decorateMarker(marker, { type: 'line-number', class: 'line-number-red' });
  }

  // テストがカバーされている行に適用
  addTestCovered(line) {
    const range = new Range([line - 1, 0], [line - 1, 0]);
    const marker = this.editor.markBufferRange(range, { invalidate: 'never' });
    // CSSでクラスを当てることでマーカーを表
    this.editor.decorateMarker(marker, { type: 'line-number', class: 'line-number-green' });
  }

  remove() {
    const markers = this.editor.getMarkers();
    if (markers.length > 0) {
      markers.forEach((marker) => {
        marker.destroy();
      });
    }
  }
}
```
マーカーのレンダリング処理はAtom APIの[TextEditorインスタンス](https://atom.io/docs/api/v1.23.1/TextEditor#instance-decorateMarker)で`decorateMarker`メソッドを呼び出しています。実際は行ごとに処理が行われるので、JSONに変換されたカバレッジデータを元にループで回してレンダリングしています。


### ファイル監視
```js
import chokidar from 'chokidar';

// chokidarインスタンスを返して、実際のファイル変更検知後の処理は別の場所で行う
function createWatcher(coverageFilePath) {
  const options = {
    awaitWriteFinish: { // ファイル変更が完了したと判断する
      stabilityThreshold: 2000, // ファイル変更が完了したと判断する秒数
      pollInterval: 100, // ファイル変更が完了したかを確認する間隔
    },
  };

  const watcher = chokidar
  .watch(coverageFilePath, options);

  return watcher;
}

const watcher = createWatcher('lcov.info');
watcher.on('change', async () => {
  // ファイルが変更されたら処理
});
```
テストを実行してカバレッジが変更された結果を反映するためにファイル監視機能を導入しています。この処理はchokidarというモジュールを使用して実装しています。

ただ１つ難点があり、モジュール内部でfseventsというmacOSのネイティブAPIにアクセス処理があるため、本パッケージをインストールやアップデートしたときに必ずAtomをリビルドしないといけない制約があります。なんとか回避するために現在他のパッケージに置き換えられないかを調べています。

[paulmillr/chokidar: A neat wrapper around node.js fs.watch / fs.watchFile / fsevents.](https://github.com/paulmillr/chokidar)

## 開発のポイント
はじめてのAtomパッケージ開発だったのでお役立ち情報をまとめておきたいと思います。

### パッケージ作成
Atomにはパッケージの雛形を作成するための[package-generator](https://atom.io/packages/package-generator)というパッケージが提供されています。基本的にはこれで雛形を作成し、それに処理が足していくのが無難だと思います。開発言語に関しては以前はCoffeeScriptでしたが、JavaScript（ES6）が使用できます。

その他パッケージ作成の参考になるページを載せます。

* [Atom Flight Manual](http://flight-manual.atom.io/)
  * Chapter 3: Hacking Atomにはパッケージ作例が載っている
* [Atom API reference documentation](https://atom.io/docs/api/v1.23.1/AtomEnvironment)
  * 作りたい機能を実現するためにはAPIリファレンスとにらめっこすることになる
* [Atom / Github Organization](https://github.com/atom)
  * 公式パッケージのコードが大量にあり実装の参考になる

### ビルド・テスト
package-generatorで雛形を作成した場合はJasmineというテスティングフレームワークがデフォルトで含まれています。これをmochaやchaiなど好みのフレームワークを使用する場合はテストランナーを作成する必要があります。

* [Atom Package CI Scripts](https://github.com/atom/ci)
  * 開発したパッケージをCIでビルド・テストを行う際に役立つスクリプトです
* [BinaryMuse/atom-mocha-test-runner: Run your Atom package tests using Mocha](https://github.com/BinaryMuse/atom-mocha-test-runner)
    * mochaを使用するためのテストランナー

## 今後の展望
<img width="1279" alt="coverage_percentage.png" src="https://qiita-image-store.s3.amazonaws.com/0/38478/d6f9a50c-d5a7-3134-e1cd-7e11cb11b541.png">
<img width="265" alt="coverage_percentage002.png" src="https://qiita-image-store.s3.amazonaws.com/0/38478/c144988d-d3bf-359f-95a5-01c438aac051.png">

カバレッジのパーセンテージ表示機能を追加するために実現する方法を探っているところです。

## さいごに
開発したAtom向けカバレッジ可視化ツールの概要と動作の仕組みを簡単ですが紹介しました。当初目指していたテストカバレッジをエディタに可視化するという目的は達成できたので満足しています。

自分がエディタに欲しいと思った機能をパッケージ開発という形で実現できるのが、Atomのパッケージエコシステムの魅力だと思うので機会があれば挑戦してみるのはいかがでしょうか。

## リポジトリ
[kentaro-m/coverage-markers: Displays JavaScript code coverage on gutter of editor in Atom.](https://github.com/kentaro-m/coverage-markers)