Atomパッケージのテストを書いてみよう

# はじめに
この記事は[Atom AdventCalendar 2016](http://qiita.com/advent-calendar/2016/atom) 22日目です。

この記事ではパッケージを作ってはみたけどテストの書き方がよく分からない、という人向けのパッケージのテストの書き方を紹介します。

# Atomの基本のspec
まずはパッケージジェネレータで最初から用意されているspecから見ていきましょう。
デフォルトで用意されているテストは`toggle`で操作されるモーダルのビューをテストしているものです。

*以降の解説ではJasmine自体の使い方は飛ばしますので、Jasmine自体が初めての方は[Jasmineのintroduction](https://jasmine.github.io/1.3/introduction)を軽く見ておくと良いでしょう。

```js:spec/my-package-spec.js
'use babel';

import MyPackage from '../lib/my-package';

describe('MyPackage', () => {
  let workspaceElement, activationPromise;

  // パッケージを起動
  beforeEach(() => {
    workspaceElement = atom.views.getView(atom.workspace);
    activationPromise = atom.packages.activatePackage('my-package');
  });

  describe('when the my-package:toggle event is triggered', () => {
    it('hides and shows the modal panel', () => {
      expect(workspaceElement.querySelector('.my-package')).not.toExist();

      // toggleコマンドを実行
      atom.commands.dispatch(workspaceElement, 'my-package:toggle');

      // パッケージが起動するまで待つ
      waitsForPromise(() => {
        return activationPromise;
      });

      // waitsForPromise()が完了したらruns()に処理が移る
      runs(() => {
        // モーダルが表示されているか、もう一度toggleを実行したら非表示になるかをチェック
        expect(workspaceElement.querySelector('.my-package')).toExist();

        let myPackageElement = workspaceElement.querySelector('.my-package');
        expect(myPackageElement).toExist();

        let myPackagePanel = atom.workspace.panelForItem(myPackageElement);
        expect(myPackagePanel.isVisible()).toBe(true);
        atom.commands.dispatch(workspaceElement, 'my-package:toggle');
        expect(myPackagePanel.isVisible()).toBe(false);
      });
    });

    it('hides and shows the view', () => {
      // jasmineによるDOMのテストなので省略
      // ...
      });
    });
  });
});
```

大枠の構造から見ていきましょう。
最初のdescribeにあるbeforeEachでは```atom.workspace.activatePackage('my-package')```でテスト対象となるパッケージを読み込みんでいます。

次のitの中で```atom.commands.dispatch(workspaceElement, 'my-package:toggle');```を実行しているところは、atom-workspaceにフォーカスしているときにmy-packageのtoggleコマンドを実行したという意味になります。

waitsForPromise()は実際にパッケージの起動が完了するまで処理をブロックします。[^1]
JSなので本来は非同期的に処理されるのですが、テストではパッケージの起動が完了しないことにはテストのしようがありませんので、ここでパッケージの起動が完了するまで待たせておきます。

[^1]: waitsForPromiseはAtomが独自にJasmineを拡張して提供している機能です。https://github.com/atom/atom/blob/master/spec/async-spec-helpers.js

waitsForPromise()のPromiseが全て完了したらruns()のブロックが処理されます。runs()の中でやっとモーダルが期待通りに表示されたかをチェックしています。

つまり流れとしては以下のような構造のテストを作る必要があります。

1. パッケージをactivateする
2. テストしたいコマンドを実行する
3. (コマンドを実行するまで実際には起動しないパッケージの場合）Promiseを待つ
4. コマンド実行後に期待する動作が行われているかチェックする

# 実践編
ここからは簡単なテキスト操作を行うコマンドを作り、その挙動を実際にテストしてみます。

## テスト用のパッケージ
この記事のためにとても簡単なコマンドを作ります。今回は実行するとfooが入力される`insert`と、選択範囲中のfooをbarに変換する`replace`というコマンドを用意しました。

```js:lib/my-package.js
'use babel';

import { CompositeDisposable } from 'atom';

export default {
  subscriptions: null,

  activate(state) {
    this.subscriptions = new CompositeDisposable();

    // テキストエディタにカーソルが存在するときにinsertとreplaceが有効になる
    this.subscriptions.add(atom.commands.add('atom-text-editor', {
      'my-package:insert': () => this.insert(),
      'my-package:replace': () => this.replace()
    }));
  },

  deactivate() {
    this.subscriptions.dispose();
  },

  // fooを入力する
  insert() {
    const textEditor = atom.workspace.getActiveTextEditor()
    textEditor.insertText('foo')
  },

  // 選択範囲内のfooをbarに置換する
  replace() {
    const textEditor = atom.workspace.getActiveTextEditor()
    const range = textEditor.getSelectedBufferRange()
    textEditor.backwardsScanInBufferRange(/foo/g, range, (matched) => { matched.replace('bar') })
  }
};
```

## insertのテスト
まずは`insert`に対するテストを書いてみましょう。
`insert`が実行されたあとにfooという文字が存在していれば`insert`の挙動として正しいので、大体こんな流れのテストを書くことにします。

1. パッケージ起動
2. テキストエディタ領域にフォーカスする(atom-text-editor)
3. `insert`を実行
4. fooが入力されているか確認する

上記の処理をテストコードで書くとこのようになります。

```js:spec/my-package-spec.js
'use babel';

describe('MyPackage', () => {
  let textEditor, textEditorElement;

  beforeEach(() => {
    // workspace.open()を実行しないとgetActiveTextEditor()がundefinedしか返してくれない
    waitsForPromise(() => { return atom.workspace.open() });
    // テストに必要なパッケージの起動
    waitsForPromise(() => { return atom.packages.activatePackage('my-package') });
    // waitsForPromise()のpromiseが全て完了したらruns()が実行される
    runs(() => {
      textEditor = atom.workspace.getActiveTextEditor();
      textEditorElement = atom.views.getView(textEditor);
    });
  });
  afterEach(() => {
    // パッケージの終了処理
    atom.packages.deactivatePackage('my-package');
  })

  describe('when the my-package:insert event is triggered', () => {
    beforeEach(() => {
      // textEditorのビューをフォーカス時にinsert実行を再現
      atom.commands.dispatch(textEditorElement, 'my-package:insert');
    });
    it("fill 'foo' in TextEditor", () => {
      // fooが入力されたことをチェック
      expect(textEditor.getText()).toEqual('foo');
    });
  });
});
```

メニューのView→Packages→Developper→Run Package Specs（ショートカットはcommand + options + ctrl + P）からテストを実行してみましょう。
テストが通ることを確認できるはずです。

## replaceのテスト
次は`replace`のテストを書いてみます。既に存在するテキストを変更するコマンドの場合、ダミーのテキストをテスト中にオープンして使用するという方法がよく使われるので、まずはspec/fixture/dummy.txtを用意します。

```spec/fixture/dummy.txt
foo bar
```

ではreplaceに対するテストコードも追加してみましょう。dummy.txtを開いてからreplaceを実行するという流れになります。

```js:spec/my-package-spec.js
'use babel';
import * as path from 'path'

describe('MyPackage', () => {
  let textEditor, textEditorElement;

  beforeEach(() => {
    // workspace.open()を実行しないとgetActiveTextEditor()がundefinedしか返してくれない
    waitsForPromise(() => { return atom.workspace.open() });
    // テストに必要なパッケージの起動
    waitsForPromise(() => { return atom.packages.activatePackage('my-package') });
    // waitsForPromise()のpromiseが全て完了したらruns()が実行される
    runs(() => {
      textEditor = atom.workspace.getActiveTextEditor();
      textEditorElement = atom.views.getView(textEditor);
    });
  });
  afterEach(() => {
    // パッケージの終了処理
    atom.packages.deactivatePackage('my-package');
  })

  describe('when the my-package:insert event is triggered', () => {
    beforeEach(() => {
      // textEditorのビューをフォーカス時にinsert実行を再現
      atom.commands.dispatch(textEditorElement, 'my-package:insert');
    });
    it("fill 'foo' in TextEditor", () => {
      // fooが入力されたことをチェック
      expect(textEditor.getText()).toEqual('foo');
    });
  });
  describe('when the my-package:replace event is triggered', () => {
    beforeEach(() => {
      // spec/fixture/dummy.txtを開く
      waitsForPromise(() => { return atom.workspace.open(path.join(__dirname, 'fixture', 'dummy.txt')) });
      runs(() => {
        // 最初のbeforeEachとは別のファイルを開いたことになるのでtextEditorを代入し直す
        textEditor = atom.workspace.getActiveTextEditor();
        textEditorElement = atom.views.getView(textEditor);

        // 選択してreplaceを実行
        textEditor.selectAll()
        atom.commands.dispatch(textEditorElement, 'my-package:replace');
      });
    });
    it("replace 'foo' to 'bar'", () => {
      // fooがbarに置換されたことをチェック
      expect(textEditor.getText()).toEqual('bar bar\n');
    });
  });
});
```

大元のbeforeEach()でatom.workspace.open()してしまっているためtextEditorを再代入するあたりで同じような処理を繰り返していますが、insertとreplaceでやっていることはそれほど変わらず、コマンド実行後にテキストエディタの中身が期待する内容になっているかチェックしているだけです。

# テストの実行
テストを実行する方法もいくつかあるのでそれぞれ紹介いたします。

## Run Package Specs
既に紹介した方法です。Atom内のメニューから呼び出せるので最も手軽ですが、実行するたびにウィンドウが増え続けるので残念ながら使い勝手としてはイマイチ・・・
結果はHTMLで表示されるので見やすいです。

## apm test
ターミナルから実行する方法です。パッケージのディレクトリ上で`apm test`を実行するとspec/内にあるテストを全て実行してくれます。ターミナルとAtomを両方使って開発する人には手軽でオススメです。
Atomを開いていなくてもテストを実行することができるので、後述するCIでテストを実行するときにはこの方法になります。
あとはconsole.log()を使ったいわゆるプリントデバッグもやりやすいです。

## Script
元々はAtomで編集中のスクリプトをその場で実行する方法として最も有名なパッケージです。
メニューのPackages→Script→Configure Scriptで表示される設定を以下のようにすることでapm testを実行することができます。

```
Command: apm
Command Arguments: test
```

Atom内だけで開発をしている人には便利な方法でしょう。

## npmとの組み合わせ
apm testが実行できればOKなので、npmと組み合わせるともっと便利にできる可能性があります。
自分がパッケージ開発するときには[npm-watch](https://github.com/M-Zuber/npm-watch)でspecディレクトリを監視して変更があったときに自動的に`apm test`が実行されるようにしていました。

https://github.com/Kesin11/atom-vim-like-tab/blob/v1.3.0/package.json#L16-L51

# CI
テストが作れたら最後にCIサービスと連携させましょう。
有名なパッケージのリポジトリを見ると.travis.ymlやcricle.yamlが置かれているのを見かけるはずです。

Atomチームは有名なCIサービス用の設定ファイルのテンプレートを公開しています。
https://github.com/atom/ci

詳しい手順はREADMEに書いてありますが、パッケージ作者が行うのは対応するサービスの設定ファイルを自分のリポジトリにコピーするだけです。

TravisCIを使うと.travis.ymlを用意するだけでlinuxとmac、stable版とbeta版の4パターンの組み合わせでテストを実行してくれるのでオススメです。
atom-scriptの例: https://travis-ci.org/rgbkrk/atom-script

ちなみに、自分のパッケージでは1パターンのテストだけで1-2分程度かかっています。.travis.ymlの中で実行しているスクリプトを見てみると、Atom自体をダウンロードしてからテストを走らせるというなかなか豪快なことをやっているのでさすがに仕方ないですね。

# 終わりに
Atomパッケージのテストの書き方、実行の方法、CIサービスとの連携の方法を紹介いたしました。
パッケージのテストはE2Eテストですので慣れるまではテストコードを書くこと自体にそこそこ時間がかかるかもしれません。
自分がパッケージのテストを最初に書いたときは手探り状態で公開されている色々なパッケージのテストを見ながら見よう見まねで書いたので理解するまでなかなか大変でした。

ですがそこそこ開発が進んできてコマンドの数が増えてくると前に作ったコマンドもデグレしていないことを確かめるのが中々大変になってきます。
そのときにテストが存在していると今までのコマンドも正しく動作していることが確認でき、安心してリリースやアップデートをできる状態を維持できるのでカバレッジ100%とはいかないまでも主要な機能についてはテストを書いておきたいものです。

この記事によってこれからパッケージを作る人達のテストを書くコストが減ってくれれば嬉しいなと思います。