Skip to main content

elf-unit

Elf Express 單元測試規範(xUnit + coverlet / Vitest + @vue/test-utils):規定測試檔放哪、怎麼命名、AAA 結構、 Fake 與 vi.mock 的用法、邊界與例外測試、覆蓋率門檻(line 55%)。 當任務涉及撰寫或修改單元測試、新增 `*.test.ts` 或 `server/tests/<Project>.Tests/`、選擇 mock 方式、 測試命名、覆蓋率設定或「這段該不該寫單元測試」的判斷時觸發。

معلومات المصدر

المستودع
elf-express/skillshare-source
آخر نشاط في المصدر
٢٠ أكتوبر ٢٠٢٦ في ٠٢:٤٣
لغة SKILL.md المكتشفة
الصينية
النجوم
٠
التفرعات
٠

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

مستكشف الملفات
2 ملفات

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
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
ملف SKILL.md هذا كبير جدا، لذلك يعرض SkillsMP القسم الاول فقط هنا. عرض على GitHub