ASP.NET Core null 回應與 Model Binding:建立可測試的 API 契約
ASP.NET Core API 的回傳型別看似只是 C# 設計,實際上會直接改變 HTTP 狀態碼、回應本文與前端分支。當 action 回傳 null、空集合或 JsonResult(null) 時,三者並不等價;輸入 JSON 多了未知欄位,也不一定會報錯。本文以 .NET 9 的本機測試專案說明如何把這些隱性行為改寫成明確、可驗證的 API 契約。
為什麼 null 回應會讓 API 契約失真
ASP.NET Core null 回應的核心問題不是值為空,而是相同的 C# 空值可經由不同結果管線形成不同 HTTP 狀態與本文,呼叫端若只假設成功一定是 200 加 JSON,就會出現難以追查的分支落差。
MVC 控制器的具體回傳型別會交給輸出格式化流程。預設的 HttpNoContentOutputFormatter 可把 null 視為沒有內容,因此回傳具體物件型別但實際值為 null 時,常見結果是 204 No Content,而非帶有 JSON null 的 200 OK。這不是序列化器把字串吞掉,而是結果管線先決定不需要本文。
差異會一路傳到前端。只在 status === 200 時解析資料的程式,遇到 204 可能跳過成功邏輯;無條件執行 response.json() 的程式,則可能因空本文解析失敗。兩種狀況都可能把正常的「找不到資料」誤呈現為空白畫面、一般錯誤或無限載入。正確做法不是要求前端猜測,而是在 API 層先決定每個端點的空值語意。
回傳集合的端點通常應以空陣列表示「查詢成功但沒有項目」。[] 具有穩定的 JSON 型別,前端可以直接迭代,狀態碼也能維持 200。單筆查詢則應依領域語意選擇明確結果:資源不存在時回 404;資源存在但某個可空欄位沒有值時,仍回物件並讓該欄位為 null;若契約明確允許整個 JSON 值為空,則用可產生 200 與 null 本文的結果型別,並以整合測試固定行為。
三種空值結果應如何選擇
具體型別的裸 null、空集合與 JsonResult(null) 應視為三份不同契約:第一種預設可能成為 204,第二種是 200 加空陣列,第三種可明確形成 200 加 JSON null;選擇依據是資源語意,不是程式碼最短。
第一種寫法適合真的要表達「成功且無回應本文」的命令,但若方法名稱與回傳型別暗示會有 JSON,裸 null 就不夠清楚。讀者看到 Payload? 不一定知道執行階段會把它轉成 204,而且日後替換 formatter、改用 Minimal API 或改成 JsonResult,可觀察結果也可能改變。因此,公開契約不應只靠框架預設推論。
第二種寫法適合查詢清單。回傳 Array.Empty() 或同等空集合,能保持 JSON shape,省去每個客戶端自行把空值正規化的負擔。這項慣例也讓 OpenAPI 描述、測試斷言與 UI state 更一致:成功是陣列,失敗才走錯誤狀態,而不是讓 null 同時扮演沒有項目、查詢失敗與尚未載入三種角色。
第三種寫法用 JsonResult(null) 明確要求 JSON 結果。在本文的固定版本測試中,它形成 200 OK,本文為 null。這項結果應由測試保護,而不是被解讀成所有 ASP.NET Core 回傳模式都保證相同。若業務上「不存在」才是真正語意,直接回 NotFound() 通常比 200 加 null 更可讀;若更新成功但無需回傳資料,NoContent() 則比依賴 null 的隱含轉換更清楚。
建議在 API 設計檢視時建立一張結果矩陣,逐列記錄成功有資料、成功無項目、資源不存在、輸入無效與未預期錯誤的狀態碼及本文 shape。矩陣確認後,再讓 controller、OpenAPI、呼叫端與整合測試共同遵守,而不是先寫 action,等 UI 出現空白才回頭猜 framework 行為。
Model Binding 未知欄位為何需要契約測試
Model Binding 的風險在於「成功繫結」不代表輸入完全符合預期;JSON 中拼錯、改名或額外加入的欄位可能未進入模型,action 仍繼續執行,因此 HTTP 成功也可能伴隨遺失的篩選條件或預設值。
假設後端模型屬性是 ResourceTypeList,呼叫端卻傳送 resourceType。在預設 JSON 反序列化行為下,這個未知欄位可能被忽略,而 ResourceTypeList 保持 null。若端點只是回傳查詢結果,錯誤輸入不一定觸發 ModelState 問題;程式會以較寬鬆的條件執行,使用者看到的只是結果不符預期。這類錯誤靠編譯器抓不到,也不能只靠確認 HTTP 200 判定成功。
契約測試必須同時檢查輸出值與輸入邊界。對必要欄位,使用資料註解、可驗證 DTO 或應用層規則,讓缺值形成可觀察的 400。對選填欄位,測試應確認正確名稱會進入模型、錯誤名稱不會偷偷改變查詢語意。若團隊決定拒絕未知 JSON 成員,應在固定的 JSON 設定與版本下驗證拒絕結果;若仍採忽略策略,至少要讓端到端測試以真實 payload 保護欄位名稱。
DTO 也不應直接等同資料實體。輸入模型只列出端點允許的欄位,能降低導覽屬性、內部欄位或序列化標註意外影響公開介面的機會。輸出則使用獨立 response model,明確控制 camelCase 名稱、可空性與集合 shape。這樣才能把「資料層能表示什麼」和「HTTP 契約承諾什麼」分開管理。
回傳型別、狀態碼與 JSON shape 的設計規則
穩定的 ASP.NET Core API 應先定義可觀察結果,再選 ActionResult、IActionResult、具體型別或結果物件;框架預設可作為實作細節,但不能代替端點對 200、204、404 與 JSON shape 的明確承諾。
對單筆 GET,建議使用 ActionResult 並明確處理不存在分支,例如 return NotFound();找到資料才回傳 DTO。對集合 GET,成功時始終回陣列,即使長度為零。對不需要本文的操作,直接回 NoContent(),避免用 return null 間接觸發 formatter。對確實需要 JSON null 的特殊契約,使用明確 JSON 結果並寫下原因,否則後續維護者很容易把它改成具體型別而改變狀態碼。
回應本文與狀態碼必須一起測試。只斷言反序列化後的 C# 值,可能看不到 204 根本沒有本文;只斷言 2xx,又會把 200 空陣列、200 null 與 204 混為一談。最低限度應檢查狀態碼、Content-Type、原始本文以及反序列化後的 shape。若端點由瀏覽器呼叫,也要測試客戶端對 204 是否跳過 JSON 解析,避免伺服器測試通過但 UI 邏輯失敗。
下列模式把意圖寫在結果中,而不是交給 null 推導:
[HttpGet("{id:int}")]
public ActionResult GetById(int id)
{
ItemDto? item = repository.Find(id);
return item is null ? NotFound() : Ok(item);
}
[HttpGet]
public ActionResult> List()
{
IReadOnlyList items = repository.List();
return Ok(items); // 沒有項目時仍是 []
}
這段範例故意不展示真實資料來源。驗證重點是 HTTP 形狀,不需要連接外部系統。測試專案可使用記憶體資料、假的 repository 或固定結果,讓每次執行都只觀察框架行為,不受網路、資料狀態或時間影響。
本機驗證與重現步驟
以下測試已在 Windows 排程環境以 .NET SDK 9.0.305、net9.0 與 ASP.NET Core Runtime 9.0.9 實際執行;測試只啟動本機 sandbox,觀察到裸 null 為 204、空集合為 200 加 []、JsonResult null 為 200 加 null,未知欄位則被忽略。
前置條件是已安裝 .NET SDK 9.0.305,並保留可用的 5187 本機連接埠。建立空資料夾,加入固定 SDK 的 global.json、Web SDK 專案檔與本文端點後執行。若 dotnet --version 不是 9.0.305,應先停止,避免把其他版本行為混入結果。
- 建立
net9.0Web 專案,加入四個端點:具體型別回傳裸null、回傳空陣列、回傳new JsonResult(null),以及接收FilterInput的 POST。 - 執行
dotnet --version與dotnet build -c Release;通過條件是版本精確為 9.0.305 且建置沒有錯誤,失敗條件是版本不同或 build 非零結束。 - 以
dotnet run -c Release --no-build --urls http://localhost:5187啟動 sandbox,分別以curl -i呼叫三個 GET;通過條件依序為 204 空本文、200[]、200null。 - POST
{"resourceType":"report","extraField":123}到 bind 端點;通過條件是 200,且回傳的resourceTypeList與enabled都是 null,代表未知成員沒有填入模型。
dotnet --version
dotnet build -c Release
dotnet run -c Release --no-build --urls http://localhost:5187
curl -i http://localhost:5187/api/probe/raw-null
curl -i http://localhost:5187/api/probe/empty-list
curl -i http://localhost:5187/api/probe/json-null
curl -i -H "Content-Type: application/json" -d '{"resourceType":"report","extraField":123}' http://localhost:5187/api/probe/bind
實際結果的可觀察摘要為:raw-null 回應 HTTP/1.1 204 No Content 且本文為空;empty-list 回應 HTTP/1.1 200 OK 且本文為 [];json-null 回應 HTTP/1.1 200 OK 且本文為 null;bind 回應 200,本文顯示兩個已知屬性皆為 null。這些是本次固定環境的真實結果,不是效能數據,也不推論其他版本必然相同。
審查清單與相容性邊界
審查時應逐一確認端點的空值語意、回傳型別、formatter 設定、JSON 選項與呼叫端解析策略,並把框架版本納入測試條件;只看 controller 程式碼不足以判定最終 HTTP 結果。
第一,檢查每個集合端點是否永遠回陣列,單筆端點是否明確區分不存在與欄位為空。第二,搜尋裸 return null,逐筆確認它是刻意 204,還是遺漏 NotFound、空集合或明確 JSON 結果。第三,檢查呼叫端是否把所有 2xx 視為成功,並在解析前判斷是否真的有本文。第四,為 DTO 欄位改名建立相容策略;若要改公開 JSON 名稱,先加入新欄位或版本化端點,不要讓前端 payload 被靜默忽略。
相容性查核不能把一個版本的結果寫成永久定律。本文使用 .NET 9 固定 SDK 與 runtime,官方 API 文件也以 ASP.NET Core 9 檢視。升級 framework、切換 Minimal API、替換 MVC formatter、調整 TreatNullValueAsNoContent 或改用不同結果型別後,都要重跑相同黑箱測試。真正穩定的是測試所描述的契約,而不是某個內部類別名稱。
效能方面,本文不宣稱空陣列或 204 具有可量化優勢。少量 JSON 位元組通常不是選擇語意的首要依據;若要比較傳輸成本,應另建基準,固定 payload、壓縮、協定與執行次數。此處優先目標是可預期、可除錯與可演進的介面,避免用未量測的效能理由犧牲契約清晰度。
結論
ASP.NET Core null 回應應被當成 HTTP 契約決策,而不是 C# 的小細節:集合回空陣列、單筆不存在回明確狀態、無本文時直接宣告 204,需要 JSON null 時則使用明確結果並以固定版本測試保護。
Model Binding 也不能只以「action 有進入」判定正確。未知欄位可能被忽略,已知屬性可能保持 null,因此測試要用真實 JSON 名稱斷言模型與輸出。當狀態碼、本文、型別與輸入邊界都被寫進整合測試,前後端就不必靠隱含預設互相猜測,升級時也能立即看見契約是否改變。