JavaScript / TypeScriptの引数をひと工夫しよう
こんにちは、2020年10月に入社したばかりの坂口です。現在SET(Software Enginner in Test)として頑張っています💪
SETでは主にE2Eテストの作成/保守を担当しています。弊社のプロダクトはWebアプリケーションが多いため、WebのE2EテストとしてPlaywrightを採用し日々テストの拡充をしています。
日々テストコードを書いている中で少しでも可読性をあげようと引数を工夫してわかりやすくした例を紹介します。
環境
・Node.js 15.8.0
・TypeScript 4.2.3
この記事ではより恩恵が受けられるTypeScriptを使います。
オブジェクト引数
引数は通常numberを受け取ったり、stringを受け取ったり、クラスやインターフェースといった型を引数に指定します。
async function setLocation(latitude: number, longitude: number) {
// test code
}
// 呼び出す時はこんな感じ
await testPage.setLocation(59.329336, 18.068576);
この関数はテストしたいページに位置情報をセットして地図や周辺のランドマーク情報が表示されるか確認するために使うものとしましょう。
ところでみなさん、下の呼び出している1行だけを見て緯度と経度、つまりどっちがlatitudeでどっちがlongitudeかパッとすぐわかりますでしょうか。
ちなみにこちらはスウェーデンの首都ストックホルムの位置情報になります。日本の位置情報だと経度が135度前後のため推測も可能ですが、今回のようにどちらも90度以内の値であることはありえますし、うっかり実装ミスで緯度経度を逆にしてしまうことも考えられます。
引数の順番を確認するにはsetLocation関数の実装を見て確認する必要があります。引数が増えてくるとコードレビューでは毎回確認が必要になってくるためレビューコストがかさみます。
この場合レビューコストを減らすには、呼び出した時点でどういう指定をしているかが分かれば良さそうです。例えば以下のコードはいかがでしょうか。
await testPage.setLocation({
latitude: 59.329336,
longitude: 18.068576
});
呼び出し側で緯度と経度にそれぞれ値を指定しているのが読み取れますね。これならsetLocation関数の実装を見なくても判別が可能になりますね。
ではこの呼び出し方をするための実装側を見てみましょう。
async function setLocation(location: { latitude: number, longitude: number}) {
console.log(location.latitude);
// test code
}
引数に連想配列を指定します。この連想配列から値を取り出すには location.latitude と書けばOKです。TypeScriptではわざわざタイプエイリアスやインターフェースを定義せずともこのように直接連想配列を指定できます。もちろん型チェックもきちんと行われます。
このパターンとTypeScriptの型システムを組み合わせれば、どのオプションが必須でどのオプションは任意指定なのかも表現できます。例えば緯度経度に加え精度も追加で渡せるよう変更してみます。精度はあっても良いしなくとも良いとする場合はこのように実装します。
async function setLocation(location: { latitude: number, longitude: number, accuracy?: number}) {
const accuracy = location.accuracy ?? 0;
// test code
}
この場合 location.accuracy はundefinedの可能性があるため、null合体演算子でなければ初期値にするとすれば良さそうですね。
このようにちょっとの工夫で呼び出し側からどう値を渡しているのかも同時にコード上で表すことができ、さらにこの関数はどんな引数を要求しているのか、引数は必須なのかなくても良いのかということを関数自体で表現もできました。
厳密には一度オブジェクトを挟むことによってオブジェクト生成のオーバーヘッドが発生するかもしれませんが、個人的にはそのオーバーヘッドよりも正しくないコードをデプロイしてしまったことによるバグを生み出してしまうことの方がよりユーザーに与える影響がでかいと考え、こちらの形で書くようにしています。
ストリングリテラル型
もう1つTypeScriptの機能を使って工夫している点を紹介します。
TypeScriptでは文字列のUnion型を組むとそれをenumとして扱えるような特徴があります。
type MenuName = 'ラーメン' | '餃子' | 'ライス';
// MenuNameで定義した中からでしか選べず、それ以外は構文エラーになる
const name: MenuName = 'ラーメン';
例えば特定のメニューを選択して注文ができるか、というテストシナリオがあるとしましょう。この時メニューを選択するための関数selectMenu関数を以下のように作成しました。
/** 指定のテキストがあるdivタグをクリックして選択します。 */
async function selectMenu(menuName: string) {
await page.click(`//div[contains(text(), "${menuName}")`);
}
呼び出す時はこのようにします。
await selectMenu('ラ-メン');
さて実行......するとなんとうまく動きません。なぜでしょうか。ここでテストの勘所が良い人はピンと来たのではないでしょうか。
なんとラーメンの伸ばし棒が ー ではなく - になっていました!
......はいというわけで、テストで使用するメニュー名がある程度決まっているのであればそれを定数として定義すると間違いが起きにくく、またテストに存在しないメニューの指定を未然に防ぐことができます。
変更は簡単で先ほど作成したMenuNameを引数で指定します。このストリングリテラルはenumのようであり指定された値をstringとしても扱えるので本当に軽微な修正ですみます。
async function selectMenu(menuName: MenuName) {
await page.click(`//div[contains(text(), "${menuName}")`);
}
これで呼び出し側は3つのメニューの中からしか選べなくなりました。
さらにこれを発展させると、選択されたメニューの金額が正しいかのテストも行えます。
interface MenuInfo {
price: string;
}
const menus: { [key in MenuName]: MenuInfo } = {
ラーメン: { price: '¥790' },
餃子: { price: '¥300' },
ライス: { price: '¥250' }
};
金額を検証する関数はMenuNameを引数で受け取ります。menusからMenuNameをキーとして対応するMenuInfoオブジェクトが取得できます。
async function assetPrice(menuName: MenuName) {
const price = await page.textContent('#price');
const menu = menus[menuName];
expect(price).toBe(menu.price);
}
呼び出す場合はメニュー名を指定するだけです。
await assetPrice('餃子');
餃子を指定すれば menu.price で ¥300 が取得できます。あとは実際のDOMがその値と合致しているか検証すればOKです。
まとめ
私が実際にテストコードを書く時に行っているテクニックを2つ紹介しました。この書き方をしないといけないというわけではありません。が、私の場合はテストコードとは何より可読性を高めリーディングコストとメンテナンスコストを省くことが大事かなと思いやっています。
何よりここまでお膳立てした後にテストステップを書く時にほぼすべてが補完で入力できるため、テストコードを書いていてとても気持ちがよいです!私はコードを書くモチベーションの維持のためにもこのようにしていたりします。