Day 54 verifyPassword 的第一個 Vitest 測試(續);CSS 留白與定位屬性

今日閱讀:《單元測試的藝術》3/e 2.9 p.65 - 2.11 p.71 第二章完

參數化的測試程式

測試相同的情境,但工作單元的輸入有一些小變化時使用 先前練習題已經做過,略讀

Act API:test.each([]) 用陣列封裝輸入的參數,Vitest 會自動映射到對應位置,對每個陣列執行一次 [ parameter, asserertion ]完成相同情境不同輸入值的測試

使用JS 原生 for loop 也可以做到,但要考慮可讀性 另外,使用參數化測試要注意是否把不同條件包在一起測試了,以書中範例為例,輸入全小寫字母與一個大寫字母就應該被視為不同情境,拆開測試

檢查預期會被拋出的錯誤

先前練習題已經做過,略讀

Assersion API:expect().toThrowError()

作者附註的快照測試 API 也練習過用來比對 ASCII 圖形處理結果toMatchInlineSnapshot

測試分類

一、Vitest 測試分類功能對照

Jest vs Vitest 對照表

功能

Jest

Vitest

差異說明

檔案路徑過濾

--testPathPattern

直接傳入檔案名稱

Vitest 簡化語法 vitest

測試名稱過濾

-t, --testNamePattern

-t, --testNamePattern

完全相同 vitest

配置檔案

jest.config.js

vitest.config.js

格式類似 vitest

指定測試模式

testMatch

include

語意相同 lutece.github

排除測試

testPathIgnorePatterns

exclude

語意相同 lutece.github

多專案配置

-

projects

Vitest 獨有 vitest

測試標籤

-

--tags-filter

Vitest 4.1+ 獨有 main.vitest

CLI 指令範例

# 檔案路徑過濾
vitest basic                    # 執行包含 "basic" 的測試檔案
vitest auth login               # 執行包含 "auth" 和 "login" 的檔案

# 測試名稱過濾
vitest -t "user login"          # 根據測試名稱過濾
vitest --testNamePattern="auth.*"  # 使用正則表達式

# 指定檔案與行號(Vitest 3+)
vitest basic/foo.test.ts:10     # 執行特定檔案的特定行測試

配置檔案:使用 Projects 分類

// vitest.config.js
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    // 基本配置
    include: ['**/*.{test,spec}.{js,ts}'],
    exclude: ['node_modules', 'dist', '**/*.e2e.test.js'],
    
    // 多專案配置
    projects: [
      {
        // 單元測試專案
        test: {
          name: 'unit',
          include: ['**/*.unit.test.ts'],
          isolate: false,  // 提升速度
        },
      },
      {
        // 整合測試專案
        test: {
          name: 'integration',
          include: ['**/*.integration.test.ts'],
          testTimeout: 30000,  // 較長超時
        },
      },
    ],
  },
})

package.json 腳本設定

{
  "scripts": {
    "test": "vitest --project=unit",           // 開發時持續執行
    "test:integration": "vitest --project=integration",
    "test:all": "vitest",                      // CI/CD 完整測試
    "test:auth": "vitest auth",                // 測試特定功能模組
    "precommit": "vitest run --project=unit --changed"  // Git hook
  }
}

二、為什麼需要測試分類?

1. 執行速度差異

核心差異

  • 單元測試:隔離測試個別元件,純邏輯運算,執行時間 < 10ms repeato

  • 整合測試:驗證多元件協作,涉及外部資源(資料庫、網路、檔案系統),執行時間 > 500ms stackoverflow

// 單元測試:快速(< 10ms)
describe('calculateDiscount', () => {
  it('應計算九折優惠', () => {
    expect(calculateDiscount(100, 0.9)).toBe(90);
  });
});

// 整合測試:較慢(> 500ms)
describe('UserRepository', () => {
  it('應從資料庫讀取使用者資料', async () => {
    const user = await userRepo.findById(123);
    expect(user.name).toBe('John');
  });
});

測試套件膨脹問題

專案成長後測試數量可達數百至數千個: repeato

  • 不分類:全部測試可能需要 30 分鐘以上

  • 分類後:單元測試 2-3 分鐘,整合測試按需執行

2. TDD 紅綠重構循環需求

幾秒鐘完成一輪循環

TDD 講究「紅燈 → 綠燈 → 重構」的快速迭代,理想循環時間為數秒鐘 。如果測試套件包含慢速整合測試,會打斷開發心流,失去即時回饋優勢。 itential

開發節奏對比

  • 有測試分類:改代碼 → 2 秒後看到單元測試結果 → 繼續開發

  • 無測試分類:改代碼 → 等待 5 分鐘全部測試跑完 → 心流中斷

3. 測試穩定性與維護成本

隔離測試的穩定性

  • 單元測試:不受外部環境影響,測試結果穩定可靠 vocus

  • 整合測試:可能因網路波動、資料庫狀態、第三方 API 變更產生「片狀測試」(flaky test),需額外維護 stackoverflow

測試失敗的診斷效率

  • 單元測試失敗:精準定位到特定函式問題

  • 整合測試失敗:需檢查多個元件互動,診斷時間較長 codefresh

4. 測試演化與重構

單元測試可能變成整合測試

隨著重構,原本的單元測試可能演變成整合測試: stackoverflow

// 最初:單一類別的單元測試
describe('OrderProcessor', () => {
  it('應計算訂單總金額', () => {
    const processor = new OrderProcessor();
    expect(processor.calculateTotal([{price: 100}])).toBe(100);
  });
});

// 重構後:多個類別協作的整合測試
describe('OrderService', () => {
  it('應處理完整訂單流程', () => {
    // 現在涉及 OrderProcessor、PaymentGateway、InventoryService
    const result = orderService.processOrder(orderData);
    expect(result.status).toBe('success');
  });
});

此時應為新拆分的類別撰寫新的單元測試,並將原測試重新分類為整合測試。

判斷整合測試的標準

符合以下任一條件即為整合測試: ithelp.ithome.com

  • 與外部環境互動(資料庫、檔案系統、網路、時間)

  • 需要多個真實元件協作

  • 執行時間明顯較長


三、測試分類對 CI/CD 的好處

1. 快速回饋循環

階段性品質閘門

設計多層測試閘門,越快的測試越早執行: codefresh

# .github/workflows/ci.yml
name: CI Pipeline

on: [push, pull_request]

jobs:
  # 第一階段:單元測試(1-3 分鐘)
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm install
      - run: npm run test:unit

  # 第二階段:整合測試(5-10 分鐘)
  integration-tests:
    needs: unit-tests  # 單元測試通過才執行
    runs-on: ubuntu-latest
    steps:
      - run: npm run test:integration

  # 第三階段:E2E 測試(15-30 分鐘)
  e2e-tests:
    needs: integration-tests
    runs-on: ubuntu-latest
    steps:
      - run: npm run test:e2e

即時問題定位

  • 單元測試 2 分鐘內失敗 → 記憶猶新時修正,成本低

  • 混合測試 30 分鐘後失敗 → 已切換任務,修復成本高 provar

2. 資源使用效率

快速失敗策略

CI/CD 平台通常按運算時數計費,測試分類可避免浪費: 33rdsquare

// vitest.config.js - 設定失敗即停止
export default defineConfig({
  test: {
    bail: 1,  // 第一個失敗就停止
  },
})

效益範例

  • 單元測試失敗:2 分鐘 × 成本 → 立即停止

  • 不分類全跑:30 分鐘 × 成本 → 浪費 15 倍資源 codefresh

3. 並行執行最佳化

Vitest Sharding 分片執行

# CI 環境分配到 3 台機器
# Machine 1
vitest --project=unit --shard=1/3 --reporter=blob

# Machine 2
vitest --project=unit --shard=2/3 --reporter=blob

# Machine 3
vitest --project=unit --shard=3/3 --reporter=blob
vitest --project=integration --reporter=blob

# 最後合併報告
vitest --merge-reports --reporter=junit

時間節省計算

假設測試套件:

  • 單元測試:300 個(共 10 分鐘)

  • 整合測試:50 個(共 20 分鐘)

執行策略

時間

不分類順序執行

30 分鐘

分類並行執行

23.3 分鐘

智慧分配(單元與整合同時跑)

20 分鐘 33rdsquare

4. 不同觸發情境的測試策略

精準匹配測試範圍

觸發情境

執行測試

時間

目的

本地提交前

單元測試

1-2 分鐘

快速驗證邏輯正確性 dzone

Push 到分支

單元 + 整合

5-15 分鐘

確保元件協作正常 codefresh

Pull Request

全部測試

30-60 分鐘

完整品質驗證 codefresh

定時任務(每晚)

全部 + 效能測試

數小時

深度回歸測試 provar

// package.json - 不同場景的腳本
{
  "scripts": {
    "precommit": "vitest run --project=unit --changed",
    "test:ci-push": "vitest run --project=unit --project=integration",
    "test:ci-pr": "vitest run",
    "test:nightly": "vitest run && npm run test:e2e && npm run test:perf"
  }
}

5. 測試穩定性管理

針對不同測試類型設定重試策略

// vitest.config.js
export default defineConfig({
  test: {
    projects: [
      {
        name: 'unit',
        include: ['**/*.unit.test.ts'],
        retry: 0,  // 單元測試不重試,失敗即有問題
      },
      {
        name: 'integration',
        include: ['**/*.integration.test.ts'],
        retry: 2,  // 整合測試允許重試 2 次
        testTimeout: 30000,  // 較長超時時間
      },
    ],
  },
})

穩定性監控目標

  • 單元測試成功率:應維持 99.9%+ katalon

  • 整合測試成功率:目標 95%+,需持續優化片狀測試

6. 不同環境的測試配置

環境隔離策略

# CI 配置範例
jobs:
  unit-tests:
    runs-on: ubuntu-latest  # 輕量環境
    services: {}  # 不需要額外服務

  integration-tests:
    runs-on: ubuntu-latest
    services:
      postgres:  # 需要資料庫
        image: postgres:14
      redis:     # 需要快取服務
        image: redis:7

成本控管策略 codefresh

  • 單元測試:使用免費 CI runner

  • 整合測試:使用付費 runner 但限制並行數量

  • E2E 測試:僅在 PR 與 merge 時執行


四、實務建議

檔案命名慣例

用檔案名稱區分測試類型,方便 Vitest 過濾: vitest

src/
├── utils/
│   ├── math.ts
│   ├── math.unit.test.ts          // 單元測試
│   └── math.integration.test.ts   // 整合測試
├── services/
│   ├── orderService.ts
│   ├── orderService.unit.test.ts
│   └── orderService.integration.test.ts

測試金字塔原則

維持「70% 單元測試 + 20% 整合測試 + 10% E2E 測試」的比例,確保: codefresh

  • 大部分問題在單元測試階段(1-2 分鐘)被攔截

  • 整合測試驗證關鍵互動(5-10 分鐘)

  • E2E 測試保障使用者情境(15-30 分鐘)

監控 CI 管道效能

定期檢視測試執行時間趨勢: octopus

  • 單元測試平均時間 < 5 分鐘

  • Pull Request 完整測試 < 15 分鐘

  • 主分支全量測試 < 30 分鐘

當測試時間持續增長時:

  1. 檢查是否有測試分類錯誤(整合測試誤放在單元測試)

  2. 啟用並行執行與分片功能

  3. 移除冗餘或重複的測試案例

測試覆蓋率分層追蹤

針對不同測試類型設定合理覆蓋率目標:

// vitest.config.js
export default defineConfig({
  test: {
    projects: [
      {
        name: 'unit',
        coverage: { lines: 90 },  // 單元測試應覆蓋大部分邏輯
      },
      {
        name: 'integration',
        coverage: { lines: 60 },  // 整合測試聚焦關鍵路徑
      },
    ],
  },
})

五、核心結論

測試分類是現代軟體工程的必備實踐,讓 CI/CD 流程從「全有全無」變成「漸進式驗證」: getscandium

  1. 開發體驗:TDD 紅綠重構保持數秒鐘快速循環

  2. 品質保證:多層閘門確保不同層級的問題都能被攔截

  3. 成本效益:避免浪費運算資源,快速失敗節省時間與金錢

  4. 維護性:穩定的單元測試 + 可控的整合測試,降低長期維護成本

Vitest 提供比 Jest 更現代化的測試分類功能(projects、tags、sharding),讓測試組織更靈活彈性,是前端工程師值得深入學習的工具。


參考資料

CSS 留白與定位屬性

學習目標:理解傳統 CSS(不用 Flex/Grid)如何用留白 + 定位打造精準佈局


一、對話中提到的所有 CSS 屬性清單

1.1 盒模型留白屬性

屬性

作用範圍

特性

margin

元素與元素之間

不會有背景色,可以用負值,會發生合併(collapse)

padding

元素內容到邊框

會保留背景色,撐大可點擊範圍,影響總寬度

border

元素邊框

佔據空間,可設定顏色/樣式/粗細

gap

Flex/Grid 子元素間距

只作用於現代佈局,不會在容器邊緣產生空隙

1.2 文字留白屬性

屬性

作用對象

使用情境

line-height

文字行高

段落可讀性,單行置中,用純數字最佳(如 1.6)

letter-spacing

字元間距

標題視覺效果,中英文混排,Logo 設計

word-spacing

單詞間距

英文排版(對中文無效)

text-indent

段落首行縮排

傳統文章排版

white-space

空白字符處理

控制換行與空白顯示

1.3 盒模型計算屬性

屬性

計算邏輯

推薦值

box-sizing: content-box

width 只算 content,padding/border 會額外增加

預設值(不推薦)

box-sizing: border-box

width = content + padding + border,往內推擠

全站設定(推薦)

1.4 定位屬性

屬性

定位基準

是否脫離文件流

position: static

預設,按文件流排列

否

position: relative

自己的原始位置

否(保留原空間)

position: absolute

最近的已定位父元素

是(不佔空間)

position: fixed

瀏覽器視窗

是(不佔空間)

1.5 定位座標屬性

屬性

作用

搭配對象

top / bottom / left / right

指定元素距離定位基準的距離

position: relative/absolute/fixed

transform: translate()

相對自身尺寸位移

常與 position: absolute 搭配置中

1.6 其他相關屬性

屬性

作用

使用情境

display: table-cell

假裝元素是表格儲存格

搭配 vertical-align: middle 垂直置中

vertical-align

行內元素垂直對齊

只對 inline/inline-block/table-cell 有效


二、定位屬性的核心概念

2.1 position: relative(相對定位) steam.oxxostudio

比喻:像在地上畫記號,告訴物件「從你原本的位置移動」。 steam.oxxostudio

.box {
  position: relative;
  top: 20px;  /* 從原位置往下移 20px */
  left: 30px; /* 從原位置往右移 30px */
}

特性: web

  • 保留原空間:元素移走後,原位置仍保留空位(其他元素不會遞補)

  • 不影響其他元素:位移只是視覺效果,不會推擠鄰居

  • 常用於建立定位參考點:讓子元素的 absolute 以它為基準 vocus

2.2 position: absolute(絕對定位) eudora

比喻:像飛出文件流的氣球,可以飄到任何地方,不佔地面空間。 vocus

.parent {
  position: relative; /* 建立參考點 */
}

.child {
  position: absolute;
  top: 0;    /* 距離父元素上緣 0px */
  right: 0;  /* 距離父元素右緣 0px */
}

定位基準規則: eudora

  1. 有定位的父元素:找最近的 position: relative/absolute/fixed 的父元素

  2. 沒有定位的父元素:以 <body> 或初始包含區塊為基準 vocus

特性: eudora

  • 脫離文件流:不佔原本空間,其他元素會遞補上來

  • 不推擠其他元素:怎麼移動都不影響鄰居排列

  • 寬高自動收縮:沒指定寬高時,會縮到內容大小 eudora


三、留白屬性 × 定位屬性的搭配技巧

3.1 margin 與 top/left 的差異 code2study.blogspot

當元素使用 position: relative 時:

屬性組合

效果

適用情境

margin-top: 20px

推擠其他元素,會影響佈局流

需要調整整體佈局

top: 20px

只移動自己,不影響其他元素

微調單一元素位置

/* 範例對比 */
.method-1 {
  position: relative;
  margin-top: 20px; /* 會把下方所有元素往下推 */
}

.method-2 {
  position: relative;
  top: 20px; /* 只有自己往下移,保留原空間 */
}

當元素使用 position: absolute 時: code2study.blogspot

  • margin 和 top/left 效果相同,因為元素已脫離文件流

  • 推薦用 top/left:語意更清楚,表示「定位座標」

3.2 padding 在 absolute 元素中的應用 zero-plus

.parent {
  position: relative;
  padding: 50px; /* 父元素的 padding 會影響子元素的定位起點 */
  border: 20px solid orange;
}

.child {
  position: absolute;
  top: 0;   /* 起點是父元素 padding 內緣,不是 border 內緣 */
  left: 0;
  width: 100px;
  height: 100px;
}

關鍵規則: zero-plus

  • absolute 子元素的定位起點 = 父元素的 padding 內緣

  • 父元素的 padding 會「內推」子元素的 (0, 0) 起點

  • 父元素的 margin 對子元素定位無影響

3.3 margin: auto 置中法(需搭配 absolute) realnewbie

.outer {
  position: relative;
  height: 300px;
}

.inner {
  position: absolute;
  top: 0;
  bottom: 0;    /* 上下都設為 0 */
  left: 0;
  right: 0;     /* 左右都設為 0 */
  margin: auto; /* 瀏覽器會自動計算剩餘空間 */
  
  width: 200px;  /* 必須有固定寬高 */
  height: 100px;
}

運作原理: realnewbie

  1. 同時設定 top: 0; bottom: 0; 會產生「垂直方向的拉扯」

  2. margin: auto 會平均分配上下剩餘空間

  3. 水平方向同理

限制:子元素必須有明確的寬高,不適合響應式內容。 realnewbie

3.4 transform 位移不佔空間

.box {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%); /* 百分比相對自身尺寸 */
}

關鍵差異: pjchender.github

  • top: 50%:相對父元素高度的 50%

  • transform: translateY(-50%):相對自身高度的 50%

  • transform 不會改變元素佔據的空間,只是視覺位移


四、實戰案例:留白 + 定位組合拳

4.1 卡片角標設計

.card {
  position: relative; /* 建立定位參考點 */
  padding: 20px;      /* 內容留白 */
  border: 1px solid #ddd;
  margin-bottom: 16px; /* 卡片間距 */
}

.badge {
  position: absolute;
  top: -10px;    /* 從卡片上緣往上偏移(負值讓它跑出去) */
  right: 10px;   /* 從卡片右緣往左 10px */
  padding: 5px 10px; /* 角標內部留白 */
  background: red;
}

留白作用分析:

  • card 的 padding:保護內文不貼邊

  • card 的 margin-bottom:卡片之間保持距離

  • badge 的 padding:讓文字不貼齊角標邊緣

  • badge 的 top(負值):讓角標突出卡片上方

4.2 彈跳視窗置中 + 內容留白

/* 遮罩層(不用定位,用固定定位覆蓋全螢幕) */
.overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
}

/* 彈跳視窗本體 */
.modal {
  position: fixed;    /* 相對視窗定位 */
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%); /* 完美置中 */
  
  width: 90%;
  max-width: 500px;
  padding: 30px;      /* 內容與邊框留白 */
  background: white;
  box-sizing: border-box; /* 確保 padding 不會讓寬度爆掉 */
}

.modal-title {
  margin-bottom: 20px; /* 標題與內文間距 */
  line-height: 1.3;    /* 標題行高 */
}

.modal-content {
  line-height: 1.6;    /* 內文行高 */
}

技巧拆解:

  1. position: fixed + transform:讓視窗永遠在畫面中央,不受捲動影響 pjchender.github

  2. padding + box-sizing: border-box:確保內容留白不會破壞寬度計算

  3. margin-bottom:用於內部元素之間的間距

  4. line-height:提升文字可讀性

4.3 固定導覽列 + 內容偏移

/* 固定在頂部的導覽列 */
.navbar {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 60px;
  padding: 0 20px;  /* 左右內邊距 */
  background: white;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

/* 主要內容區 */
.main-content {
  margin-top: 60px; /* 留出導覽列的高度,避免被遮住 */
  padding: 20px;    /* 內容區留白 */
}

為什麼用 margin-top 而非 padding-top?

  • navbar 使用 position: fixed 會脫離文件流

  • 主內容區會跑到畫面頂部被遮住

  • 用 margin-top 推擠主內容區,讓它從導覽列下方開始顯示 web


五、易錯提醒與除錯技巧

5.1 定位基準找錯

/* ❌ 錯誤:子元素會以 body 為基準 */
.parent {
  /* 沒有設定 position */
}
.child {
  position: absolute;
  top: 20px; /* 會從 body 頂部開始算 */
}

/* ✅ 正確:子元素以父元素為基準 */
.parent {
  position: relative; /* 建立參考點 */
}
.child {
  position: absolute;
  top: 20px; /* 從父元素頂部開始算 */
}

5.2 padding 讓 absolute 子元素位移

.parent {
  position: relative;
  padding: 30px; /* 注意這個 padding */
}

.child {
  position: absolute;
  top: 0;
  left: 0;
  /* 實際位置會距離父元素 border 內緣 30px(受 padding 影響) */
}

/* 如果想讓子元素貼齊 border 內緣,用負值抵消 */
.child {
  position: absolute;
  top: -30px;   /* 往上抵消 padding */
  left: -30px;  /* 往左抵消 padding */
}

5.3 box-sizing 影響定位計算

.box {
  box-sizing: border-box;
  width: 200px;
  padding: 20px;
  border: 10px solid black;
  /* 實際可定位區域 = 200px(包含 padding 和 border) */
}

.box-child {
  position: absolute;
  width: 100%; /* 這裡的 100% = 200px,不是 content 寬度 */
}