[實作筆記] 記錄用 TDD 寫一個 Entity Parser

如何用 TDD 寫一個 Entity Parser

情境,假設有一包 JSON 檔案如下

1
2
3
4
5
{
"FirstName": "Tian",
"LastName": "Tank",
"BirthDate": "1989/06/04"
}

我想轉換成 C# Entity ,
這個 Entity 裡面包含兩個商業邏輯

  1. 提供全名
  2. 提供年紀

ex:

1
2
Name = Tank Tian
Age = 35

如何透過 TDD 的工序達到這個目標 ?

Arrange

傳入的 JSON 字串如下

1
2
3
4
5
{
"FirstName": "Tian",
"LastName": "Tank",
"BirthDate": "1989/06/04"
}

Act

執行 Parse 方法之後

Assert

回傳一個 PersonEntity ,裡面的資料應該要是

1
2
3
4
new PersonEntity {
Name = "Tian Tank"
Age = 30
}

工序

Step1

第一個案例,我會只驗証 Name 的組合邏輯

1
2
3
4
5
6
7
8
9
10
[Fact]
public void CovertName()
{
////Arrange

////Act
var actual = _target.Parse(testJson).Name;
////Assert
actual.Should().BeEquivalentTo("Tian Tank");
}

Production Code 簡單寫可以這樣,
這個可以說是極致的 Baby Step 吧 ?

1
2
3
4
5
6
7
public PersonaEntity Parse(string json)
{
return new PersonaEntity
{
Name = "Tian Tank"
};
}

不過這個階段可以透過 IDE 工具長出 PersnaEntity

1
2
3
4
public class PersonaEntity
{
public string Name { get; set; }
}

Step1.1

透過分析過需求,我們應該可以理解到 PersonaEntity.Name
其實是 FirstName 與 LastName 的組合,
所以我們可重構一下 Production Code,
這個步伐會大步一點,如下

1
2
3
4
5
6
7
public PersonaEntity Parse(string json)
{
return new PersonaEntity
{
Name = "Tian" + "Tank"
};
}

Step1.2

Oops !! 我拿到了一個紅燈,因為我忘了空白
修改 Production Code 如下,得到綠燈

1
2
3
4
5
6
7
public PersonaEntity Parse(string json)
{
return new PersonaEntity
{
Name = "Tian" +" "+ "Tank"
};
}

Step1.3

開始重構,這樣的 Baby Step 會不會真的太小步了 ?

1
2
3
4
5
6
7
8
9
public PersonaEntity Parse(string json)
{
var firstName = "Tian";
var lastName = "Tank";
return new PersonaEntity
{
Name = firstName + " " + lastName
};
}

Step1.4

長出新的 Entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public PersonaEntity Parse(string json)
{
var firstName = "Tian";
var lastName = "Tank";
var originEntity = new PersonaOriginEntity
{
FirstName = firstName,
LastName = lastName
};
return new PersonaEntity
{
Name = $"{originEntity.FirstName} {originEntity.LastName}"
};
}
1
2
3
4
5
public class PersonaOriginEntity
{
public string LastName { get; set; }
public string FirstName { get; set; }
}

Step1.5

重構

1
2
3
4
5
6
7
8
9
10
11
12
13
public PersonaEntity Parse(string json)
{
var originEntity = new PersonaOriginEntity
{
FirstName = "Tian",
LastName = "Tank"
};

return new PersonaEntity
{
Name = $"{originEntity.FirstName} {originEntity.LastName}"
};
}

Step1.6

改用 JSON Parser

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public PersonaEntity Parse(string json)
{
//var originEntity = new PersonaOriginEntity
//{
// FirstName = "Tian",
// LastName = "Tank"
//};
var originEntity = JsonSerializer.Deserialize<PersonaOriginEntity>(json);

return new PersonaEntity
{
Name = $"{originEntity.FirstName} {originEntity.LastName}"
};
}

Step1.7

移除無用代碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public PersonaEntity Parse(string json)
{
//var originEntity = new PersonaOriginEntity
//{
// FirstName = "Tian",
// LastName = "Tank"
//};
var originEntity = JsonSerializer.Deserialize<PersonaOriginEntity>(json);

return new PersonaEntity
{
Name = $"{originEntity.FirstName} {originEntity.LastName}"
};
}

Step2

第二個案例,我會驗証 Age 的計算邏輯

Case 2. Age 是現在時間減去生日的年份差

首先,要如何處理 現在時間 ??
一般的 Production Code 會用 DateTime.Now
這是一個 struct 的 static 方法。

Step2.1

1
2
3
4
5
6
7
8
9
[Fact]
public void CovertAge()
{
////Arrange
////Act
var actual = _target.Parse(testJson).Age;
////Assert
actual.Should().Be(30);
}

透過測試逼出 Age 欄位

1
2
3
4
5
public class PersonaEntity
{
public string Name { get; set; }
public int Age { get; set; }
}

快速讓測試綠燈

1
2
3
4
5
6
7
8
9
10
public PersonaEntity Parse(string json)
{
var originEntity = JsonSerializer.Deserialize<PersonaOriginEntity>(json);

return new PersonaEntity
{
Name = $"{originEntity.FirstName} {originEntity.LastName}",
Age = 30
};
}

Step2.2

實作計算 Age

1
2
3
4
5
6
7
8
9
10
public PersonaEntity Parse(string json)
{
var originEntity = JsonSerializer.Deserialize<PersonaOriginEntity>(json);

return new PersonaEntity
{
Name = $"{originEntity.FirstName} {originEntity.LastName}",
Age = originEntity.BirthDate.Year - DateTime.Now.Year
};
}

這個測試案例會在 2019 年為綠燈,而在 2020 年變為紅燈
這不是一個好的測試,好的測試應該符合可重複性(Repeatable),
所以我們要試著 Mock 掉 DateTime.Now

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public struct SystemDateTime
{
private static DateTime? _mockDateTime = null;

public static DateTime Now
{
get => _mockDateTime ?? DateTime.Now;
internal set => _mockDateTime = value;
}

internal static void Reset()
{
_mockDateTime = null;
}
}

Step2.3

改寫測試案例

1
2
3
4
5
6
7
8
9
10
[Fact]
public void CovertAgeTodayIs2019()
{
////Arrange
SystemDateTime.Now = Convert.ToDateTime("2019/12/28");
////Act
var actual = _target.Parse(testJson).Age;
////Assert
actual.Should().Be(30);
}

Step2.4

Mock 時間後再寫一個測試案例

1
2
3
4
5
6
7
8
9
10
[Fact]
public void CovertAgeTodayIs2020()
{
////Arrange
SystemDateTime.Now = Convert.ToDateTime("2020/12/28");
////Act
var actual = _target.Parse(testJson).Age;
////Assert
actual.Should().Be(31);
}

Step2.5

改用 Given When Then,
測試案例如下,這樣會比較好讀嗎?:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[Fact]
public void parse_name()
{
AfterParseJson().Name.Should().Be("Tian Tank");
}

[Fact]
public void parse_age_today_is_2019()
{
GiveTodayIs("2019/12/28").Age.Should().Be(30);
}

[Fact]
public void parse_age_today_is_2030()
{
GiveTodayIs("2030/05/06").Age.Should().Be(41);
}

後續

  • 如果未來有多新的欄位再逐步加上測試。
  • 但在實務上我極有可能會同時驗証大量的欄位 ,比如說欄位是一對一的 Mapping
  • 想省略 PersonaOriginEntity ,有可能用 Dynamic 嗎 ?

(fin)