「テスト駆動開発」をDartで写経してみた
モチベーション
- 以前からServerpodが気になっていた
- Technology Radarを眺めていたらDartの名前を見かけた
- Languages and Frameworks | Technology Radar 2023 | Thoughtworks
- DartはTrialという評価
- Adoptに近付きつつある模様
- 言語を新しく学ぶ時はTDD本を写経するようにしているので今回もやってみた
Technology Radarって何
- Dartは下記4段階のうちTraialという評価
- 採用できる(Adopt)
- 投資価値あり(Trial)
- 調査段階(AssesS)
- やめておけ(Hold)
Dartに対する雑感
- Dart本体にテストランナー、パッケージマネージャーが組み込まれているのでセットアップが楽
- 前置型の言語なのでメインウェポンがPHPの筆者には親しみやすい
成果物
学んだこと
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); // {}を消して;を付けるだけ
...
}
親クラスのプロパティに子クラスのコンストラクタで値を入れたい
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も下記のように書ける
set right(double value) => left = value - width;
- Methods | Dart
余談
以下のようにすると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
キーワードがないため書籍通りの手順で進めることができない -
currency
はpublic
にしてfinal
で再代入から守る設計にした
interface class
とabstruct 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