Help us understand the problem. What is going on with this article?

ユニットテストコードの基礎の基礎

More than 3 years have passed since last update.

はじめに

テストコードを書けない、書こうとしない毎日をおくっていましたが、
いよいよ逃げ切れなくなってきたので、頑張ってやってみたいと思います。

間違っていたらコメントいただければありがたいです。

ユニットテストが必要なケース

いきなりなのですが、私だけかもしれませんが、ユニットテストの入れどころがよくわからなかったです。
他の方の引用になるが、下記などがユニットテストしたほうがよい項目の参考になるかもしれません。

LV.10 フロントエンドのユニットテスト

・複数箇所で共有する汎用のJSモジュールを作成した場合
・AJAXを利用したAPIとの通信が発生する場合
・データを処理するビジネスロジックが発生した場合
・関数へ渡す引数パターンが複数存在する場合

前提

KarmaとJasmineを使う。
angularjsも使う。

Karma

テストフレームワーク。
テスト環境を提供するツールです。

Jasmine

テストランナー。
テストを実行する為のツールです。

ユニットテストの準備

Karmaをインストール

Karma本体と、Karmaコマンドを利用する為のクライアントツールのkarma-cliをインストール

npm install karma
sudo npm install -g karma-cli

Jasmineをインストール

npm install --save-dev karma-jasmine

Karmaを初期化する

下記コマンドを実行して、いくつかの質問に答えるとkarma.conf.jsが作成される。

karma init
# 利用するテストフレームワーク
Which testing framework do you want to use ?
Press tab to list possible options. Enter to move to the next question.
> jasmine

# Require.js を利用するか
Do you want to use Require.js ?
This will add Require.js plugin.
Press tab to list possible options. Enter to move to the next question.
> no

#  テストランナーが利用するブラウザを選択(複数指定が可能)
Do you want to capture any browsers automatically ?
Press tab to list possible options. Enter empty string to move to the next question.
> PhantomJS
>

# テスト対象とテストコードとなるファイル。空欄で後から指定しても問題ない
What is the location of your source and test files ?
You can use glob patterns, eg. "js/*.js" or "test/**/*Spec.js".
Enter empty string to move to the next question.
>

# テスト対象ファイルの中で、除外したいファイル(対象ファイルで、**/**.jsとかした場合に使う)
Should any of the files included by the previous patterns be excluded ?
You can use glob patterns, eg. "**/*.swp".
Enter empty string to move to the next question.
>

# コード変更時にテストを再実行するかどうか
Do you want Karma to watch all the files and run the tests on change ?
Press tab to list possible options.
> yes

ユニットテストをするには

とりあえず最低限下記の4つが必要だと思う

  1. describeでテストグループを作成し
  2. itでテストケースを作成し
  3. expectでテスト対象を渡し
  4. Matcher(toEqualとか)でテスト対象をが期待した値かをチェック!
  5. 下記のコマンドで1~4を実行
karma start

describe

describeでテストグループ名を定義する。

javascript
describe('テストのグループ', function() {
    //テスト内容を記載
});

下記の用に入れ子にすることもできる

javascript
describe('テストのグループ', function() {
    describe(テスト名,function(){
    });
});

it

itでテストケースを定義する。

javascript
describe('テストのグループ', function() {
    //テスト内容を記載
    it('テストケース',function(){
    });
});

expectとMatcher

テスト対象が、期待する値かを確認する為の関数。

expectでテスト対象を渡し、Matcherで渡された対象が、期待する結果かどうかをチェックします。

javascript
describe('テストのグループ', function() {
    //テスト内容を記載
    it('テストケース',function(){
    expect(1).toEqual(1);//テストは成功する
    });
});

上記でテストを実行すると、1と1を比較して同じ値なので結果はSUCCESSとなる。

log
PhantomJS 1.9.8 (Mac OS X 0.0.0): Executed 1 of 1 SUCCESS (0.007 secs / 0.033 secs)
javascript
describe('テストのグループ', function() {
    //テスト内容を記載
    it('テストケース',function(){
    expect(1).toEqual(2);//テストは失敗する
    });
});

上記でテストを実行すると、1と2を比較して異なる値なので結果はFAILEDとなる。

log
PhantomJS 1.9.8 (Mac OS X 0.0.0) テストのグループ ログイン処理 FAILED

jasminから提供されているMatcher

Matcherは自分で独自に定義もできるようですが、
下記のものが用意されています。

下記参考

JasmineによるJavaScriptのテスト その4

関数名 説明
expect(a).toEqual(b) aがbと同値であることを期待する
expect(a).toBe(b) aがbと同一オブジェクトであることを期待する
expext(a).toBeDefined() aが定義されていることを期待する(undefinedでない)
expect(a).toBeNull() aがnullであることを期待する
expect(a).toBeTruthy() aがtrueであることを期待する
expect(a).toBeFalsy() aがfalseであることを期待する
expect(a).toContain(b) aにbが含まれていることを期待する
expect(a).toBeLessThan(b) aがbより小さいことを期待する
expect(a).toBeGreaterThan(b) aがbより大きいことを期待する
expect(fn).toThrow(e) fnが例外をスローすることを期待する

否定形をテストをする場合は、notでつなぐ。

expect(a).not.toEqual(b);//aがbと異なる値であることを期待する

beforeEach

テスト単位で、テスト前に必ず実行される処理。

module関数はangularモジュールを読み込むために使用します。

describe('テストのグループ', function() {
     beforeEach(module('app'));
});

injectは必要なサービスのインジェクトする為に使用します。

describe('テストのグループ', function() {
     beforeEach(module('app'));

     it('テストケース',inject(function(getMyNameService){
        expect(getMyNameService()).toEqual('太郎');
     }));

});

module関数と、inject関数はangular-mock.jsで提供されている関数なので、angularjsを使用するには必ずテスト対象ファイルとして読み込む必要があります

afterEach

テスト単位で、テスト後に必ず実行される処理。

andularjsのモジュール、サービスを使ってテストする

上記でも少しかきましたが、angularjsを使っているコードをテストする場合、
必ずやらなければならないことが幾つかあります。

使うモジュールは明示的に読み込む必要があります。

describe('テストのグループ', function() {
     beforeEach(module('app'));
});

利用するサービスを読み込む必要があります。

describe('テストのグループ', function() {

    var $filter;

     beforeEach(module('app'));
     beforeEach(inject(function(_$filter_){
        $filter = _$filter_;
     }));
});

ちょっとテストの話からそれますが、
サービスをインジェクトする際の、_$filter_アンダースコアが何なのかがわかりませんでした。
$filterでも動くので作法てき何かかと思っていたのですが、どうも違うみたいです。

テストコードを読みやすくする為に、自分で定義した変数名を、サービス名と同じにしたい。
でも、依存注入時の変数と衝突してしまう。
それを回避する為に、angularjsではアンダースコアラッピングという方法が用意されているようです。

下記に詳しく載ってます。

angular.mock.inject

フィルターをテストしてみる

filterサービスをテストしてみます。
全角英数字を半角英数字に変換するfilterです。

filter.js
  angular.module('app',[])
  .filter('oneByte', [function() {
    return function(input) {
    return input.replace(/[A-Za-z0-9]/g, function(s) {
      return String.fromCharCode(s.charCodeAt(0) - 0xFEE0);
    });
  }]);
filter_test.js
describe('テストのグループ', function() {

    var $filter;

     beforeEach(module('app'));
     beforeEach(inject(function(_$filter_){
        $filter = _$filter_;
     }));

    it('全角英数字を半角に変換するテスト', function() {
      var oneByte = $filter('oneByte');//使いたいfilterをセット
      expect(oneByte('12A')).toEqual('12A');
    });
});

log
PhantomJS 1.9.8 (Mac OS X 0.0.0): Executed 8 of 8 SUCCESS (0.006 secs / 0.062 secs)

controllerをテストしてみる

controller.js
  angular.module('app',[])
  .controller('MyController', ['$scope',function($scope) {
    $scope.name = '太郎';
    $scope.setName = function(str) {
      $scope.name = str;
    }
   }]);
controller_test.js
describe('テストのグループ', function() {
  var scope;
  var $controller;
  var $rootScope;

  beforeEach(module('app'));

  beforeEach(inject(function(_$controller_, _$rootScope_) {
    $controller = _$controller_;
    $rootScope = _$rootScope_;
    scope = $rootScope.$new();
    $controller('MyController', {
      $scope: scope
    });
  }));

  it('名前を設定するテスト', function() {
    expect(scope.name).toEqual('太郎');
    scope.setName('花子');
    expect(scope.name).toEqual('花子');
  });
});

controllerをテストするには、まずcontrollerサービスをインジェクトします。
そして、controllerサービスを使ってコントローラーをインスタンス化します。

インスタンス化する方法は、下記の方法になります。

$controller(コントローラー名,{引数名:値})

MyControllerは、引数としてscopeオブジェクトを受け取っているので、
明示的にscopeを$controllerサービスに渡します。

  beforeEach(inject(function(_$controller_, _$rootScope_) {
    $controller = _$controller_;
    $rootScope = _$rootScope_;
    scope = $rootScope.$new();
    $controller('MyController', {
      $scope: scope
    });
  }));

$controllerサービスは戻り値としてコントローラーのインスタンスを返します。

var instance = $controller('MyController', {
      $scope: scope
    });

scopeオブジェクトを作成するには、下記の方法になります。

$rootScope().$new(isolate);

isolate ・・・親スコープを継承するかしないかのフラグ。デフォルトはfales。
isolateをtrueにした場合は、親スコープからプロトタイプ継承を行わず、独立した分離スコープを作成します。

directiveをテストしてみる

directiveをテストする際は、下記が必要なようです。

・directiveのエレメントを作成する
・directiveのエレメントをcompileする。
・directiveのエレメントとscopeをリンクさせる。
・digestループを発生させる。

コンパイル処理

コンパイル処理は$compileサービスを利用する。

※$compoileは、通常はangularjsがディレクティブを処理するために自動でおこなっています。
テスト時は手動で行う必要があります。

var link = $compoile(element,transclude,priority);

element・・・コンパイル対象要素(文字列、もしくはjqLietオブジェクトを指定できる)
transclude・・・ディレクティブで利用する関数(非推奨)
priority・・・優先順位

test.js
appModule.directive('testDirective', [function() {
  return {
    'restrict': 'E',
    'link': function(scope) {
      scope.name = '太郎';
      scope.setName = function(str) {
        scope.name = str;
      };
    }
  };
}]);
test_spec.js
describe('テストのグループ', function() {
  var scope;
  var $rootScope;
  var $compile;
  beforeEach(module('app'));

  beforeEach(function() {
    spyOn(ons, 'isWebView').and.returnValue(true);
  });

  beforeEach(inject(function( _$rootScope_, _$compile_) {
    $rootScope = _$rootScope_;
    $compile = _$compile_;
    scope = $rootScope.$new();
  }));

  it('ディレクティブのテスト', function() {
    var element = angular.element('<test-directive>{{name}}</test-directive>');
    var link = $compile(element);
    link(scope);
    scope.$digest();
    expect(element.text()).toEqual('太郎');
    scope.setName('花子');
    scope.$digest();
    expect(element.text()).toEqual('花子');
  });
});

HTTP通信のテストをしてみる

HTTP通信のテストを実行する場合、モックを利用する。

モックとは
ユニットテストの為のダミーオブジェクト。
モックを利用することで実際はサーバーに接続が必要な通信処理を、
サーバーに接続したことにして、仮の値を返すことができます。

angularjsのユニットテストでモックを利用する場合は、angular-mock.jsを読み込む必要がある。

$httpBackendを使用して通信処理のテストを試す

test.js
angular.module('app',[])
.controller('AccountCtrl', function(){
  $scope.getName = function() {
    $http.get('/name')
    .success(function(data) {
      $scope.name = data;
    })
    .error(function(){
      $scope.phones = '';
    });
  }
});

test_spec.js
describe('テストグループ', function() {

  var $controller;
  var $httpBackend;
  var $rootScope;
  var $scope;
  var response = [{name: '太郎'}, {name: '花子'}];

  beforeEach(module('app'));

  beforeEach(inject(function(_$controller_, _$rootScope_, _$httpBackend_) {
    $controller = _$controller_;
    $rootScope = _$rootScope_;
    $httpBackend = _$httpBackend_;
    $scope = scope = $rootScope.$new();
    var controller = $controller('myCtrl', {
      $scope: $scope
    });
  }));

  it('HTTP通信のテスト', function() {
    expect($scope.phones).toBeUndefined();

    $scope.getName();
    $httpBackend.expect('GET','/name').respond(response);
    $httpBackend.flush();
    expect(scope.name).toEqual([{
      name: '太郎'
    }, {
      name: '花子'
    }]);

    $scope.getName();
    $httpBackend.expect('GET','/name').respond(500,response);
    $httpBackend.flush();
    expect(scope.name)toEqual('');
  });
});

$httpBackendを利用する場合は、下記がポイントとなります。
・expectで想定されるリクエストを定義
・respondで対応するレスポンスを定義
・flushメソッドでリクエストをフラッシュする

expect

expect(method,url,data,headers)

method ・・・HTTPメソッド(GET,POST,PUT,DELETEなど)
url ・・・リクエストurl
data ・・・リクエストボディ
headers・・・リクエストヘッダー

respond

respond(status,data,headers)

status・・・応答ステータスコード(200など)
data ・・・レスポンスボディ
header・・・レスポンスヘッダー({ヘッダー名:値})

flush

フラッシュするという表現がよくわからなかった。
単語の意味はトイレに水を流すとかの意味になるようです。

つまり、ウンコ(リクエスト)をして、水を流す(実行する)ようなイメージだろうか。

flush(count)

count・・・
フラッシュ(到着した順に)するレスポンスの数値を指定します。
もし未定義であれば、保留中の全てのリクエストがフラッシュされます。
もし、flushメソッドが呼ばれた際に、保留中のリクエストが無ければ、スローされます。

終わり

まだ2割くらいしか理解していませんが、今後も学んだことを追記していこうと思います。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away