経験を重ねることでテストの書き方にも少しずつ慣れてきたのだが、非同期のテストには未だに苦手意識がある。
今回書いた内容についても、これがベストプラクティスだとは思わない。
実戦でやろうとすると、いろいろと課題が出てくると思う。
ただ、自分なりに少しは取っ掛かりを掴めた気がする。
なお、Promise
を使ってサンプルやテストを書いた。
Promise
についてはこちらを参照。
Promiseによる非同期処理の書き方
テスト対象のコードを書く
まず、テストの対象となるコードを書く。
作るのは、非同期でAPIから情報を取ってきて、それをビューに反映させるコンポーネント。
ただ、今回重要なのは非同期ということなので、APIとのやり取りは実際には行わない。
const usersData = [ {'name':'Bob', 'country':'America'}, {'name':'Cate', 'country':'England'}, {'name':'Bob', 'country':'Canada'}, {'name':'Taro', 'country':'Japan'} ]; function searchUsername(name){ return new Promise((resolve, reject)=>{ let result = []; usersData.forEach(i=>{ if(i.name === name){ result.push(i) }; }); if(result.length === 0){ reject(new Error('該当するユーザーは存在しません。')); } else { resolve(result); }; }); }; export function inquireDatabase(name, callback){ return searchUsername(name) .then(result => {callback(result);}) .catch(result => {callback(result);}); };
usersData
オブジェクトに、ユーザーの情報が入っている。
そしてその中身を検索して、該当するユーザーの情報を非同期で返すのが、searchUsername()
。
そしてそれをラップして、指定したコールバック関数を非同期で実行するのが、inquireDatabase()
。
例えば、inquireDatabase('John', myFunc)
とすれば、John
というユーザーの情報を探し、その結果を引数としてmyFunc
を実行してくれる。
これらのコードを利用したReactのコンポーネントが、以下。
export default class SearchBox extends React.Component { constructor(props){ super(props); this.state = {result: ''}; }; showUsersList(received){ if(received instanceof Error){ this.setState({result: received.message}); } else { let array = received.map(obj=>{ let data = []; Object.keys(obj).forEach(elem=>{ data.push(obj[elem]); }); return data.join(' '); }); this.setState({result: array.join(', ')}); }; }; render(){ return( <div> <input type="text" onKeyUp={e=>{ if(e.keyCode !== 13){ return; }; inquireDatabase(e.target.value, this.showUsersList.bind(this)); }}></input> <div>{this.state.result}</div> </div> ); }; };
テキストボックスのonKeyUp
で、inquireDatabase()
を呼び出している。
得られた結果を引数にしてshowUsersList()
を呼び出し、これにより、画面上の表示が変更される。
このサンプルではローカルのオブジェクトを検索しているが、実際の開発では、APIに問い合わせて検索を行うイメージ。
テストを書いてみる
inquireDatabase()
をテストしていく。
これは非同期で動くので、これまでのような書き方だと、失敗する。
例えば、下記のテストは通らない。
describe('basic', ()=>{ let mock; beforeEach(()=>{ mock = jest.fn(); }); test('called', ()=>{ inquireDatabase('Taro', mock); expect(mock).toHaveBeenCalled(); }); });
inquireDatabase()
のコールバック関数としてmock
を設定しているのだが、mock
が実行されたかテストすると、実行されていないと判定される。
これはもちろん、非同期で実行されるからだ。mock
が呼び出される前に次の行のexpect()
が実行されてしまうため、「mock
は実行されていないよ」と判断されてしまう。
このような事態を避け、非同期の関数を上手くテストできるようにすることが、この記事の目的である。
setTimeout()を使う
要はコールバックが呼び出されるまでテストを実行しなければいいのだから、setTimeout()
で適当に待機することで、先程のテストは通るようになる。
inquireDatabase('Taro', mock); setTimeout(()=>{ expect(mock).toHaveBeenCalled(); }, 1000);
だがこれは、スマートではないだろう。
何秒待てばいいかは事前には分からないし、テストによって異なる。
こういう形ではなく、コールバックが呼び出されたことを検知してテストを実行したほうがいい。
Promise
を使うことで、それが可能になる。
Promiseを使う
以下のように書くと、上手く動いてくれる。
return inquireDatabase('Taro', mock) .then(() => expect(mock).toHaveBeenCalled());
inquireDatabase()
にthen()
をつなげ、そのなかでexpect()
を呼び出している。
こうすることで、コールバックの実行が終わった時点でテストを実行してくれる。
return
も重要である。
これがあるから、expect()
は実行される。
もしこのreturn
がなかったら、then()
の呼び出しを待つこと無くプログラムは進んでいき、そのままテストが終わってしまう。
mock
が意図した引数を受け取れていることも、確認できる。
return inquireDatabase('Taro', mock) .then(() => expect(mock).toHaveBeenCalledWith([{'name':'Taro', 'country':'Japan'}]));
Promiseオブジェクトでのみ、可能
このようなテストを書けるのは、テスト対象であるinquireDatabase()
が、Promise
オブジェクトを返すからである。
export function inquireDatabase(name, callback){ return searchUsername(name) .then(result => {callback(result);}) .catch(result => {callback(result);}); };
だからこそ、then()
をつなげていくことが出来る。
テストを上手くやるためにはテストしやすいコードを書くべき、というのはよく言われるが、それは非同期でも同じということだ。
ここまでのまとめ
ここまで書いたテストコードをまとめたものを、以下に載せておく。
describe('basic', ()=>{ let mock; beforeEach(()=>{ mock = jest.fn(); }); test('not to be called', ()=>{ inquireDatabase('Taro', mock); expect(mock).not.toHaveBeenCalled(); }); test('setTimeout', ()=>{ inquireDatabase('Taro', mock); setTimeout(()=>{ expect(mock).toHaveBeenCalled(); }, 1000); }); test('Promise', ()=>{ return inquireDatabase('Taro', mock) .then(() => expect(mock).toHaveBeenCalled()); }); test('Promise argument', ()=>{ return inquireDatabase('Taro', mock) .then(() => expect(mock).toHaveBeenCalledWith([{'name':'Taro', 'country':'Japan'}])); }) });
複数のexpect()を実行
上記のコードだと、1回のtest()
で1度しかexpect()
を実行していない。
これを複数回実行することは、もちろん可能である。
方法は2つ。
まず先に、コードを載せておく。
describe('multiple', ()=>{ let mock; beforeEach(()=>{ mock = jest.fn(); }); test('single', ()=>{ return inquireDatabase('Taro', mock) .then(()=>{ expect(mock).toHaveBeenCalled(); expect(mock).not.toHaveBeenCalledTimes(2); expect(mock).toHaveBeenCalledWith([{'name':'Taro', 'country':'Japan'}]); }); }); test('chain', ()=>{ return inquireDatabase('Sam', mock) .then(() => expect(mock).toHaveBeenCalled()) .then(() => expect(mock).toHaveBeenCalledWith(new Error('該当するユーザーは存在しません。'))) .then(() => expect(mock).toHaveBeenCalledTimes(1)) .then(() => inquireDatabase('Cate', mock)) .then(() => expect(mock).toHaveBeenCalledWith([{'name':'Cate', 'country':'England'}])) .then(() => expect(mock).toHaveBeenCalledTimes(2)); }); test('combine', ()=>{ return inquireDatabase('Taro', mock) .then(()=>{ expect(mock).toHaveBeenCalled(); expect(mock).toHaveBeenCalledTimes(1); expect(mock).toHaveBeenCalledWith([{'name':'Taro', 'country':'Japan'}]); }) .then(() => inquireDatabase('Cate', mock)) .then(() => expect(mock).toHaveBeenCalledTimes(2)); }); });
1つ目は、単純にthen()
のなかで複数のexpect()
を実行するということ。難しいことは何もない。
もう一つは、次々にthen()
をつなげていき、そのなかでexpect()
を実行する方法。これでも問題ない。
両方を組み合わせることも、もちろん可能。
どういう方法でもいいと思うが、then()
をつなげていく場合、途中で再び関数を呼び出せるという利点がある。
上記のコードではinquireDatabase('Cate', mock)
がそれにあたる。