2025年8月18日 星期一

Tailwind CSS 快速升級 v4

Tailwind CSS v4 在2025年1月推出,主打在效能上有顯著的提升,配置也變得更簡便。依照指示一步一步來調整專案,可以簡單快速完成設定。

手動升級

1. 更新及安裝套件

安裝新版的 tailwindcss@tailwindcss/postcss

npm install -D tailwindcss@4.1.12 @tailwindcss/postcss@4.1.12

兩個套件 autoprefixerpostcss 都可以從 package.json 中移除。


2. 調整 postcss.config.js

把新的套件設定放上去。

Before
export default {
  plugins: {
    "postcss-import": {}, // remove
    tailwindcss: {},      // remove
    autoprefixer: {},     // remove
  },
};
After
export default {
  plugins: {
    "@tailwindcss/postcss": {}, // new
  },
};

3. 移除 @tailwind
Before
@tailwind base;
@tailwind components;
@tailwind utilities;
After
@import "tailwindcss";

我的專案本來就沒有使用 @tailwind,因為 SASS 的 @import 準備棄用(參考資料),因此使用 @use 也效果相同。

Before
@use 'tailwindcss/base';
@use 'tailwindcss/components';
@use 'tailwindcss/utilities';
After
@use "tailwindcss";

到這邊基本上就大功告成了,可以測試看看是不是都正常呢



自動升級

另外官方這次也有提供指令來進行升級:

npx @tailwindcss/upgrade

執行時目錄內不能有任何 unstaged files,否則不會進行任何操作。可以根據Terminal上的訊息了解到執行內容基本上相同。

因此除了專案配置以外的改變,需要再參考指南確認有哪些影響,如果有v3, v4之間不兼容的調整,需要手動修正這些地方。


同場加映,額外分享兩個這次的調整:

  • Automatic content detection:過往會在tailwind.config.ts 內定義 content 偵測檔案範圍,v4不再依賴這個設定,可以大膽地把這些設定拿掉。當然!會自動忽略 .gitignore 內提及的範圍,來確保效能。
  • First-party Vite plugin:Vite目前真的可以說是強勢的存在,v4也特地為它打造一個 plugin 取代 PostCSS,只要在 vite.config.ts 加入設定,就可以移除檔案 postcss.config.js 讓專案更乾淨。

2025年8月14日 星期四

[Vue.js] SVG 引入與 Typescript 型別設定

❌錯誤示範

如何在vue專案裡使用 svg 檔案,最直覺的做法:

<template>
    <svg>
        <use href="./assets/penguin-svgrepo-com.svg" width="50%" height="50%" />
    </svg>
</template>

開發階段只要注意路徑正確,就能正常顯示。

但編譯到正式環境時,這段 <use> 標籤使用了 href="data:image/svg+xml,... 來嵌入 SVG 圖片,但這種用法在 <use> 上是無效的,因此圖示不會正確顯示。


改用圖片 <img> 來處理或許可以解決:

<img src="data:image/svg+xml,<svg ...>..." />

雖然這樣瀏覽器就會把它當成圖片正常顯示,但無法用 CSS 改 fillstroke,可調整性較差。


✔️解決方法
1. 安裝 vite-svg-loader 及設定
npm install -D vite-svg-loader

接著,vite.config.ts也要增加設定:

import svgLoader from 'vite-svg-loader';

export default {
    plugins: [
        svgLoader(),
    ],
    // ...
};

2. 新增型別宣告

假設專案並不是使用 Typescript,可以省略這個步驟。

新增 vite-env.d.ts,其主要用途是為 Vite 專案提供型別宣告。它能確保你的 TypeScript 專案能夠正確識別和處理 Vite 特有的功能,而不會報錯。

簡單來說,它就像一個翻譯官,告訴 TypeScript 編譯器:「嘿,Vite 會處理這些特殊檔案,請不要報錯,它們的型別是長這樣。」

// src/vite-env.d.ts
declare module '*.svg' {
    const content: string

    export default content
}

你也可以使用 env.d.ts。但 vite-env.d.ts 的命名方式更明確地表示它是為 Vite 專案服務的。

建議放在 src/ 資料夾的根目錄,Vite 會自動尋找資料夾中的 env.d.tsvite-env.d.ts 檔案,並將其視為專案的型別宣告。

如果你將它放在其他位置,你需要在 tsconfig.jsonincludefiles 欄位中手動指定它的路徑,確保 TypeScript 編譯器能夠找到它。

3. 使用 svg

最後只要調整引用的方法,可以把 svg 當作一個 component 來使用。

<template>
    <Penguin />
</template>

<script setup lang="ts">
import Penguin from '@/assets/penguin.svg'
</script>

這樣一來原本支援的 attr 都可以使用,例如:

<Penguin width="200" height="200" />

參考資料:


2025年7月27日 星期日

[Recat] JSX 常見語法雷區

1. JSX 中 HTML 標籤必須自閉合

HTML 中 input 是 void element,本來不需要閉合標籤。

// ✅ 正確
<input type="text" />

// ❌ 錯誤
<input type="text" >  // JSX 會報錯

-----
2. class 要改寫成 className

JSX 是 JavaScript,不允許使用 JS 關鍵字 class

// ✅ 正確
<div className="box" />

// ❌ 錯誤
<div class="box" />  // React 不認得

-----
3. for 要改寫成 htmlFor

表單 label 的 for 在 JSX 中是保留字,要改成 htmlFor

<label htmlFor="email">Email</label>

-----
4. JS 表達式要放在 {}

Vue.js template 則使用是雙括號 {{}}

const name = "Annie";

// ✅ 正確
<p>Hello, {name}</p>
<h1>{name.toUpperCase()}</h1>

// ❌ 錯誤
<p>Hello, {{name}}</p>

-----
5. style 需用物件寫法,且值要加引號或轉成 string

style 物件的 key 是 camelCase(如: fontSize),不同於 HTML/CSS 的 kebab-case(如: font-size

// ✅ 正確
<div style={{ color: 'red', fontSize: '16px' }} />

// ❌ 錯誤
<div style="color: red; font-size: 16px;" />  // HTML 寫法不支援

雙括號讓解讀困難,因此也把物件先儲存到一個變數中再傳給 style,效果完全一樣。

const config = { color: 'red', fontSize: '16px' };
const element = <div style={config} />

-----
6. JSX 裡只能回傳單一根元素

必須使用 Fragment

// ✅ 正確(使用 <></> Fragment)
return (
  <>
    <Header />
    <Content />
  </>
);

// ❌ 錯誤(JSX 中不能 return 兩個平行元素)
return (
  <Header />
  <Content />
);

-----
7. 事件寫法是駝峰命名,值是函式

非常容易與 HTML 原本的寫法混淆

// ✅ 正確
<button onClick={handleClick}>Click</button>

// ❌ 錯誤
<button onclick="handleClick()">Click</button>

-----
8. 條件渲染

在 JSX 中,沒有像 Vue.js 的 v-if 那樣的指令語法,但可以用 JavaScript 表達式來實現相同的功能,或是三元運算或邏輯 (&&) 來達到條件渲染。

{isLoading && <Loading />}
{isLoggedIn ? <Dashboard /> : <Login />}

-----
9. 條件式為 0 時,通常是非預期的顯示結果

像這樣 isLoading 為 boolean,True 顯示 Loading。

{isLoading && <Loading />}

'', null, False 代表否定、不會顯示任何內容,0 雖然也歸類成否定,對 jsx 卻是可以正確顯示在畫面上的值,務必要讓條件輸出成 boolean 來避免問題。

// ✅ 正確
{item.length > 0 && <List item={item} />}

// ❌ 錯誤
{item.length && <List item={item} />} // 得到的結果是 {0}

-----
10. 列表渲染

JSX 本質上就是 JavaScript + HTML 的混合語法,所有邏輯請交給 JS 處理,Vue.js 的 v-for 可以對應到 .map(),同樣要設定 key 屬性,其值必須唯一。

{items.map((item, index) => (
  <li key={index}>{item}</li>
))}

Vue 支援 JSX 的寫法,但可以直接使用使用 HTML attributes classfor

2024年3月28日 星期四

[Javascript] 四種併發 Promise 差異比較


Promise 代表非同步操作的物件,一個 Promise 可區分成三種狀態:

  • pending - 初始狀態,可以視為處理中
  • fulfilled - 表示操作成功
  • rejected - 表示操作失敗

Promise 的併發控制就是當我們傳入多個 Promise時,藉由達成不同的條件,返回不同狀態的 Promise。


Promise.all()

當輸入的所有 Promise 的狀態都為 fulfilled 時,回傳 Promise 也是 fulfilled、操作成功;可按照輸入 Promise 的順序取得其回傳值。若有任何一項 rejected,立即判定操作失敗。

Promise.all([requestA, requestB, requestC]).then(result => {
    const [resultA, resultB, resultC] = result;
    
    // 全部都成功
}).catch(err => {
    // 可取得第一個失敗原因 
    console.log(err);
})
全部成功才是成功

Promise.allSettled()

當輸入的所有 Promise 的執行完畢時,不論為 fulfilled、rejected,回傳 Promise 必為 fulfilled;可按照其輸入的順序,取得各別的狀態(status)、回傳值(value)或錯誤原因(reason)。

Promise.all([requestA, requestB, requestC]).then(result => {
    const [resultA, resultB, resultC] = result;
    
    // 操作成功時,可以取得回傳值
    // result = {status: 'fulfilled', value: 'Great Job!'}
    // 操作失敗時,可以取得原因
    // result = {status: 'rejected', reason: 'Because ....'}
})
全部完成比較重要,但誰輸誰贏很清楚

Promise.any()

當輸入的所有 Promise 有任一個操作成功時,回傳 Promise 狀態為 fulfilled,且可取得其回傳值。若沒有任何一項成功,則是 rejected,錯誤原因是AggregateError。

Promise.any([requestA, requestB, requestC]).then(result => {
  // 取得最快成功的回傳值
  console.log(result);
}).catch(err => {
  // AggregateError: No Promise in Promise.any was resolved
  console.log(err);
});
龜兔賽跑,爭取第一名

Promise.race()

當輸入的所有 Promise 有任一個操作完成時,根據其狀態決定回傳 Promise 狀態,若第一個操作結果為成功,則為回傳狀態為 fulfilled;反之是 rejected。

Promise.race([requestA, requestB, requestC]).then(result => {
  // 第一個人成功,走這條路
  console.log(result);
}).catch(err => {
  // 第一個人失敗,走這條路
  console.log(err);
});
成敗不是關鍵,速度才是絕對

2023年10月12日 星期四

npm 安裝不依賴 --legacy-peer-deps 解決 Conflicting Peer Dependency 錯誤

當執行 npm install 時,出現錯誤 Conflicting peer dependecy: XXX,並出現提示:Fix the upstream dependecy conflict, or retry this commaned with --force or --legacy-peer-deps

此時只要嘗試執行

npm install --legacy-peer-deps

確實能夠避免這個錯誤,然而卻無法保證套件運作都是正常的,這個方法只能當作緩解,更正確的作法是將套件更新以保證互相兼容,以避免未來引發其他潛在問題。


首先,列出目前套件版本落後的項目:

npm outdated

可以注意我們會得到哪些訊息,Current 為目前安裝的版本;Wanted則是 npm 自動安裝的版本,確保版號的第一碼一致,盡可能地安裝最新的版本;Lastest 則是目前最新的版本。例如下方這個設定,對照圖片上的訊息,npm會自動安裝版本 7.23.2。

{
  "devDependencies": {
    "@babel/core": "^7.16.12"
  }
}

因為下一步我們會將package-lock.json移除,因此若 Current 和 Wanted 不一致,必須確保 Wanted 顯示的版本並不會影響到目前的專案,根據版本的需求做出對應的調整。若確定不升級,請記得務必調整package.json,確保安裝正確的版本。

下一步,移除目錄node_modules/和檔案package-lock.json

最後重新安裝套件:

npm install

確認一下專案是否都正常,大功告成!


2023年8月26日 星期六

[Javascript] stopPropagation 停止事件捕捉及冒泡

近期工作上修正一個因為冒泡(bubbling) 導致的錯誤,神奇的是 chrome 異常但 safari 正常,因此想知道兩個瀏覽器為什麼會有這樣的差異,順便整理一下stopPropagation 的使用方法。

冒泡(bubbling) 是 Javacript 事件傳遞的方式,當一個 div 物件裡有個按鈕(button),也就是 div 為 button 的父元件,兩者都監聽點擊(click) 事件,當使用者點擊按鈕時,按鈕會先被觸發並進一步傳遞給 div 物件觸發點擊事件。

因此依據這樣的特性,可以將事件監聽 (addEventListener) 放在共同的父元件上,達到效能的優化,動態產生 DOM 元件時也不必額外處理事件監聽

<!-- 比較兩者差異 -->
<ul>
    <li onclick="doSomething()"> ... </li>
    <li onclick="doSomething()"> ... </li>
</ul>

<ul onclick="doSomething()">
    <li> ... </li>
    <li> ... </li>
</ul>

但總會有一些例外,例如只希望觸發目標元件(event.target) 的事件,而 stopPropagation 就是可以阻止事件傳播冒泡。

<div id="c">
    <div id="b">
        <button id="a">click</button>
    </div>
</div>

對每一層物件都加上監聽

<script>
document.querySelector("#a").addEventListener('click', event => {
    console.log('a');
});
document.querySelector("#b").addEventListener('click', event => {
    console.log('b');
});
document.querySelector("#c").addEventListener('click', event => {
    console.log('c');
});
</script>

若點擊按鈕,會依需印出 a b c


接下來,其他設定不變,我們稍微調整 #b 加上 stopPropagation

document.querySelector("#b").addEventListener('click', event => {
    event.stopPropagation(); // HERE!!!!

    console.log('b');
});

若點擊按鈕,會依需印出 a b,最外層並不會執行。


另外有 stopImmediatePropagation 極為相似,但不只是停止冒泡到父元件,同一個元件的相同事件也會被停止。舉例 #b 不只是一項 click 事件監聽:

document.querySelector("#b").addEventListener('click', event => {
    event.stopImmediatePropagation(); // HERE!!

    console.log('b');
});
document.querySelector("#b").addEventListener('click', event => {
    console.log('other');
});

若點擊按鈕,也是印出 a b,包含最外層 cother都不會執行。


最後,safari 冒泡的行為我參考 Mouse event bubbling in iOS 這篇文章,必須符合一些情境才會冒泡,ios chrome 也會是相同的實作,但不確定為什麼 116.0.5845.96 這個版本的 chrome 突然又會冒泡。

這次的情境是子元件可以點擊編輯、父元件可以點擊拖曳,因為冒泡所以最後都會進入拖曳的狀態而無法編輯。之前因為大多使用 ios 瀏覽器操作系統,所以沒發現這個 bug,windows 系統下,若子元件未加上 stopPropagation 一樣會遇到無法編輯的問題。


2023年4月29日 星期六

[Javascript] 潛藏 setTimeout 的陷阱


setTimeoutsetInterval 常用在倒數計時或是提醒等,一些時間有關的實作,然而精準校時卻是不可能的任務,只能盡可能減少誤差。


使用方法

說明與範例
非同步函式(Asynchronous function)

setTimeout不會使得任何執行暫停

console.log(new Date());

setTimeout(() => {
  console.log(new Date());
}, 5000);
setTimeout(() => {
  console.log(new Date());
}, 3000);
setTimeout(() => {
  console.log(new Date());
}, 1000);

可以視同三個Timer同時開始倒數,而非停止等候5秒完成執行後再往下一項執行,因此輸出內容:

Sat Apr 29 2023 21:58:34
Sat Apr 29 2023 21:58:35
Sat Apr 29 2023 21:58:37
Sat Apr 29 2023 21:58:39


零延遲 (Zero deplay)

setTimout 參數第二位置傳入 delay 代表指定時間後執行第一個位置的 function,先看下面這個例子:

console.log("This is first line");

setTimeout(() => {
	console.log("This is second line");
}, 0);

console.log("This is third line");

輸出的結果是

This is first line
This is third line
This is second line

當設定成 0 時,預期是「馬上」執行,但更精準地說其實是下一個 event loop,也就是 setTimeout 會被放到 queue 裡等待下一次執行。

以這個例子來看,delay只能當作最短的執行時間。

const s = new Date().getSeconds();

setTimeout(function() {
	// prints out "2", meaning that the callback is not called immediately after 500 milliseconds.
	console.log("Ran after " + (new Date().getSeconds() - s) + " seconds");
}, 500)

while (true) {
	if (new Date().getSeconds() - s >= 2) {
		console.log("Good, looped for 2 seconds")
		break;
    }
}

可以當作 setTimeout 地位是最低的,因此即使設定的 delay 時間雖然到了,只能等待 queue 其他事件處理完成。

Good, looped for 2 seconds
Ran after 2 seconds

2023年3月11日 星期六

原始碼解析 create-vue 快速建立 Vue 專案


Vue 可以透過下面的指令,接著詢問一系列的問題,按照設定快速初始一個專案。(目前版本為 3.6.1)

npm init vue@latest

實際是執行這個 create-vue,想透過原始碼的解析,來了解怎麼樣做一個類似的功能,這個專案架構是如何、如何達成建立專案的功能。


1. playground

運用 git submodules的功能,主要是將各種條件下的樣板做snapshot,形成另一個 create-vue-templates 專案。


2. scripts

顧名思義分別對應到 package.json 的 scripts 所執行的檔案名。

  • build
  • prepublish
  • snapshot
  • test

3. template

將程式碼依照性質分成不同的目錄,根據使用者的設定將對應的檔案複製或合成等,形成 Vue 的專案。

  • base: 基本必備的程式碼,主要套件為 vue, vite
  • code: HelloWorld.vue 元件以及 vue-router 範例程式碼,會分成有無使用 ts 版本。
  • config: 裡面每個目錄依照套件名稱命名,目錄包含:cypress-ct, cypress, jsx, pinia, playwright, router, typescript, vitest,目錄裡是安裝套件的設定檔。主要會有 package.json 設定安裝那些套件,後續會將內容與現有設定合併,再來以 cypress 為例,會有 cypress.config.jscypress.config.ts,屆時會根據是否有用 ts 來複製對應的檔案。
  • entry: 指的是 vite 打包的 entry main.js,執行 createApp 的功能,總共有預設, pinia, router, pinia+router 這四種版本。
  • eslint
  • tsconfig

4. utils

目錄及檔案合併等方法都在這個目錄裡,檔名命名已經相當清楚。


5. index

主要邏輯在這個檔案

  • 讀 argv 指令
  • 使用 prompts 詢問一連串問題
  • 依照專案名稱等,產生 package.json
  • 複製 base
  • 複製 config
  • 複製 tsconfig
  • 產生 eslint
  • 複製 entry
  • 清除 js or ts 多餘的檔案或重新命名
  • 產生 README.md
  • 專案已完成,顯示指令提示

拆解過後,我覺得最重要的是如何整理出 template/,讓不同的安裝需求可以拆解出來,有秩序地組成這個初始的專案。


2023年1月27日 星期五

[Jest+Testing Library] Vue + storybook interaction 元件測試範例


storybook 可以獨立建立元件,並搭配 @storybook/addon-controls 控制 props 輸入及 @storybook/addon-actions 顯示 emit 輸出的內容,當然可以透過手動開啟 GUI 來測試元件是否符合規格,storybook 已經完成 render 的步驟,自動化的測試只差一步了!

storybook 可以搭配 interaction 套件進行元件測試,先按照「[Vue.js] storybook安裝方式與環境建置」完成 vue+storybook 的安裝,文內還是使用 Vue2,如果使用 Vue3 其實步驟差異不大。


安裝

執行指令

npm install --save-dev @storybook/addon-interactions @storybook/jest @storybook/testing-library


設定

請開啟 .storybook/main.js,註冊這個 addon interactions 套件,@storybook/addon-interactions 一定要放在 @storybook/addon-actions@storybook/addon-essentials 的後面。

module.exports = {
    addons: [
        '@storybook/addon-actions', // or '@storybook/addon-essentials'
        '@storybook/addon-interactions',
    ],
    features: {
        interactionsDebugger: true,
    },
};


撰寫測試

寫測試前,要先了解測試元件的重點是「邏輯!」

第一視覺的邏輯,經由 props 或 slots 改變元件顯示上的差異,例如文字內容或是決定是否顯示哪些區域等;第二行為的邏輯,當使用者輸入或點擊按鈕等,此時畫面產生的變化或是 emit 的事件內容等。

使用 jest + testing library 做測試,testing library底層為 vue test utils,兩者相比 testing library 可以更聚焦於元件測試,不容易因為非邏輯的變更,連同測試都要一起做調整。


簡單寫一個元件 NameEditor.vue,有兩個 props,按下按鈕可以發出 event。

<template>
    <div>
        <div
            v-text="message" />
        <input
            type="text"
            placeholder="name"
            v-model.trim="name">
        <button
            type="button"
            @click="onButtonClick"
            v-text="buttonText" />
    </div>
</template>

<script>
import {ref} from 'vue';

export default {
    props: {
        message: {
            type: String,
            required: true,
        },
        buttonText: {
            type: String,
            required: true,
        },
    },
    setup(props, {emit}) {
        const name = ref(undefined);

        const onButtonClick = () => {
            emit('subit', name);
        };

        return {
            name,
            onButtonClick,
        };
    },
};
</script>

接著,NameEditor.stories.js story基本的內容:

import {userEvent, screen} from '@storybook/testing-library';
import {expect} from '@storybook/jest';
import NameEditor from './NameEditor';

export default {
    title: 'NameEditor',
    argTypes: {
        message: {
            control: 'text',
        },
        buttonText: {
            control: 'text',
        },
        onNameSubmit: {
            action: 'submit',
        },
    },
};

const Template = (args, {argTypes}) => ({
    props: Object.keys(argTypes),
    components: {
        NameEditor,
    },
    template: `
        <NameEditor
            :message="message"
            :button-text="buttonText"
            @submit="onNameSubmit" />
    `,
});

export const Default = Template.bind({});

Default.args = {
    message: 'Input your name',
    buttonText: 'Submit',
};

interaction 的測試,必須寫在 play 裡面,測試流程是先輸入文字後,按下按鈕:

Default.play = async ({args}) => {
    const mockName = 'Chenuin';
    const button = screen.getByRole('button');

    await userEvent.type(screen.getByPlaceholderText('name'), mockName); // input name
    await userEvent.click(button); // click button

    const {message, buttonText, onNameSubmit} = args;

    expect(onNameSubmit).toHaveBeenCalledTimes(1);
    expect(onNameSubmit).toBeCalledWith(mockName);

    expect(screen.getByText(message)).toBeTruthy();
    expect(button.textContent).toBe(buttonText);
};

可以透過 args 取得 argTypes 的內容,發出的 event 自動 mock 可以直接驗證,只要把握 testing library獲取Dom的方式,需要注意 getBy...queryBy... 兩者差別,前者若取不到任何相符條件的element,會拋出exception,後者不會。其他部分就是 jest 語法。



執行

開啟 storybook 就會自動執行測試內容,如果有錯誤也會將訊息顯示畫面。

npm run storybook

(畫面如下)


到這邊就告一段落,如果希望能夠導入CI,可以使用 @storybook/test-runner

npm install --save-dev @storybook/test-runner

開啟 package.json 加入 npm script

{
  "scripts": {
    "test-storybook": "npx test-storybook"
  }
}

預設 test runner 會找到 http://localhost:6006 進行測試,可以加上 --url 來改變路徑。

npm run test-storybook

如果不是本地啟用storybook,可以利用 https://www.chromatic.com/ 來管理 storybook,就可以透過 internet 進行。Chromatic 可以用來發布 storybook、主要方便團隊 UI Review,藉此進行 CI 測試整合會更加容易。



參考資料

storybook 套件


2023年1月14日 星期六

第一個 Vue3 元件測試從 Vitest + Vue Test Utils 開始

延續「mocha + webpack 的 Vue3 元件單元測試」,既然使用 mocha 測試太卡關,這次安裝 Vitest 來試試看!

一、安裝

先安裝 Vitest,另外 Vitest 需要使用 Node >=v14,並安裝 Vite >=v3.0.0,還有 vue3 對應的測試套件

npm install --save-dev vite vitest @vue/test-utils @vitejs/plugin-vue

下面這幾個根據需求來選擇是否安裝

npm install --save-dev jsdom

提供UI介面,可參考 Test UI

npm install --save-dev @vitest/ui


二、設定

先到package.json設定npm指令

"scripts": {
    "test": "npx vitest",
}

在根目錄新增檔案 vitest.config.js

import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'

export default defineConfig({
    plugins: [
        Vue(),
    ],
    test: {
        globals: true,
        environment: 'jsdom',
    },
});

常用的設定像是include指定測試檔案的目錄或名稱等,和exclude排除檔案

export default defineConfig({
    test: {
        include: ['**/*.test.js'],
        exclude: ['**/node_modules/**'],
    },
});

設定路徑的alias

export default defineConfig({
    resolve: {
        alias: {
            '@': path.resolve(__dirname, 'src/'),
        },
    },
});

或者可以設定 csssetupFiles來套用css style

export default defineConfig({
    test: {
        css: true,
        setupFiles: './src/vitest/setup.js',
    },
});

src/vitest/setup.js import css

// src/vitest/setup.js

import 'todomvc-app-css/index.css';
import 'todomvc-common/base.css';

其他設定可以參考官方文件

完成基本設定後,就可以開始寫測試囉!



三、測試

針對現有的元件 TodoListEditor 撰寫測試

import { mount } from '@vue/test-utils';
import { test, expect } from 'vitest';
import TodoListEditor from 'components/TodoListEditor.vue';

test('TodoListEditor', () => {
    expect(TodoListEditor).toBeTruthy()

    const wrapper = mount(TodoListEditor, {
        props: {
            todoList,
        },
    });

    expect(wrapper.vm.todoList).toStrictEqual(todoList);
    expect(wrapper.props('todoList')).toStrictEqual(todoList);
});

參考資料



四、執行
npm run test

執行後就可以看到所有的測試項目及結果,若有錯誤也會對應的訊息顯示

有安裝@vitest/ui,可以開啟ui試試看

npm run test -- --ui


五、CI/CD

有了測試之後,我們利用 github action 設定每次 push 分支時,執行 vitest 的測試,請新增檔案 .github/workflows/test.yml

name: Test

on:
  push:
    branches: main

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Before srcipt
        run: npm install

      - name: Test
        run: npm run test -- --run

.github/workflows/gh-pages.yml內的設定:

on:
  workflow_run:
    workflows: ["Test"]
    types:
      - completed

jobs:
  build:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest

    steps:
      ....

我們的目標是當 Test 成功時,才會執行部屬,否則忽略。請注意 completed 指的是完成時,無論測試結果成功與否都會觸發。因此 if: ${{ github.event.workflow_run.conclusion == 'success' }} 的設定是必要的。

也可變化成 ${{ github.event.workflow_run.conclusion == 'failure' }},當測試結果失敗時可以發送通知,根據應用情境改寫。

workflows可以設定多項,只要有任何一項執行就會觸發。


安裝 Vitest 來進行 Vue3 測試十分容易。語法跟 jest 很相像,甚至原本的 mocha 寫的測試也一行都沒改,都能夠支援測試。以上的內容有上傳到 github 上(專案),可以點選下方連結了解執行的內容。


2022年9月28日 星期三

[E2E Testing] 如何從 cypress 9 升級 10

使用 cypress 進行 E2e Testing,針對比較常用的部分整理從 Cypress 9 升級到 10 一些異動,最大的差別是不再支援 cypress.json,並移除了 plugins 目錄。


安裝 cypress 10

npm install --save-dev cypress@10

如何升級至 cypress 10

主要針對 e2e 調整的部分進行說明,啟動 cypress 時,原本會讀取根目錄建立 cypress.json的設定,cypress 10不再支援。檔案格式可以支援 js/ts,底下範例以cypress.config.js為主,另外參數的設定也做出調整:

baseUrl

從設定在最上層,改成專屬 e2e 測試的設定,若只做 component testing 就不需要設定這個參數,讓 e2e / component testing 的設定更一目瞭然。

Before cypress.json
{
  'baseUrl': 'http://localhost:1234'
}
After cypress.config.js
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:1234'
  }
});

pluginsFile

這個設定不需要,直接移除即可。

Before cypress.json
{
  'pluginsFile': 'cypress/plugins/index.js'
}

setupNodeEvents

移除 pluginsFile 的設定後,原本的內容也直接移到 cypress.config.js 進行設定,過去版本9若同時使用 component / e2e testing,可以將設定直接分開來寫。

Before cypress/plugins/index.js
module.exports = (on, config) => {
  if (config.testingType === 'component') {
    // component testing dev server setup code
    // component testing node events setup code
  } else {
    // e2e testing node events setup code
  }
};
After cypress.config.js
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  component: {
    devServer(cypressConfig) {
      // component testing dev server setup code
    },
    setupNodeEvents(on, config) {
      // component testing node events setup code
    },
  },
  e2e: {
    setupNodeEvents(on, config) {
      // e2e testing node events setup code
    },
  },
});

完整內容請參考官網升級指引


相關文章:


2022年7月25日 星期一

Vue3.2 <script setup> 實作 TodoMVC


Vue3.2開始將 <script setup> 移除experimental status,和setup()區別在有許多 option API 有了替代方案,props, emit 也可以寫在 setup`,variable 或 function 也不需要透過 return 才能讓 <template> 使用,哇!寫法怎麼好像有點既是感呢

下面會利用 TodoMVC 的練習,比較與統整 <script setup>setup() 常用的方法的差異。

TodoMVC 完整程式碼上傳至 Github (連結)。

data

setup
<script>
import { ref } from 'vue';

export default {
    setup() {
        const newTodo = ref(undefined);

        return {
            newTodo,
        };
    },
}
</script>
<script setup>
宣告 ref 沒有差異,差在需不需要 return
<script setup>
import { ref } from 'vue';

const newTodo = ref(undefined);
</script>


component

<template>
    <TodoListEditor />
</template>
setup
<script>
import TodoListEditor from 'components/TodoListEditor.vue';

export default {
    components: {
        TodoListEditor,
    },
}
</script>
<script setup>
import 之後就可以直接在 <template> 使用
<script setup>
import TodoListEditor from 'components/TodoListEditor.vue';
</script>


props

setup
<script>
export default {
    props: {
        todoList: {
            required: true,
            type: Array,
        },
    },
}
</script>
<script setup>
defineProps 裡面內容與之前 props 相同
<script setup>
const props = defineProps({
    todoList: {
        required: true,
        type: Array,
    },
});
</script>


emit

setup
<script>
export default {
    emits: ['remove:todo', 'update:todo'],
    setupt(props, {emit}) {
        function removeTodoItem(id){
            emit('remove:todo', id);
        }
    },
}
</script>
<script setup>
defineEmits 裡面內容與之前 emits 相同
<script setup>
const emit = defineEmits(['remove:todo', 'update:todo']);

function removeTodoItem(id){
    emit('remove:todo', id);
}
</script>


directive

directive是所有寫法中我最不適應的,底下是頁面載入時可以有 autofocus 的效果,可以根據不同 lifecyle 定義,利如 mouted
<input
    v-model="newTodo"
    v-focus>
setup
<script>
const focus = {
    mounted: el => el.focus(),
};

export default {
    directives: {
        focus,
    },
}
</script>
<script setup>
<script setup>
const vFocus = {
    mounted: el => el.focus(),
};
</script>


lifecycle

基本上沒有什麼區別
setup
<script>
import {onMounted} from 'vue';

export default {
    setup() {
        onMounted(() => {
            // to something
        });
    },
}
</script>
<script setup>
<script setup>
import {onMounted} from 'vue';

onMounted(() => {
    // to something
});
</script>


2022年7月24日 星期日

mocha + webpack 的 Vue3 元件單元測試

先說結論,我認為不適合用 mocha 進行 vue3 單元測試(@vue/test-utils),反覆查了很久的資料,相關的套件支援度不足等有重重的障礙,根據 @vue/test-utils 目前提供的測試範例,選擇 Vitest 會更適合。

完整程式碼上傳至 Github (連結)。

一、安裝

首先,第一個問題就是 vue 的版本不能太新,目前只支援 3.0.7,因此對應安裝了相同版本的 @vue/server-renderer

npm install --save-dev @vue/server-renderer@3.0.7

再來請安裝 webpck 和 mocha,mochapack,mochapack 是用來讀取 webpack 設定將元件 render 出來的套件,可支援 webpack5 和 mocha 9

npm install --save-dev webpack mocha mochapack

其他有兩個類似的套件:

mochapack 是由 mocha-webpack 延伸而來的,三者用法都非常接近,但上述兩個對 webpack 和 mocha 版本的支援度都比 mochapack 差


再來請安裝 vue3 官方的元件測試套件 @vue/test-utils

npm install --save-dev @vue/test-utils

mocha 不像是 jest 已經內建支援 jsdom、assertion ,所以要另外安裝

npm install --save-dev webpack-node-externals jsdom-global
npm install --save-dev expect


二、設定

新增測試專用的 webpack 設定檔 webpack.config-test.js

// webpack.config-test.js

const nodeExternals = require('webpack-node-externals');
const { VueLoaderPlugin } = require('vue-loader');

module.exports = {
    mode: 'development',
    target: 'node',  // webpack should compile node compatible code
    externals: [nodeExternals()], // in order to ignore all modules in node_modules folder
    devtool: 'inline-cheap-module-source-map',
    module: {
        rules: [
            {
                test: /\.vue$/,
                loader: 'vue-loader',
            },
            {
                test: /\.css$/i,
                use: ['style-loader', 'css-loader'],
            },
        ],
    },
    plugins: [
        new VueLoaderPlugin(),
    ],
};

設定 jsdom 設定檔 src/tests/setup.js

// src/tests/setup.js

require('jsdom-global')();

最後,請到 package.json 設定 script 指令

Usage: mochapack [options] [<file|directory|glob> ...]

Options
 --webpack-config  path to webpack-config file
 --require, -r     require the given module
{
  "scripts": {
    "test": "npx mochapack --webpack-config webpack.config-test.js --require src/tests/setup.js src/tests/**/*.spec.js",
  }
}


三、執行
新增測試(範例請參考 Counter.spec.js)後,執行指令即可
npm run test


四、限制

1. 無法支援目前 vue 最新版本 3.2.37,發現底下錯誤訊息:

ReferenceError: SVGElement is not defined

2. 無法支援 SFC ,發現底下警告訊息:

[Vue warn]: Component is missing template or render function.


參考文件:


2022年6月25日 星期六

【升版指南】Vue3 非兼容的特性 v-model 跟 Vue2 完全不一樣


vue2 v-model 的使用方式在這篇「[Vue.js] 如何在component自訂v-model」介紹,簡單說就是結合v-model其實綁定了名為 valuepropsinputemit事件,因此所謂的 v-model 就是一個父子元件的雙向溝通,在 vue2 兩者是相同的:

<input v-model="username">

<input :value="username"
       @input="(value)=>(username=value)">


但是!

vue3 props 預設名稱從 value 改為 modelValueemit 名稱則從 input 改為 update:modelValue,如果用錯了就無法達成雙向的綁定。

<input v-model="username">

<input :model-value="username"
       @update:modelValue="(value)=>(username=value)">

在 vue2 中可以用自訂 model 來自訂 propsemit 名稱,在 Vue3 移除不再支援,如果不想使用 modelValue 的話,在 Vue3 可以這樣寫:

<MyComponent v-model:username="username" />

<MyComponent
       :username="username"
       @update:username="(value)=>(username=value)" />

元件本身應該增加這個 props,更新時會呼叫 update:username

// MyComponent.vue
props: {
    username: {
        type: String,
        required: true,
    },
}

跟 vue2 .sync 有點相似,vue3不再支援 .sync ,寫法會是 v-model:[NAME] 後面 Name 就是想自訂的名稱,更新時update:[NAME],以事件名稱來看不算是完全客製,須按照規則觸發向上更新

但這個特性最棒的是支援多組 v-model ,你可以這樣寫:

<MyComponent
       v-model:username="username"
       v-model:password="password" />

總結將 vue2 升至 vue3,v-model 應該注意三個地方:

1. 不再使用 .sync

<MyComponent :username.sync="name" />

<!-- to be replaced with -->

<MyComponent v-model:username="name" />

2. 將所有的 value 和 input 和 modelValue 和 update:modelValue

<MyComponent v-model="name" />
// MyComponent.vue

export default {
  props: {
    modelValue: String, // "vaule" is replaced
  },
  emits: ['update:modelValue'],
  methods: {
    changeName(name) {
      this.$emit('update:modelValue', name);  // "input" is replaced
    },
  },
};

3. 不再使用 model

直接用 v-model ,v-model:[Name]


參考文章:https://v3-migration.vuejs.org/breaking-changes/v-model.html


2022年6月8日 星期三

Github Action 自動部署 github-pages


Github pages 適合展示靜態頁面,只要在 github 建立 gh-pages 分支,可依據用戶名稱和repo名稱來開啟頁面。

https://[USER_NAME].github.io/[REPO_NAME]/

以這個專案為例

https://github.com/chenuin/JavaScript30

路徑會是

https://chenuin.github.io/JavaScript30/


使用 JamesIves/github-pages-deploy-action@v4.3.3 有兩個必填參數:

  • branch: 分支
  • folder: 目錄,.代表整個repo根目錄

參數詳細說明請參考這裡,在 repo 裡新增 github action (連結):

name: Github pages

on:
  # Triggers the workflow on push events but only for the "master" branch
  push:
    branches: master

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout 🛎️
        uses: actions/checkout@v3

      - name: Deploy 🚀
        uses: JamesIves/github-pages-deploy-action@v4.3.3
        with:
          branch: gh-pages
          folder: .
          token: ${{ secrets.GH_PAT }}

GH_PAT 設定方式,請先至 Settings => Developer settings => Personal access tokens (或下方連結),按右上角新增Token,scopes 可直接勾選 repo 並將Token複製。

https://github.com/settings/tokens


再到專案的 Settings => Secrets => Actions (或下方連結),按右上角新增,命名 GH_PAT 並將剛剛複製的Token貼上。

https://github.com/[USER_NAME]/[REPO_NAME]/settings/secrets/actions


EASY!!


相關文章:


2022年5月28日 星期六

Vue3+jest 測試 composable 範例


Vue3 的 composable 乍看下和 mixin 用途很類似,可以提供各個元件共用程式碼。但與 mixin 相比,composable 主要有三個優勢:

第一,元件可以很明確的區分使用的 composable 來源,當使用的 mixin 一多時,追朔來源相對困難,無法一眼看出由哪個 mixin 實作。

第二,多個 mixin 無法確保使用了相同的名稱,可能造成覆蓋,但 composable 即使有相同的名稱,也能透過結構式賦值、重新命名。

最後,多個 mixin 需要交互作用時,通常會使用相同的參數命名來達到這個目的,隱性的耦合使得辨識和debug難度增加,composable可藉由其一的回傳值,作為其他composable輸入的參數達到共享的目的。(參考資料)


安裝 Jest 測試 vue 時,首先要注意版本,請參考下面的對照表安裝套件:

Vue version Jest Version npm Package

Vue 2 Jest 26 and below vue-jest@4

Vue 3 Jest 26 and below vue-jest@5

Vue 2 Jest 27 and above @vue/vue2-jest@27

Vue 3 Jest 27 and above @vue/vue3-jest@27

Vue 2 Jest 28 and above @vue/vue2-jest@28

Vue 3 Jest 28 and above @vue/vue3-jest@28


composable 的測試主要分成兩種,第一種元件比較無關,可以當作普通的 js code 測試。

// counter.js
import {ref} from 'vue';

export function useCounter() {
  const count = ref(0);
  const increment = () => count.value++;

  return {
    count,
    increment,
  };
};
// counter.test.js
import {useCounter} from './counter.js';

test('useCounter', () => {
  const {count, increment} = useCounter()
  expect(count.value).toBe(0);

  increment();
  expect(count.value).toBe(1);
});

如果 composable 牽涉到元件 lifecyle hooks 或是 provide/inject 時,需要依附一個元件進行測試。

// test-utils.js
import {createApp} from 'vue';

export function withSetup(composable) {
  let result;

  const app = createApp({
    setup() {
      result = composable();
      // suppress missing template warning
      return () => {};
    },
  });

  app.mount(document.createElement('div'));
  // return the result and the app instance
  // for testing provide / unmount
  return [result, app];
};
// counter.js
import {ref, onMounted} from 'vue';

export function useCounter() {
  const count = ref(0);
  const increment = () => count.value++;
  
  onMounted(() => {
    increment();
  });

  return {
    count,
    increment,
  };
};

result是 composable 的回傳值(return),測試裡面 app.mount();,將執行 onMounted

// counter.test.js
import {withSetup} from './test-utils';
import {useCounter} from './counter.js'

test('useCounter', () => {
  const [result, app] = withSetup(() => useCounter());

  // run assertions
  expect(result.count.value).toBe(0);

  // trigger onMounted hook if needed
  app.mount();

  expect(result.count.value).toBe(1);
});


另外還有一個常犯的錯誤,先看 composable 程式碼,有一個 computed 使用 formatFn 轉換 list:

// useListFormatter.js
import {computed, unref} from 'vue';

export function useListFormatter(list, formatFn) {
  const formattedList = computed(() => (
    unref(list).map(item => formatFn(item))
  ));

  return {
    formattedList,
  };
};

測試時需要注意回傳值被使用之前,formattedList 不會被執行。

// useListFormatter.test.js
import {useListFormatter} from './useListFormatter.js';

test('useListFormatter', () => {
  const list = ['a', 'b'];
  const formatFn = jest.fn();

  formatFn.mockImplementation(value => `new-${value}`)

  const {formattedList} = useListFormatter();
  
  // Don't do it! formatFn won't be excuted until formattedList is called.
  // expect(formatFn).toHaveBeenCalled();
  
  expect(formatFn).not.toHaveBeenCalled();

  expect(formattedList.value).toEqual(['new-a', 'new-b']);
  expect(formatFn).toHaveBeenCalledTimes(2);
  expect(formatFn).toHaveBeenNthCalledWith(1, 'a');
  expect(formatFn).toHaveBeenNthCalledWith(2, 'b');
});


參考資料:

https://vuejs.org/guide/scaling-up/testing.html#testing-composables


2022年3月5日 星期六

測試套件 Jest mock 基礎概念及 spyOn, fn 範例程式

當測試的對象有使用其他引用(import)時,我們希望能做為控制變因,設法聚焦測試的對象。Jest是常用的js單元測試套件,我們會 mock 方法spyOnfn來達成這個目的,Jest 安裝可以參考「[JavaScript] 安裝Jest單元測試」,底下說明幾種常用的情境。

jest.spyOn(object, methodName)

1. [基本用法] 監聽
單純測試某個method被呼叫的次數,或(官方說明),舉例下面範例是輸入兩個數字,並隨機輸出這個範圍內的數字。
// getRandonInt.js

export const getRandonInt = (min, max) => (
    Math.floor(Math.random(max) * (max - min) + min)
);
例如 Math.floor
jest.spyOn(Math, 'floor')
同理 Math.random ,所以可以將測試寫成:
import {getRandonInt} from './getRandonInt.js';

test('Should get random number between input min and max', () => {
    const spyFloor = jest.spyOn(Math, 'floor');
    const spyRandom = jest.spyOn(Math, 'random');

    const result = getRandonInt(1, 100);

    expect(result).toBeGreaterThanOrEqual(1);
    expect(result).toBeLessThanOrEqual(100);

    expect(spyFloor).toHaveBeenCalledTimes(1);

    expect(spyRandom).toHaveBeenCalledTimes(1);
    expect(spyRandom).toHaveBeenCalledWith(100);
});

因此可看到我們監聽的兩個method,第一 toHaveBeenCalledTimes 分別被呼叫的次數,及 toHaveBeenCalledWith 傳入的參數。


2. [進階] 替換回傳值
改變某個method的回傳值,以下面的範例是希望輸入一個值後,可以隨機產生一個數字並得到兩者相乘的結果
// multiplyNumber.js

export const multiplyNumber = num => (
    Math.random(10) * num
);
如果希望控制隨機產生的數字,可以使用 mockImplementation
import {multiplyNumber} from './multiplyNumber.js';

test('Should get the multiple of random number and input num', () => {
    const spyRandom = jest.spyOn(Math, 'random')
        .mockImplementation(() => 5);

    expect(Math.random(10)).toBe(5);
    expect(multiplyNumber(1000)).toBe(5000);

    spyRandom.mockRestore();

    expect(Math.random(10)).not.toBe(5);
});
可以理解成實際我們已經將 Math.random 變成:
Math['random'] = () => 5;

// you could definitely get parameter when method was called
// so implementation would be like ...
// Math['random'] = a => (a + 1);

mockRestore()可以恢復原本method的功能,因此直到我們呼叫 mockRestore()為止,無論我們呼叫幾次 Math.random ,都會按照我們的設定回傳數字 5。


假設希望這個設定是一次性的,則可以改用 mockImplementationOnce,這個寫法比較不容易出錯,是比較推薦的用法。

jest.spyOn(Math, 'random')
    .mockImplementationOnce(() => 5);
    .mockImplementationOnce(() => 20);

expect(Math.random(10)).toBe(5);
expect(Math.random(10)).toBe(20);


jest.fn(implementation)

用法和 spyOn非常類似,可以用來驗證呼叫次數及傳入參數等

const mockFn = jest.fn();

mockFn(100);

expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(100);
驗證的部分,toHaveBeenCalledTimestoHaveBeenCalledWith這兩個還有另一個寫法:
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn.mock.calls.length).toBe(1);

expect(mockFn).toHaveBeenCalledWith(100);
expect(mockFn.mock.calls[0][0]).toBe(100);

calls是一個陣列,長度等同於呼叫的次數,陣列內每一個位置也都是一個陣列,每個位置則對應到呼叫時的參數內容。可以實際印出得到 calls = [[100]],所以calls[0][0]代表的是第一次呼叫時第一個參數是什麼


依照 multiplyNumber.js 的範例,可以使用fn達到同樣的效果,測試結束前也要記得呼叫mockRestore免得影響其他測試‧。

const spyRandom = jest.fn(() => 5);
Math['random'] = spyRandom;
const spyRandom = jest.fn()
    .mockImplementation(() => 5);
Math['random'] = spyRandom;
const spyRandom = jest.fn()
    .mockReturnValueOnce(5);
Math['random'] = spyRandom;


jest.mock(module)

當測試有引入(import)其他模組、函式時,可以使用mock來替換回傳內容,我們稍微改寫剛剛的 multiplyNumber

import {getRandonInt} from './combineArray.js';

export const multiplyNumber = (min, max, num) => (
    getRandonInt(min, max) * num
);

為了清楚此時的getRandonInt已經被替換掉,我們刻意重新命名,在測試裡將回傳值設定為數字 20

import {getRandonInt as mockGetRandonInt} from './getRandonInt';
import {multiplyNumber} from './multiplyNumber';

jest.mock('./getRandonInt');

test('Should get the multiple of random number and input num', () => {
    mockGetRandonInt.mockReturnValueOnce(20);

    expect(multiplyNumber(1, 2, 3)).toBe(60);

    expect(mockGetRandonInt).toHaveBeenCalledTimes(1);
    expect(mockGetRandonInt).toHaveBeenCalledWith(1, 2);
});

另外可以利用requireActual可以取得原始的功能,當我們只需要部份取代時,可以藉此替換部分的內容,其餘皆按照原設定

const {getRandonInt} = jest.requireActual('../src/libs/combineArray');

mockGetRandonInt.mockImplementationOnce((a, b) => a + b);

expect(getRandonInt(1, 2)).toBeLessThanOrEqual(2);

expect(mockGetRandonInt(1, 2)).toBe(3);
expect(mockGetRandonInt).toHaveBeenCalledTimes(1);

最後一個重點,建立和初始化一個class的物件時,可以透過instances來得到建立的物件,它是一個陣列,會依照建立順序放在對應的位置上。

const myMock = jest.fn();

const a = new myMock();

expect(a).toBe(myMock.mock.instances[0]);

參考資料:



2022年2月22日 星期二

Cypress+Vue 元件測試 (component testing)

接續「e2e 測試工具 Cypress 安裝及介紹」,除了用在實際開啟瀏覽器的測試,cypress也支援元件測試,需要安裝相關的套件將元件render出來。


安裝
npm install --save-dev cypress @cypress/vue @cypress/webpack-dev-server webpack-dev-server

如果是 Vue3 ,請記得把 @cypress/vue 換成:

npm install --save-dev @cypress/vue@next


設定

先到 cypress/plugins/index.js 的註冊 dev-server:start 事件,再來要注意 require('@vue/cli-service/webpack.config.js')這段,你的專案不見得有安裝這個套件,你可以建立自己的webpack設定,並替換上正確的路徑。

// cypress/plugins/index.js

const { startDevServer } = require('@cypress/webpack-dev-server');
// Vue's Webpack configuration
const webpackConfig = require('@vue/cli-service/webpack.config.js');

module.exports = (on, config) => {
  on('dev-server:start', options =>
    startDevServer({ options, webpackConfig });
  );
};

若我們要測試的元件內容為:

<!-- Button.vue -->
<template>
  <button>
    <slot />
  </button>
</template>

在測試時,vue2和vue3引入 mount 的寫法相同,並且將設定元件所需的輸入

import {mount} from '@cypress/vue'
import Button from './Button.vue';

it('Button', () => {
  mount(Button, {
    slots: {
      default: 'Test button',
    },
  });

  cy.get('button').contains('Test button').click();
});


執行

提供 GUI 介面操作

npx cypress open-ct

無GUI,在 Terminal 上執行

npx cypress run-ct


參考文件: https://docs.cypress.io/guides/component-testing/introduction


2022年1月31日 星期一

e2e 測試工具 Cypress 安裝及介紹


e2e測試(End-to-End testing)是模擬使用者的行為,實際打開瀏覽器操作頁面的測試方式,因此不限於vue, react的專案,e2e的測試可以反映出頁面在不同瀏覽器的執行情形。

Cypress便是其中一個常見工具,Cypress在安裝上簡單,語法採用 mocha 的句法、chai 的斷詞(expect, should, assert),撰寫的方式跟 unit test 相似。


安裝

進到想要測試的專案目錄內,使用 npm 安裝。(官網說明)

npm install cypress --save-dev

接著在目錄裡,建立先建立一個 cypress.json,內容放一個空物件,此時你可以直接執行下面的命令(官網說明):

npx cypress open

會發現多了一個 cypress/ 的資料夾,裡面有四個目錄:

  • integration: 主要的測試檔案
  • fixtures: 固定的資料
  • plugins: 主要有 on 定義全局的不同時間點的執行工作,以及 config 提供全局使用的參數,如常用的 env
  • support: 可提供自定義的指令、或引入擴充的指令,供撰寫測試檔案使用


Cypress.json 與環境變數(.env)

使用 cypress 都必須在專案的根目錄裡新增 cypress.json,如果直接放 {} 空物件,代表直接使用預設值。

下面是我的設定,從字面上不難理解設定的內容,第一個testFiles是測試的檔案名稱,接著各個目錄的路徑,video是 Boolean 決定是否錄製測試過程的影片,screenshotOnRunFailure也是 Boolean 是否在執行失敗時擷取畫面。

{
    "testFiles": "**/*.spec.js",
    "integrationFolder": "resources/cypress/integration",
    "componentFolder": "resources/cypress/components",
    "fixturesFolder": "resources/cypress/fixtures",
    "pluginsFile": "resources/cypress/plugins",
    "supportFile": "resources/cypress/support",
    "video": false,
    "screenshotOnRunFailure": false
}

除了cypress基本的設定,常常也需要讀取環境設定檔(.env),假設內容如下(官方說明):

APP_URL=https://chenuin.github.io/
USER_NAME=chenuin

plugins/index.js可以設定:

// plugins/index.js

require('dotenv').config();

module.exports = (on, config) => {
    config.baseUrl = process.env.APP_URL;

    config.env.USER_NAME = process.env.USER_NAME;

    return config;
}
測試檔案需要使用時,可以直接呼叫:
Cypress.config('baseUrl')
Cypress.env('USER_NAME')

打開GUI,點選上方的 Settings 可以看到設定的結果,上面的顏色可以區分目前的設定是讀取哪裡,像是 baseUrl 是紫色的 plugin,

因此,可以知道下面兩種寫法都可以達到相同的效果:

// plugins/index.js
config.baseUrl = process.env.APP_URL;


// cypress.json
{
    "baseUrl": "https://chenuin.github.io/"
}


執行

提供 GUI 介面操作

npx cypress open

無GUI,在 Terminal 上執行

npx cypress run


訪問頁面 visit

測試的第一步常常是先進入某一個頁面,通常專案內都在相同的Domain下,如果有設定 baseUrl,Cypress會直接將設定作為前綴,導到相應的頁面,在寫法上簡潔許多。

// https://chenuin.github.io/webpack/2021/08/08/webpack5-getting-started.html

cy.visit('/webpack/2021/08/08/webpack5-getting-started.html')

若未設定,需要傳入完整的路徑,或者cypress會嘗試在你的web server上找到對應位置的頁面。(官方說明)


intercept與fixtures的使用

使用 intercept 來監聽由頁面上請求(request)及回應(response),下面三者是相同的意思:

cy.intercept('/users/**')
cy.intercept('GET', '/users/**')
cy.intercept({
  method: 'GET',
  url: '/users/**',
})

你也可以為這個路由(route)新增別名(alias),除了方便辨識外,如果需要在request後執行的工作,可以使用下面的寫法:

cy.intercept('POST', '/users').as('createUser');

// once a request to create user responds, 'cy.wait' will resolve
cy.wait('@createUser')

GUI上可以看到這個 route 表格

執行此請求時會留下Log記錄,所有被監聽的路由會列在route表格,表格上可以看到所有route被呼叫的次數(表格最右側的數字),對應的別名(alias)是什麼。

上面 Stubbed 是代表回應(response)是否被取代了,這個是很好用的功能,例如:當我們將 users 這個請求的回應,取代為一個列表:

// requests to '/users' will be fulfilled
// with a body of list: ['John', 'Ben', 'Mary]

cy.intercept(
  '/users',
  ['John', 'Ben', 'Mary],
)

或者可以使用 fixture ,後面填寫 fixture/ 目錄裡的檔案名稱。

// requests to '/users' will be fulfilled
// with the contents of the "users.json" fixture

cy.intercept(
  '/users',
  { fixture: 'users.json' },
)
(官方說明)


ESLint設定

請在 .eslintrc.js 加上設定,不需要安裝任何套件:

module.exports = {
  extends: [
    'plugin:cypress/recommended',
  ],
};



2022年1月25日 星期二

js 單元測試套件 Macha+Chai (es6 語法 Babel 設定)

安裝

首先安裝 mocha (官網)和 chai (官網)

npm install mocha chai --save-dev

chai 是一種斷言庫(Assertion Library),mocha 不像是jest已經內建,可以自行決定要使用哪一種套件,chai是其中最常見的一種。

package.json加入測試指令

"scripts": {
  "test": "npx mocha"
}


設定 Babel

安裝 Babel,再來的說明都會直接使用es6的語法,如果不需要,可以略過這部分。

npm install @babel/core @babel/preset-env @babel/register --save-dev

接著在根目錄新增 babel.config.js

// babel.config.js

module.exports = {
    presets: [
        '@babel/preset-env',
    ],
};

接著在根目錄新增 .mocharc.jsspec是設定 mocha 執行測試的檔案,預設為 test/ ,這裡設定成自動讀取 src/test/ 底下所有以 spec.js 為結尾的檔案。

require可以接陣列設定所需的 module

// .mocharc.js

module.exports = {
    spec: ['src/test/*.spec.js'],
    require: ['@babel/register'],
};

.mocharc.js 是 mocha 執行時會自動讀取的設定檔,也支援json 或 yaml(說明),可以選擇喜歡的格式新增,設定檔的內容可以參考:https://github.com/mochajs/mocha/tree/master/example/config

設定參考資料:
https://github.com/mochajs/mocha-examples/tree/master/packages/babel



建立測試

新增一個簡單的測試

// src/test/array.spec.js

import chai from 'chai';

describe('Array', () => {
  describe('#indexOf()', () => {
    it('should return -1 when the value is not present', function() {
      chai.assert.equal([1, 2, 3].indexOf(4), -1);
    });
  });
});

馬上執行試試看

npm run test

應該會看到下面的資訊,

> vue3-webpack5-template@1.0.0 test
> npx mocha


  Array
      #indexOf()
          should return -1 when the value is not present

1 passing (8ms)

完成第一個測試囉!



支援 async/await

請安裝套件

npm install @babel/plugin-transform-runtime --save-dev

把套件加到 babel.config.js的 plugin 設定

module.exports = {
    require: ['@babel/register'],
    plugins: ['@babel/plugin-transform-runtime'],
};


Root Hooks

mocha 提供的 hook 包含 before(), after(), beforeEach(), and afterEach(),從語意應該不難看出用途,前兩個只會執行一次,後兩個則是每項測試都會執行。

若要在所有的測試上加上 Hook,則是有這四種 beforeAll(), afterAll(), beforeEach(), and afterEach(),執行時機不變,請根據情境加上執行內容:

// src/test/hooks.js

export const mochaHooks = {
  beforeEach: () => {
    // do something before every test

  }
};

接著在 .mocharc.js 加上設定

module.exports = {
    spec: ['src/test/*.spec.js'],
    require: [
      '@babel/register',
      'src/test/hooks.js',
    ],
};

https://mochajs.org/#hooks
https://mochajs.org/#defining-a-root-hook-plugin



測試結果

mocha 是個可愛的名稱,測試結果也可以換成很多有趣的模式:

npx mocha --report landing

https://mochajs.org/#reporters



支援瀏覽器介面

加上指定的路徑執行下面的指令

npx mocha init [PATH]
  • index.html
  • mocha.css
  • mocha.js
  • test.spec.js

目錄底下會多了上述這些檔案,此時只要將 test.spec.js 加入你的測試,再重新整理 index.html,就能看到測試結果已經輸出在畫面上。


可以將 chai 加入 index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Mocha</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="mocha.css" />
    <script src="https://unpkg.com/chai/chai.js"></script>
  </head>
  <body>
    <div id="mocha"></div>
    <script src="mocha.js"></script>
    <script>
      mocha.setup('bdd');
    </script>
    <script src="tests.spec.js"></script>
    <script>
      mocha.run();
    </script>
  </body>
</html>

再來可以直接將前面新增的 src/test/array.spec.js 內容貼過去,index.html 已經載入 mocha 和 chai ,記得把第一行的 import 拿掉。