0%

【Unit Test】Day 3 - 專注於邏輯,隔離與外部的關聯

Demo檔案 : Git傳送門

昨天有提到對於外部有關聯時該如何寫單元測試,例如API就是一個典型的例子,當在沒有網路環境時,對於要拿到API的結果做後續處理,這時候該怎麼辦?今天要來實作並且落實昨天提到的重要特性

單元測試應該是隨時隨地都要能正確執行,只要它本身的邏輯是正確的!!

**
**來看看今天的情境範例
我們有一個需求,它要能夠讓我們帶入台北市的公車路線名稱,並取回該路線的站點資料,該資料來源從公共運輸整合資訊流通服務平台的API取得。

從呼叫API看到的呼叫結果大致如下

開始寫程式
首先我們準備了一個主控台專案,裡面有一個PTX的Class,希望他有個方法能帶入縣市公車路線名稱,然後回傳該路線所有站牌名稱與ID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/// <summary>
/// 巴士路線(該方法要回傳的結果)
/// </summary>
public class BusRouteDTO
{
/// <summary>
/// 路線名稱
/// </summary>
public string Name { get; set; }

/// <summary>
/// 巴士站列表
/// </summary>
public List<BusStop> BusStops { get; set; }

/// <summary>
/// 巴士站
/// </summary>
public class BusStop
{
/// <summary>
/// 站名
/// </summary>
public string Name { get; set; }

public string ID { get; set; }
}
}

用來承接API回傳資料的DTO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/// <summary>
/// 取PTX BusRoute的結果
/// </summary>
public class PTXBusRouteResult
{
public NameDTO RouteName { get; set; }

public List<StopDTO> Stops { get; set; }

/// <summary>
/// 巴士路線名稱
/// </summary>
public class NameDTO
{
public string Zh_tw { get; set; }
public string En { get; set; }
}

/// <summary>
/// 站點
/// </summary>
public class StopDTO
{
/// <summary>
/// 站點ID
/// </summary>
public string StopUID { get; set; }

/// <summary>
/// 站點名稱
/// </summary>
public NameDTO StopName { get; set; }

public class NameDTO
{
public string Zh_tw { get; set; }
public string En { get; set; }
}
}
}

PTX的程式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class PTX
{
/// <summary>
/// 取得巴士路線資料
/// </summary>
/// <param name="city">縣市名稱</param>
/// <param name="routeName">巴士路線名稱</param>
/// <returns></returns>
public BusRouteDTO Get(string city,string routeName)
{
BusRouteDTO Result = null;

//Use RestSharp Call API
var client = new RestClient($"http://ptx.transportdata.tw/MOTC/v2/Bus/StopOfRoute/City/{city}/{routeName}?%24top=1&%24format=JSON");
var request = new RestRequest(Method.GET);
request.AddHeader("cache-control", "no-cache");
IRestResponse response = client.Execute(request);

if (response.StatusCode == HttpStatusCode.OK)
{
var APIResult = JsonConvert.DeserializeObject<List<PTXBusRouteResult>>(response.Content);

if (APIResult != null && APIResult.Count > 0)
{
var Route = APIResult.First();
Result = new BusRouteDTO
{
Name = Route.RouteName.Zh_tw,
BusStops = new List<BusRouteDTO.BusStop>()
};

foreach (var stop in Route.Stops)
{
Result.BusStops.Add(new BusRouteDTO.BusStop
{
ID = stop.StopUID,
Name = stop.StopName.Zh_tw
});
}
}
}

return Result;
}
}

這邊呼叫API用的套件為RestSharp

將回傳的Json格式轉成DTO的套件是Newtonsoft.Json

OK,因為是範例程式,所以一些寫作風格或是是否會Null的問題我們就先擺一邊吧,還是專注在我們的怎麼寫單元測試上。

接著我們來執行看看程式是否依照我們預期的可以取回結果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Program
{
static void Main(string[] args)
{
var PTXFunction = new PTX();

var Result = PTXFunction.Get("Taipei","307");

Console.Write(JsonConvert.SerializeObject(Result));

Console.ReadKey();
}
}

程式正確執行也取得回資料,第一階段搞定!!接下來開始寫單元測試了

單元測試

首先替它加上單元測試吧,老樣子在PTX的Get方法右鍵 > 建立單元測試

寫單元測試,這邊稍微解釋一下,因為還沒說怎麼驗證整個Class的內容比對,所以這邊先驗證回傳結果名稱就好,之後的章節會講怎麼驗證整個回傳內容的數值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[TestClass()]
public class PTXTests
{
[TestMethod()]
public void GetTest_傳入縣市和公車路線名稱_如果查的到資料_應回傳該路線的BusRouteDTO()
{
//arrange
var Sut = new PTX();
var City = "Taipei";
var RouteName = "307";

var expected = "307";
//act
var actual = Sut.Get(City, RouteName);

//assert
Assert.AreEqual(actual.Name, expected);
}
}

執行看看,得到正確的結果!!

但這邊我們都知道,如果今天網路斷掉或是對方API暫時提供服務,則我們的單元測試就會壞掉,因為他呼叫不到真實的API,而這其實違反我們之前說的「單元測試應該關注的是它的邏輯,而非外部的關聯」。如果今天因為環境就出錯,單元測試一多,可能我們常常都要花很多時間去找目前狀況是什麼,更可能的是查到最後才發現原來你的程式沒錯….

所以回頭看看我們的PTX程式,它的外部關聯是什麼?它關注的邏輯又是什麼?

PTX.Get程式目前內部的邏輯如下
1.將帶入的參數組成API Url並進行呼叫
2.將取回的Json轉成DTO
3.判斷回傳的DTO是否有值?有則轉成我們要的結果回傳
3.1 否則直接回傳Null

從這邊我會把它拆成1.呼叫外部API為外部行為,因為如果今天網路斷掉或是對方API壞掉、甚至是對方的API還沒開發好,理論上都不是我們程式的問題,而是網路或是對方要依照規格來處理。

所以第一步是我們要把外部行為隔離出去,並且想辦法讓我們可以模擬它而不是真正去呼叫線上的API,當然這邊做法有很多種,我選擇將Call API的行為還是視為PTX這個物件的內部行為,所以沒有獨立到別的Class去處理,而是封裝起來

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class PTX
{
/// <summary>
/// 取得巴士路線資料
/// </summary>
/// <param name="city">縣市名稱</param>
/// <param name="routeName">巴士路線名稱</param>
/// <returns></returns>
public BusRouteDTO Get(string city,string routeName)
{
BusRouteDTO Result = null;

var JsonResult = CallAPI(city, routeName);

if (!string.IsNullOrEmpty(JsonResult))
{
var APIResult = JsonConvert.DeserializeObject<List<PTXBusRouteResult>>(JsonResult);

if (APIResult != null && APIResult.Count > 0)
{
var Route = APIResult.First();
Result = new BusRouteDTO
{
Name = Route.RouteName.Zh_tw,
BusStops = new List<BusRouteDTO.BusStop>()
};

foreach (var stop in Route.Stops)
{
Result.BusStops.Add(new BusRouteDTO.BusStop
{
ID = stop.StopUID,
Name = stop.StopName.Zh_tw
});
}
}
}

return Result;
}

/// <summary>
/// Call API
/// </summary>
/// <param name="city">縣市</param>
/// <param name="routeName">巴士路線名稱</param>
/// <returns></returns>
private string CallAPI(string city, string routeName)
{
//Use RestSharp Call API
var client = new RestClient($"http://ptx.transportdata.tw/MOTC/v2/Bus/StopOfRoute/City/{city}/{routeName}?%24top=1&%24format=JSON");
var request = new RestRequest(Method.GET);
request.AddHeader("cache-control", "no-cache");
IRestResponse response = client.Execute(request);

if (response.StatusCode == HttpStatusCode.OK)
{
return response.Content;
}

return string.Empty;
}
}

這樣就達到分離了Call API這個外部行為,但是接下來的問題是我們該如何模擬CallAPI的行為,因為目前的程式還是會去呼叫外部API。

讓我們把CallAPI從private改成protect吧,並且把它改成可以virtual,讓繼承它的子類別都可以改寫它

接著我們在單元測試專案中,然後開一個PTXStub的Class來繼承它並賦予他特殊屬性能改寫CallAPI的內部行為

這邊提到Stub,在單元測試中還有一種叫做Mock,詳情定義跟討論可以參考
stackoverflow - What’s the difference between a mock & stub?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class PTXStub :PTX
{
/// <summary>
/// 用來模擬API回傳的Json Result
/// </summary>
public string CallAPIResult;
protected override string CallAPI(string city, string routeName)
{
if (!string.IsNullOrEmpty(CallAPIResult))
{
return CallAPIResult;
}

return base.CallAPI(city, routeName);
}
}

我們將CallAPIResult的方法改寫掉,讓他回傳我們對外開放出來的CallAPIResult這個屬性,此屬性可以讓我們設定API應該回傳的結果。

接下就回頭改單元測試的部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[TestMethod()]
public void GetTest_傳入縣市和公車路線名稱_如果查的到資料_應回傳該路線的BusRouteDTO()
{
//arrange
var Sut = new PTXStub(); //改成PTXStub
var City = "Taipei";
var RouteName = "307";

//API應該回傳的結果
var sb = new System.Text.StringBuilder(12766);
sb.AppendLine(@"[{""RouteUID"":""TPE16111"",""RouteID"":""16111"",""RouteName"":{""Zh_tw"":""307"",""En"":""307""},""KeyPattern"":false,""SubRouteUID"":""TPE157462"",""SubRouteID"":""157462"",""SubRouteName"":{""Zh_tw"":""307莒光經板橋前站"",""En"":""307""},""Direction"":0,""Stops"":[{""StopUID"":""TPE15294"",""StopID"":""15294"",""StopName"":{""Zh_tw"":""莊敬里"",]............");

//設定CallAPI回傳結果
Sut.CallAPIResult = sb.ToString();

var expected = "307";
//act
var actual = Sut.Get(City, RouteName);

//assert
Assert.AreEqual(actual.Name, expected);
}
}

API應該回傳的結果那邊因為太長,所以省略部分回傳結果,但在實際程式是完整貼上,詳請看Git的Source Code。

這樣就可以模擬CallAPI這個跟外部連結的結果,但依然可以測試我們所關注的邏輯,是否可卻轉成我們要的結果,執行後可以發現單元測試還是顯示綠燈的正確無誤。

但這個方法也不是百分之百沒有缺點,你應該也可以觀察到其中一段在這單元測試中無法被涵蓋到,那就是組API Url那段,在這種透過繼承解耦合的方式中,因為回傳結果是自己設定的,實際上組成的URL正確與否我們並不知道,換句話說,如果我們今天這樣寫測試可能會過,但上線後才會發現錯誤,檢查之下發現原來是在串Url的時候錯字之類的。

這是這種測試方法的缺點,但今天就先談到這邊,還有別種方法可以解決這個問題,只是通常有一好沒兩好,各自都會有優缺點,就看實際專案情形自行判斷跟取捨了。