iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Software Development

諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道系列 第 17

Day 17 -「普羅克瑞提斯之床」我明明寫過這個測試啊?

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260816/20182564Ue5orgUJbx.png

忒修斯準備從特羅增前往雅典,與父親相認時,母親與外祖父都勸他搭船,因為海路安全得多,陸路則到處都是襲擊旅人的強盜與惡人。

他偏偏選了陸路,忒修斯不想繞開危險抵達雅典,而是打算沿途處理那些危害旅人的傢伙。

走到接近雅典時,他遇上了住在路旁的普羅克瑞提斯。

普羅克瑞提斯會招待經過的旅人,再把他們放上屋裡一大一小的兩張床,身材較矮的旅人會被放上長床,再用槌子把身體敲打拉長,身材高的則被放上短床,超出床沿的部分直接鋸掉。

他不是在替旅人找一張合適的床,而是強迫每個人配合自己訂下的尺寸。

普羅克瑞提斯成了忒修斯這趟陸路旅程遇到的最後一名惡人,忒修斯逼他躺上自己的床,用他對待旅人的方式殺了他,也讓這套只要求別人服從的規則,最後落回制定規則的人身上。


混亂的測試專案

普羅克瑞提斯先認定只有一套標準,再把所有不同情況強迫修剪成同一個樣子。

我們在整理測試專案時,也很容易做出類似的事情。

看到別人的 GitHub 專案把 Unit、Integration、Functional Tests 拆得很漂亮,就覺得自己的專案也一定要照抄,或者公司十年前只有一個 MyApp.Tests,現在系統已經長成幾十個模組,還是規定所有測試只能塞進同一個 project。

目錄結構本來是要幫助人找到測試,不是讓測試去遷就某一張看起來很專業的床。

有一段時間,我們的測試專案其實很亂,每次想確認某段邏輯有沒有測試,隱約還記得:「這個以前好像有寫測試吧?」

接著開始搜尋 class 名稱,沒找到,再搜尋 method 名稱,也沒有,後來才發現測試是用另一個業務情境命名,還被放在完全沒想到的資料夾裡。

找了幾分鐘還沒有找到之後就想說:

「算了,我再寫一支比較快。」

測試多了雖也會變成一種維護上的問題,但解法不會是減少測試,是讓測試在需要的時候找得到、看得懂。


.NET 的測試專案

在 Java 專案中常把產品程式碼與測試目錄設成同一個 package 結構,物理上分開、邏輯上仍能鏡像對照,.NET 團隊則通常把 production code 與 test code 拆成不同的專案,這是常見且好用的組織方式,不是 C# 編譯器的硬性限制,同一個專案裡技術上仍然放得進測試,只是依賴、發佈內容就會比較難管理。

所以以 .NET 來看的話,通常要解決的問題會是「測試專案命名叫什麼、內部結構怎麼組織」。

常見的兩個方向:

MyApp.Tests              // 全部測試混在一個專案
MyApp.UnitTests          // 單元測試
MyApp.IntegrationTests   // 整合測試

MyApp.Tests 最單純,但單元測試和整合測試混在一起,CI 想分開執行,就得靠像 xUnit [Trait] 這類 metadata,再搭配 filter,拆成兩個專案會讓依賴與執行更好篩選。

[Trait]xUnit 提供的 Attribute,它可以替測試加上一組自訂的名稱與值,讓 test runner 知道這支測試屬於哪個分類,例如我們可以同時標記測試層級與功能模組:

[Fact]
[Trait("Category", "Unit")]
[Trait("Module", "Scheduling")]
public void 當指派班次會導致每週工時超過上限時_應拒絕該指派()

之後就可以依 Trait 的名稱和值篩選:

dotnet test --filter "Category=Unit"

而測試目錄可以優先鏡像 production 的模組、namespace 或功能邊界,但不需要強迫每個 production class 都有一個一對一的 Tests class:

// MyApp(production)
Scheduling/
  ShiftScheduler.cs
  OvertimePolicy.cs
Employees/
  EmployeeRepository.cs

// MyApp.UnitTests
Scheduling/
  ShiftSchedulerTests.cs
  OvertimePolicyTests.cs
Employees/
  EmployeeRepositoryTests.cs

鏡像結構的好處是,要找測試就不需要猜想,production class 在哪個模組或目錄,測試就在對應位置,在兩個 project 之間切換都不需要多想,我們團隊在開發時也很常採用這種方式。


沒有唯一正解,先決定主要索引

我去看了幾個 .NET 官方專案,實際上也沒有一套所有 repository 公定的「專業目錄」。

例如 dotnet/runtime 的 Libraries 中很明顯是以模組來組織專案目錄,隨便點一個System.Collections.Concurrent 來看,目前的主要目錄大致長這樣:

src/libraries/System.Collections.Concurrent/
├── ref/
├── src/
├── tests/
├── Directory.Build.props
└── System.Collections.Concurrent.slnx

production code 與 tests 都收在同一個 library 目錄裡,開發者修改某個 library 時,可以直接在該模組裡完成 build 與 test。

Microsoft 的 ASP.NET Core Integration Tests 範例 則是把應用程式放在 src/RazorPagesProject,測試專案放在 tests/RazorPagesProject.Tests,再於測試專案內繼續依測試用途與輔助程式分類。

如果現在只有幾十支快速測試,一個 MyApp.Tests 搭配清楚的目錄 就可能很夠用,當整合測試開始需要資料庫、容器或不同 CI 排程時,再拆 project 會比較好。

Microsoft 的 ASP.NET Core 文件 也建議把 unit tests 與 integration tests 拆成不同 project,以隔離基礎設施依賴與控制執行範圍,不過我比較想強調的是,拆 project 應該是在解決邊界問題,而不是把拆分本身當成目的。

若團隊平常是依模組分工,讓測試靠近各自的 production,通常又比全部集中在 solution 最底下更容易找。


測試名字

檔案結構怎麼放解決了,但測試方法叫什麼也很重要,最好是讓我們第一眼就可以看到這個測試在做什麼。

英文三段式命名在很多團隊中都會選擇採用:

[Fact]
public void CanAssignShift_WhenWeeklyHoursWouldExceedLimit_ShouldReturnFalse()

不過我們自己在 codebase 裡最後選了中文命名,理由是我們跟 PM 討論需求的語言是中文,測試名稱能直接對應到需求語言,CI 失敗的時候一眼就能告訴非技術的人「哪個行為沒過」:

[Fact]
public void 當指派班次會導致每週工時超過上限時_應拒絕該指派()

中文 method 名稱本身是合法的 C# identifier,但 CI log 能不能正常顯示,仍取決於 runner、終端字型與編碼設定。

當然也可以直接用 xUnit 中的 Display Name 替測試加上註解:

[Fact(DisplayName = "當指派班次會導致每週工時超過上限時_應拒絕該指派")]
public void CanAssignShift_WhenWeeklyHoursWouldExceedLimit_ShouldReturnFalse()

不過我們其實就是比較懶得同時維護兩套命名,不如就直接取中文好了,就看團隊的共識怎麼樣,主要是容易看懂就可以了。

命名慣例存在的理由不是讓專案「看起來整齊」,是讓測試在需要的時候找得到,讓寫測試這件事不要比不寫測試更費力,我覺得這蠻重要的。


有沒有什麼好工具

當然有。

關於好用的工具,我自己是蠻喜歡用 Rider IDE 中的 JetBrains dotCover 工具。

dotCover 有一個我最常用的功能,有 per-test data 的情況下,點一個程式碼段落就可以列出覆蓋它的測試,這個在做反向搜尋很有幫助,改一個地方之前可以先找候選測試。

我們執行測試時可以先選用 Cover All Tests

https://ithelp.ithome.com.tw/upload/images/20260816/20182564ClW7hHKuZv.png

執行測試後不只可以看到覆蓋率,且程式碼左側也可以明顯看到哪些有被覆蓋到,在找測試這方面算是蠻方便的!

https://ithelp.ithome.com.tw/upload/images/20260816/20182564gkZ0q3UChj.png

dotCover 的完整 IDE 整合屬於商業工具,另外也有免費的 Command Line Tools,Coverlet 則是開源選項,先看團隊真正需要的是什麼,再決定要不要付錢會比較好 XD。

工具的順序也很重要,命名與目錄先讓人能找到候選測試,IDE 則可以再幫我們縮小範圍跟維持開發的好心情 :)


總結

其實維護測試很重要,同時也很容易被忽略。

專案怎麼拆、目錄怎麼放、測試怎麼命名,都不是為了做出人人看了都說專業的分層,而是為了讓下一個準備修改程式的人,能快速找到與這次修改相關的測試。

很常有人不解啊:

「我們維護 production code 都分身乏術了,連測試都做到這種程度,會不會太過分了?」

通常能找到志同道合、理解這樣做帶來好處的人並不多,畢竟當我們開始決定要帶來一些改變時,這時候我們就停不下來了,維護的東西也確實會變多,而如果在這時候覺得怕麻煩就避而退之,走回老路,那就失去能讓我們不斷延伸進步的機會了,除了軟體要迭代,我們也要跟著迭代的嘛!

沒有最好,只有適不適合!

接下來我們繼續看:測試放好了、名字也寫清楚了,但規格裡的內容,大家的理解真的一樣嗎?

Reference


上一篇
Day 16 -「冥府之旅」別只用看的,透過草稿重構直接動手
下一篇
Day 18 -「德爾菲神諭」BDD?我知道啊!Given When Then 嘛!
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言