前にとある DDD の座談会で「静的言語で DDD をしているならテストはいらない?」という質問が出て、思ったより多くの人が「そうだ / そうかも」の空気になって驚いたので、ちょっと整理してみることにした。
結論から言うと、僕の持論は「もちろんコンパイルの恩恵はとてもあるんだけど、思考停止で全てが解決するわけではないし、学びも多いので、書いた方が良い」です。
条件分岐は型があってもバグになりやすい
例えばelse ifが増えるような修正は、型があるだけだと割と危険。
public enum Plan {
PLAN_1,
PLAN_2,
PLAN_3;
}
public class Price {
private final int value;
public static Price of(Plan plan) {
if (plan == Plan.PLAN_1)
return new Price(100);
else if (plan == Plan.PLAN_2)
return new Price(200);
else
return new Price(300);
}
}
これにPLAN_4を足した。
値段は 400 円にしたかったけど、else ifを書き足し忘れた。
public enum Plan {
PLAN_1,
PLAN_2,
PLAN_3,
+ PLAN_4;
}
当然コンパイルは通り、PLAN_4は 300 円になる。
怖い。
とても怖い。
余談
くだらない例だけど、冗談ではないよ。
Planに応じて値段、明細品目、メールの文面、システムコード等々、else ifを忘れずに書き足さないといけないプロダクトなんて珍しくないはず。
大規模でこの手の漏れを策なく防ぐのは割と無謀。
じゃあどうすんの
テストを書けば少しはマシになるはず。
enum の全要素 ( Plan.values() ) とかをテストコードにうまく絡めていると、検知できたりする。
// テストコードは groovy と spock を用いた
class PriceTest extends Specification {
def "#plan -> #exp"() {
expect:
Price.of(plan) == new Price(exp)
where:
plan || exp
Plan.PLAN_1 || 100
Plan.PLAN_2 || 200
Plan.PLAN_3 || 300
}
def guard() {
expect:
Plan.values().length == 3
}
}
シンプルだけど、Planに依存してるテストに== 3が入っていると、Planが増減した時に気づける。
elseを値段判定に使わない、とかもアリ。
else if (plan == Plan.PLAN_3)
return new Price(300);
else
throw new RuntimeException("match error");
テストコードがあればこれでも気づける。
整理: ここが怖いよ
- 例えば条件分岐の書き方によってはコンパイルが通ってもバグる可能性があるよ
- 一番最初に書くときは簡単なので「まさかバグんないだろ〜」と思っても、保守と追記で地獄を見るよ
引数不正なんかは恩恵が得られる、かも
逆に、しっかりやればこんなバグはバッチリ防げる
例1
public class MailService {
public void send(String itemName, String userName) {
String mailBody = MailBodyFactory.create(itemName, userName);
...
}
}
例2
public class FooService {
FooRepository fooRepository;
public void replace(Foo usingOne) {
Foo newOne = Foo.newOne();
fooRepository.replace(newOne, usingOne);
}
}
どこが間違ってるかというと...
例1
public class MailBodyFactory {
public static String create(String userName, String itemName) {
return String.format("%s 様 %s のお買い上げありがとうございます", userName, itemName);
}
}
例2
public interface FooRepository {
void replace(Foo usingOne, Foo newOne);
}
引数の順番が逆でした。
また余談
「知るかよ!」って思った?
でも大体の場合プロダクトコードの引数順なんて覚えてないし、気をつけながら実装するのは案外とても疲れる。
さらに、こういうのは下手に動くだけにとてもタチが悪い。
じゃあどうすんの
Stringは乱用しないでUserNameとItemNameのクラスを作ろう。
そしたら絶対に逆にできない。
Fooの方はドメインクラスを作ってみたみたいだけど、例えば状態ごとにわけてみよう。
public interface FooRepository {
- void replace(Foo usingOne, Foo newOne);
+ void replace(UsingFoo usingFoo, NewFoo newFoo);
}
これで絶対に逆にならない。
(状態別クラスについては勢いで一緒にこんな記事を書いて見ました → 全部のステータスを1クラスで表現する神エンティティをやめよう!)
大体の目安だけど、変数名で説明を頑張ってる箇所は、型にしてしまった方が良いと思う。
赤線になってくれた方がよっぽど楽だし、エディタも補完候補とかを賢く考えてくれる。
ちなみに、似た様な話は以前も書いたので興味があればそちらも見てみてください。
同じテーブルの値でも違うクラスを用意すると良い感じ
整理: ここが怖いよ
- 型があると言っても、全部
Stringじゃあ恩恵を全然享受できない - ドメインクラスを作ってみても、それがちゃんと設計されていなければ恩恵は大きくならない
テストがあると使いづらさに気づく、かも
例えばこんなコード
public enum CampaignCode {
CODE_A, CODE_B;
public static CampaignCode create() {
if (LocalDateTime.now().getDayOfMonth() < 15) {
return CODE_A;
} else {
return CODE_B;
}
}
}
テストコードを書こうとするとnowが邪魔で「あれ、テスト書けないぞ...??」ってなるはずなんだ。
これはつまり作ったプロダクトもnowでしか動かないので、他の部分のテストを邪魔したり、モッキングや結合試験とかで融通が効かなかったりする。
テストコードは最初にそのメソッドを使うので、こういう「あれ?このメソッド使いづらいな?」みたいなのに気づくチャンスに、結構なる。
(この例はnowは引数で渡した方が良い)
まとめ
- 静的言語で DDD をやっているからと言って、全てのバグが抜けるわけではないよ
- 条件分岐の例とか
- 他にすぐ思いつくのだと配列操作とか(前のどこかの処理が間違ってて index out of range が出るとか、よくあるよね)
- 同じく前処理のどっかがおかしくて
Optional.getしたらemptyだった、とか - イテレータのカーソルが思った位置にないとか
- java でたまに見るのは
Streamの終端操作を2回しちゃうとか
- ドメインクラスを作ってるからと言って、思考停止で型の恩恵が得られるわけではないよ
-
Foo usingの例とか
-
- テストコードは作った処理の使いづらさとかに気づく大事なチャンス
- 品質面では当然
- すぐ学習と経験のフィードバックが得られるという意味でも、とても大事
- (忘れてたのでさらっと追記)リファクタリングを促進するためにも、テストはあった方が良い
- モデルのアップデートをかけてドメイン層をどんどん直せるためにも、テストコードは大事
- ただし不必要に書きすぎると逆にリファクタリングを阻害することにもなる
おわりに
例えば enum の件なんて言語によっては警告やコンパイルエラーが出るんだけど、会場の大きな流れとしては「コンパイルはテストコードを不要にするか」って感じだと思ったので、思い立ってアンチテーゼとして書いて見ました。
本記事はあくまで僕個人の持論です。
最近聞いたある人の言葉を真似るなら「テストいらないって聞いたんだけどマジ?」って思った人は、まずは素直にテストを書きなさい。
と僕は思います。おしまい。