書籍「テスト駆動開発」をTypeScriptで勉強するときのつまづきポイント
はじめに
書籍「テスト駆動開発」の第1部「多国籍通貨」に載っている内容をTypeScriptで写経しながら学んだ際に、書籍とは異なる挙動をしたりエラーになって詰まった点をまとめました。
書籍ではJavaを用いているので、主にJavaとTypeScriptの仕様の違いによって発生した部分となります。
環境
- node: 12.16.1
- typescript: 4.1.2
- jest: 26.6.3
- ts-jest: 26.4.4
第7章:equalsメソッド内のクラス名の比較
第7章ではDollar
クラスのインスタンスとFranc
クラスのインスタンスが異なるかどうかのテストを追加しています。
書籍ではgetClass
というメソッドを利用していますが、TypeScriptにはgetClass
というメソッドはありません。
その代わりに、以下のようにconstructor.name
を比較することで等価性の比較を行うことができます。
equals({ amount, constructor }: Money): boolean {
return (
this.amount === amount && this.constructor.name === constructor.name
);
}
第8章:Factory Methodを作成するとテストがレッドになる
Money
クラスにDollar
を返すFactory Methodを作成した際(p.48)、以下のようなエラーが発生しました。
TypeError: Object prototype may only be an Object or null: undefined
at setPrototypeOf (<anonymous>)
この原因としては、Dollar
クラスとMoney
クラスが相互にimport
していたからでした。
詳しくはTypeScriptとJavaとの違いによるつまずきと「循環参照」 - こまどブログを読んでください。解決する際に参考にさせていただきました。
解決策としては、Dollar
クラスとMoney
クラス、Franc
クラスを同じファイルに記述して、import
しないようにします。
Money
クラスを具象クラスにした後、テストがレッドにならない
第10章, 第11章:今度は書籍(Java)ではテストが通らないと書かれているが、TypeScript(&Jest)で実装するとテストがグリーンになるパターンです。
第10章(p.67)でMoney
クラスを抽象クラスから具象クラスにした後、テストを実行すると(書籍とは異なり)テストは通りました。
(テストコードの実装方法によってはレッドになります。)
まず、書籍(Java, JUnit)で何が起きているか説明します。
以下のテストがレッドになります。
@Test
public void testFrancMultiplication() {
Money five = Money.franc(5);
assertEquals(Money.franc(10), five.times(2));
assertEquals(Money.franc(15), five.times(3));
}
JUnitのassertEquals()
はインスタンスのequals
メソッドを実行するので、ここではMoney
クラスに定義されたequals
メソッドを実行しまs。
なのでp.69でequals
メソッドについて言及しており、修正することでテストをグリーンに戻しています。
一方、TypeScript(Jest)では、以下のようなテストコードを作り、実行するとテストが通りました。
describe('Francのテスト', () => {
it('掛け算', () => {
const five = Money.franc(5);
expect(five.times(2)).toEqual(Money.franc(10));
expect(five.times(3)).toEqual(Money.franc(15));
});
});
JavaとTypeScriptの違いとしては、等価性の比較方法にあります。
JestのtoEqual
関数では、オブジェクトのプロパティの一致を検証しているので、equals
メソッドの内容は関係ありません。
オブジェクトは { amount: 10, currency: 'CHF' }
で一致していたため、テストが通りました。(参考:Expect · Jest)
10章ではテストが通るためequals
メソッドの修正はせずに第11章へ進んでもp.76でテストがレッドになります。
equals
メソッドの修正はその際に行えばよいでしょう。変更によってテストがレッドになる→即座にバグを発見→修正できる、というのがTDDのメリットだと思います。
また、JestのtoEqual
関数を使うのではなく、Money
クラスに定義したequals
メソッドで等価性を比較するようにテストコードを実装していたら、書籍と同じようにテストがレッドになります。
describe('Francのテスト', () => {
it('掛け算', () => {
const five = Money.franc(5);
expect(five.times(2).equals(Money.franc(10))).toBeTruthy();
expect(five.times(3).equals(Money.franc(15))).toBeTruthy();
});
});
ただし、このテストケースはequals
メソッドについての検証ではないので、toEqual()
を利用してテストコードを書いた方が分かりやすいと思います。
第14章:Pairクラスの作りかた
第14章にて、Mapクラスで為替レートを管理するためのキーとしてPair
クラスを作っています。
書籍ではhashCode
メソッドを実装していますが、TypeScriptでは何の役にも立ちません。
理由としては、先ほどと同様にJavaとTypeScriptではMapクラスのキーの等価性の比較方法が異なるためです。
Javaでは、Money
クラスをキーとして持つMapクラスMap<Money, Integer>
は、検索(get)するときにMoney
クラスのhashCode
メソッドを利用します。よって、hashCode
メソッドが同じ値を返却する限り、同じ値(為替レート)を取得することができます。
一方、TypeScriptのMapクラスでは、オブジェクトがキーである場合は参照が同じでないと同じキーとして扱いません。(参考:Map - JavaScript | MDN)
プロパティが同じオブジェクト{ from: 'CHF', to: 'USD' }
であっても参照が異なる場合、Mapクラスから取得する値(為替レート)は異なります。
つまり、Bank
クラスのrate()
メソッド内でnew Pair(from, to)
している限り、延々に為替レートを取得することができないのです。(いつもundefined
が返ってきます。)
解決策の1つとしては、Mapクラスのキーをオブジェクトではなく、stringにするという方法があります。stringであれば値で比較するので、同じ文字列のキーであれば、同じ値を取得することができます。
私はキーを(変換元):(変換先)
というように表現することにしました。その際に、表現方法が変わったりキー同士の比較をできるようにするため、値オブジェクト(ValueObject)と呼ばれるパターンを採用することにしました。
以下はPairクラスに相当するRateKeyクラスです。
/**
* 為替レート用のキーを表す値オブジェクトクラス
*/
export class RateKey {
constructor(private from: string, private to: string) {}
get value(): string {
return `${this.from}:${this.to}`;
}
equals(key: RateKey): boolean {
return this.from === key.from && this.to === key.to;
}
}
これによりBank
クラスは以下のようになりました。
// import文は省略
export class Bank {
/** 為替レートを管理するマップ */
private rateMap = new Map<string, number>();
/**
* 式を指定した通貨に換算する
* @param source 変換対象の式
* @param toCurrency 変換後の通貨単位
*/
reduce(source: Expression, toCurrency: string): Money {
return source.reduce(this, toCurrency);
}
/**
* 為替レートを追加する
* @param from 変換元
* @param to 変換先
* @param rate レート
*/
addRate(from: string, to: string, rate: number): void {
const key = new RateKey(from, to).value;
this.rateMap.set(key, rate);
}
/**
* 為替レートを取得する
* @param fromCurrency 変換前通貨
* @param toCurrency 変換後通貨
*/
fetchRate(fromCurrency: string, toCurrency: string): number {
if (fromCurrency === toCurrency) return 1;
const key = new RateKey(fromCurrency, toCurrency).value;
const rate = this.rateMap.get(key);
assertIsDefined(rate);
return rate;
}
}
これで、為替レートを管理できるようになりました。
さいごに
この記事は書籍「テスト駆動開発」の第1部に載っているチュートリアルをTypeScriptで学ぼうとした際に詰まった点をまとめました。
ここで紹介した解決策がすべてではないので、もっと良い方法がありましたらぜひアドバイスいただけると幸いです。
この記事がテスト駆動開発を学ぶ方の助けに少しでもなれれば嬉しいです。
ありがとうございました。
Discussion