- name
- elf-unit
- description
- Elf Express 單元測試規範(xUnit + coverlet / Vitest + @vue/test-utils):規定測試檔放哪、怎麼命名、AAA 結構、
Fake 與 vi.mock 的用法、邊界與例外測試、覆蓋率門檻(line 55%)。
當任務涉及撰寫或修改單元測試、新增 `*.test.ts` 或 `server/tests/<Project>.Tests/`、選擇 mock 方式、
測試命名、覆蓋率設定或「這段該不該寫單元測試」的判斷時觸發。
- metadata
- {"version":"1.0.0","owner":"Elf Express"}
# Elf Express 單元測試規範
> 相關 skill:`elf-stack`(版本定本)、`elf-dotnet`、`elf-vue`、`elf-integration`(碰 DB / HTTP 管線時改看它)、
> `elf-e2e`(焦點、IME、版面等瀏覽器行為)、`elf-cicd-frontend` / `elf-cicd-backend`(CI 上跑測試與門檻)。
>
> 參考專案:`TypingTrainer`(`server/TypeLab.Api.Tests/`、`apps/src/**/*.test.ts`、`apps/vite.config.ts`)。
## 1. 何時使用
- 為純函式、Service、composable、Pinia store、Vue 元件新增或修改單元測試
- 修 bug:先寫一個會紅的測試,再修
- 設定 Vitest / xUnit 專案、覆蓋率門檻
- 判斷某段邏輯該寫單元測試、整合測試還是 E2E(見 §2.6)
## 2. 固定規則
### 2.1 測試檔放置位置
#### 前端(Vue):與元件同資料夾
```
components/
├── OrderCard.vue
└── OrderCard.test.ts
```
- 測試檔與被測檔**放在同一個資料夾**,檔名 = 被測檔名 + `.test.ts`
- 副檔名必須是 `.test.ts`,**不可寫成 `.test.vue`**:Vitest 預設只收
`*.{test,spec}.{js,ts,jsx,tsx}`,`.test.vue` 會被忽略,測試不會執行、也不計入覆蓋率
- 元件以 `@vue/test-utils` 的 `mount()` / `shallowMount()` 測試;
composable、store、純函式同樣以 `xxx.test.ts` 放在原檔旁
- `*.spec.ts` 保留給 Playwright E2E(放 repo 根目錄 `e2e/`),單元測試**一律** `.test.ts`
#### 後端(.NET):獨立 `server/tests/` 目錄
```
server/
├── Acme.slnx
├── src/
│ └── Acme.Api/
└── tests/
├── Acme.Api.Tests/ ← 單元測試,參照 server/src/Acme.Api
└── Acme.Api.IntegrationTests/ ← 整合測試(需要 Docker),見 elf-integration
```
- 每個主專案對應一個 `<主專案名>.Tests` 測試專案,一律放在 `server/tests/` 底下(主專案在 `server/src/`),並加入 `server/<App>.slnx`
- 測試專案內的資料夾結構比照主專案(例如 `Services/OrderPricingServiceTests.cs`)
- 框架:xUnit;涵蓋率收集:`coverlet.msbuild`(**不用** `coverlet.collector`)
- 測試類別名 = 被測類別名 + `Tests`,一個被測類別一個測試檔
### 2.2 命名
1. **MUST**(.NET)測試方法名用 `MethodName_Scenario_ExpectedResult`,三段以底線分隔,每段 PascalCase:
`CalculateTotal_WithExpiredCoupon_IgnoresDiscount`、`TryCancel_WhenAlreadyShipped_ReturnsFalse`。
2. **MUST**(前端)`describe('<被測單元名>')` + `it('<預期行為的英文句子>')`,句子描述行為而非實作:
`it('returns null for empty input')`、`it('does not advance in strict mode until the right key comes')`。
3. **MUST NOT** 用 `Test1`、`ShouldWork`、`it('works')` 這類不說明情境與結果的名字。
4. 參考專案的 .NET 測試用 `ResolveLocale_honours_q_values` 這種「方法名 + 小寫句子」寫法;
**新測試一律照第 1 條**,舊測試不必為改名而改名。
### 2.3 AAA 結構
1. **MUST** 每個測試依序為 Arrange → Act → Assert,三段之間空一行。
測試本體(不含方法簽章與大括號)**超過 10 行**時 **MUST** 寫 `// Arrange`、`// Act`、`// Assert` 三個註解;
10 行以內 **MUST NOT** 寫(以空行分段即可)。
2. **MUST** Act 只有一個動作(一次方法呼叫 / 一次使用者操作)。要測兩個動作就拆兩個測試。
3. **MUST** 每個測試至少一個 assert;**MUST NOT** 只呼叫方法不驗證(為了衝覆蓋率)。
4. **MUST NOT** 在測試內寫 `if` / `for` 決定要不要 assert。多組輸入用 `[Theory]` + `[InlineData]` 或 `it.each`。
5. **MUST NOT** 測試之間共用可變狀態。.NET 每個測試會 new 一個測試類別實例;
前端用 `beforeEach` 重建(例如 `setActivePinia(createPinia())`)。
### 2.4 Mock / Stub / Fake 取捨
| 情境 | .NET | 前端 |
|------|------|------|
| 依賴是介面,需要可控回傳值 / 記錄呼叫次數 | **手寫 `private sealed class FakeXxx : IXxx`**(放在測試類別內) | `vi.fn()` |
| 依賴是整個模組(API client、Tauri plugin、第三方 SDK) | — | `vi.mock('<module>', factory)` + `vi.hoisted` |
| `ILogger<T>` | `NullLogger<T>.Instance` | — |
| `IOptions<T>` | `Options.Create(new T { ... })` | — |
| `IConfiguration` | `new ConfigurationBuilder().AddInMemoryCollection(...)` | — |
| 時間 | 注入 `TimeProvider`,測試用手寫 `FixedTimeProvider`(見 §3.2) | `vi.useFakeTimers()`,測完 `vi.useRealTimers()` |
| `ISqlSugarClient` / 資料庫 | **不要 mock**,改寫整合測試(`elf-integration`);團隊**沒有** repository 層,不要為了單元測試新增 | — |
1. **MUST**(.NET)依參考專案做法,用**手寫 Fake**(見 §3.2 範本)。**MUST NOT** 自行引入 Moq 或
NSubstitute —— 是否導入 mock 函式庫尚未決定(見 §6)。
2. **MUST** Fake 只實作測試需要的行為,用建構子參數切換(`succeeds: false`),並公開 `Calls` 之類的計數讓測試驗證。
3. **MUST**(前端)`vi.mock` 的 factory 內要引用的 `vi.fn()` 一律用 `vi.hoisted()` 宣告,避免 hoisting 後讀到未初始化變數。
4. **MUST**(前端)`beforeEach(() => vi.clearAllMocks())`;有改 `window` / 全域的測試在 `afterEach` 還原。
5. **MUST** 模組在載入時就讀取環境(例如 `const IS_DESKTOP = '__TAURI_INTERNALS__' in window`)時,
先設好環境再 `vi.resetModules()` + 動態 `await import('./xxx')`(參考 `apps/src/api/desktop.test.ts`)。
6. **MUST NOT** mock 被測物件自己,也不要 mock 純函式 / 值物件 —— 直接用真的。
### 2.5 邊界與例外測試
每個公開方法的**每個參數**,依型別 **MUST** 覆蓋下表全部列出的情況(不適用者須在測試類別註解寫明原因):
| 參數型別 | 必測情況 |
|----------|----------|
| `string` / `string?` | `null`、`""`、`" "`(空白)、長度剛好等於上限、上限 + 1;有格式時加非正規輸入(`"ZH-tw"`,參考 `CatalogueLocalizerTests`) |
| 整數 / `decimal` | `0`、`1`、`-1`(或最小負數)、上限、上限 + 1 |
| 集合 / 陣列 | `null`(可為 null 時)、空集合、單一元素、多個元素(含重複) |
| 分頁(`page` / `size`) | 第一頁、最後一頁、超過最後一頁、`size` 為 0 與上限 + 1 |
| 時間 / 日期 | 剛好等於到期時間、到期前 1 tick、到期後;跨日 / 時區邊界(以 UTC 斷言) |
| enum | 每個已定義值各一次(`[Theory]` / `it.each`),加一個未定義值(`(Status)999`) |
| 可為 null 的物件 | `null` 與非 null 各一次 |
1. 空值類輸入用 `[Theory]` + `[InlineData(null)] [InlineData("")] [InlineData(" ")]`(前端 `it.each`)。
2. **領域拒絕不丟例外**:業務規則不允許的操作(已出貨不能取消、庫存不足)回傳 `false` 或結果 enum,
測試斷言回傳值與狀態**未改變**,**MUST NOT** 期待例外(錯誤回應由 endpoint 對應成 ProblemDetails,見 `elf-dotnet`)。
3. 例外只用於程式錯誤 / 前置條件(`null` 引數、負數金額):**MUST** 同時斷言例外型別與參數名或關鍵訊息。
- .NET 同步:`var ex = Assert.Throws<ArgumentException>(() => sut.Do(x));`
- .NET 非同步:`await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => sut.DoAsync(x));`
- 前端:`expect(() => fn(x)).toThrow('message')`;非同步 `await expect(p).rejects.toThrow(...)`
4. 失敗後的降級行為(fallback、重試、回傳預設值)要驗證「有降級」也要驗證「沒呼叫不該呼叫的依賴」
(參考 `TranslationServiceTests.Falls_back_to_the_next_provider_when_one_fails`)。
5. 平台差異:路徑、換行用 `OperatingSystem.IsWindows()` 分支準備輸入值,**MUST NOT** 寫死 `C:\`。
### 2.6 不該寫單元測試的情境
| 情境 | 改寫什麼 |
|------|----------|
| SqlSugar 查詢、CodeFirst、交易、多租戶過濾 | `elf-integration`(真 PostgreSQL 18) |
| Endpoint 路由、model binding、認證授權、middleware 順序 | `elf-integration`(`WebApplicationFactory<Program>`) |
| 焦點、IME 組字、捲軸 / 版面是否溢出、真實事件順序 | `elf-e2e`(jsdom 觀察不到) |
| DI 註冊是否齊全、設定檔綁定 | `elf-integration`(啟動整個 host 即可涵蓋) |
| 純 DTO / 自動屬性 / 產生的程式碼 / 第三方套件本身 | 不寫 |
判斷原則:**如果要 mock 的東西比被測邏輯還多,就不是單元測試的對象。**
## 3. 標準範本
### 3.1 .NET:測試專案 csproj
`server/tests/Acme.Api.Tests/Acme.Api.Tests.csproj`。團隊採 Central Package Management:
`TargetFramework` / `ImplicitUsings` / `Nullable` 由 `server/Directory.Build.props` 提供,
套件版本一律在 `server/Directory.Packages.props`(定本見 `elf-stack`),csproj **不寫** `Version=`、**不寫**自己的 `TargetFramework`。
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.msbuild" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Acme.Api\Acme.Api.csproj" />
</ItemGroup>
</Project>
```
建立並加入方案:
```bash
dotnet new xunit -n Acme.Api.Tests -o server/tests/Acme.Api.Tests
dotnet sln server/Acme.slnx add server/tests/Acme.Api.Tests/Acme.Api.Tests.csproj
dotnet add server/tests/Acme.Api.Tests reference server/src/Acme.Api/Acme.Api.csproj
```
`dotnet new xunit` 產生的 csproj 帶有 `TargetFramework`、`Version=` 與 `coverlet.collector`:**MUST** 改成上面的內容
(刪掉 `TargetFramework` / `ImplicitUsings` / `Nullable` 與所有 `Version=`,`coverlet.collector` 換成 `coverlet.msbuild`),
否則 CPM 會報 `NU1008`,覆蓋率門檻也不會生效。
### 3.2 .NET:Service 單元測試(手寫 Fake + AAA + Theory + 例外 + 領域拒絕)
被測的是不碰資料庫的邏輯:價格計算與外部匯率服務(typed `HttpClient` 背後的介面)。
碰 `ISqlSugarClient` 的查詢改寫整合測試(`elf-integration`),**不要**為了能 mock 而加 repository 層。
`server/tests/Acme.Api.Tests/Services/OrderPricingServiceTests.cs`:
```csharp
using Acme.Api.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace Acme.Api.Tests.Services;
public class OrderPricingServiceTests
{
private static readonly DateTimeOffset Now = new(2026, 9, 1, 0, 0, 0, TimeSpan.Zero);
/// <summary>Stand-in for the external exchange-rate API; records how often it was called.</summary>
private sealed class FakeExchangeRateClient(decimal? rate = 0.031m) : IExchangeRateClient
{
public int Calls { get; private set; }
public Task<decimal?> GetRateAsync(string from, string to, CancellationToken ct = default)
{
Calls++;
return Task.FromResult(rate);
}
}
/// <summary>A clock that never moves.</summary>
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
private static OrderPricingService Build(IExchangeRateClient? rates = null)
{
return new OrderPricingService(
rates ?? new FakeExchangeRateClient(),
Options.Create(new PricingOptions { BaseCurrency = "TWD" }),
new FixedTimeProvider(Now),
NullLogger<OrderPricingService>.Instance);
}
[Fact]
public void CalculateTotal_WithValidCoupon_AppliesDiscount()
{
var sut = Build();
var total = sut.CalculateTotal(subtotal: 1000m, new Coupon("SAVE10", 0.1m, ExpiresAt: Now.AddDays(1)));
Assert.Equal(900m, total);
}
[Fact]
public void CalculateTotal_WithCouponExpiringExactlyNow_IgnoresDiscount()
{
var sut = Build();
var total = sut.CalculateTotal(subtotal: 1000m, new Coupon("EDGE", 0.1m, ExpiresAt: Now));
Assert.Equal(1000m, total);
}
[Theory]
[InlineData(-0.01)]
[InlineData(-1000)]
public void CalculateTotal_WithNegativeSubtotal_ThrowsArgumentOutOfRange(double subtotal)
{
var sut = Build();
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => sut.CalculateTotal((decimal)subtotal, coupon: null));
Assert.Equal("subtotal", ex.ParamName);
}
[Fact]
public async Task ConvertAsync_ToBaseCurrency_SkipsRateLookup()
{
var rates = new FakeExchangeRateClient();
var sut = Build(rates);
var converted = await sut.ConvertAsync(1000m, to: "TWD");
Assert.Equal(1000m, converted);
Assert.Equal(0, rates.Calls);
}
[Fact]
public async Task ConvertAsync_WhenRateUnavailable_ReturnsNull()
{
var sut = Build(new FakeExchangeRateClient(rate: null));
var converted = await sut.ConvertAsync(1000m, to: "USD");
Assert.Null(converted);
}
}
```
領域拒絕回傳 `false`、狀態不變(不丟例外):
```csharp
[Fact]
public void TryCancel_WhenAlreadyShipped_ReturnsFalse()
{
var order = new Order { Status = OrderStatus.Shipped };
var cancelled = order.TryCancel();
Assert.False(cancelled);
Assert.Equal(OrderStatus.Shipped, order.Status);
}
```
- 上面為節錄,示範寫法;實際測試類別仍須依 §2.5 表格補齊每個參數的必測情況(例:`subtotal` 為 `0` / `1`、`coupon` 為 `null`、
到期前 1 tick),`ConvertAsync` 的 `to` 也要測 `null` / `""` / `" "`
### 3.3 前端:`apps/vite.config.ts` 的 test 區塊(含覆蓋率門檻)
```ts
/// <reference types="vitest/config" />
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
test: {
environment: 'jsdom',
// Tests sit next to the file they cover: foo.ts -> foo.test.ts
GitHubで見る