セゾン情報システムズ Advent Calendar 2020 9日目の記事です。
はじめに
- DDDを活用したプロジェクトでテストを行うために考えたことをまとめます。
複雑な業務ロジック
DDDなどで用いられるドメインモデルでは、業務ロジックを手続きではなく、オブジェクトで表現します。
複雑な状態遷移を持つような場合でも、機能の凝集度を高め、メンテナンス性を向上させることができる。
例えば、書籍を貸し借りするようなシステムの書籍オブジェクトは下記のような形式で表現される。
@Getter
@Builder
@EqualsAndHashCode
@ToString
public class Book {
private final BookId id;
private final Isbn13 isbn13;
private final Title title;
private BookStatus status;
private UserId borrowerId;
private Collection<ReviewId> reviewIds;
public static Book create(@NonNull BookId bookId, Isbn13 isbn13, @NonNull Title title) {
return Book.builder()
.id(bookId)
.isbn13(isbn13)
.title(title)
.status(BookStatus.Lendable)
.borrowerId(null)
.reviewIds(new ArrayList<>())
.build();
}
public Optional<Isbn13> getIsbn13() {
return Optional.ofNullable(isbn13);
}
public Optional<UserId> getBorrowerId() {
return Optional.ofNullable(borrowerId);
}
public void lend(@NonNull UserId borrowerId) {
if (status != BookStatus.Lendable) {
throw new IllegalStateException("bookStatus must be " + BookStatus.Lendable.name());
}
this.borrowerId = borrowerId;
status = BookStatus.InLending;
}
public void giveBack(@NonNull UserId borrowerId) {
if (status != BookStatus.InLending) {
throw new IllegalStateException("bookStatus must be " + BookStatus.InLending.name());
}
if (!(this.getBorrowerId().orElseThrow(IllegalStateException::new).equals(borrowerId))) {
throw new IllegalArgumentException("borrowerId does not match");
}
this.borrowerId = null;
status = BookStatus.Lendable;
}
public void addReview(@NonNull ReviewId reviewId) {
this.reviewIds.add(reviewId);
}
public void removeReview(@NonNull ReviewId reviewId) {
this.reviewIds.remove(reviewId);
}
}
テストを書こう
書籍のテスト
状態に応じて振る舞いが変化するような業務ロジックはバグが発生しやすく、ユニットテストで振る舞いが意図せず変更されていないか管理されている状態が望ましいです。
例えば、書籍の場合のテストケースは下記のようになるはずです
状態 | テストケース | 事後確認項目 |
---|---|---|
生成前 | 書籍を生成できること | ・書籍IDが生成で指定した書籍IDであること ・Isbn13が生成で指定したIsbn13であること ・titleが生成で指定したtitleであること ・statusがLendableであること ・BorrowerIdが空であること |
貸出可能状態 | 貸出処理が可能であること | ・statusがInLendingであること ・BorrowerIdが操作したユーザーのIDであること |
貸出可能状態 | 返却処理を実行した場合、例外が発生すること | ・IllegalArgumentExceptionが発生すること ・例外のメッセージが"bookStatus must be InLending"であること |
貸出中状態 | 返却処理が可能であること | ・statusがLendableであること ・BorrowerIdが空であること |
貸出中状態 | 貸出処理を実行した場合、例外が発生すること | ・IllegalArgumentExceptionが発生すること ・例外のメッセージが"bookStatus must be Lendable"であること |
貸出中状態 | 貸出利用者以外が返却処理を実行した場合、例外が発生すること | ・IllegalArgumentExceptionが発生すること ・例外のメッセージが"borrowerId does not match"であること |
そして、書籍を返却する場合のテストケースは下記のように実装されるはずです。
@Test
void 書籍を返却できる(){
var sut = Book.create(
BookId.of(UUID.fromString("00000000-0000-0000-0000-000000000001")),
Isbn13.of("978-4-7741-5377-3"),
Title.of("JUnit実践入門")
);
var borrowerId = UserId.of(UUID.fromString("00000000-0000-0000-0001-000000000001"));
sut.lend(borrowerId);
sut.giveBack(borrowerId);
assertAll(
() -> assertEquals(Optional.empty(), sut.getBorrowerId()),
() -> assertEquals(BookStatus.Lendable, sut.getStatus())
);
}
テストの網羅性を上げる
このような対象をテストする場合、テスト対象の状態を変更して網羅性を上げたくなる場合があります。
たとえば今回の場合、下記のパターンを追加して書籍の状態が異なる場合でも同じ結果になることを確認したくなることがあります。
返却処理を確認する場合の追加パターン | 期待する結果 |
---|---|
ISBN13を持たない書籍の場合 | ・statusがLendableであること ・BorrowerIdが空であること |
Reviewがついている書籍の場合 | ・statusがLendableであること ・BorrowerIdが空であること |
しかし、これらを確認するために、微妙にコードの違うテストケースを追加することは本当に適切でしょうか?
CSV形式パラメタライズドテスト
問題を解決する一つの方法として、パラメタライズドテストを使用する方法があります。
パターン | BookId | Isbn13 | Title | BookStatus | borrowerId | ReviewId[0] | ReviewId[1] |
---|---|---|---|---|---|---|---|
通常 | 00000000-0000-0000-0000-000000000001 | 978-4-7741-5377-3 | JUnit実践入門 | Lendable | 00000000-0000-0000-0001-000000000001 | ||
ISBN13を持たない書籍の場合 | 00000000-0000-0000-0000-000000000001 | JUnit実践入門 | Lendable | 00000000-0000-0000-0001-000000000001 | |||
Reviewがついている書籍の場合 | 00000000-0000-0000-0000-000000000001 | 978-4-7741-5377-3 | JUnit実践入門 | Lendable | 00000000-0000-0000-0001-000000000001 | 00000000-0000-0000-0002-000000000001 | 00000000-0000-0000-0002-000000000002 |
しかし、パラメタライズドテストでよく用いられるCSV形式のファイルでは、ドメインモデルの構造を適切に表現できないことがあります。
たとえば、Collectionを含むドメインモデルを表現すると、適切な表現にならず、メンテナンス性が悪くなっています。
JSON形式を活用する
JSONでパラメタライズテストを実施するライブラリが公開されています。
https://github.com/joshka/junit-json-params
これを活用することでJsonファイルでテストパターンを外出しすることができます。
先程の書籍を返却する場合のテストケースは下記のようになります。
@ParameterizedTest
@JsonFileSource(resources = "/domain/model/bookaggregate/GiveBackBook.json")
void 書籍を返却できる_Parameterized(JsonObject jsonObject) throws Exception{
var paramJsonObject = jsonObject.getJsonObject("params");
var dataJsonObject = jsonObject.getJsonObject("data");
var expectJsonObject = jsonObject.getJsonObject("expect");
var borrowerId = TestUtils.convertFromJson(paramJsonObject.get("borrowerId").toString(), UserId.class);
var book = TestUtils.convertFromJson(dataJsonObject.get("book").toString(), Book.class);
var expectBook = TestUtils.convertFromJson(expectJsonObject.get("book").toString(), Book.class);
book.giveBack(borrowerId);
assertEquals(expectBook, book);
}
今回、テストケースに渡されるパラメータは3つに分類しています。
パラメータの分類 | 用途 |
---|---|
param | テスト対象に渡すパラメータ |
data | テスト対象の状態を再現するために必要な情報 |
expect | テストの結果を確認するために必要な情報 |
Jsonファイルでは下記のように表現しています。
[
{
"_comment": "通常 ",
"params": {
"borrowerId": "00000000-0000-0000-0000-000000000001"
},
"data": {
"book": {
"id": "00000000-0000-0000-0000-000000000001",
"isbn13": "978-4-7741-5377-3",
"status": "InLending",
"title": "JUnit実践入門",
"borrowerId": "00000000-0000-0000-0000-000000000001",
"reviewIds": []
}
},
"expect": {
"book": {
"id": "00000000-0000-0000-0000-000000000001",
"isbn13": "978-4-7741-5377-3",
"status": "Lendable",
"title": "JUnit実践入門",
"borrowerId": null,
"reviewIds": []
}
}
},
{
"_comment": "ISBN13を持たない書籍の場合",
"params": {
"borrowerId": "00000000-0000-0000-0000-000000000001"
},
"data": {
"book": {
"id": "00000000-0000-0000-0000-000000000001",
"isbn13": null,
"status": "InLending",
"title": "JUnit実践入門",
"borrowerId": "00000000-0000-0000-0000-000000000001",
"reviewIds": []
}
},
"expect": {
"book": {
"id": "00000000-0000-0000-0000-000000000001",
"isbn13": null,
"status": "Lendable",
"title": "JUnit実践入門",
"borrowerId": null,
"reviewIds": []
}
}
},
{
"_comment": "Reviewがついている書籍の場合",
"params": {
"borrowerId": "00000000-0000-0000-0000-000000000001"
},
"data": {
"book": {
"id": "00000000-0000-0000-0000-000000000001",
"isbn13": "978-4-7741-5377-3",
"status": "InLending",
"title": "JUnit実践入門",
"borrowerId": "00000000-0000-0000-0000-000000000001",
"reviewIds": [
"00000000-0000-0000-0002-000000000001",
"00000000-0000-0000-0002-000000000002"
]
}
},
"expect": {
"book": {
"id": "00000000-0000-0000-0000-000000000001",
"isbn13": "978-4-7741-5377-3",
"status": "Lendable",
"title": "JUnit実践入門",
"borrowerId": null,
"reviewIds": [
"00000000-0000-0000-0002-000000000001",
"00000000-0000-0000-0002-000000000002"
]
}
}
}
]
Jsonファイルからオブジェクトをデシリアライズするために下記のようなUtilクラスを使用しています。
public class TestUtils {
private static final Gson gson;
static {
gson = new GsonBuilder()
.registerTypeAdapter(LocalDateTime.class, (JsonDeserializer<LocalDateTime>) (json, type, jsonDeserializationContext) ->
LocalDateTime.parse(json.getAsJsonPrimitive().getAsString()))
.registerTypeAdapter(LocalDateTime.class, (JsonSerializer<LocalDateTime>) (src, type, jsonSerializationContext) ->
jsonSerializationContext.serialize(src.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)))
.registerTypeAdapter(ZonedDateTime.class, (JsonDeserializer<ZonedDateTime>) (json, type, jsonDeserializationContext) ->
ZonedDateTime.parse(json.getAsJsonPrimitive().getAsString()))
.registerTypeAdapter(ZonedDateTime.class, (JsonSerializer<ZonedDateTime>) (src, type, jsonSerializationContext) ->
jsonSerializationContext.serialize(src.format(DateTimeFormatter.ISO_ZONED_DATE_TIME)))
.registerTypeAdapter(BookId.class, (JsonDeserializer<BookId>) (json, type, jsonDeserializationContext) ->
BookId.fromString(json.getAsJsonPrimitive().getAsString()))
.registerTypeAdapter(UserId.class, (JsonDeserializer<UserId>) (json, type, jsonDeserializationContext) ->
UserId.fromString(json.getAsJsonPrimitive().getAsString()))
.registerTypeAdapter(ReviewId.class, (JsonDeserializer<ReviewId>) (json, type, jsonDeserializationContext) ->
ReviewId.fromString(json.getAsJsonPrimitive().getAsString()))
.registerTypeAdapter(Title.class, (JsonDeserializer<Title>) (json, type, jsonDeserializationContext) ->
Title.of(json.getAsJsonPrimitive().getAsString()))
.registerTypeAdapter(Isbn13.class, (JsonDeserializer<Isbn13>) (json, type, jsonDeserializationContext) ->
Isbn13.of(json.getAsJsonPrimitive().getAsString()))
.create();
}
public static <T> T convertFromJson(String json, Class<T> classOfT) {
return gson.fromJson(json, classOfT);
}
public static <T> T convertFromJson(String json, Type typeOfT) {
return gson.fromJson(json, typeOfT);
}
public static <T> List<T> convertListFromJson(String json, Class<T> classOfT) {
Type type = $Gson$Types.newParameterizedTypeWithOwner(null, ArrayList.class, classOfT);
return gson.fromJson(json, type);
}
public static String toJson(Object src) {
return gson.toJson(src);
}
}
結論
DDDで使用されるような複雑な状態と振る舞いを持ったオブジェクトであっても、Json形式のパラメタライズドテストにすることで、パターンを容易に増やすことができました。