
忒修斯準備從特羅增前往雅典,與父親相認時,母親與外祖父都勸他搭船,因為海路安全得多,陸路則到處都是襲擊旅人的強盜與惡人。
他偏偏選了陸路,忒修斯不想繞開危險抵達雅典,而是打算沿途處理那些危害旅人的傢伙。
走到接近雅典時,他遇上了住在路旁的普羅克瑞提斯。
普羅克瑞提斯會招待經過的旅人,再把他們放上屋裡一大一小的兩張床,身材較矮的旅人會被放上長床,再用槌子把身體敲打拉長,身材高的則被放上短床,超出床沿的部分直接鋸掉。
他不是在替旅人找一張合適的床,而是強迫每個人配合自己訂下的尺寸。
普羅克瑞提斯成了忒修斯這趟陸路旅程遇到的最後一名惡人,忒修斯逼他躺上自己的床,用他對待旅人的方式殺了他,也讓這套只要求別人服從的規則,最後落回制定規則的人身上。
普羅克瑞提斯先認定只有一套標準,再把所有不同情況強迫修剪成同一個樣子。
我們在整理測試專案時,也很容易做出類似的事情。
看到別人的 GitHub 專案把 Unit、Integration、Functional Tests 拆得很漂亮,就覺得自己的專案也一定要照抄,或者公司十年前只有一個 MyApp.Tests,現在系統已經長成幾十個模組,還是規定所有測試只能塞進同一個 project。
目錄結構本來是要幫助人找到測試,不是讓測試去遷就某一張看起來很專業的床。
有一段時間,我們的測試專案其實很亂,每次想確認某段邏輯有沒有測試,隱約還記得:「這個以前好像有寫測試吧?」
接著開始搜尋 class 名稱,沒找到,再搜尋 method 名稱,也沒有,後來才發現測試是用另一個業務情境命名,還被放在完全沒想到的資料夾裡。
找了幾分鐘還沒有找到之後就想說:
「算了,我再寫一支比較快。」
測試多了雖也會變成一種維護上的問題,但解法不會是減少測試,是讓測試在需要的時候找得到、看得懂。
在 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

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

dotCover 的完整 IDE 整合屬於商業工具,另外也有免費的 Command Line Tools,Coverlet 則是開源選項,先看團隊真正需要的是什麼,再決定要不要付錢會比較好 XD。
工具的順序也很重要,命名與目錄先讓人能找到候選測試,IDE 則可以再幫我們縮小範圍跟維持開發的好心情 :)
其實維護測試很重要,同時也很容易被忽略。
專案怎麼拆、目錄怎麼放、測試怎麼命名,都不是為了做出人人看了都說專業的分層,而是為了讓下一個準備修改程式的人,能快速找到與這次修改相關的測試。
很常有人不解啊:
「我們維護 production code 都分身乏術了,連測試都做到這種程度,會不會太過分了?」
通常能找到志同道合、理解這樣做帶來好處的人並不多,畢竟當我們開始決定要帶來一些改變時,這時候我們就停不下來了,維護的東西也確實會變多,而如果在這時候覺得怕麻煩就避而退之,走回老路,那就失去能讓我們不斷延伸進步的機會了,除了軟體要迭代,我們也要跟著迭代的嘛!
沒有最好,只有適不適合!
接下來我們繼續看:測試放好了、名字也寫清楚了,但規格裡的內容,大家的理解真的一樣嗎?