株式会社クリアコード > フリーソフトウェア開発 > UxU - UnitTest.XUL > モックを使ったテスト

モックを使ったテスト

モックの作成

UxUに組み込まれているモック機能の基本的な利用方法はMockObject.jsのAPIに基づいています。ただし、互換性および実用上の利便のため、他にもいくつかのモックライブラリと共通のAPIを備えています

モックオブジェクト

モックオブジェクトの作成

UxUのテスト実行時の名前空間では、コンストラクタ関数Mock()が利用できます。コンストラクタ関数の引数として文字列を与えると、モックに任意の名前を付けることができます。この時付けた名前はテスト失敗時のメッセージに表示されることになりますので、デバッグをより容易にするために、すべてのモックには名前を付けておくことをお勧めします。

var mock = new Mock('my mock');

既存のクラスのコンストラクタ関数やインスタンスのオブジェクトをモックのコンストラクタ関数に渡すと、そのプロパティやメソッドが継承されると同時に、コンストラクタから自動的にモックの名前が決定されます。

// from class
var arrayMock = new Mock(Array);
// from instance
var windowMock = new Mock(window);
// 名前との同時指定も可能
var documentMock = new Mock('unique name', document);

後述する方法でエクスペクテーションを設定していない状態で、継承元のクラスやオブジェクトで定義されていたwindowMock.titleなどのプロパティやwindowMock.alert()などのメソッドにアクセスすると、予期しないアクセスが行われたものとして例外が発生します。

メソッド呼び出しのエクスペクテーション

作成されたばかりのモックは、(モック自体の操作に使用するメソッドを除いて)どのメソッドを呼んでも、予期しないメソッド呼び出しが行われたものとして例外が発生します。実際のモジュールの代用としてモックを使うためには、必要に応じてエクスペクテーションexpectation(期待される呼び出され方と、その時の挙動)を設定する必要があります。

例えばgBrowser.addTab()gBrowser.removeAllTabsBut()がそれぞれ1回ずつこの順番の通りに呼ばれることが期待されるのであれば、以下のようにして2つのエクスペクテーションを設定します。

var mock = new Mock('gBrowser');
mock.expect('addTab', uri, tab); // mock.addTab(uri) => tab
mock.expect('removeAllTabsBut', tab); // mock.removeAllTabsBut(tab) => undefined
gBrowser = mock; // モックを本来のgBrowserの代わりに使う

expect()メソッドは、第1引数にメソッド名を、第2引数に期待される引数を、第3引数に返り値を受け取ります。複数個の引数が期待される場合は、第2引数には配列を渡します(引数に配列が渡されることが期待される場合は2次元の配列として指定します)。引数を伴わない呼び出しが期待される場合は、空の配列を渡します。このようにしてエクスペクテーションを設定しておくと、期待される引数と同じ引数を伴ってメソッドが呼び出された際に、第3引数で指定された返り値(省略時はundefined)が返されます。

mock.expect('open', [uri, name, features], null);
// mock.open(uri, name, features) => null

mock.expect('slice', [[item1, item2], 1], [item2]);
// mock.slice([item1, item2], 1) => [item2]

メソッドの呼び出し時に例外が発生する場面を再現させたい場合は、expect()メソッドの代わりにexpectThrows()メソッドを使います。この場合、期待される引数と同じ引数を伴ってメソッドが呼び出されると例外が投げられるようになります。

mock.expectThrows('loadURI', 'urn:isbn:xxxxx', 'invalid URI');
// mock.loadURI('urn:isbn:xxxxx') => throw new Error('invalid URI')

// 例外のクラスとメッセージを指定すると、そのクラスのインスタンスを例外として投げる
mock.expectThrows('eval', '{', SyntaxError, 'missing } in compound statement');
// mock.eval('{') => throw new SyntaxError('missing } in compound statement')

値を返す場合でも例外を発生させる場合でも、1つのエクスペクテーションは1回の呼び出しに対応します。また、エクスペクテーションを設定した順番がそのまま、呼び出し時に期待される順番となります。何回呼び出されても常に固定の値を返すという風な、呼び出し順も引数も重要でない物は、モックではなくスタブとして定義して下さい

// こういう物はモックではなくスタブ(stub)
var gBrowser = {
      addTab : function() {
        return document.createElement('tab');
      }
    };

プロパティのエクスペクテーション

メソッドではないプロパティの参照やプロパティへの代入に対しても、エクスペクテーションを設定することができます。

var loc = new Mock('location');

// hostプロパティが1回参照されるはずなので、その時は'www.example.jp'を返す
loc.expectGet('host', 'www.example.jp');
// loc.host => 'www.example.jp'

// hrefプロパティに'http://www.example.com'が1回代入されるはず
loc.expectSet('href', 'http://www.example.com/');
// loc.href = 'http://www.example.com/' => OK
// loc.href = 'http://www.example.net/' => NG

また、メソッドの呼び出しと同様に、プロパティの参照や代入についても例外の発生をエクスペクテーションとして設定することができます。

loc.expectGetThrows('host', 'permission denied');
// loc.host => throw new Error('permission denied')

loc.expectSetThrows('href', 'urn:isbn:xxxx', 'invalid uri');
// loc.href = 'urn:isbn:xxxx' => throw new Error('invalid uri')

これらの場合も、1つのエクスペクテーションの設定が1回の参照(代入)に対応します。また、エクスペクテーションを設定した順番がそのまま、プロパティの参照や代入が期待される順番となります。何回参照されても同じ値を返すプロパティや、何を代入しても問題ないプロパティのように、参照順も回数も重要でない物は、モックではなくスタブとして定義して下さい

// こういう物はモックではなくスタブ(stub)
var location = {
      href : 'http://www.exemple.jp/',
      host : 'www.exemple.jp'
    };

モックの検証

作成したモックのassert()メソッドを呼ぶと、任意の時点でモックの検証を行えます。検証を実施した時点ですべてのエクスペクテーションが処理済みであれば、検証は成功し、処理が先に進みます。検証時に未処理のエクスペクテーションが1つでも残っている場合は、期待された通りに処理が行われなかったものとして例外が発生します。

gBrowser = new Mock('gBrowser mock');
gBrowser.expect('addTab', uri, tab);
gBrowser.expect('removeAllTabsBut', tab);

var newTab = openNewTabAndCloseOthers(uri);

gBrowser.assert();

assert()メソッドは通常、テストの中で手動で呼ぶ必要はありません。テストの中で1度もassert()メソッドが呼ばれなかった場合は、tearDownが実施される前のタイミングでUxUが自動的にassert()メソッドを呼び、強制的に検証を行います

モック関数

UxUでは、特定のオブジェクトのメソッドとして紐付けられていない単独の関数オブジェクトとして振る舞う、モック関数オブジェクトを扱うことができます。モック関数はコールバック関数などの代わりとして利用できます。

モック関数の作成

UxUのテスト実行時の名前空間では、コンストラクタ関数FunctionMock()およびMockFunction()が利用できます。(両者は同じ物ですので、どちらを使っても構いません。以下の例では便宜的にMockFunction()に統一します。)コンストラクタ関数の引数として文字列を与えると、モックに任意の名前を付けることができます。この時付けた名前はテスト失敗時のメッセージに表示されることになりますので、デバッグをより容易にするために、すべてのモックには名前を付けておくことをお勧めします。

var fmock = new MockFunction('my mock function');

既存の名前付き関数をモックのコンストラクタ関数に渡すと、関数の名前から自動的にモックの名前が決定されます。ただし、匿名の関数からは名前を取得できませんので、特に必要のない限りは前述の方法で明示的に名前を付けることをお勧めします

var openNewTabWith = new MockFunction(openNewTabWith);
var openUILink = new MockFunction(openUILink);

関数呼び出しのエクスペクテーション

作成されたばかりのモック関数は、どんな引数を渡して呼んでも、予期しない呼び出しが行われたものとして例外が発生します。実際の関数の代用としてモック関数を使うためには、必要に応じてエクスペクテーションを設定する必要があります。

例えばopenNewTabWith()'http://www.example.com/''urn:isbn:xxxx'のそれぞれを引数として呼ばれることが期待されるのであれば、以下のようにして2つのエクスペクテーションを設定します。

var fmock = new MockFunction('openNewTabWith');
fmock.expect('http://www.example.com/', tab); // openNewTabWith('http://www.example.com/') => tab
fmock.expect('urn:isbn:xxxx'); // openNewTabWith('urn:isbn:xxxx') => undefined
openNewTabWith = fmock; // モック関数を本来のopenNewTabWithの代わりに使う

expect()メソッドは、第1引数に期待される引数を、第2引数に返り値を受け取ります。複数個の引数が期待される場合は、第1引数には配列を渡します(引数に配列が渡されることが期待される場合は2次元の配列として指定します)。引数を伴わない呼び出しが期待される場合は、空の配列を渡します。このようにしてエクスペクテーションを設定しておくと、期待される引数と同じ引数を伴って関数が呼び出された際に、第2引数で指定された返り値(省略時はundefined)が返されます。

fmock.expect([uri, name, features], null);
// fmock(uri, name, features) => null

fmock.expect([[item1, item2], 1], [item2]);
// fmock([item1, item2], 1) => [item2]

関数の呼び出し時に例外が発生する場面を再現させたい場合は、expect()メソッドの代わりにexpectThrows()メソッドを使います。この場合、期待される引数と同じ引数を伴ってメソッドが呼び出されると例外が投げられるようになります。

openMock.expectThrows('urn:isbn:xxxxx', 'invalid URI');
// openMock('urn:isbn:xxxxx') => throw new Error('invalid URI')

// 例外のクラスとメッセージを指定すると、そのクラスのインスタンスを例外として投げる
evalMock.expectThrows('{', SyntaxError, 'missing } in compound statement');
// evalMock('{') => throw new SyntaxError('missing } in compound statement')

値を返す場合でも例外を発生させる場合でも、1つのエクスペクテーションは1回の呼び出しに対応します。また、エクスペクテーションを設定した順番がそのまま、呼び出し時に期待される順番となります。何回呼び出されても常に固定の値を返すという風な、呼び出し順も引数も重要でない物は、モックではなくスタブとして定義して下さい

// こういう物はモックではなくスタブ(stub)
var openUILink = function(aURI) {
      return true;
    };

モック関数の検証

作成したモック関数のassert()メソッドを呼ぶと、任意の時点でモック関数の検証を行えます。検証を実施した時点ですべてのエクスペクテーションが処理済みであれば、検証は成功し、処理が先に進みます。検証時に未処理のエクスペクテーションが1つでも残っている場合は、期待された通りに処理が行われなかったものとして例外が発生します。

openNewTabWith = new MockFunction('openNewTabWith');
openNewTabWith.expect(uri1, document.createElement('tab'));
openNewTabWith.expect(uri2, document.createElement('tab'));

var newTabs = openNewTabs([uri1, uri2]);

openNewTabWith.assert();

assert()メソッドは通常、テストの中で手動で呼ぶ必要はありません。テストの中で1度もassert()メソッドが呼ばれなかった場合は、tearDownが実施される前のタイミングでUxUが自動的にassert()メソッドを呼び、強制的に検証を行います

既存オブジェクトの機能をモックで置き換える

既存のオブジェクトの一部のメソッドやプロパティだけをモックで置き換えたい(付け加えたい)場合は、Mockのクラスメソッドを使用します。

Mock.expect(gBrowser, 'addTab', uri, document.createElement('tab'));
Mock.expect(gBrowser, 'removeAllTabsBut', tab);
Mock.expectThrows(gBrowser, 'loadURI', ['urn:isbn:xxxx', referrer], 'invalid URI');
Mock.expectGet(gBrowser, 'contentDocument', content.document);
Mock.expectGetThrows(gBrowser, 'docShell', 'permission denied');
Mock.expectSet(gBrowser, 'selectedTab', tab);
Mock.expectSetThrows(gBrowser, 'contentDocument', 'readonly');

通常のモックのメソッド呼び出しのエクスペクテーションプロパティアクセスのエクスペクテーションとは異なり、メソッドの最初の引数としてメソッドやプロパティを保持するオブジェクトを指定することに注意して下さい。

メソッドチェインによるエクスペクテーションの定義

エクスペクテーションを定義する際は、メソッドチェインによってより細かい挙動を指定することができます。

var windowMock = new Mock(window);

windowMock.expect('alert', 'OK').andReturn(true)
                                .times(3);

モックのexpect()expectThrows()expectGet()expectGetThrows()expectSet()expectSetThrows()、およびモック関数のexpect()expectThrows()の返り値に対しては、以下のメソッドチェインを任意に繋げることができます。

Object times(in Number aTimes)

最後のエクスペクテーションについて、そこまでの時点で定義されている内容が特定の回数繰り返されることを指定します。

このメソッドが呼ばれた後に書かれた指定の内容は繰り返しの対象になりませんので、メソッドチェインを各順番には気をつけて下さい。以下の2つはそれぞれ意味が異なります。

// alert('OK')が呼ばれてtrueを返す、という呼び出しが3回行われる
mock.expect('alert', 'OK').andReturn(true)
                          .times(3);

// alert('OK')が3回呼ばれて、そのうち最後の1回だけtrueを返す
mock.expect('alert', 'OK').times(3)
                          .andReturn(true);
Object bindTo(in Object aContext) 別名:boundTo(), andBindTo(), andBoundTo()

最後のエクスペクテーションについて、そのメソッド(関数)やゲッタ・セッタ関数を実行する時のthisが指定のオブジェクトとなることを指定します。指定されたオブジェクト以外がthisになる形でメソッドが呼ばれると、例外を発生させます。

Object andReturn(in Object aReturnValue) 別名:andReturns(), thenReturn(), thenReturns()

最後のエクスペクテーションについて、そのメソッド(関数)を実行したりプロパティを参照したりした時に返されることが期待される返り値を指定します。expectThrows()expectGetThrows()からのメソッドチェインの場合、この指定は無視されます。

Object andThrow(in Object aException, [in String aMessage]) 別名:andThrows(), andRaise(), andRaises(), thenThrow(), thenThrows(), thenRaise(), thenRaises()

最後のエクスペクテーションについて、そのメソッド(関数)を実行したりプロパティを参照したりした時に投げられることが期待される例外を指定します。第1引数だけが指定された場合はそれを例外として投げ、第1引数にコンストラクタ関数・第2引数にメッセージ文字列が指定された場合はnew aException(aMessage)した物を例外として投げます。expect()expectGet()expectSet()からのメソッドチェインの場合、この指定の方が優先されます(例外が投げられるため、返り値は返されません)。

Object andStub(in Function aOperation) 別名:then()

最後のエクスペクテーションについて、そのメソッド(関数)を実行したりプロパティを参照したりした時に同時に実行させる処理を、関数で指定します。メソッドやプロパティの副作用を再現する為に利用できます。関数が実行される時は、そのエクスペクテーションで指定されているメソッドに渡された引数がそのまま関数に対しても渡されます。

他のモックライブラリの記法によるモックの定義

UxUに内蔵されているモック機能は、JavaScriptにおける既存の代表的ないくつかのモックライブラリと共通のAPIを備えています。既存のモックライブラリを使用した経験がある場合には、慣れ親しんだ記法でモックを定義することができます。

MockObject.js

MockObject.jsの以下の記法に対応しています。

var mock = MockCreate(Window);
mock._expect('open', [uri, name, features], null);
mock._expectThrows('moveBy', [20, 20], 'permission denied');
...
mock._verify();

JSMock

JSMockの以下の記法に対応しています。

var mockControl = new MockControl();
windowMock = mockControl.createMock(Window);

windowMock.expects().open(uri, name, features).andReturn(null);
windowMock.expects().moveBy(20, 20).andThrow('permission denied');
windowMock.expects().setTimeout(TypeOf.isA(Function), 1000)
             .andStub(function(aFunction, aTimeout) { aFunction(); });

...

mockControl.verify();

JSMock.extend()を実行していない状態でも、createMock()resetMocks()およびverifyMocks()を利用することができます。(UxUのテストケースにおいては、JSMock.extend()は実際には何も行いません。)

var windowMock, documentMock;

function setUp() {
  JSMock.extend(this); // 省略可能

  windowMock = createMock();
  windowMock.addMockMethod('alert');

  documentMock = createMock();
  documentMock.addMockMethod('open');
}

function tearDown() {
  verifyMocks(); // 省略可能
}

function testMyFeature() {
  windowMock.expects().alert('OK');
  documentMock.expects().open();
  ...
}

JsMockito

JsMockitoの以下の記法に対応しています。

var windowMock = mock(Window);
when(windowMock).open(uri, name, features).thenReturn(null);
when(windowMock).moveBy(20, 20).thenThrow('permission denied');
when(windowMock).setTimeout(anything(), 1000)
             .then(function(aFunction, aTimeout) { aFunction(); });
var addTab = mockFunction();
when(addTab)(uri).thenReturn(document.createElement('tab'));
when(addTab)('urn:isbn:xxxx').thenThrow('invalid URI');
when(addTab).call(gBrowser, uri) // bind "this" to the gBrowser
             .thenReturn(null);

UxUはJsMockitoの機能のうち、上記の例のようなあらかじめモックの振る舞いを定義しておくスタイルの利用方法にのみ対応しています。verify()を使って後から検証を行う利用方法には対応していませんので、ご注意下さい。また、JsHamcrest互換のAPIは備わっていないため、引数の柔軟なマッチングにも対応していません。

ローカルHTTPサーバをモックとして利用する

UxU内で起動されたローカルHTTPサーバのインスタンス(utils.setUpHttpServer()およびutils.getHttpServer()の返り値)に対してエクスペクテーションを設定することで、ローカルHTTPサーバをモックとして利用することができます。

var server = utils.setUpHttpServer(4445, baseURL);

server.expect('/index.html.en', '/index.html'); // 200 OK
// server.expect('/index.html.en', 200, '/index.html'); と同じ意味

server.expect(/^\/subdir\/.*/, '/index.html'); // 正規表現も利用可能
server.expect(/([^\/\.]+)\.jpg/, '/images/$1.jpg'); // RewriteRuleのような置換

server.expect('/users', 301, '/accounts'); // リダイレクト

server.expectThrows('/deleted', 404);

gBrowser.loadURI('http://localhost:4445/index.html.en'); // => "/index.html" 200 OK

expect()およびexpectThrows()の第1引数は期待されるリクエストの内容(絶対パスの文字列または正規表現)で、第2引数以降の引数はその時返すレスポンスの内容です。レスポンスとしてファイル(nsIFileのオブジェクト)を渡すと、そのファイルの内容が返されます。ステータスコードが3XX以外の場合、レスポンスのURIのスキーマおよびホスト名は無視されます。

エクスペクテーションを設定している場合、期待されないアクセスはすべて500 Internal Server Errorとなります。また、期待されないアクセスによってHTTPサーバ内で発生した例外は、utils.tearDownHttpServer()でサーバを終了する際にまとめて報告されます。

エクスペクテーションの設定においては、返り値をハッシュの形式で指定することもできます。この場合、delayプロパティによってミリ秒単位でレスポンスの遅延を指定できますので、反応が遅いWWWサーバと通信する場合のテストも行えます。

server.expect('/index.html.en', { path : '/index.html', delay : 3000 });
// server.expect('/index.html.en', { status : 200, path : '/index.html', delay : 3000 }); と同じ

server.expect('/moved', { status: 301, path : '/index.html', delay : 3000 }); // 遅延されたリダイレクト

エクスペクテーションの設定には、他のモックライブラリの記法と同様のAPIも利用できます。

// JSMock風
server.expects()('(.*)\.en').andReturn(302, '$1')

// JsMockito風
when(server)('/deleted').thenThrow(404);