🐕

「テスト駆動開発」をDartで写経してみた

2024/03/22に公開

モチベーション

Technology Radarって何

https://twitter.com/t_wada/status/1321393337421582337

  • Dartは下記4段階のうちTraialという評価
    • 採用できる(Adopt)
    • 投資価値あり(Trial)
    • 調査段階(AssesS)
    • やめておけ(Hold)

Dartに対する雑感

  • Dart本体にテストランナー、パッケージマネージャーが組み込まれているのでセットアップが楽
  • 前置型の言語なのでメインウェポンがPHPの筆者には親しみやすい

成果物

https://github.com/isanasan/tddbook-dart

学んだこと

標準で提供されているアサーションはexpect() しかない

第二引数に渡すmatherでテスト内容を制御する思想。

expect(emptyList, isEmpty);

xUnitで慣れ親しんだAssertionではないので驚いた。

再代入に注意

p14でvalueオブジェクト化する際下記のように書けない。

void main() {
    test('Multiplication', () {
        var five = Dollar(5);
        Dollar product = five.times(2);
        expect(product.amount, 10);
        Dollar product = five.times(3); // productに再代入する
        expect(product.amount, 15);
    });
}

テストを実行すると下記のエラーになる。

dart test
00:00 +0 -1: loading test/money_test.dart [E]
  Failed to load "test/money_test.dart":
  test/money_test.dart:10:12: Error: 'product' is already declared in this scope.
      Dollar product = five.times(3);
             ^^^^^^^
  test/money_test.dart:7:12: Context: Previous declaration of 'product'.
      Dollar product = five.times(2);
             ^^^^^^^

迂闊に再代入できない言語仕様で安心感がある。

型キャストはas

p22の型キャストはasキーワードを使う。

bool equals(Object object) {
    Dollar dollar = object as Doller;
    return amount == dollar.amount;
}

上記のコードの場合は意味的にis キーワードでもよさそう。

オブジェクトの等価性比較

p26のオブジェクトの等価性比較は以下のようには書けない。

test('Multiplication', () {
    var five = Dollar(5);
    Dollar ten = five.times(2);
    expect(Dollar(10), equals(ten)); // 値は同じだがインスタンスは別
    Dollar fifteen = five.times(3);
    expect(Dollar(15), equals(fifteen)); // 値は同じだがインスタンスは別
})

値は同じだがインスタンスは別なので以下のエラーになる。

dart test
00:00 +1 -1: test/money_test.dart: Multiplication [E]
  Expected: <Instance of 'Dollar'>
    Actual: <Instance of 'Dollar'>

  package:matcher           expect
  test/money_test.dart 8:5  main.<fn>

operatorをoverrideすることで挙動をコントロールできる。

class Money {
  int amount = 0;

  Money(this.amount);

  
  bool operator ==(Object other) {
    if (identical(this, other)) {
      return true;
    }
    if (other is Money) {
      return runtimeType == other.runtimeType && amount == other.amount;
    } else {
      return false;
    }
  }

  
  int get hashCode => amount.hashCode; // この時点では通過の概念を実装してないのでこのような実装になっている
}

test('Equality', () {
    expect(Dollar(5), Dollar(5));
    expect(Dollar(5) == Dollar(6), false); // falseの場合こう書くのがイケてない
    expect(Franc(5), Franc(5));
    expect(Franc(5) == Franc(6), false);
    expect(Franc(5) == Dollar(5), false);
});

抽象メソッドにabstractキーワードは不要

驚いた。

abstract class Money {
  int amount = 0;

  Money(this.amount);

  Money times(int multiplier); // {}を消して;を付けるだけ
  ...
}

Methods | Dart

親クラスのプロパティに子クラスのコンストラクタで値を入れたい

p37

class Money {
  int amount = 0;

  Money(this.amount);
}

子クラスでsuperキーワードを呼ぶ。

class Dollar extends Money {
  Dollar(amount) : super(amount); // 親クラスのコンストラクタを呼ぶ
}
  • クラスをインスタンス化する時、親クラスのコンストラクタが先に実行されることを明示することを強制する言語仕様らしい
  • PHPは明示的にparent::construct()しないと親クラスのコンストラクタが呼ばれないから驚いた

GetterとSetterはPublicなプロパティに対して暗黙的に定義される

  • プロパティにfinalとかconstを付ければreadonlyにできる
  • getキーワードとアロー演算子を使ったシンタックスシュガーがある
    • double get right => left + width;
  • getter同様setterも下記のように書ける

余談

以下のようにするとgetterの実装を強制できる。

abstract class Money {
  int amount = 0;

  Money(this.amount);

  String get currency; // 抽象メソッドでgetterを定義する
}
class Dollar extends Money {
  Dollar(amount) : super(amount);

  
  String get currency => 'USD'; // こうする
}

何の役に立つかは不明。

プロパティとメソッドの名前が衝突してはいけない

p58を実装していて驚いた。

class Franc extends Money {
  Franc(amount) : super(amount) {
    this.currency = "CHF"; // Methods can't be assigned a value.
  }

  
  String currency() {
    return this.currency; // A value of type 'String Function()' can't be returned from the method 'currency' because it has a return type of 'String'.
  }

  
  Money times(int multiplier) {
    return Franc(amount * multiplier);
  }
}

protectedキーワードがない

  • 驚いた
  • chapter9はDartにprotectedキーワードがないため書籍通りの手順で進めることができない
  • currencypublicにしてfinalで再代入から守る設計にした

インタフェースにはinterface classabstruct interface classの 2 つがある

  • 一般的にオブジェクト指向で利用されるインタフェースはabstruct interface classの方
  • Class modifiers | Dart

Map のキーにValueオブジェクトを使う方法

多国通貨のキモになる部分。

p.106

下記のテストは通らないので、pairクラスを実装してMapのキーとして動作させなければならない。

test('array equals', () {
var test = {
  {
    "from": "USD",
    "to": "CHF",
  }: 2,
};
expect(
    test[{
      "from": "USD",
      "to": "CHF",
    }],
    2);
});

等価性比較の挙動確認

Pairの実装(hashCodeは未実装)

class Pair {
  final String from;
  final String to;

  Pair(this.from, this.to);

  
  bool operator ==(Object other) {
    if (identical(this, other)) {
      return true;
    }
    if (other is Pair) {
      return from == other.from && to == other.to;
    } else {
      return false;
    }
  }
}

下記の通り確認した。

test('test pair', () {
    Pair test = Pair("USD", "CHF");
    print(test.hashCode);
    Pair test2 = Pair("USD", "CHF");
    print(test2.hashCode);

    expect(test, test2);
});

実行結果は下記のとなる。

❯ dart test -n 'test pair'
00:00 +0: test/money_test.dart: test pair
877292708
276936682
00:00 +1: All tests passed!

アサーションライブラリはhashCodeが不一致でも等しいと評価するようだ。

hashCodeを実装するとMapのキーにPairクラスを指定できる

hashCode未実装の状態でテストする。

  test('test pair', () {
    final rates = <Pair, int>{};
    Pair test = Pair("USD", "CHF"); // Pairクラスを2つインスタンス化する
    Pair test2 = Pair("USD", "CHF"); // 両者は別インスタンスだが値は等しい
    rates[test] = 2;

    expect(2, rates[test2]); // 等価な別インスタンスでMapから値を引けるかテスト
});

結果、値として等価でも別インスタンスだとMapから値を引けない。

❯ dart test -n 'test pair'
00:00 +0 -1: test/money_test.dart: test pair [E]
  Expected: <null>
    Actual: <2>

  package:matcher            expect
  test/money_test.dart 68:5  main.<fn>

hashCodeを実装することで実現できる。

class Pair {
  final String from;
  final String to;

  Pair(this.from, this.to);

  
  bool operator ==(Object other) {
    if (identical(this, other)) {
      return true;
    }
    if (other is Pair) {
      return from == other.from && to == other.to;
    } else {
      return false;
    }
  }

  
  int get hashCode => '$from$to'.hashCode; //ここの実装が必要
}

Null Saftyありがたい

p. 107

書籍だと通るが、為替レートが見付からなかった時を想定していないと下記のエラーになる。

❯ dart test
00:00 +0 -1: loading test/money_test.dart [E]
  Failed to load "test/money_test.dart":
  lib/money/bank.dart:17:18: Error: A value of type 'int?' can't be returned from a function with return type 'int' because 'int?' is nullable and 'int' isn't.
      return _rates[Pair(from, to)];

こういうのはPHPだと人間が注意深くコーディングする(あるいはPHPStanを使う)必要があるので言語仕様で守られているのはありがたい。

動的なメソッド呼び出しはリフレクションを使う

PHPでいうところのcall_user_func()的なやつは下記で実現できる。

void run() {
    var mirrror = reflect(this); // インスタンスのミラーを作る
    var symbol = Symbol(name); // 呼び出したいメソッドの名前を示す文字列をSymbolにキャストする
    mirrror.invoke(symbol, <dynamic>[]); // Symbol型でメソッド名を渡すとinvokeできる
}

アサーションを実行したい時はオプションで有効化する

dart --enable-asserts lib/xUnit/xunit.dartで実行するとアサーションが有効になる。

アサーションが効いてないことに途中で気がついたのでワザと落ちるテストを書くことも時には必要。

まとめ

TDD本の写経は4回目なのでスムーズにキャッチアップができた。

次はServerpodのキャッチアップをしつつ、動くアプリケーションを作ってみたい。

Discussion