目次
Node.jsとJasmineとKarmaでのモジュールテストの続き。
ここでは、複数のモジュールが連携している場合のテストと、モックの利用について説明する。
3つのモジュール、Product,Wallet,Bankがあり、それぞれ以下のような役割分担を持つ。
下に行くほど、上のモジュールに依存している。
var Product = function (name, price) {
this.name = name;
this.price = price;
this.print = function () {
return this.name + "は" + this.price + "円です";
};
};
module.exports = Product;var Wallet = function (name, amo) {
this.name = name;
this.amo = amo;
this.buy = function (p) {
this.amo = this.amo - p.price;
};
this.charge = function (a) {
this.amo = this.amo + a;
};
this.print = function () {
return this.name + "の残金は" + this.amo + "円です";
}
};
module.exports = Wallet;var Wallet = require("./Wallet");
var Bank = function () {
this.wallets = [];
// 財布を預ける
this.keep = function (w) {
for (var i = 0; i < this.wallets.length; i++)
if (this.wallets[i].name == w.name) return;
this.wallets.push(w);
};
// 財布を返す
this.back = function (n) {
for (var i = 0; i < this.wallets.length; i++) {
if (this.wallets[i].name == n) {
var w = this.wallets[i];
this.wallets.splice(i, 1);
return w;
}
}
return null;
};
// 財布にお金をチャージする
this.charge = function (n, a) {
for (var i = 0; i < this.wallets.length; i++) {
if (this.wallets[i].name == n) {
this.wallets[i].charge(a);
return;
}
}
};
// 財布から製品の代金を支払う
this.payForProduct = function (n, p) {
for (var i = 0; i < this.wallets.length; i++) {
if (this.wallets[i].name == n) {
this.wallets[i].buy(p);
return;
}
}
};
// 新しい財布を返す
this.newWallet = function (n, a) {
return new Wallet(n, a);
};
};
module.exports = Bank;単体テストを行う場合、対象となるモジュールが期待通りに動いていることを確認することが必要である。
例えば、Bankモジュールをテストする場合、Bankが提供する機能のうち、他のモジュールが行っていることではない機能についてテストする。他のモジュールの機能はそのモジュールの単体テストによってテストされるべきである。
var Product=require("../src/Product");
describe("製品の生成テスト",function(){
it("かばん",function(){
var p=new Product("かばん",5000);
expect(p.print()).toBe("かばんは5000円です");
});
it("うどん",function(){
var p=new Product("うどん",240)
expect(p.print()).toBe("うどんは240円です");
});
});var Wallet = require("../src/Wallet");
var Product = require("../src/Product");
describe("財布のテスト", function () {
describe("生成テスト", function () {
it("太郎", function () {
var w = new Wallet("太郎", 10000);
expect(w.print()).toBe("太郎の残金は10000円です");
})
it("花子", function () {
var w = new Wallet("花子", 25000);
expect(w.print()).toBe("花子の残金は25000円です");
})
});
describe("買い物のテスト", function () {
var taro, hanako, bag, udon;
beforeEach(function () {
taro = new Wallet("太郎", 10000);
hanako = new Wallet("花子", 25000);
bag = new Product("かばん", 5000);
udon = new Product("うどん", 240);
})
it("太郎がかばんを買った", function () {
taro.buy(bag);
expect(taro.amo).toBe(5000);
});
it("花子がかばんを買ってうどんを2杯食べた", function () {
hanako.buy(bag);
hanako.buy(udon);
hanako.buy(udon);
expect(hanako.amo).toBe(25000 - 5000 - 240 * 2);
})
});
describe("チャージのテスト",function(){
it("太郎の財布に5000円チャージした",function(){
var taro=new Wallet("太郎",10000);
taro.charge(5000);
expect(taro.amo).toBe(15000);
});
});
});var Bank = require("../src/Bank");
var Wallet = require("../src/Wallet");
var Product = require("../src/Product");
describe("銀行のテスト", function () {
var bank, taro, hanako;
describe("生成テスト", function () {
it("最初の財布の数は0だ", function () {
bank = new Bank();
expect(bank.wallets.length).toBe(0);
});
});
describe("預入のテスト", function () {
beforeEach(function () {
bank = new Bank();
taro = new Wallet("太郎", 10000);
hanako = new Wallet("花子", 25000);
});
it("太郎と花子の財布を預けた", function () {
bank.keep(taro);
bank.keep(hanako);
expect(bank.wallets.length).toBe(2);
});
it("太郎と太郎の財布を預けた", function () {
bank.keep(taro);
bank.keep(taro);
expect(bank.wallets.length).toBe(1);
});
})
describe("返却のテスト", function () {
// beforeEachをしないと、直上の状態が引き継がれる
it("太郎の財布を返した", function () {
bank.keep(hanako);
var ret_wallet = bank.back('太郎');
// 花子の財布だけが残っているはず
expect(bank.wallets.length).toBe(1);
expect(bank.wallets[0].name).toBe("花子");
// 返された財布は太郎のもののはず
expect(ret_wallet.name).toBe("太郎");
});
});
describe("チャージのテスト", function () {
it("太郎に5000円チャージした", function () {
taro = new Wallet("太郎", 10000);
spyOn(taro, 'charge');
bank.keep(taro);
bank.charge("太郎", 5000);
expect(taro.charge).toHaveBeenCalled();
});
});
describe("引落しのテスト",function(){
it("太郎がカバンを買った",function(){
var bag=new Product("カバン",5000);
spyOn(taro,'buy');
bank.payForProduct("太郎",bag);
expect(taro.buy).toHaveBeenCalled();
});
});
describe("新しい財布の生成テスト", function () {
it("3000円入りの次郎の財布を作った", function () {
var w=bank.newWallet("次郎",3000);
expect(w instanceof Wallet).toBeTruthy();
})
});
});モックとは「偽物」や「模造品」のことで、本物のふりをして本物らしく動作するけれども本物ではないものを意味する。
ちなみにモックの他に似たような言葉としてスタブ、フェイク、ダミーなどがあり、正確に言うとそれぞれで意味が異なるのだが、その辺の細かいことはここでは省略。
ここでは、テストスペックにモックを使用するためにrewireというモジュールを使用する。rewireモジュールはすでに前回のセミナーで、Node.jsのnpmによってインストール済み。(第4回参照)
どのような状況でモックが使用されるのか?
例えば、上記の3つのプログラムを3名の開発者が開発しており、まだProductとWalletは出来上がっていないが、Bankだけはもう完成してテストしたい、というような状況である。
この場合、ProductとWalletをモックにしてBankSpecに注入することにより、まだ実体のないProductとWalletに依存するBankをテスト可能となる。
rewireを使ったモックの注入手順は以下の通り。
下記のようにモックを注入し、src/Product.jsとsrc/Wallet.jsの中身をすべてコメントアウトしてから、テストする。
var rewire = require("rewire");
var Bank = rewire("../src/Bank");
//var Wallet = require("../src/Wallet");
//var Product = require("../src/Product");
// 製品モック
var Product = function (name, price) {
this.name = name;
this.price = price;
this.print = function () {};
};
// 財布モック
var Wallet = function (name, amo) {
this.name = name;
this.amo = amo;
this.print = function () {};
this.buy = function (p) {};
this.charge = function (a) {};
};
// モックの注入
Bank.__set__('Product',Product);
Bank.__set__('Wallet',Wallet);
// 実際のテストを行う
describe("銀行のテスト", function () {
var bank, taro, hanako;
describe("生成テスト", function () {
it("最初の財布の数は0だ", function () {
bank = new Bank();
expect(bank.wallets.length).toBe(0);
});
});
describe("預入のテスト", function () {
beforeEach(function () {
bank = new Bank();
taro = new Wallet("太郎", 10000);
hanako = new Wallet("花子", 25000);
});
it("太郎と花子の財布を預けた", function () {
bank.keep(taro);
bank.keep(hanako);
expect(bank.wallets.length).toBe(2);
});
it("太郎と太郎の財布を預けた", function () {
bank.keep(taro);
bank.keep(taro);
expect(bank.wallets.length).toBe(1);
});
})
describe("返却のテスト", function () {
// beforeEachをしないと、直上の状態が引き継がれる
it("太郎の財布を返した", function () {
bank.keep(hanako);
var ret_wallet = bank.back('太郎');
// 花子の財布だけが残っているはず
expect(bank.wallets.length).toBe(1);
expect(bank.wallets[0].name).toBe("花子");
// 返された財布は太郎のもののはず
expect(ret_wallet.name).toBe("太郎");
});
});
describe("チャージのテスト", function () {
it("太郎に5000円チャージした", function () {
taro = new Wallet("太郎", 10000);
spyOn(taro, 'charge');
bank.keep(taro);
bank.charge("太郎", 5000);
expect(taro.charge).toHaveBeenCalled();
});
});
describe("引落しのテスト", function () {
it("太郎がカバンを買った", function () {
var bag = new Product("カバン", 5000);
spyOn(taro, 'buy');
bank.payForProduct("太郎", bag);
expect(taro.buy).toHaveBeenCalled();
});
});
describe("新しい財布の生成テスト", function () {
it("3000円入りの次郎の財布を作った", function () {
var w = bank.newWallet("次郎", 3000);
expect(w instanceof Wallet).toBeTruthy();
})
});
});
Karmaとはブラウザベースのテスト環境で、通常はユーザが手作業で行わなければならない、テキスト欄に文字を入れたりボタンをクリックしたりして行うテストを自動化できる。
Karmaは様々なブラウザに対応しているが、ここではChromeだけを対象として検証する。
なお、Karmaと並んで有名なUIテスト環境としてSeleniumがある。
Karmaのインストールも前回に済ませてある。
前回のインストール時に、
>karma init
を実行してプロジェクトルートフォルダにkarma.conf.jsというファイルを作ってあるが、それに次の設定を追加する。
module.exports = function (config) {
config.set({
…
files: [
'*.html', // ←ここ
'src/*.js',
'spec/*Spec.js' // ←ここも
],
…
preprocessors: {
'*.html': 'html2js' // ←ここ
},
…
});
};
最初の設定はテスト対象として.htmlファイルも含むようにする。
2つ目の設定は.htmlファイルを読むときhtml2jsという前処理を行うようにする。karma-html2js-preprocessorというモジュールも前回インストール済みである。
ブラウザテストを行うには、通常のウェブアプリと同様にHTMLファイルをJavascriptファイルが必要となる。
<!doctype html>
<html>
<head>
<script type="text/javascript" src="src/add2.js"></script>
</head>
<body>
<p>
<input id="inp1" type="text" size="4" /> +
<input id="inp2" type="text" size="4" /> =
<span id="ans"></span>
</p>
<button id="btn" onclick="add2()">足す</button>
</body>
</html>var add2 = function () {
var n1=parseFloat(document.getElementById("inp1").value);
var n2=parseFloat(document.getElementById("inp2").value);
var ans=document.getElementById("ans");
ans.innerHTML=(n1+n2);
}Karmaでテストを行う場合は、テストファイルの中にrequire()を書く必要はない。karma.conf.jsの中の['src/*.js','spec/*.js']で相互参照が行われるからである。
また、テストコードの最初で、html2jsを使用してHTMLをJavascriptとして扱えるようにしている。これにより、DOMツリーが使用できる。
describe("Add2のテスト", function () {
var inp1, inp2, ans, btn;
beforeEach(function () {
document.body.innerHTML = window.__html__['add2.html'];
inp1 = document.getElementById('inp1');
inp2 = document.getElementById('inp2');
ans = document.getElementById('ans');
btn = document.getElementById('btn');
});
it("2+3は5である", function () {
inp1.value = 2;
inp2.value = 3;
btn.click();
expect(ans.innerHTML).toBe('5');
});
});Karmaを使ったテストは以下のように実行する。
karma start karma.conf.js
これで、設定ファイルに従って自動的にブラウザが起動し、テストが実行される。
パスが通っていない場合、以下のようにしてパスを通す。
>set Path=.\node_modules\.bin;%Path%
karma-html-reporterを使うと、テストの結果を見やすく表示してくれる。
そのためには、karma.conf.jsを以下のように修正する。
// test results reporter to use
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
//reporters: ['progress'], // ←コメントアウト
reporters: ['spec','html'], // ←追加
ディレクティブとは、HTML命令を拡張する機能である。
AngularJSには、最初から多くのディレクティブが準備されており、それを組み合わせて使うことでほとんどの処理は実現できる。
したがって、あまり独自のディレクティブを作らなければならないような場面は少ないと思うが、ここでは例としてディレクティブの作成について説明する。
独自のディレクティブは以下のような構文で宣言する。
var app=angular.module("App");
app.directive("DirectiveName",function(){
return {
// ここに、必要なディレクティブの設定項目を書く
};
});
設定項目には以下のものがある。
| 項目名 | 意味 |
|---|---|
| restrict | このディレクティブの使われ方。要素(E)、属性(A)、クラス(C)、およびコメント(M)を組み合わせて文字列で指定する。デフォルトは'EA'である。 |
| priority | 同じ要素で指定されているディレクティブの優先順位を決める数値。デフォルトは0。 |
| template | インラインのテンプレート文字列。この文字列がHTMLとして使用される。 |
| templateUrl | テンプレートとして使う文字列が入っているURL。長い場合はこちらが良い。 |
| replace | trueならばもとのHTMLがディレクティブのHTMLで置き換わる。falseならば挿入される。デフォルトはfalse。 |
| transclude | trueの場合、元のHTMLの子要素がディレクティブのHTMLのng-transcludeで指定されている要素へと移される。 |
| scope | 親の$scopeを継承せずに、このディレクティブのために新しい$scopeを生成する。 |
| controller | ディレクティブ間での通信を可能にするコントローラを生成する。 |
| require | このディレクティブが正しく機能するために必要な他のディレクティブを指定する。 |
| link | ディレクティブが生成された後で、DOMを変更したりイベントリスナーを追加したりデータバインディングを定義したりする。 |
| compile | ディレクティブが生成される際に1回だけ呼び出される処理を記述する。$scopeは利用できない。link関数を返すこともできる。 |
単純なディレクティブからちょっと複雑なディレクティブまでを扱う例として、ここでは4種類のディレクティブを見てみる。
hello></hello>ディレクティブは、「こんにちは」という文字列を表示する。
replaceプロパティがfalseならば、
<hello><div>こんにちは</div></hello>
のようにタグの間に挟みこまれる。replace:trueならば、
<div>こんにちは</div>
のように<hello></hello>は除去される。
transcludeプロパティを指定すると、ディレクティブ内部のng-transcludeを指定した要素に、元の要素の内部の文字列が移行される。
クリックすると開いたり閉じたりするディレクティブ。
このディレクティブは、分離スコープを使用しており、外部のスコープの影響を受けずに独自のスコープを使って表示処理を行っている。
また、linkプロパティに指定した関数はディレクティブの生成後に呼び出されるため、ここで表示・非表示の切り替え処理を定義している。
エキスパンダーを複数個並べて、同時には1つ以上開かないようにしたディレクティブ。
ここでは、controllerプロパティとrequireプロパティを使って、複数のディレクティブ間でコントローラを共有する方法の例を示している。
<!doctype html>
<html ng-app="TestDirective">
<head>
<script type="text/javascript" src="vendors/angular.js"></script>
<script type="text/javascript" src="js/app.js"></script>
<script type="text/javascript" src="js/directives.js"></script>
<script type="text/javascript" src="js/controllers.js"></script>
<link rel="stylesheet" href="css/app.css">
</head>
<body ng-controller="AppController">
<p hello onclick="xxx"></p>
<p>
<hello-you>太郎</hello-you>
</p>
<p>
<hello-you>花子</hello-you>
</p>
<h3>エキスパンダー</h3>
<expander class="expander" expander-title="title1">
{{msg1}}
</expander>
<expander class="expander" expander-title="title2">
{{msg2}}
</expander>
<h3>アコーディオン</h3>
<accordion>
<expander2 ng-repeat="m in msg" class="expander" expander-title="m.title">
{{m.text}}
</expander2>
</accordion>
</body>
</html>(function(){
var app=angular.module("TestDirective",[]);
}());(function(){
var app=angular.module("TestDirective");
app.controller("AppController",function($scope){
$scope.title1="清掃作業のお知らせ";
$scope.title2="理事会のお知らせ";
$scope.msg1="2016年1月9日の午後5時より町内会の清掃作業を行います。";
$scope.msg2="本年度最後の理事会は事情により延期します。";
$scope.msg=[
{ title:"お知らせA",text:"これはお知らせAです。" },
{ title:"お知らせB",text:"これはお知らせBです。" },
{ title:"お知らせC",text:"これはお知らせCです。" },
{ title:"お知らせD",text:"これはお知らせDです。" },
];
});
}());(function () {
var app = angular.module("TestDirective");
// こんにちはディレクティブ
app.directive("hello", function () {
return {
template: '<div>こんにちは</div>',
replace: false,
};
});
// こんにちはあなたディレクティブ
app.directive("helloYou", function () {
return {
template: '<div>こんにちは、<span ng-transclude></span>さん</div>',
replace: true,
transclude: true,
};
});
// エキスパンダーディレクティブ
app.directive("expander", function () {
return {
replace: true,
transclude: true,
scope: {
title: "=expanderTitle"
},
template: '<div>' +
'<div class="title" ng-click="toggle()">{{title}}</div>' +
'<div class="body" ng-show="showMe" ng-transclude></div>' +
'</div>',
link: function (scope, element, attrs) {
scope.showMe = false;
scope.toggle = function () {
scope.showMe = !scope.showMe;
};
},
};
});
// エキスパンダー2ディレクティブ
app.directive("expander2", function () {
return {
replace: true,
transclude: true,
require: "^?accordion",
scope: {
title: "=expanderTitle"
},
template: '<div>' +
'<div class="title" ng-click="toggle()">{{title}}</div>' +
'<div class="body" ng-show="showMe" ng-transclude></div>' +
'</div>',
link: function (scope, element, attrs, ac) {
scope.showMe = false;
ac.addExpander(scope);
scope.toggle = function () {
scope.showMe = !scope.showMe;
ac.closeOthers(scope);
};
},
};
});
// アコーディオンディレクティブ
app.directive("accordion", function () {
return {
replace: true,
transclude: true,
template: '<div ng-transclude></div>',
controller: function () {
var expanders = [];
this.closeOthers = function (selected_expander) {
angular.forEach(expanders, function (ex) {
if (selected_expander != ex)
ex.showMe = false;
});
}
this.addExpander = function (ex) {
expanders.push(ex);
}
},
};
});
}());.expander {
border: 1px solid black;
width: 300px;
}
.expander > .title {
background-color: black;
color: white;
padding: .1em .3em;
cursor: pointer;
}
.expander > .body {
padding: .1em .3em;
}AngularJSを使ったウェブアプリの開発の課題である。
数字が大きくなるにつれて複雑になるので、まずは1から順にこなしていってほしい。
<!doctype html>
<html ng-app="SimpleCalc">
<head>
<script type="text/javascript" src="vendors/angular.js"></script>
<script type="text/javascript" src="js/app.js"></script>
<script type="text/javascript" src="js/controllers.js"></script>
<script type="text/javascript" src="js/services.js"></script>
<link rel="stylesheet" href="css/app.css">
</head>
<body ng-controller="CalcController">
<h1>シンプル電卓 <small>Ver 0.01</small></h1>
<table>
<tr>
<td id="disp" colspan="5">0</td>
</tr>
<tr ng-repeat="br in cs.buttons">
<td ng-repeat="b in br">
<button>{{b}}</button>
</td>
</tr>
</table>
</body>
</html>(function(){
var app=angular.module("SimpleCalc",[]);
}());(function(){
var app=angular.module("SimpleCalc");
app.controller("CalcController",function($scope,CalcService){
$scope.cs=CalcService;
});
}());(function(){
var app=angular.module("SimpleCalc");
app.factory("CalcService",function(){
var cs={};
cs.buttons=[
['7','8','9','÷','C'],
['4','5','6','×','M'],
['1','2','3','-','←'],
['0','.','=','+','√'],
];
return cs;
});
}());button{
width:40px;
height:40px;
font-size: 20px;
}
#disp{
background-color: black;
color: white;
font-size: 40px;
text-align: right;
padding: 10px;
}