書籍『テスト駆動開発』の第1部には、テスト駆動開発で多国通貨を実装するサンプルが載っています。このサンプルは丁寧に書かれていて参考になりますが、Java + JUnitなので、自分の馴染みのある言語+テスティングフレームワークではどうなるのか気になります。
そこで、本記事では、JavaScript(Node.js + AVA)で『テスト駆動開発』の第1部を実習します。
なお、本記事のコードはGitHubの下記リポジトリでも公開しています。
https://github.com/ryo-utsunomiya/tdd-js-ava
環境構築
Node.jsがインストール済みであることを前提にします。私の手元の環境は以下の通りです。
- Node.js v8.7.0
- npm 5.5.1
AVAのインストール
テスティングフレームワークはAVAを使用します。AVAはシンプルなAPIとモダンな機能を備えています。
npm init
npm install ava --save-dev
./node_modules/.bin/ava --init
AVAの動作確認として、1つテストを書いてみましょう。AVAはBabelによるトランスパイル機能を内蔵しているので、テストはES2015+で書くことができます。
// test.js
import test from 'ava';
test('foo', (t) => {
t.pass();
});
実行はnpm test
です。以下のような結果が表示されればAVAのセットアップは完了です。
Babel設定
テストがES Modulesなのにコードの方はCommonJSなのは気持ち悪いので、コードの方もBabelでトランスパイルするよう設定します。package.jsonに以下を追記しましょう。
"ava": {
"require": [
"babel-register"
]
},
"babel": {
"presets": [
"@ava/stage-4"
]
}
次に、モジュールを読み込んで動作させるテストを書きます。
// test.js
import test from 'ava';
import foo from './foo';
test('test foo()', t => {
t.is(foo(), 'foo');
});
モジュールの方も書きます。
export default () => 'foo';
もう一度実行してみましょう。今度はnpm t
という短縮コマンドを使ってみるとよいでしょう。
「1 passed」と表示されればセットアップ完了です!
第1章
はじめにテストを書きます。ファイル名を「XX.test.js」とすると、AVAはそのファイルをテストとみなします。
// Money.test.js
import test from 'ava';
test('test multiplication', t => {
const five = new Dollar(5);
five.times(2);
t.is(10, five.amount);
});
まだ Dollar
オブジェクトは定義されていないので、ReferenceErrorが発生してテストは失敗します。
ReferenceErrorを解消するための最小限の実装をしてみます。
// Dollar.js
export default class Dollar {
constructor(amount) {
this.amount = amount;
}
times(multiplier) {
}
}
次に、テストでこのファイルを読み込みます。
import test from 'ava';
import Dollar from './Dollar';
test('test multiplication', t => {
const five = new Dollar(5);
five.times(2);
t.is(10, five.amount);
});
今度は、エラーではなくアサーションが失敗します。出力を見てみましょう。
どこで失敗しているか、わかりやすく表示してくれます。AVAのアサーションには、『テスト駆動開発』訳者のt-wadaさん作のpower-assertが使われています。
コードを編集するたびに npm t
を実行するのは面倒なので、npm t -- --watch
で、コードの変更に応じてテストが実行されるようにしておくと便利です。
この辺で下準備が終わったので、以降は各章の進捗をまとめて載せていきます。詳細なステップは書籍を参照してください。
第1章完了時点では以下のようになります。
import test from 'ava';
import Dollar from './Dollar';
test('test multiplication', (t) => {
const five = new Dollar(5);
five.times(2);
t.is(10, five.amount);
});
export default class Dollar {
constructor(amount) {
this.amount = amount;
}
times(multiplier) {
this.amount *= multiplier;
}
}
第2章
Dollar.times()
のテストを増やし、テストをパスするように実装します。
import test from 'ava';
import Dollar from './Dollar';
test('test multiplication', (t) => {
const five = new Dollar(5);
t.is(10, five.times(2).amount);
t.is(15, five.times(3).amount);
});
export default class Dollar {
constructor(amount) {
this.amount = amount;
}
times(multiplier) {
return new Dollar(this.amount * multiplier);
}
}
第3章
DollarをValueObjectにするために、オブジェクトの等価性を比較できるようにします。テストケースが複数になったので、async
を導入して並列実行できるようにしましょう。
import test from 'ava';
import Dollar from './Dollar';
test('multiplication', async (t) => {
const five = new Dollar(5);
t.is(10, five.times(2).amount);
t.is(15, five.times(3).amount);
});
test('equality', async (t) => {
t.true(new Dollar(5).equals(new Dollar(5)));
});
export default class Dollar {
constructor(amount) {
this.amount = amount;
}
times(multiplier) {
return new Dollar(this.amount * multiplier);
}
equals(object) {
return this.amount === object.amount;
}
}
第4章
この章では、オブジェクト同士を比較するよう、テストを改善します。しかし、JavaScriptには、JavaのObject.equals
のようなオブジェクトの等価性比較をオーバーライドするためのAPIはありません。そのため、本章のリファクタリングは適用できません。
第5章
新しくFrancオブジェクトを追加します。この時点では、テストも実装もコピペです。
import test from 'ava';
import Dollar from './Dollar';
import Franc from './Franc';
test('multiplication', async (t) => {
const five = new Dollar(5);
t.true(new Dollar(10).equals(five.times(2)));
t.true(new Dollar(15).equals(five.times(3)));
});
test('franc multiplication', async (t) => {
const five = new Franc(5);
t.true(new Franc(10).equals(five.times(2)));
t.true(new Franc(15).equals(five.times(3)));
});
test('equality', async (t) => {
t.true(new Dollar(5).equals(new Dollar(5)));
});
export default class Franc {
constructor(amount) {
this.amount = amount;
}
times(multiplier) {
return new Franc(this.amount * multiplier);
}
equals(object) {
return this.amount === object.amount;
}
}
第6章
ここからは、DollarとFrancの共通部分をMoneyに引き上げていきます。
test('equality', async (t) => {
t.true(new Dollar(5).equals(new Dollar(5)));
t.false(new Dollar(5).equals(new Dollar(6)));
t.true(new Franc(5).equals(new Franc(5))); // テストケース追加
t.false(new Franc(5).equals(new Franc(6))); // テストケース追加
});
export default class Money {
constructor(amount) {
this.amount = amount;
}
equals(money) {
return this.amount === money.amount;
}
}
import Money from './Money';
export default class Dollar extends Money {
times(multiplier) {
return new Dollar(this.amount * multiplier);
}
}
import Money from './Money';
export default class Franc extends Money {
times(multiplier) {
return new Franc(this.amount * multiplier);
}
}
第7章
DollarとFrancが等しくなってしまうバグがあるので、修正します。JavaScriptにはクラスはないので、コンストラクタの名前を比較することで代用しています。イマイチな実装ですが、後でリファクタリングするので…。
test('equality', async (t) => {
t.true(new Dollar(5).equals(new Dollar(5)));
t.false(new Dollar(5).equals(new Dollar(6)));
t.true(new Franc(5).equals(new Franc(5)));
t.false(new Franc(5).equals(new Franc(6)));
t.false(new Franc(5).equals(new Dollar(5))); // テストケース追加
});
export default class Money {
constructor(amount) {
this.amount = amount;
}
equals(money) {
return this.amount === money.amount
&& this.constructor.name === money.constructor.name;
}
}
第8章
ここでは、timesメソッドをMoneyに引き上げる前準備として、DollarとFrancのFactory MethodをMoneyに作成します。
ここで問題発生。以下のような複数の循環参照があるモジュールでは、参照を解決できないのです。
import Dollar from './Dollar';
import Franc from './Franc';
export default class Money {
static dollar() {
return new Dollar();
}
static franc() {
return new Franc();
}
}
import Money from './Money';
export default class Dollar extends Money {}
import Money from './Money';
export default class Franc extends Money {}
全てのクラスを同一ファイル内に含めれば正常に実行できるので、これはモジュールのインポートの問題だと思います。
今後DollarとFrancは削除する予定なので、ひとまず、Money.jsにDollarとFrancを含めるようにして対応します。
import test from 'ava';
import Money from './Money';
test('multiplication', (t) => {
const five = Money.dollar(5);
t.true(Money.dollar(10).equals(five.times(2)));
t.true(Money.dollar(15).equals(five.times(3)));
});
test('franc multiplication', (t) => {
const five = Money.franc(5);
t.true(Money.franc(10).equals(five.times(2)));
t.true(Money.franc(15).equals(five.times(3)));
});
test('equality', (t) => {
t.true(Money.dollar(5).equals(Money.dollar(5)));
t.false(Money.dollar(5).equals(Money.dollar(6)));
t.true(Money.franc(5).equals(Money.franc(5)));
t.false(Money.franc(5).equals(Money.franc(6)));
t.false(Money.franc(5).equals(Money.dollar(5)));
});
export default class Money {
constructor(amount) {
this.amount = amount;
}
equals(money) {
return this.amount === money.amount
&& this.constructor.name === money.constructor.name;
}
static dollar(amount) {
return new Dollar(amount);
}
static franc(amount) {
return new Franc(amount);
}
}
class Franc extends Money {
times(multiplier) {
return Money.franc(this.amount * multiplier);
}
}
class Dollar extends Money {
times(multiplier) {
return Money.dollar(this.amount * multiplier);
}
}
第9章
ここでは、通貨(currency)の概念を導入します。Javaでは Money.currency() はメソッド、 Money.currency はフィールドで併存可能ですが、JavaScriptでは併存できないので、Money.currencyはメソッドではなくプロパティにしています。
// currencyのテストを追加
test('currency', (t) => {
t.is('USD', Money.dollar(1).currency);
t.is('CHF', Money.franc(1).currency);
});
export default class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
equals(money) {
return this.amount === money.amount
&& this.constructor.name === money.constructor.name;
}
static dollar(amount) {
return new Dollar(amount, 'USD');
}
static franc(amount) {
return new Franc(amount, 'CHF');
}
}
class Franc extends Money {
times(multiplier) {
return Money.franc(this.amount * multiplier);
}
}
class Dollar extends Money {
times(multiplier) {
return Money.dollar(this.amount * multiplier);
}
}
第10章
times() メソッドをMoneyクラスに引き上げ、同じ通貨かの比較はcurrencyプロパティを使うようにします。DollarとFrancを消す準備ができました。
import test from 'ava';
import { Money, Franc } from './Money';
test('multiplication', (t) => {
const five = Money.dollar(5);
t.true(Money.dollar(10).equals(five.times(2)));
t.true(Money.dollar(15).equals(five.times(3)));
});
test('franc multiplication', (t) => {
const five = Money.franc(5);
t.true(Money.franc(10).equals(five.times(2)));
t.true(Money.franc(15).equals(five.times(3)));
});
test('equality', (t) => {
t.true(Money.dollar(5).equals(Money.dollar(5)));
t.false(Money.dollar(5).equals(Money.dollar(6)));
t.true(Money.franc(5).equals(Money.franc(5)));
t.false(Money.franc(5).equals(Money.franc(6)));
t.false(Money.franc(5).equals(Money.dollar(5)));
});
test('currency', (t) => {
t.is(Money.dollar(1).currency, 'USD');
t.is(Money.franc(1).currency, 'CHF');
});
test('different class equality', (t) => {
t.true(new Money(10, 'CHF').equals(new Franc(10, 'CHF')));
});
export class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
equals(money) {
return this.amount === money.amount
&& this.currency === money.currency;
}
times(multiplier) {
return new Money(this.amount * multiplier, this.currency);
}
static dollar(amount) {
return new Money(amount, 'USD');
}
static franc(amount) {
return new Money(amount, 'CHF');
}
}
export class Franc extends Money {
}
export class Dollar extends Money {
}
第11章
DollarとFrancはもう不要です。消してしまいましょう。
import test from 'ava';
import Money from './Money';
test('multiplication', (t) => {
const five = Money.dollar(5);
t.true(Money.dollar(10).equals(five.times(2)));
t.true(Money.dollar(15).equals(five.times(3)));
});
test('equality', (t) => {
t.true(Money.dollar(5).equals(Money.dollar(5)));
t.false(Money.dollar(5).equals(Money.dollar(6)));
t.false(Money.franc(5).equals(Money.dollar(5)));
});
test('currency', (t) => {
t.is(Money.dollar(1).currency, 'USD');
t.is(Money.franc(1).currency, 'CHF');
});
export default class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
equals(money) {
return this.amount === money.amount
&& this.currency === money.currency;
}
times(multiplier) {
return new Money(this.amount * multiplier, this.currency);
}
static dollar(amount) {
return new Money(amount, 'USD');
}
static franc(amount) {
return new Money(amount, 'CHF');
}
}
第12章
ここからは、異なる通貨の加算を実装します。まず、同一通貨の加算のテストを書きます。
import test from 'ava';
import Money from './Money';
import Bank from './Bank';
test('multiplication', (t) => {
const five = Money.dollar(5);
t.true(Money.dollar(10).equals(five.times(2)));
t.true(Money.dollar(15).equals(five.times(3)));
});
test('equality', (t) => {
t.true(Money.dollar(5).equals(Money.dollar(5)));
t.false(Money.dollar(5).equals(Money.dollar(6)));
t.false(Money.franc(5).equals(Money.dollar(5)));
});
test('currency', (t) => {
t.is(Money.dollar(1).currency, 'USD');
t.is(Money.franc(1).currency, 'CHF');
});
test('simple addition', (t) => {
const five = Money.dollar(5);
const sum = five.plus(five);
const bank = new Bank();
const reduced = bank.reduce(sum, 'USD');
t.true(reduced.equals(Money.dollar(10)));
});
書籍では、ここでExpressionというインタフェースを導入しています。しかし、JavaScriptにはインタフェースはありません。クラスで代用するのも違和感があったのと、この時点ではExpressionインタフェースは何の仕事もしないので、ひとまずExpressionは無視してBankだけ仮実装します。
export default class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
equals(money) {
return this.amount === money.amount
&& this.currency === money.currency;
}
times(multiplier) {
return new Money(this.amount * multiplier, this.currency);
}
plus(addend) {
return new Money(this.amount + addend.amount, this.currency);
}
static dollar(amount) {
return new Money(amount, 'USD');
}
static franc(amount) {
return new Money(amount, 'CHF');
}
}
import Money from './Money';
export default class Bank {
reduce(source, to) {
return Money.dollar(10);
}
}
第13章
加算処理を表すクラスを追加していきます。
// テスト追加
test('plus returns Sum', (t) => {
const five = Money.dollar(5);
const sum = five.plus(five);
t.is(five, sum.augend);
t.is(five, sum.addend);
});
test('reduce sum', (t) => {
const sum = new Sum(Money.dollar(3), Money.dollar(4));
const bank = new Bank();
const result = bank.reduce(sum, 'USD');
t.true(Money.dollar(7).equals(result));
});
test('reduce money', (t) => {
const bank = new Bank();
const result = bank.reduce(Money.dollar(1), 'USD');
t.true(result.equals(Money.dollar(1)));
});
本章の主役、Sumクラスを追加します。
import Money from './Money';
export default class Sum {
constructor(augend, addend) {
this.augend = augend;
this.addend = addend;
}
reduce(to) {
const amount = this.augend.amount + this.addend.amount;
return new Money(amount, to);
}
}
Bank.reduce()は渡されたもののreduce()メソッドを呼ぶようにします。ここには、SumまたはMoneyを渡す想定です。
import Money from './Money';
export default class Bank {
reduce(source, to) {
return source.reduce(to);
}
}
Money.reduce() を追加します。
import Sum from './Sum';
export default class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
equals(money) {
return this.amount === money.amount
&& this.currency === money.currency;
}
times(multiplier) {
return new Money(this.amount * multiplier, this.currency);
}
plus(addend) {
return new Sum(this, addend);
}
reduce() {
return this;
}
static dollar(amount) {
return new Money(amount, 'USD');
}
static franc(amount) {
return new Money(amount, 'CHF');
}
}
第14章
通貨の交換レートを実装していきます。
// テスト追加
test('reduce money different currency', (t) => {
const bank = new Bank();
bank.addRate('CHF', 'USD', 2);
const result = bank.reduce(Money.dollar(1), 'USD');
t.true(result.equals(Money.dollar(1)));
});
test('identity rate', (t) => {
t.is(new Bank().rate('USD', 'USD'), 1);
});
レートの取得処理はいったんオブジェクトで実装します。あとでMap等に変更するかもしれません。
export default class Bank {
constructor() {
this.rates = {};
}
reduce(source, to) {
return source.reduce(this, to);
}
addRate(from, to, rate) {
this.rates[from + to] = rate;
}
rate(from, to) {
if (from === to) return 1;
return this.rates[from + to];
}
}
reduceメソッドの第1引数にBankオブジェクトを渡して、交換レートを取得できるようにします。
import Sum from './Sum';
export default class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
equals(money) {
return this.amount === money.amount
&& this.currency === money.currency;
}
times(multiplier) {
return new Money(this.amount * multiplier, this.currency);
}
plus(addend) {
return new Sum(this, addend);
}
reduce(bank, to) {
const rate = bank.rate(this.currency, to);
return new Money(this.amount / rate, to);
}
static dollar(amount) {
return new Money(amount, 'USD');
}
static franc(amount) {
return new Money(amount, 'CHF');
}
}
import Money from './Money';
export default class Sum {
constructor(augend, addend) {
this.augend = augend;
this.addend = addend;
}
reduce(bank, to) {
const amount = this.augend.amount + this.addend.amount;
return new Money(amount, to);
}
}
第15章
本章では、異なる通貨の加算処理を完成させ、Expressionインタフェースの拡充を行っています。が、本記事ではここまでExpressionインタフェース相当のものを実装しておらず、必要性も感じないので、このまま進みます。
// テストケース追加
test('mixed addition', (t) => {
const fiveBucks = Money.dollar(5);
const tenFrancs = Money.franc(10);
const bank = new Bank();
bank.addRate('CHF', 'USD', 2);
const result = bank.reduce(fiveBucks.plus(tenFrancs), 'USD');
t.true(result.equals(Money.dollar(10)));
});
import Money from './Money';
export default class Sum {
constructor(augend, addend) {
this.augend = augend;
this.addend = addend;
}
reduce(bank, to) {
const amount = this.augend.reduce(bank, to).amount +
this.addend.reduce(bank, to).amount;
return new Money(amount, to);
}
}
第16章
Sum.times()メソッドを実装して仕上げです。また、テストのasync
が途中から消えていたので、改めてつけ直しています。
import test from 'ava';
import Money from './Money';
import Bank from './Bank';
import Sum from './Sum';
test('multiplication', async (t) => {
const five = Money.dollar(5);
t.true(Money.dollar(10).equals(five.times(2)));
t.true(Money.dollar(15).equals(five.times(3)));
});
test('equality', async (t) => {
t.true(Money.dollar(5).equals(Money.dollar(5)));
t.false(Money.dollar(5).equals(Money.dollar(6)));
t.false(Money.franc(5).equals(Money.dollar(5)));
});
test('currency', async (t) => {
t.is(Money.dollar(1).currency, 'USD');
t.is(Money.franc(1).currency, 'CHF');
});
test('simple addition', async (t) => {
const five = Money.dollar(5);
const sum = five.plus(five);
const bank = new Bank();
const reduced = bank.reduce(sum, 'USD');
t.true(reduced.equals(Money.dollar(10)));
});
test('plus returns Sum', async (t) => {
const five = Money.dollar(5);
const sum = five.plus(five);
t.is(five, sum.augend);
t.is(five, sum.addend);
});
test('reduce sum', async (t) => {
const sum = new Sum(Money.dollar(3), Money.dollar(4));
const bank = new Bank();
const result = bank.reduce(sum, 'USD');
t.true(Money.dollar(7).equals(result));
});
test('reduce money', async (t) => {
const bank = new Bank();
const result = bank.reduce(Money.dollar(1), 'USD');
t.true(result.equals(Money.dollar(1)));
});
test('reduce money different currency', async (t) => {
const bank = new Bank();
bank.addRate('CHF', 'USD', 2);
const result = bank.reduce(Money.dollar(1), 'USD');
t.true(result.equals(Money.dollar(1)));
});
test('identity rate', async (t) => {
t.is(new Bank().rate('USD', 'USD'), 1);
});
test('mixed addition', async (t) => {
const fiveBucks = Money.dollar(5);
const tenFrancs = Money.franc(10);
const bank = new Bank();
bank.addRate('CHF', 'USD', 2);
const result = bank.reduce(fiveBucks.plus(tenFrancs), 'USD');
t.true(result.equals(Money.dollar(10)));
});
test('sum plus money', async (t) => {
const fiveBucks = Money.dollar(5);
const tenFrancs = Money.franc(10);
const bank = new Bank();
bank.addRate('CHF', 'USD', 2);
const sum = new Sum(fiveBucks, tenFrancs).plus(fiveBucks);
const result = bank.reduce(sum, 'USD');
t.true(result.equals(Money.dollar(15)));
});
test('sum times', async (t) => {
const fiveBucks = Money.dollar(5);
const tenFrancs = Money.franc(10);
const bank = new Bank();
bank.addRate('CHF', 'USD', 2);
const sum = new Sum(fiveBucks, tenFrancs).times(2);
const result = bank.reduce(sum, 'USD');
t.true(result.equals(Money.dollar(20)));
});
最終形なので、変更のないMoneyとBankも再掲します。
import Sum from './Sum';
export default class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
equals(money) {
return this.amount === money.amount
&& this.currency === money.currency;
}
times(multiplier) {
return new Money(this.amount * multiplier, this.currency);
}
plus(addend) {
return new Sum(this, addend);
}
reduce(bank, to) {
const rate = bank.rate(this.currency, to);
return new Money(this.amount / rate, to);
}
static dollar(amount) {
return new Money(amount, 'USD');
}
static franc(amount) {
return new Money(amount, 'CHF');
}
}
export default class Bank {
constructor() {
this.rates = {};
}
reduce(source, to) {
return source.reduce(this, to);
}
addRate(from, to, rate) {
this.rates[from + to] = rate;
}
rate(from, to) {
if (from === to) return 1;
return this.rates[from + to];
}
}
import Money from './Money';
export default class Sum {
constructor(augend, addend) {
this.augend = augend;
this.addend = addend;
}
reduce(bank, to) {
const amount = this.augend.reduce(bank, to).amount +
this.addend.reduce(bank, to).amount;
return new Money(amount, to);
}
plus(addend) {
return new Sum(this, addend);
}
times(multiplier) {
return new Sum(this.augend.times(multiplier), this.addend.times(multiplier));
}
}
感想
Node + AVAだと手軽にテストが書けるので、『テスト駆動開発』の実習にはもってこいの環境でした。設計面で、Expressionインタフェースの扱いをどうするか迷いましたが、素のJavaScriptにインタフェースはないので、ダックタイピング的な設計でいくのがJavaScriptらしいかなと思いました。
元がJavaのコードなだけあり、最終形はクラス3つ、単体の関数はゼロという構成になっています。これはこれで良いですが、特定のオブジェクトに属さない関数が全然登場しないのは、JavaScriptっぽくないです。別の設計方針を考えてみるのも面白いかもしれません。