🦁

書籍「テスト駆動開発」をTypeScriptで勉強するときのつまづきポイント

2020/12/20に公開

はじめに

書籍「テスト駆動開発」の第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を比較することで等価性の比較を行うことができます。

Money.ts
    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しないようにします。

第10章, 第11章:Moneyクラスを具象クラスにした後、テストがレッドにならない

今度は書籍(Java)ではテストが通らないと書かれているが、TypeScript(&Jest)で実装するとテストがグリーンになるパターンです。

第10章(p.67)でMoneyクラスを抽象クラスから具象クラスにした後、テストを実行すると(書籍とは異なり)テストは通りました。
(テストコードの実装方法によってはレッドになります。)

まず、書籍(Java, JUnit)で何が起きているか説明します。
以下のテストがレッドになります。

MoneyTest.java
@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)では、以下のようなテストコードを作り、実行するとテストが通りました。

Franc.spec.ts
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メソッドで等価性を比較するようにテストコードを実装していたら、書籍と同じようにテストがレッドになります。

Franc.spec.ts
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クラスです。

RateKey.ts
/**
 * 為替レート用のキーを表す値オブジェクトクラス
 */
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クラスは以下のようになりました。

Bank.ts
// 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