[實作筆記] Github 結合 Stryker 作變異測試

前情提要

前年初次接觸變異測試,去年在看重構時偷換了概念,
將兩者結合了。

這次我想更進一步,將 CI Server 與之結合。

Stryker.Net

根據 Stryker Handbook
Stryker 主要有三個專案,

  • Stryker (Javascript & TypeScript)
  • Stryker4s (Scala)
  • Stryker.NET (.NET)

我會使用我的練習用專案作為目標,
所以很理所當然的我會選擇 Stryker.NET,那我們就開始吧。

開始

安裝 Stryker

1
dotnet tool install -g dotnet-stryker

執行 Stryker

1
dotnet stryker -tp "['./test/Marsen.NetCore.Dojo.Tests/Marsen.NetCore.Dojo.Tests.csproj','./test/Marsen.NetCore.Dojo.Integration.Tests/Marsen.NetCore.Dojo.Integration.Tests.csproj']" -p="Marsen.NetCore.Dojo.csproj" -dk=$STRYKER_DASHBOARD_API_KEY

注意幾個 cli 參數,
-tp 明確指定測試的專案有哪些,可以用中括號[]傳入多個測試專案名稱,使用,作為分隔符
-p 專案名稱
-dk dashboard-api-key 這組 key 是用來與 https://dashboard.stryker-mutator.io/ 互動的,
最主的功能是將報告上傳。
$STRYKER_DASHBOARD_API_KEY 是 Github 的 Secrets

你可以執行 dotnet stryker -h 查看更多原始說明

1
2
3
4
-tp|--test-projects Specify what test projects should run on the project under test.
-p|--project-file <projectFileName> Used for matching the project references when finding the project to
mutate. Example: "ExampleProject.csproj"
-dk|--dashboard-api-key <api-key> Api key for dashboard reporter. You can get your key here: https://dashboard.stryker-mutator.io

專案設定

新增一個 stryker-config.json
對我來說,最重要的是要記得設定 stryker-config > reporters > dashboard 這筆資料。
詳細的說明的可以參考這篇

1
2
3
4
5
6
7
{
"stryker-config": {
"dashboard-project": "github.com/marsen/Marsen.NetCore.Dojo",
"dashboard-version": "master",
"reporters": ["json", "dashboard"]
}
}

在 Github 上工作

首先你必須在 Dashboard中 Enable Repository

Enable Repository

然後產生一組 Key

產生一組 Key

接下來到 Github , 我們將這組 Key 設定到 Secrets 之中

將 Key 設定至 Github

再到 Github Actions 中,把 workflow 指令設定完成,

Github Actions

觸發 CI 完成後,就可以在 Dashboard 上看到報表啦。

結果呈現

參考

(fin)

[實作筆記] Github 結合 SonarCloud 作代碼質量檢查 - 測試覆蓋率篇

前情提要

上篇,
我已經將 SonarCloud 的代碼檢查與 Github Action 結合在一起了。
透過專案首頁的 Dashboard 與專案的 Budget 我可以知道目前專案的一些狀況,
壞味道、技術債等 …

題外話,測試分類

在我的專案中有兩種測試,單元測試與整合測試。
我的分類方法,單一類別的 public 方法就用單元測試包覆。
不同的類別之如果有組合的交互行為,就用整合測試包覆。

舉個簡單的例子,以購物流程來說,我有的類別如下 :

  • Cart(購物車)
    • Add (加入商品)
    • Substract (移除商品)
    • CheckOut (結帳)
  • Order (訂單)
    • Caculate (計算結帳金額)

另外有兩種類別如下,

  • ECoupon
    • Caculate
  • Promotion
    • Caculate

上面的所有方法我都會加上單元測試作保護,
但是 Order 在呼叫 Caculate 的時候,
會以不同的順序組合 ECoupon.Caculate 與 Promotion.Caculate,
這個時候就有可能會產生不同的結果。

執行測試

同上一篇,整體的流程我們只要在 Build 完後加上測試即可。

  1. Begin
  2. MSBuild
  3. Test
  4. End

開發環境執行測試

1
dotnet test ./test/Marsen.NetCore.Dojo.Tests/Marsen.NetCore.Dojo.Tests.csproj

產生測試報告

1
dotnet test ./test/Marsen.NetCore.Dojo.Tests/Marsen.NetCore.Dojo.Tests.csproj --logger:trx;LogFileName=result.trx

測試報告會產生一份 result.trx 檔,在測試專案目錄底下的 TestResults 資料夾裡。
如果要在 SonarCloud 上使用請設定 sonar.cs.vstest.reportsPaths (VSTest 適用),更多資訊請參考

result.trx

1
2
3
4
dotnet sonarscanner begin /k:"Marsen.NetCore.Dojo" /o:"marsen-github" /d:"sonar.host.url=https://sonarcloud.io" /d:"sonar.login="$SONAR_LOGIN
dotnet build ".\Marsen.NetCore.Dojo.Integration.Test.sln"
dotnet test ./test/Marsen.NetCore.Dojo.Tests/Marsen.NetCore.Dojo.Tests.csproj --logger:trx;LogFileName=result.trx
dotnet sonarscanner end /d:"sonar.login="$SONAR_LOGIN

覆蓋率

  1. Begin
  2. MSBuild
  3. Test -產生報告
  4. Test -覆蓋率
  5. End

這裡實作上很簡單,但是在選擇上有一些困難與試誤,稍微作個記錄。

  1. dotconver (棄選)

    • 似乎要綁定 resharper 的 lincese
    • 有 30 天的限制,不知道會不會影響功能
    • 不知道怎麼用 commandline 下載執行程式至 Github Action 執行實體上。
  2. vstest.console.exe (棄選)

    • 似乎只能在 windows 上執行
    • 可以透過參數開始功能 --collect:"Code Coverage",但產生的 .cover 檔 SonarCloud 不支援需要轉換格式
    • 不知道如何將 .cover 轉換為 .coverxml , 可能是 visual studio enter prise 才有的功能 ?
  3. opencover (coverlet)

    • /p:CoverletOutputFormat=opencover /p:CoverletOutput=./coverage/ 的語法不 Work
    • --collect:"XPlat Code Coverage" 是比較新的參數用法,可以使用設定檔

因諸多原因,我最後選擇了 Opencover(Coverlet) 的作法,
記錄步驟如下,

首先要在測試專案上安裝 Nuget 套件 coverlet.collector
然後執行以下語法產生覆蓋率報告。

1
dotnet test ./test/Marsen.NetCore.Dojo.Tests/Marsen.NetCore.Dojo.Tests.csproj --settings coverage.xml

coverage.xml 設定檔如下,這個檔案必須是 xml 檔,檔名並沒有限制 :
Configuration > Format 請記得填寫 opencover

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="utf-8" ?>
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat code coverage">
<Configuration>
<Format>opencover</Format>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>

最後至 SonarCloud 上設定 sonar.cs.opencover.reportsPaths 的路徑

result.trx

最後的最後,就來個 Budget 大集合吧

Bugs
Code Smells
Coverage
Duplicated Lines (%)
Lines of Code
Maintainability Rating
Quality Gate Status
Reliability Rating
Security Rating
Technical Debt
Vulnerabilities
SonarCloud
Quality gate

參考

(fin)

[實作筆記] Github 結合 SonarCloud 作代碼質量檢查

前情提要

大概一年前我曾寫過一篇 Blog [實作筆記] 讓 SonarQube 檢查你的代碼
沒什麼含金量,只是我個人用來記錄的筆記。
當初有一些問題沒有排除,加上工作一忙就沒有後續了。

我的理想目標是,每當我上 Code 到線上 Repo 時(Github),
SonarCloud 可以幫我檢查代碼,跑跑測試覆蓋率,刷新一下 Budget,
如果有異常(覆蓋率下降、壞味道等…)最好再發個通知給我。
這些功能要怎麼作到呢 ?

然後我會實際用在我的 SideProject 上,
這個 Project 單純只是為了練習而生,
專注於我個人的測試項目,主要語言為 Csharp 也有一些 TypeScript 。

分析

首先先排序一下優先序吧。

  1. 一定要可以執行代碼檢查
  2. 要能結合 CI ,我以 Github Action 作為我的主要 CI 工具
  3. 能夠跑測試並輸出測試報告
  4. Badge 刷新
  5. 發通知

這篇主要會說明如何結合 CI 執行代碼檢查。
執行代碼檢查就發現有一個問題, SonarCloud 一次只能對一種語言作檢查,
雖然我的專案裡有兩種語言,但是以 C# 佔大宗(93%),所以調整一下目標,
先優先完成 C# 的代碼檢查、結合 CI 與輸出測試報告。
之後再進行 Typescript 的檢查與測試,最後通知有的話很棒,沒有也沒關係啦 :)。

本機執行代碼檢查

我本機的環境有兩個,一個是 Windows 一個 macOS,我這裡只討論 Windows 的作法,
然後我就要直上 CI 了,Github Action 我並不熟悉,但是我知道上面應該是執行 Linux like 的作業系統。

首先要在 SonarCloud 上建立 Project ,
可以參考 Get started with GitHub.com 快速建立。

Administrator > Analysis Method

Analysis Method

這裡要把 SonarCloud Automatic Analysis 的功能關掉。
SonarCloud 支援自動分析語言只有以下

ABAP, Apex, CSS, Flex, Go, HTML, JS, Kotlin, PHP, Python, Ruby, Scala, Swift, TypeScript, XML.

雖然有很多,但可惜並沒有 C# ,所以要先關掉,不然 Github Action 執行時會收到下面的錯誤。

You are running CI analysis while Automatic Analysis is enabled. Please consider disabling one or the other.

另外目前支援的 CI 服務有 Circle CI 與 Travis CI ,
一樣殘念的是沒有支援 Github Action 。
另外兩個選項目是 Other CI 與 Manually (手動) 。
我的前一篇文章就是使用手動的方式把檢查報告打到 SonarCloud。
雖然只隔一年,但 UI 介面上已經有些差距,我還是再作一次介紹。

Analysis Method

首先先下載 SonarScanner,選擇正確的語言(Others)與 OS(Windows)後下載,
接著設定環境變數

Setting Path

最後開啟 CMD 切換到專案目錄底下後。
執行語法,如果照著上述步驟,你可以在 Download 的按鈕下方找到語法,同時它會幫你填好 Token。

Begin

1
dotnet sonarscanner begin /k:"$ProjectKey" /o:"$Organization" /d:"sonar.host.url=https://sonarcloud.io" /d:"sonar.login="$Sonar_Login

MSBuild

1
dotnet build ".\Marsen.NetCore.Dojo.Integration.Test.sln"

End

1
dotnet sonarscanner end /d:"sonar.login="$Sonar_Login

$ProjectKey$Organization 這兩個變數可以在 SonarCloud 的 Overview 介面的右下角找到,
$Sonar_Login 則可以透過 Security 設定。

執行命名完成後,大概幾秒內就可以在 SonarCloud 中看到結果了。

簡單總結一下

  1. 你要有 SonarCloud
  2. 要下載 SonarScanner
  3. 依序執行 Begin > MSBuild > End

另外有一些雷包,在這裡也記錄一下

  • 要安裝 Java (Java8)
  • 執行語法的目錄底下不能有sonar-project.properties
    • 不然會報錯 (sonar-project.properties files are not understood by the SonarScanner for MSBuild.)
    • 我覺得應該是我的檔案內容有誤,但是還不知道怎麼修正。總之直接移除對我來說是可以 work 的。

CI 執行代碼檢查

同上面的概念,只要讓你的 CI Server 在執行的過程中依序執行 Begin > MSBuild > End 即可,
參考 Github Action 的 yaml 檔

1
2
3
4
5
6
7
8
9
10
11
12
#上略
- name: Install Dotnet Sonarscanner
run: dotnet tool install --global dotnet-sonarscanner --version 4.8.0
- name: SonarScanner Begin
run: dotnet sonarscanner begin /k:"Marsen.NetCore.Dojo" /o:"marsen-github" /d:"sonar.host.url=https://sonarcloud.io" /d:"sonar.login="$SONAR_LOGIN
- name: Build with dotnet
run: dotnet build ".\Marsen.NetCore.Dojo.Integration.Test.sln"
- name: SonarScanner End
run: dotnet sonarscanner end /d:"sonar.login="$SONAR_LOGIN
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_LOGIN: ${{ secrets.SONAR_LOGIN }}

這裡要注意的是,
首先每次你都需要安裝 Dotnet Sonarscanner ,
其實我不清楚 Github Action 背後的機制,但是我猜測應該是用到容器化的技術,
每次 CI 執行時都會起一個實體(這個可設定,但是 Linux Like 的 OS 又快又便宜,就別考慮 Windows 了吧參考)
所以每次都要重頭安裝相關的軟體,比如 : Dotnet Sonarscanner 。

另外一點是,環境變數的設定,可以看到最後面的 env 變數。
這個是機制是將 CI 的設定傳到實體的環境變數之中。
在 yaml 中綁定要使用 $+變數名 EX: SONAR_LOGIN
其它的變數,你可以在 Github 的 Secrets 頁面設定。
另外 Github 有些預設的變數
更多資訊可以參考

設定成功後,每次進 Code 就能看到代碼的壞味道、重複或是資安風險等資訊囉。
可以參觀一下我的專案

Quality gate

參考

補充

(fin)

[實作筆記] 使用 IIS 作為 Reverse Proxy Server

情境

  1. 作業系統為 Windows 10
  2. IIS 的版本為 10.0.18362.1
  3. 前端使用 nodejs 開發了一個網站,會在 localhost:3000 執行,提供 UI
  4. 使用 dotnet core 開發了一個網站,在 IIS 上執行,用來提供 Api
  5. 透過鎖 Host 的方式 dotnet core 的網站綁定在 http://dev.api.test

目標

  1. 即使只有開發階段,我也不想看到 localhost:3000 作為我的網址
  2. 我想看到 http://dev.site.test 作為我的站台

本文

首先,鎖 Host 127.0.0.1 dev.site.test
Host 的檔案路徑為 C:\Windows\System32\drivers\etc

接下來請下載並安裝 URL RewriteApplication Request Routing

IIS 建立網站,繫結我設定為 dev.site.test:80
應用程式集區我沒有特別處。
到 IIS 選取站台的 Url Rewrite 新增規則
到 IIS 選取站台的 Url Rewrite 新增規則
選取 Reverse Proxy 規則

選取 Reverse Proxy 規則

填寫 localhost:3000

填寫 `localhost:3000`

這個時候前往 dev.site.test 就可以看到站台囉。

參考

(fin)

[實作筆記] 從 TDD 到 TDD ,Todo 到 Test 趨動開發(二)

上篇

異常處理的 Todo 項目

異常處理有幾種狀況,
一種是回傳的狀態有異常,
一種是回傳的資料有異常,
最後一種是超乎預期的異常,
比如說 Http 通訊上本身有問題。

再進一步分析這三種狀況,
我會寫下以下幾種情境

  1. 回傳的狀態有異常,記錄回傳的異常狀態,拋出 Exception
  2. 回傳的資料有異常,記錄回傳的異常資料,回傳空資料
  3. 超乎預期的異常,記錄異常資料, 拋出 Exception

測試案例 回傳的狀態有異常,記錄回傳的異常狀態,拋出 Exception

新增測試案例
這裡複製之前的測試案例,
再透過 inline Method 還原 arrange 部份的代碼,
再修改成我們想要的測試案例。

這裡我們先驗証拋出 Exception

1
2
3
4
5
6
7
8
9
10
11
12
[Fact]
public void Case6_Query_Error_Result()
{
_configService.GetAppSetting("pickup.service.url")
.Returns(UrlMockResultError);
_storeSettingService.GetValue(_testStoreId, "pickup.service", "loginId").Returns("testId");
_storeSettingService.GetValue(_testStoreId, "pickup.service", "auth").Returns("testAuth");
target = new PickupService(_configService, _storeSettingService);

Action act = () => target.GetUpdateStatus(_testStoreId, _testWaybillNo);
act.Should().Throw<Exception>();
}

Production Code 就單純很多了

1
2
3
4
if (obj.result == "error")
{
throw new Exception();
}

下一步我要驗証記錄 Log 的行為
出錯的時候應該呼叫 LogError 的方法

原本想直接驗証 LogError 有沒有被呼叫

1
2
3
4
5
6
7
8
[Fact]
public void Case7_Query_Error_Result_Should_LogError()
{
GetPickupServiceWith(UrlMockResultError);
target.GetUpdateStatus(_testStoreId, _testWaybillNo);
_logger.Received().LogError(Arg.Any<string>());
}

因為這裡會拋出 Exception ,
所以無法直接呼叫 GetUpdateStatus
要修改前一個測試

1
2
3
4
5
6
7
8
[Fact]
public void Case6_Query_Error_Result()
{
GetPickupServiceWith(UrlMockResultError);
Action act = () => target.GetUpdateStatus(_testStoreId, _testWaybillNo);
act.Should().Throw<Exception>();
_logger.ReceivedWithAnyArgs().LogError(default(string));
}

而 Production Code 很單純的加上 Logger 並調整建構子

1
2
3
4
5
6
7
8
9
10
11
12
13
-   public PickupService(IConfigService configService, IStoreSettingService storeSettingService)
+ public PickupService(IConfigService configService, IStoreSettingService storeSettingService, ILogger logger)
{
this._configService = configService;
this._storeSettingService = storeSettingService;
+ this._logger = logger;
}

if (obj.result == "error")
{
+ this._logger.LogError(obj.result);
throw new Exception();
}

回傳的資料有異常,記錄回傳的異常資料,回傳空資料

測試案例

1
2
3
4
5
6
7
[Fact]
public void Case7_Query_Error_Content()
{
GetPickupServiceWith(UrlMockContentError);
var actual = target.GetUpdateStatus(_testStoreId, _testWaybillNo);
actual.Should().BeEmpty();
}

調整代碼以通過測試

1
2
3
4
5
6
7
+    if (string.IsNullOrEmpty(c.ErrorCode))
+ {
switch (c.Status)
{
////略…
}
+ }

超乎預期的異常,記錄異常資料, 拋出 Exception

測試

1
2
3
4
5
6
7
8
[Fact]
public void Case8_Query_Exception()
{
GetPickupServiceWith(UrlMockException);
Action act = () => target.GetUpdateStatus(_testStoreId, _testWaybillNo);
act.Should().Throw<Exception>();
_logger.ReceivedWithAnyArgs().LogError(default(string));
}

Production Code 就直接整個用 try Catch 包起來再記 Log

實務上的案例

這裡補充一些實務上的情境,

  1. 呼叫狀態查詢時,對方的 API 只允許同查詢 100 筆 WayBillNo
  2. 呼叫 API 後多了幾種文件外的狀態需要處理
    • D → Finish
    • F → Finish
    • E → Abnormal

單元測試現身

現在我已經有一些整合測試作保護了,
但是想要修改或重構仍然很麻煩,
原因是我每次有新的情境就需要準備新的 Mock API(實務上我需要準備符合情境的 WayBillNo),
透過 Todo 與整合測試,已經讓我們的代碼有了雛型。
在一切太晚之前,我們需撰寫單元測試。

Do TODO 建立單元測試

這裡小小提個 Visual Studio 2019 的小問題 ,
預設只會安裝 MSTest 的 Generator ,
這裡我要安裝 XUnit 的 Generator
安裝完成後再透過 Code Generator 產生第一個測試,紅燈。

當然這種 Generator 產生的 Code 不是實際要的測試案例
調整一下測試案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ILogger logger = Substitute.For<ILogger>();
IStoreSettingService storeSettingService = Substitute.For<IStoreSettingService>();
storeSettingService.GetValue(Arg.Any<long>(), "pickup.service", "auth").Returns("FakeAuth");
IConfigService configService = Substitute.For<IConfigService>();
configService.GetAppSetting("pickup.service.url").Returns("https://test.com/");

var target = new PickupService(configService, storeSettingService, logger);

var actual = target.GetUpdateStatus(2, new List<string> {"TestWayBillNo"});
actual.Should().BeEquivalentTo(new List<ShippingOrderUpdateEntity>
{
new ShippingOrderUpdateEntity
{
AcceptTime = new DateTime(2020, 03, 03, 17, 51, 20),
OuterCode = "TestWayBillNo",
Status = StatusEnum.Finish
}
});

Legacy Code 相依 HttpClient

大部份的功能我都可以透過 DI 的手段隔離,
但是之前的 Test Driven Develop 的方法並沒有將 HttpClient 轉換成可以隔離的物件。
另外一部份代碼是透過 Copy Paste 手法產生的代碼,所以也有可能會有 Legacy Code。
這裡我優先處理 HttpClient 。

首先我要重構一小段代碼,所幸之前的整合測試可以保護我這段重構

1
2
3
4
5
6
7
8
+       internal HttpClient HttpClient;
private const string DeliveryOrder = "DeliveryOrder";

try
{
var result = new List<ShippingOrderUpdateEntity>();
- var httpClient = new HttpClient();
+ this.HttpClient?? = new HttpClient();

在測試的保護下,我要逐步修改我的 HttpClient ,
好讓我的單元測能夠通過。
其實我目前的單元測試還未完成,所以可以先 Skip 掉,
等 HttpClient 隔離完成後再回頭完成單元測試。

隔離 HttpClient

這裡我要回顧一下,之前在作 Kata_Api_Pay 的時候,
我在 Production Code 建立了 IHttpClient 的介面,
用於隔離 HttpClient
我可以延用 HttpClient 但是因為我未實作 DefaultRequestHeaders 欄位,
這會導致一些錯誤;
雖然我可以一併調整但是這樣我要同時面對兩份遺留代碼,
我認為這樣的風險太大,而且使用 IHttpClient 目前看起來出現一些問題。

  1. 雖然抽出介面,但依賴在 HttpClient 之上,未來有功能不足或未實作 HttpClient 的功能就仍需要調整。
  2. 最初的目的其實是為了隔離,而隔離的目的是為了好測試,這些代碼卻放在 Production Code 上實在很奇怪。

基於以上種種理由,我要重新作一次隔離。
要達到幾個目地。

  1. 真正的與 HttpClient 解耦,未來再有用到 HttpClient 的任何方法/欄位皆不影響即有代碼。
  2. 將這類的工具放到正確專案 TestingToolkit 之下,不再影響 Production Code

首先允許測試專案存取 Production Code 的 Internal 欄位

1
2
+       [assembly: InternalsVisibleTo("Marsen.NetCore.Dojo.Tests")]
namespace Marsen.NetCore.Dojo.Kata_PickupService

下一步,偽造 HttpClient 的回傳值,
我們可以透過 HttpClient 的建構子作到這件事。
參考這篇文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
target.HttpClient =
new HttpClient(
new MockHttpMessageHandler(JsonSerializer.Serialize(
new ResponseEntity
{
Result = "",
Content = new List<Content>
{
new Content
{
ErrorCode = string.Empty,
Status = Status.DONE,
lastStatusDate = "2020-03-03",
lastStatusTime = "17:51:20"
}
}
}),
HttpStatusCode.OK));

偽造 HttpClient 的回傳值後,我就可以把單元測試的部份完成,
案例蠻多的,但是大同小異也沒有什麼特別的技法,
就不多贅述。

稍微提一下,反而在寫 Unit Test 過程中,
發現了 Production Code 一些 Over Design 的代碼。

比如說,多餘的邏輯分支,在某些因果條件,跟本不可能被執行到的代碼。
我視作無用的代碼將他移除。

另外也有發現一些 Entity 在呼叫 API 的過程不會取用它的資料或欄位,
也許有得人會想要移除這些 Entity ,但我會傾向保留,
原因是這些 Entity 是在整合測試階段被趨動出來的,
雖然沒有用到而且會使代碼的覆蓋率下降,
但是我認為這些代碼很有可能再下一個階段就會被用到,
在不影響功能的情況我不會刻意移除。

整體而言,測試已 100% 覆蓋,
也記錄了如何從 Todo Driven 到 TDD 的想法與技巧。
最後整理一下代碼,
把 MockHttpMessageHandler 搬到 TestingToolkit。
最後回頭把 api Pay 對 HttpClient 的處理調整一下就大功告成啦。

參考

(fin)

[實作筆記] Macbook SSH 設置與疑問

前情提要

最近轉換了一下跑道 ,
剛好又了一點時間就想說順便換一下 OS 學一點新東西,
就敗了一台 MacBook ,  這幾天就忙著整理開發環境  。
在 ssh 上卡住了點 , 就作點記錄順便上來提問  。

情境

我安裝了 git-fork 作為我的 Git GUI 工具。
裡面有一個很方便的功能可以快速的設定 ssh ,

fork

於是我很輕鬆愉快的設定了一組 ssh key 我命名為 MacBook
也可以很正常的在 fork 裡面作一些 git 的操作 ,
比如說 fetch push pull 等。

不過人在江湖飄, 哪有不挨刀。
身為一個開發者與一個 Git 的愛用者,
有很多情境是會離開 GUI 工具操作 Git 的 。
但是很奇怪,只要離開了 fork 我的 Git 部份指令就會失效,
錯誤訊息如下

1
2
3
[email protected]: Permission denied (publickey).
fatal: Could not read from remote repository.
Please make sure you have the correct access rights and the repository exists.

很明顯是權限不足的原因。
但是我查找了 ~/.ssh 資料夾 ,
我設定的 private key MacBook 確實存在。

所以我透過一個指令來取得更多資訊

ssh -vT [email protected]

輸出如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
marsen@MarsendeMacBook-Pro ~ % ssh -vT [email protected]
OpenSSH_7.9p1, LibreSSL 2.7.3
debug1: Reading configuration data /etc/ssh/ssh_config
debug1: /etc/ssh/ssh_config line 48: Applying options for *
debug1: Connecting to github.com [192.30.253.112] port 22.
debug1: Connection established.
debug1: identity file /Users/marsen/.ssh/id_rsa type -1
debug1: identity file /Users/marsen/.ssh/id_rsa-cert type -1
中間省略...
debug1: Trying private key: /Users/marsen/.ssh/id_ecdsa
debug1: Trying private key: /Users/marsen/.ssh/id_ed25519
debug1: Trying private key: /Users/marsen/.ssh/id_xmss
debug1: No more authentication methods to try.
[email protected]: Permission denied (publickey).

幾個奇怪的地方
一個是 identity file 或是 Trying private key 的檔案我都找不到 ,
另一個奇怪的點是 ~/.ssh/MacBook 這組我剛剛建立的 private key 明明存在 ,
我確找不到他顯示在 identity file 或是 Trying private key 的記錄之中
總而言之,最後的訊息仍然是

1
[email protected]: Permission denied (publickey).

解決方法

指令 ssh-add -K MacBook 執行後, 就可正常運作。
小小猜測一下 , 應該是 fork 運作的環境與一般 terminal 的環境有所差異,
所以需要額外透過指令加上 private key 才能夠執行。

不過我仍有疑問, fork 的設定在什麼地方可以調整呢 ?
另一個問題是 log 裡面這些不存在 private key 是在哪裡設定的 ?
查找過 /etc/ssh/ssh_config 裡面並沒有相關的設定

1
2
3
4
5
debug1: Will attempt key: /Users/marsen/.ssh/id_rsa
debug1: Will attempt key: /Users/marsen/.ssh/id_dsa
debug1: Will attempt key: /Users/marsen/.ssh/id_ecdsa
debug1: Will attempt key: /Users/marsen/.ssh/id_ed25519
debug1: Will attempt key: /Users/marsen/.ssh/id_xmss

希望有人能有答案或提供文件可以參考一下。
不求甚解繼續玩 Mac 去。

後記

20200407 : 在安裝 git-fork 的時候 , 它會另外安裝一個 git 實體 ,
所以與 global 環境的 git 不會是同一個 , 導致在 git-fork 的行為不一致 。
另外之所以使用 ssh 作為連線手段 , 是因為以前使用 SourceTree 作為 Git Gui Tool 時
常常會 Https 的密碼過期而需要重新輸入 , 如果信任 git-fork 的開發者 ,
可以考慮使用 Https 連線 , 看起來 git-fork 會記錄你的連線資訊 ,
而不會在每次操作 git 時詢問你密碼。

(fin)

[實作筆記] 從 TDD 到 TDD ,Todo 到 Test 趨動開發(一)

前情提要

上次作完金流後,這次換作物流,
在開發的過程中首次全程用 TDD 進行(?),
因為某些因素,測試沒有進版控,所以稍微記錄一下心路歷程。
但是那個 T 是什麼? 就請各位看下去了…

需求說明

出貨流程為:

  1. 訂單成立
  2. 透過物流服務配號
  3. 出貨
  4. 透過物流服務查詢物流狀態
  5. 狀態正確,通知消費者取貨
  6. 狀態不正確,通知商家,人工處理。

這次 TDD 進行的部份為流程上的第 4 步,
往下看我怎麼做的

整體流程如下

流程

Step0 . 這次不是從無到有,而是在遺留代碼之中建立測試,
所以我會準備一些「遺留代碼」來呈現我面臨的狀況。

Step1 . 這次 TDD 先不是 Test 而 TODO,
直接對 Production Code 寫下 Todo List,
這個 TODO 的過程其實就是一種分析,一種需求拆分。

這裡我先簡單拆成兩步,

  1. 打 API 問狀態
  2. 將問到的資料轉換成回傳資料

Step 2 . 隨著過程把 TODO 拆的更細

  1. 打 API 問狀態
    1. 建立 HttpClient
    2. 建立 auth
    3. 準備 HttpContent 資料
    4. 指定 API URL
    5. 呼叫

以往這個時候,我就會進開發了,
這次我不打算刻意改變我的開發習慣,
但是我會多作一件事,寫測試。
這個測試會直接呼叫我即將開發的方法,
而我的方法會真的去打 API 存取 DB 讀 Config Files 諸如此類的事情。

測試

第一個測試,但是沒有 Assert

我目前對測試案例沒有任何的想法(這是個壞味道),
但是我打算直接透過測試呼叫我的 Production Code

1
2
3
4
5
6
7
8
[Fact]
public void Case1_Just_Run()
{
var target = new PickupService();
long storeId = 0;
List<string> waybillNo = new List<string>();
target.GetUpdateStatus(storeId, waybillNo);
}

因為沒有想法,所以沒有 Assert
這不算是測試,頂多是一個小工具可以隨時呼叫我的 Production Code 而已

Do Todo 建立 HttpClient

這裡依造我以前的開發習慣,直接開幹,
把 HttpClient new 出來,刪除 Todo Comment
Committed 然後發 Pull Request

1
2
-            //// TODO 1.建立 HttpClient
+ var httpClient = new HttpClient();

Do Todo 建立 auth

通常在串接第三方服務的過程中,
第三方會提供沙盒(SandBob)作開發人員測試使用
這裡我加了一點遮罩,但實務上如果是沙盒的 auth 資訊
我可能會直接 Commit 進去(壞味道)。

注意!! 這時候還是 Production Code 喔
我可以在測試加個 TODO ,
未來這段應該被 Mock 而不是 Hard Code 寫死。

1
2
3
4
5
-           //// TODO 2.建立 auth
+ //// TODO login id 抽參數
+ httpClient.DefaultRequestHeaders.Add("login_id", "testId");
+ //// TODO authorization 抽參數
+ httpClient.DefaultRequestHeaders.Add("authorization", "testAuth");

Do Todo 準備 HttpContent 資料

準備 HttpContent 有很多種方式,
這裡我選擇 StringContent 來實作。
所以要包含物件轉換成 Json String 的行為,
需要參考 JsonSerializer 。
如果是不太熟悉的開發人員可能會另開 TODO,
但是我這裡就一次性的作掉了 。

1
2
3
4
-           //// TODO 3.準備 HttpContent 資料

+ var requestContent = JsonSerializer.Serialize(new { Type = "DeliveryOrder", waybillNo });
+ var httpContent = new StringContent(requestContent, Encoding.UTF8, "application/json");

Take a break

稍微休息一下,這裡我的開發流程基本上沒有改變,
除了多寫一個(整合)測試,而且每次都會稍微跑一下測試,
這個測試其實沒有 Assert ,唯一的幫助只能驗証執行方法時沒有 Exception

重構

重構應該落在開發之中,我看到兩個小問題

  1. 我會 inline 掉多餘的參數 requestContent
  2. Type = “DeliveryOrder” 對我來說是個 magic variable ,我會加 TODO 預計未來抽成常數(壞味道,Why not now ?)

Do TODO 4.指定 API URL & TODO 5.呼叫

修改代碼如下,拿到第一個紅燈
因為我沒有指定 url

1
2
3
//// TODO 4.指定 API URL
string url=string.Empty;
var responseMessage = httpClient.PostAsync(url, httpContent).Result;

為了修正這個紅燈我會指定 url,
實務上我會使用沙盒的 url ,
這裡我先用 mock api 取代 ,
mock api 的服務為 Mocky
類似的服務很多,也不是本篇的重點,就不贅述了。

這次一次處理掉兩個 TODO ,
表示當初我 TODO Task 切的過小,
下次可以切大一些。

不算壞味道,就當學個經驗。

第一次 TODO 作完之後

當初規劃的 TODO Task 都作完了,
但是其實工作並沒有完成。

我會再作進一步的分析,
可以看到原本的 TODO 產生了更多的 TODO ,
另外打完 API 後的處理也是個問題。

這邊要用到 walking skeleton 的概念,
簡單說就是,先打通再迭代。

前面產生的 TODO 項目並不是「最」重要的,
我應該先處理回傳的資料,讓整件事情串通。
開立 TODO 如下

1
2
3
+ //// TODO Parse Response Entity
+ //// TODO Switch Status
+ //// TODO Return ShippingOrderUpdateEntity List

可以得知,我最終會回傳一包 List,
這個時候我可以 Assert 了

修改第一個測試案例

這個階段我開始撥雲見日,我要很明確的寫下第一個測試案例,
第一個案例我會直接作 Happy Case ,
也就是目前的呼叫的 API
只打一筆,回傳 Done 的資料。

這裡進一步作需求分析,
呼叫完 API 我會收到一大包 JSON 資料,
需要轉成我可以處理的物件,
其中最重要的欄位 lastStatusId 會回傳各種狀態,

  • DONE
  • FAIL
  • Arrived
  • Shipping
  • SMS
  • Expiry

我只處理

  • 已取貨(DONE) 系統狀態為 Finish
  • 失敗(FAIL、Expiry) 系統狀態為 Abnormal
  • 貨到待取(Arrived) 系統狀態為 Arrived
  • 出貨中(Shipping) 系統狀態為 Processing

分析後,我的測項將會是向 API 循問一筆資料
且回傳一筆為 Done 的 ShippingOrderUpdateEntity 給我。

1
2
3
4
5
6
7
8
9
[Fact]
public void Case1_Query_Done_waybillNo()
{
var target = new PickupService();
long storeId = 1;
List<string> waybillNo = new List<string> {"TEST2002181800010"};
var actual = target.GetUpdateStatus(storeId, waybillNo).FirstOrDefault().Status;
actual.Should().Be(StatusEnum.Finish);
}

拿到紅燈 ,Do TODO Parse Response Entity

如果是 Test Driven 我可能會速解再加案例,
但是我現在是 Todo Driven 所以造著 Todo 作事,
透過 json2csharp 快速產生 Entity 來轉置 JSON 資料。

如何從 T(odo)DD 到 T(est)DD

寫到這裡我已經開始感覺 Todo 的挶限性了,
由於這個方法職責不分,所以要測試是困難的,
但是 Todo 的作法是無法趨動改變的。

現在開始,試著把每個 Todo Task 改變成 Test Case

第二個測試案例開始之前

再次說明一下商務需求,我可以提供 waybillNo 向 API 循問物流的狀態。
理想上我會擁有一堆不同貨態的 waybillNo ,剛好可以作我測試的案例
但是在實務上,我拿不到這些案例, 我折衷的作法是透過 Dummy API 來偽裝回傳值。
這仍是整合測試的一種,雖然我可以 Mock API 的回傳值,
但在網路狀況異常下,測試仍會紅燈

在作 Dummy API 的前提下,要能抽換 URL
所以我會先作 TODO url 抽參數

重構如下:

Production Code

1
2
3
4
-        //// TODO url 抽參數
- //// string url= "http://www.mocky.io/v2/********";
- string url = "http://www.mocky.io/v2/********";
+ string url = this._configService.GetAppSetting("pickup.service.url");

Test Mock Return Value

1
2
3
4
var configService = Substitute.For<IConfigService>();
configService.GetAppSetting("pickup.service.url")
.Returns("http://www.mocky.io/v2/********");
var target = new PickupService(configService);

進一步增新測試的可讀性

可讀性真的是一個很抽象的觀念,之後有機會再深入探討
我的修改如下,主要的想法是「讓測試案例可以像對話般被閱讀」

1
2
3
4
5
6
[Fact]
public void Case1_Query_Done_waybillNo()
{
var actual = QueryWithDoneWaybillNo();
actual.Should().Be(StatusEnum.Finish);
}

第二個測試案例 - 出貨中(Shipping)

這裡就是用簡單的 Test Case 趨動 Production Code 的代碼生成

Test Code

1
2
3
4
5
6
[Fact]
public void Case2_Query_Shipping_waybillNo()
{
var actual = QueryWithShippingWaybillNo();
actual.Should().Be(StatusEnum.Processing);
}

Production Code (部份)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-           //// TODO Switch Status
- //// TODO Return ShippingOrderUpdateEntity List
- result.Add(new ShippingOrderUpdateEntity {Status = StatusEnum.Finish});
+ foreach (var c in obj.content)
+ {
+ switch (c.lastStatusId)
+ {
+ case "DONE":
+ result.Add(new ShippingOrderUpdateEntity {Status = StatusEnum.Finish});
+ break;
+ case "Shipping":
+ result.Add(new ShippingOrderUpdateEntity {Status = StatusEnum.Processing});
+ break;
+ }
+ }

順手再重構了一下測試,
希望能提高可讀性

1
2
3
4
5
6
[Fact]
public void Case2_Query_Shipping_waybillNo()
{
var actual = QueryWaybillNoWith(UrlMockShipping);
actual.Should().Be(StatusEnum.Processing);
}

剩下的測試案例

  • 失敗(FAIL) 系統狀態為 Abnormal
  • 失敗(Expiry) 系統狀態為 Abnormal
  • 貨到待取(Arrived) 系統狀態為 Arrived

剩下的 TODO 項目

這個時候基本的功能都好了,來收拾一下剩下的 TODO 項目吧
主要都是取得設定值的功能,實務上這些設定值可能來自不同的服務
Database、Config Service 或 Setting API 等…
我在這裡先簡化成 IStoreSettingService 取值就好。

1
2
3
4
5
-           //// TODO login id 抽參數
- httpClient.DefaultRequestHeaders.Add("login_id", "testId");

+ var loginId = this._storeSettingService.GetValue(storeId,"pickup.service","loginId");
+ httpClient.DefaultRequestHeaders.Add("login_id", loginId);

同時測試代碼也要修改,
注意這裡的 testId 其實是 Pickup Service 提供給我們的測試 Id
在 Production 你需要整合進你的 Database、Config Service 或 Setting API 裡
在整合測試可以直接 mock 它

1
_storeSettingService.GetValue(_testStoreId, "pickup.service", "loginId").Returns("testId");

Auth 也是相同的處理

1
2
3
4
-           //// TODO authorization 抽參數
- httpClient.DefaultRequestHeaders.Add("authorization", "testAuth");
+ var auth = this._storeSettingService.GetValue(storeId,"pickup.service","auth");
+ httpClient.DefaultRequestHeaders.Add("authorization", auth);

Testing Mock

1
_storeSettingService.GetValue(_testStoreId, "pickup.service", "auth").Returns("testAuth");

心得小結

TDD 不一定要用單元測試,
你可以試著從 T(Todo) Driven Development 到 T(Test) Driven Development
思考一下你目前的開發方式,
不要急著 TDD ,想像一下你開發到什麼程度會想要驗証(測試),
試著在這個時間點加上測試就好,
這樣的開發方式,會比較貼近你的開發方式,
同時也可以練習寫測試,你會發現很多問題。
比如說:

  • 你跟本沒有足夠了解需求,導致你寫不出驗收條件。
  • 你根本不熟悉測試框架或是相關的 Library。
  • 甚至你跟本不熟悉你賴以為生的開發工具與程式語言。
  • 或是你跟本不會 Debug 跟 Google 而且以前你一直以為你會。
  • 你把代碼搞得一蹋糊塗,而且沒有人愛你

下一次試著先寫測試吧,改變一下自已的工作方式。
平順把你的開發方式轉換成 TDD 吧 。

下一篇我會透過整合測試,發現一些異常狀況,
並試著加上測試案例,並試著趨動。

(fin)

[實作筆記] 用 TDD 寫一個 API Pay

需求說明

金流系統透過打 API 與第三方介接來進行付款,
為了追蹤金流,在打 API 的過程中,業務單位要求要帶著 RequestId 。
再進行付款。
而 RequestId 由另一個專門負責提供 RequestId 的 API 來提供。

整體流程如下:

  1. 打 API 取得 RequestId

    1
    GET {{url}}/api/{{version}}/requestId
  2. 組合付款資料與 RequestId

  3. 打 API 完成付款

    1
    POST {{url}}/api/{{version}}/pay/CreditCard/{{transationId}}

第一個 Case,Pay 的時候應該呼叫 GET reguestId 1 次

問題,我需要驗証 HttpClient 呼叫的 url次數

一開始會寫成這樣,

1
2
3
4
5
6
7
[Fact]
public void pay_should_Get_requestId()
{
var target = new PaymentService();
target.Pay();
httpClient.Received().GetAsync("https://testing.url/api/v1/requestId");
}

我本來就預計使用 HttpClient 來呼叫 API,
但是直接使用 HttpClient 會直接產生耦合,
所以我建立一個介面來包裝它。

1
2
3
4
public interface IHttpClient
{

}

接著馬上建立類別 HttpClientProxy 實作 IHttpClient,
這個時候我會知道我會使用 GetAsync 的方法,
所以我會讓 IHttpClient 長出這個同名方法,
實作很單純,就是呼叫 HttpClient().GetAsync 方法。

幾個想法,
這樣算是 Proxy Pattern 嗎 ? 我覺得算是:P
另一點,這個階段我會擔心 HttpClient 的問題,
不處理是對的嗎 ?
如果不刻意處理的話 HttpClientProxy 好像會長不出來

1
2
3
4
5
6
7
8
9
10
11
public interface IHttpClient
{
Task<HttpResponseMessage> GetAsync(string requestUri);
}
public class HttpClientProxy : IHttpClient
{
public Task<HttpResponseMessage> GetAsync(string requestUri)
{
return new HttpClient().GetAsync(requestUri);
}
}

完成這階段的修改後,我才可以透過 Framework 來 Mock IHttpClient,
寫好的測試如下,順利拿到第一個紅燈:

1
2
3
4
5
6
7
8
[Fact]
public void pay_should_Get_requestId()
{
IHttpClient httpClient = Substitute.For<IHttpClient>();
var target = new PaymentService(httpClient);
target.Pay();
httpClient.Received().GetAsync("https://testing.url/api/v1/requestId");
}

馬上修改 Production Code ,拿到綠燈。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class PaymentService
{
private readonly IHttpClient _httpClient;
public PaymentService(IHttpClient httpClient)
{
_httpClient = httpClient;
}

public void Pay()
{
_httpClient.GetAsync("https://testing.url/api/v1/requestId");
}
}

第二個 Case,Pay 的時候應該呼叫 POST Pay CreditCard 1 次

測試案例:

1
2
3
4
5
6
7
8
[Fact]
public void pay_should_Post_Pay_CreditCard()
{
IHttpClient httpClient = Substitute.For<IHttpClient>();
var target = new PaymentService(httpClient);
target.Pay();
this._httpClient.Received().PostAsync("https://testing.url/api/v1/pay/CreditCard", Arg.Any<HttpContent>());
}

修改 Production Code

1
2
3
4
5
6
public void Pay()
{
var readAsStringAsync = this._httpClient.GetAsync("https://testing.url/api/v1/requestId");

this._httpClient.PostAsync("https://testing.url/api/v1/pay/CreditCard", null);
}

重構測試

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
[Fact]
public void pay_should_Get_requestId()
{
WhenPay();
ShouldGetRequestId();
}

[Fact]
public void pay_should_Post_Pay_CreditCard()
{
WhenPay();
ShouldPayByCreditCard();
}

private void WhenPay()
{
var target = new PaymentService(_httpClient);
target.Pay();
}

private void ShouldGetRequestId()
{
this._httpClient.Received(1).GetAsync($"{_testingApiUrl}requestId");
}

private void ShouldPayByCreditCard()
{
this._httpClient.Received(1).PostAsync($"{_testingApiUrl}pay/CreditCard",
Arg.Any<HttpContent>()));
}

第三個 Case,Pay 的時候應該先呼叫 Get RequestId 再 POST Pay CreditCard

1
2
3
4
5
6
7
8
9
10
[Fact]
public void pay_should_Get_RequestId_Before_Post_Pay_CreditCard()
{
WhenPay();
Received.InOrder(() =>
{
ShouldGetRequestId();
ShouldPayByCreditCard();
});
}

想法,有了第三個案例,我還需要前面兩個案例嗎 ?

下一步,調整 ShouldPayByCreditCard 的 Assert 邏輯,
原因是實務上我必須將 RequestId 帶入 Post Pay 時的 HttpContent 裡面。

1
2
3
4
5
private void ShouldPayByCreditCard()
{
this._httpClient.Received(1).PostAsync($"{_testingApiUrl}pay/CreditCard",
Arg.Is<HttpContent>(x => x.ReadAsStringAsync().Result.Contains(_testRequestId)));
}

Prodouction Code 因而長出 PayEntity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void Pay()
{
var requestId = this._httpClient.GetAsync("https://testing.url/api/v1/requestId").Result.Content
.ReadAsStringAsync().Result;
HttpContent content = new StringContent(
JsonSerializer.Serialize(
new PayEntity
{
RequestId = requestId
}));

this._httpClient.PostAsync("https://testing.url/api/v1/pay/CreditCard", content);
}

public class PayEntity
{
public string RequestId { get; set; }
}

Case4 組合資料邏輯

4.1 組合 PayEntity 的邏輯

這次我假設外部的元件已組合好 PayEntity 傳入 PaymentService.Pay 方法,
唯一的組合邏輯就只剩 RequestId。
至於外部的 PayEntity 組合邏輯如何用 TDD 長出 Production Code 可以參考這篇

1
2
3
4
5
6
private void WhenPay()
{
var target = new PaymentService(_httpClient);
target.Pay(new PayEntity());
}

1
2
3
4
5
6
7
8
9
public void Pay(PayEntity payEntity)
{
var requestId = this._httpClient.GetAsync("https://testing.url/api/v1/requestId").Result.Content
.ReadAsStringAsync().Result;
HttpContent content = new StringContent(
JsonSerializer.Serialize(
payEntity.RequestId = requestId));
this._httpClient.PostAsync("https://testing.url/api/v1/pay/CreditCard", content);
}

最後,將 api 的 url 也抽成可參數化。

1
2
3
4
5
6
7
8
9
10
11
12
public PaymentServiceTests()
{
this._httpClient = Substitute.For<IHttpClient>();
this._httpClient.GetAsync(Arg.Any<string>()).ReturnsForAnyArgs(
Task.FromResult(
new HttpResponseMessage
{
Content = new StringContent(_testRequestId)
}));
this._configure = Substitute.For<IConfigure>();
this._configure.Setting("PayService.Url").Returns(_testingApiUrl);
}

Production Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public PaymentService(IHttpClient httpClient, IConfigure configure)
{
this._httpClient = httpClient;
this._configure = configure;
}

public void Pay(PayEntity payEntity)
{
var apiUrl = this._configure.Setting("PayService.Url");
var requestId = this._httpClient.GetAsync($"{apiUrl}requestId").Result.Content
.ReadAsStringAsync().Result;
HttpContent content = new StringContent(
JsonSerializer.Serialize(
payEntity.RequestId = requestId));

this._httpClient.PostAsync($"{apiUrl}pay/CreditCard", content);
}

小結

在 HttpClient 那邊卡蠻久的,用介面的方法包裝起來也不知道是否合適。
網路上有提供許多不同的作法,單元測試的 TDD 好像趨動不太出來 Production Code
是否需要加入整合測試,甚至是透過呼叫 Production Code 去打 API 作端到端測試,來趨動開發 ?
TDD 的 T 是不是不只是 Unit Test 呢 ?

參考

(fin)

[閱讀筆記] 開刀房裡的沉思 -- 一位外科醫師的精進

前言

因為「學徒模式」在最後的章節多次提到這本書,
於是去了圖書館借來翻了一下,內容大多與醫療與醫生相關,
作者在醫療上如何追求卓越,精益求精 ?
作為一個開發人員,能否借鏡不同產業的精進旅程帶來一些反思 ?

簡單先作個小結,作者認為精進有三個基本要素

  1. 努力不懈,簡單卻最困難,如何堅持?
  2. 務求正確, 這裡面臨了人性的挑戰,包含貪心、防衛心理和堅持與適時放棄
  3. 創新,勇於認錯、願意改變、見賢思齊

引述一小段。

我們不只是活在一個人的世界,
必須和別人一起工作,與科學一起進步,
也活在混亂、複雜的現實世界裡,而且任重道遠。
……
光是擔責任還不夠,選要想想如何能做得更好。

章節概要

CH 1 禍手

簡單卻作不到的事。

創新者的困境

山姆維茲雖然推論出醫生不洗手是產婦致死的原兇,
但在同業同事間推動卻因批判與溝通而大大的失敗,
成為了悲劇英雄。

現場執行的困境

  • 洗手的 SOP :首先缷下手錶,戒指…其次,用溫水潤濕雙手…最後沖水至少 30 秒…然後拿擦手紙…事實上沒有人能夠謹守這樣的步驟。
  • 「不問你為什麼不洗手,而是問你為什麼不能洗手 ?」答案多半是:沒時間。
  • 把病房變得像開刀房,使維持清潔變得容易,當他另謀高就後,原單位故態復萌、改革挫敗、白費功夫
  • 「正向偏差」:透過內部推廣取代指示改變,詢問現場一線取代宣導與命令(現場改善、由下而上)

反思:
現場如何推動單元測試與 Clean Code ?
只靠個人 ? 明星開發者 ? 嚴格的規定 ? 無處不在的口號標語 ?

Ch 6 薪事誰人知

醫師的困境是按件計酬,而存在與之對立的醫療保險業者,
原本醫療保險的立意是良善的,避免有的醫生漫天喊價。
但是時至今日,美國的醫療保險規則太過複雜,
引入第三方(保險業者)不但沒有幫助,反而變得更加複雜,
醫生無法得到合理的收費,沒有保險的病人無法得到醫療照護。

馬修.松頓保健計劃,在初期簡化與保險公司打交道的複雜性,
提昇了照護病人的成效,但在組織成長變大後,
諸多的規定與合約讓這個保險計劃也由保險集團接手,最終宣告結束。

Ch 9 艾卜佳評分表

對人類來說,生產一直是一件困難的事。
對新生兒與孕婦在生產的過程中一不小心就有生命危險,
單鈎、產鉗、多達數十種的助產法、剖腹產等…

二十世紀複,隨著知識與器械的進步,研究卻表明醫院生產的新生兒死亡率高於產婆接生
生產流程的革新與標準化才進一步確保了新生兒的生存率
艾卜佳評分表在這當中擔任關鍵的角色,而且相當簡單。
膚色、哭聲、呼吸、四肢活動力、心跳次數等,簡單的分為 0、1、2 三種評分,
這讓主觀評量變成了客觀,有了評量才有改善的方向。
新生兒加護病房、產婦脊髓麻醉與胎兒心跳監視器也相應而生。

反思 1 :
如何客觀評量績效 ?
書中說明產科的進步並不在嚴謹的「實證醫學」而是一個簡單的評分表。

那麼如何評量代碼質量 ?

  • 如何建立一個開發人員的評分表 ?
  • 如何建立代碼的艾卜佳評分表呢 ?

反思 2 :
產鉗在上個世紀是一門技藝,
儘管有研究表明剖腹產未必優於產鉗,但這門技藝仍就消失了。
如果產鉗是因為標準化的流程而消失,那軟體工程呢 ?
TDD 會是軟體工程裡的產鉗嗎 ? 2014 年的 TDD is died 仍值得回味再三。

績效又該如何轉換成合理的收入呢 ?

Ch 10 醫師的成績單

辛辛那提的醫生全力以赴,仍然只是中等平庸的醫院,
在本書中,醫生的技藝是呈現鐘型曲線。
作者表達的意思是,病人都想找最好的醫生,但在醫療的這個領域,
大多數都是平庸的。
在學徒模式書中,工程師是左傾的曲線,暗示大多數工程師都是拙劣的。
左傾的曲線

反思
雖然手頭上沒有完整數據佐証,但是一般醫生至少需要 7 年的專業訓練,
而軟體工程業界,中途出家的非本科工程師比比皆是,
一線從業人員的水平參疵不齊,甚至有達克效應

這個行業本身歷史很短也很年輕,
影響範圍卻相當的廣大,
整個世界各行各業軟體都能參予其中。
缺乏調研、認証相關機構,但是我相信在未來這點能夠逐步改善

Dunning-Kruger Effec

其它章節

  • 一個都不能漏
  • 浴血
  • 纏訟
  • 死刑室醫師
  • 戰鬥
  • 我的印度之旅

後記

2020 過年寫下這篇文章,幾個事件與書共鳴,
反思記錄一下。

先是 2020 年館長的新聞

館長已經揚言要提告了,他的需求是能同時十萬人上線的網站,
報價要 300 萬 + 30 萬壓測費,透過熟人介紹接案。
個人小小碎唸,在軟體業界常常有亂喊價,亂報時程的常態,
最後損失的往往是品質。

  • 盲信的後果,你沒有專業要如何相信對方呢 ? 反過來,你有專業要如何讓客戶相信你呢 ?
  • 本例中館長揚言要提告工程師,但我疑惑為什麼不是中間人、接案公司或業務而是工程師 ? 窗口真的是工程師嗎 ?
  • 壓測應該收多少錢 ? 10 萬人同時上線搶購,要作到什麼程度 ?
  • 資安避險應收多少錢 ? 要作到什麼程度 ?
  • 功能的缺失(帳務、發票 etc…)。如何驗收呢 ? 為何驗收失敗仍要行銷 ?

第二個反思,公開透明的組織運作,將會成為組織是否能夠進步改善的關鍵
2020 的武漢肺炎是反向的例子,

  • 武漢市長表示未被授權, 依法不能批露疫情。
  • WHO 錯誤評估風險等級
  • ICAO 屏避支持台灣的帳號
  • 陳秋實、面具人、口罩哥與黨與組織的各種輿情論戰。

這件事還沒有結束,就不多作評論了。
後續再作追踪。

最後,今年我的侄子出生了,
因為剛好讀到產婦相關章節,所以特別有感
願他平安成長。

(fin)

[實作筆記] 記錄用 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)

Please enable JavaScript to view the LikeCoin. :P