測試 Dojo 應用程式(詳解)
【翻譯】https://github.com/dojo/framework/blob/master/docs/en/testing/supplemental.md
測試服務
Intern 支援在 BrowserStack、SauceLabs 和 TestingBot 等服務上遠端執行測試。您可以選用其中一個服務,註冊一個帳號並將憑據提供給 cli-test-intern。預設情況下,所有測試服務會在 IE11、Firefox 和 Chrome 等瀏覽器上執行測試。
BrowserStack
使用 BrowserStack 服務,需提供 access key 和使用者名稱。Access key 和使用者名稱可在命令列中指定或設定為環境變數,詳見
dojo test -a -c browserstack -k <accesskey> --userName <username>
或使用環境變數
BROWSERSTACK_USERNAME=<username> BROWSERSTACK_ACCESS_KEY=<key> dojo test -a -c browserstack
SauceLabs
使用 SauceLabs 服務,需提供 access key 和使用者名稱。Access key 和使用者名稱可在命令列中指定或設定為環境變數,詳見 Intern 文件。
dojo test -a -c saucelabs -k <accesskey> --userName <username>
或使用環境變數
SAUCE_USERNAME=<username> SAUCE_ACCESS_KEY=<key> dojo test -a -c saucelabs
TestingBot
使用 TestingBot 服務,需提供 key 和 secret。Key 和 secret 可在命令列中指定或設定為環境變數,詳見 Intern 文件。
dojo test -a -c testingbot -k <key> -s <secret>
或使用環境變數
TESTINGBOT_SECRET=<secret> TESTINGBOT_KEY=<key> dojo test -a -c saucelabs
harness
當使用 @dojo/framework/testing
時,harness()
是最重要的 API,主要用於設定每一個測試並提供一個執行虛擬 DOM 斷言和互動的上下文。目的在於當更新 properties
或 children
,以及部件失效時,映象部件的核心行為,並且不需要任何特殊或自定義邏輯。
API
interface HarnessOptions {
customComparators?: CustomComparator[];
middleware?: [MiddlewareResultFactory<any, any, any>, MiddlewareResultFactory<any, any, any>][];
}
harness(renderFunction: () => WNode, customComparators?: CustomComparator[]): Harness;
harness(renderFunction: () => WNode, options?: HarnessOptions): Harness;
renderFunction
: 返回被測部件 WNode 的函式customComparators
: 一組自定義的比較器描述符。每個描述符提供一個比較器函式,用於比較通過selector
和property
定位到的properties
options
: harness 的擴充套件選項,包括customComparators
和一組 middleware/mocks 元組。
harness 函式返回一個 Harness
物件,該物件提供了幾個與被測部件互動的 API:
Harness
expect
: 對被測部件完整的渲染結果執行斷言expectPartial
: 對被測部件部分渲染結果執行斷言trigger
: 用於在被測部件的節點上觸發函式getRender
: 根據提供的索引,從 harness 中返回對應的渲染器
使用 @dojo/framework/core
中的 w()
函式生成一個用於測試的部件是非常簡單的:
const { describe, it } = intern.getInterface('bdd');
import WidgetBase from '@dojo/framework/widget-core/WidgetBase';
import harness from '@dojo/framework/testing/harness';
import { w, v } from '@dojo/framework/widget-core/d';
class MyWidget extends WidgetBase<{ foo: string }> {
protected render() {
const { foo } = this.properties;
return v('div', { foo }, this.children);
}
}
const h = harness(() => w(MyWidget, { foo: 'bar' }, ['child']));
如下所示,harness 函式也支援 tsx
。README 文件中其餘示例均使用程式設計式的 w()
API,在 單元測試 中可檢視更多 tsx
示例。
const h = harness(() => <MyWidget foo="bar">child</MyWidget>);
renderFunction
是延遲執行的,所以可在斷言之間包含額外的邏輯來操作部件的 properties
和 children
。
describe('MyWidget', () => {
it('renders with foo correctly', () => {
let foo = 'bar';
const h = harness(() => {
return w(MyWidget, { foo }, [ 'child' ]));
};
h.expect(/** assertion that includes bar **/);
// update the property that is passed to the widget
foo = 'foo';
h.expect(/** assertion that includes foo **/)
});
});
Mocking 中介軟體
當初始化 harness 時,可將 mock 中介軟體指定為 HarnessOptions
值的一部分。Mock 中介軟體被定義為由原始的中介軟體和 mock 中介軟體的實現組成的元組。Mock 中介軟體的建立方式與其他中介軟體相同。
import myMiddleware from './myMiddleware';
import myMockMiddleware from './myMockMiddleware';
import harness from '@dojo/framework/testing/harness';
import MyWidget from './MyWidget';
describe('MyWidget', () => {
it('renders', () => {
const h = harness(() => <MyWidget />, { middleware: [[myMiddleware, myMockMiddleware]] });
h.expect(/** assertion that executes the mock middleware instead of the normal middleware **/);
});
});
Harness 會自動 mock 很多核心中介軟體,並注入到任何需要他們的中介軟體中:
- invalidator
- setProperty
- destroy
Dojo Mock 中介軟體
當測試使用了 Dojo 中介軟體的部件時,有很多 mock 中介軟體可以使用。Mock 會匯出一個 factory,該 factory 會建立一個受限作用域的 mock 中介軟體,會在每個測試中使用。
Mock node
中介軟體
使用 @dojo/framework/testing/mocks/middleware/node
中的 createNodeMock
可 mock 一個 node 中介軟體。要設定從 node mock 中返回的期望值,需要呼叫建立的 mock node 中介軟體,並傳入 key
和期望的 DOM node。
import createNodeMock from '@dojo/framework/testing/mocks/middleware/node';
// create the mock node middleware
const mockNode = createNodeMock();
// create a mock DOM node
const domNode = {};
// call the mock middleware with a key and the DOM
// to return.
mockNode('key', domNode);
Mock intersection
中介軟體
使用 @dojo/framework/testing/mocks/middleware/intersection
中的 createIntersectionMock
可 mock 一個 intersection 中介軟體。要設定從 intersection mock 中返回的期望值,需要呼叫建立的 mock intersection 中介軟體,並傳入 key
和期望的 intersection 詳情。
考慮以下部件:
import { create, tsx } from '@dojo/framework/core/vdom';
import intersection from '@dojo/framework/core/middleware/intersection';
const factory = create({ intersection });
const App = factory(({ middleware: { intersection } }) => {
const details = intersection.get('root');
return <div key="root">{JSON.stringify(details)}</div>;
});
使用 mock intersection 中介軟體:
import { tsx } from '@dojo/framework/core/vdom';
import createIntersectionMock from '@dojo/framework/testing/mocks/middleware/intersection';
import intersection from '@dojo/framework/core/middleware/intersection';
import harness from '@dojo/framework/testing/harness';
import MyWidget from './MyWidget';
describe('MyWidget', () => {
it('test', () => {
// create the intersection mock
const intersectionMock = createIntersectionMock();
// pass the intersection mock to the harness so it knows to
// replace the original middleware
const h = harness(() => <App key="app" />, { middleware: [[intersection, intersectionMock]] });
// call harness.expect as usual, asserting the default response
h.expect(() => <div key="root">{`{"intersectionRatio":0,"isIntersecting":false}`}</div>);
// use the intersection mock to set the expected return
// of the intersection middleware by key
intersectionMock('root', { isIntersecting: true });
// assert again with the updated expectation
h.expect(() => <div key="root">{`{"isIntersecting": true }`}</div>);
});
});
Mock resize
中介軟體
使用 @dojo/framework/testing/mocks/middleware/resize
中的 createResizeMock
可 mock 一個 resize 中介軟體。要設定從 resize mock 中返回的期望值,需要呼叫建立的 mock resize 中介軟體,並傳入 key
和期望的容納內容的矩形區域。
const mockResize = createResizeMock();
mockResize('key', { width: 100 });
考慮以下部件:
import { create, tsx } from '@dojo/framework/core/vdom'
import resize from '@dojo/framework/core/middleware/resize'
const factory = create({ resize });
export const MyWidget = factory(function MyWidget({ middleware }) => {
const { resize } = middleware;
const contentRects = resize.get('root');
return <div key="root">{JSON.stringify(contentRects)}</div>;
});
使用 mock resize 中介軟體:
import { tsx } from '@dojo/framework/core/vdom';
import createResizeMock from '@dojo/framework/testing/mocks/middleware/resize';
import resize from '@dojo/framework/core/middleware/resize';
import harness from '@dojo/framework/testing/harness';
import MyWidget from './MyWidget';
describe('MyWidget', () => {
it('test', () => {
// create the resize mock
const resizeMock = createResizeMock();
// pass the resize mock to the harness so it knows to replace the original
// middleware
const h = harness(() => <App key="app" />, { middleware: [[resize, resizeMock]] });
// call harness.expect as usual
h.expect(() => <div key="root">null</div>);
// use the resize mock to set the expected return of the resize middleware
// by key
resizeMock('root', { width: 100 });
// assert again with the updated expectation
h.expect(() => <div key="root">{`{"width":100}`}</div>);
});
});
Mock Store
中介軟體
使用 @dojo/framework/testing/mocks/middleware/store
中的 createMockStoreMiddleware
可 mock 一個強型別的 store 中介軟體,也支援 mock process。為了 mock 一個 store 的 process,可傳入一個由原始 store process 和 stub process 組成的元組。中介軟體會改為呼叫 stub,而不是呼叫原始的 process。如果沒有傳入 stub,中介軟體將停止呼叫所有的 process。
要修改 mock store 中的值,需要呼叫 mockStore
,並傳入一個返回一組 store 操作的函式。這將注入 store 的 path
函式,以建立指向需要修改的狀態的指標。
mockStore((path) => [replace(path('details', { id: 'id' })]);
考慮以下部件:
src/MyWidget.tsx
import { create, tsx } from '@dojo/framework/core/vdom'
import { myProcess } from './processes';
import MyState from './interfaces';
// application store middleware typed with the state interface
// Example: `const store = createStoreMiddleware<MyState>();`
import store from './store';
const factory = create({ store }).properties<{ id: string }>();
export default factory(function MyWidget({ properties, middleware: store }) {
const { id } = properties();
const { path, get, executor } = store;
const details = get(path('details');
let isLoading = get(path('isLoading'));
if ((!details || details.id !== id) && !isLoading) {
executor(myProcess)({ id });
isLoading = true;
}
if (isLoading) {
return <Loading />;
}
return <ShowDetails {...details} />;
});
使用 mock store 中介軟體:
tests/unit/MyWidget.tsx
import { tsx } from '@dojo/framework/core/vdom'
import createMockStoreMiddleware from '@dojo/framework/testing/mocks/middleware/store';
import harness from '@dojo/framework/testing/harness';
import { myProcess } from './processes';
import MyWidget from './MyWidget';
import MyState from './interfaces';
import store from './store';
// import a stub/mock lib, doesn't have to be sinon
import { stub } from 'sinon';
describe('MyWidget', () => {
it('test', () => {
const properties = {
id: 'id'
};
const myProcessStub = stub();
// type safe mock store middleware
// pass through an array of tuples `[originalProcess, stub]` for mocked processes
// calls to processes not stubbed/mocked get ignored
const mockStore = createMockStoreMiddleware<MyState>([[myProcess, myProcessStub]]);
const h = harness(() => <MyWidget {...properties} />, {
middleware: [store, mockStore]
});
h.expect(/* assertion template for `Loading`*/);
// assert again the stubbed process
expect(myProcessStub.calledWith({ id: 'id' })).toBeTruthy();
mockStore((path) => [replace(path('isLoading', true)]);
h.expect(/* assertion template for `Loading`*/);
expect(myProcessStub.calledOnce()).toBeTruthy();
// use the mock store to apply operations to the store
mockStore((path) => [replace(path('details', { id: 'id' })]);
mockStore((path) => [replace(path('isLoading', true)]);
h.expect(/* assertion template for `ShowDetails`*/);
properties.id = 'other';
h.expect(/* assertion template for `Loading`*/);
expect(myProcessStub.calledTwice()).toBeTruthy();
expect(myProcessStub.secondCall.calledWith({ id: 'other' })).toBeTruthy();
mockStore((path) => [replace(path('details', { id: 'other' })]);
h.expect(/* assertion template for `ShowDetails`*/);
});
});
Custom Comparators
在某些情況下,我們在測試期間無法得知屬性的確切值,所以需要使用自定義比較描述符(custom compare descriptor)。
描述符中有一個用於定位要檢查的虛擬節點的 selector
,一個應用自定義比較的屬性名和一個接收實際值並返回一個 boolean 型別斷言結果的比較器函式。
const compareId = {
selector: '*', // all nodes
property: 'id',
comparator: (value: any) => typeof value === 'string' // checks the property value is a string
};
const h = harness(() => w(MyWidget, {}), [compareId]);
對於所有的斷言,返回的 harness
API 將只對 id
屬性使用 comparator
進行測試,而不是標準的相等測試。
selectors
harness
API 支援 CSS style 選擇器概念,來定位要斷言和操作的虛擬 DOM 中的節點。檢視支援的選擇器的完整列表以瞭解更多資訊。
除了標準 API 之外還提供:
- 支援將定位節點
key
屬性簡寫為@
符號 - 當使用標準的
.
來定位樣式類時,使用classes
屬性而不是class
屬性
harness.expect
測試中最常見的需求是斷言部件的 render
函式的輸出結構。expect
接收一個返回被測部件期望的渲染結果的函式作為引數。
API
expect(expectedRenderFunction: () => DNode | DNode[], actualRenderFunction?: () => DNode | DNode[]);
expectedRenderFunction
: 返回查詢節點期望的DNode
結構的函式actualRenderFunction
: 一個可選函式,返回被斷言的實際DNode
結構
h.expect(() =>
v('div', { key: 'foo' }, [w(Widget, { key: 'child-widget' }), 'text node', v('span', { classes: ['class'] })])
);
expect
也可以接收第二個可選引數,返回要斷言的渲染結果的函式。
h.expect(() => v('div', { key: 'foo' }), () => v('div', { key: 'foo' }));
如果實際的渲染輸出和期望的渲染輸出不同,就會丟擲一個異常,並使用結構化的可視方法,用 (A)
(實際值)和 (E)
(期望值)指出所有不同點。
Example assertion failure output:
v('div', {
'classes': [
'root',
(A) 'other'
(E) 'another'
],
'onclick': 'function'
}, [
v('span', {
'classes': 'span',
'id': 'random-id',
'key': 'label',
'onclick': 'function',
'style': 'width: 100px'
}, [
'hello 0'
])
w(ChildWidget, {
'id': 'random-id',
'key': 'widget'
})
w('registry-item', {
'id': true,
'key': 'registry'
})
])
harness.trigger
harness.trigger()
在 selector
定位的節點上呼叫 name
指定的函式。
interface FunctionalSelector {
(node: VNode | WNode): undefined | Function;
}
trigger(selector: string, functionSelector: string | FunctionalSelector, ...args: any[]): any;
selector
: 用於查詢目標節點的選擇器functionSelector
: 要麼是從節點的屬性中找到的被呼叫的函式名,或者是從節點的屬性中返回一個函式的函式選擇器args
: 為定位到的函式傳入的引數
如果有返回結果,則返回的是被觸發函式的結果。
用法示例:
// calls the `onclick` function on the first node with a key of `foo`
h.trigger('@foo', 'onclick');
// calls the `customFunction` function on the first node with a key of `bar` with an argument of `100`
// and receives the result of the triggered function
const result = h.trigger('@bar', 'customFunction', 100);
functionalSelector
返回部件屬性中的函式。函式也會被觸發,與使用普通字串 functionSelector
的方式相同。
用法示例:
假定有如下 VDOM 樹結構:
v(Toolbar, {
key: 'toolbar',
buttons: [
{
icon: 'save',
onClick: () => this._onSave()
},
{
icon: 'cancel',
onClick: () => this._onCancel()
}
]
});
並且你想觸發 save 按鈕的 onClick
函式。
h.trigger('@buttons', (renderResult: DNode<Toolbar>) => {
return renderResult.properties.buttons[0].onClick;
});
注意: 如果沒能找到指定的選擇器,則 trigger
會丟擲一個錯誤。
harness.getRender
harness.getRender()
返回索引指定的渲染器,如果沒有提供索引則返回最後一個渲染器。
getRender(index?: number);
index
: 要返回的渲染器的索引
用法示例:
// Returns the result of the last render
const render = h.getRender();
// Returns the result of the render for the index provided
h.getRender(1);
Assertion Templates
斷言模板(assertion template)允許你構建期望的渲染函式,用於傳入 h.expect()
中。斷言模板背後的思想來自經常要斷言整個渲染輸出,並需要修改斷言的某些部分。
要使用斷言模板,需要先匯入模組:
import assertionTemplate from '@dojo/framework/testing/assertionTemplate';
然後,在你的測試中,你可以編寫一個基本斷言,它是部件的預設渲染狀態:
假定有以下部件:
src/widgets/Profile.ts
import WidgetBase from '@dojo/framework/widget-core/WidgetBase';
import { v } from '@dojo/framework/widget-core/d';
import * as css from './styles/Profile.m.css';
export interface ProfileProperties {
username?: string;
}
export default class Profile extends WidgetBase<ProfileProperties> {
protected render() {
const { username } = this.properties;
return v('h1', { classes: [css.root] }, [`Welcome ${username || 'Stranger'}!`]);
}
}
基本斷言如下所示:
tests/unit/widgets/Profile.ts
const { describe, it } = intern.getInterface('bdd');
import harness from '@dojo/framework/testing/harness';
import assertionTemplate from '@dojo/framework/testing/assertionTemplate';
import { w, v } from '@dojo/framework/widget-core/d';
import Profile from '../../../src/widgets/Profile';
import * as css from '../../../src/widgets/styles/Profile.m.css';
const profileAssertion = assertionTemplate(() =>
v('h1', { classes: [css.root], '~key': 'welcome' }, ['Welcome Stranger!'])
);
在測試中這樣寫:
tests/unit/widgets/Profile.ts
const profileAssertion = assertionTemplate(() =>
v('h1', { classes: [css.root], '~key': 'welcome' }, ['Welcome Stranger!'])
);
describe('Profile', () => {
it('default renders correctly', () => {
const h = harness(() => w(Profile, {}));
h.expect(profileAssertion);
});
});
it('default renders correctly', () => {
const h = harness(() => w(Profile, {}));
h.expect(profileAssertion);
});
現在我們看看,為 Profile
部件傳入 username
屬性後,如何測試輸出結果:
tests/unit/widgets/Profile.ts
describe('Profile', () => {
...
it('renders given username correctly', () => {
// update the expected result with a given username
const namedAssertion = profileAssertion.setChildren('~welcome', [
'Welcome Kel Varnsen!'
]);
const h = harness(() => w(Profile, { username: 'Kel Varnsen' }));
h.expect(namedAssertion);
});
});
這裡,我們使用 baseAssertion 的 setChildren()
api,然後我們使用特殊的 ~
選擇器來定位 key 值為 ~message
的節點。~key
屬性(使用 tsx 的模板中是 assertion-key
)是斷言模板的一個特殊屬性,在斷言時會被刪除,因此在匹配渲染結構時不會顯示出來。此功能允許你修飾斷言模板,以便能簡單的選擇節點,而不需要擴充套件實際的部件渲染函式。一旦我們獲取到 message
節點,我們就可以將其子節點設定為期望的 the number 5
,然後在 h.expect
中使用生成的模板。需要注意的是,斷言模板在設定值時總是返回一個新的斷言模板,這可以確保你不會意外修改現有模板(可能導致其他測試失敗),並允許你基於新模板,增量逐層構建出新的模板。
斷言模板具有以下 API:
insertBefore(selector: string, children: () => DNode[]): AssertionTemplateResult;
insertAfter(selector: string, children: () => DNode[]): AssertionTemplateResult;
insertSiblings(selector: string, children: () => DNode[], type?: 'before' | 'after'): AssertionTemplateResult;
append(selector: string, children: () => DNode[]): AssertionTemplateResult;
prepend(selector: string, children: () => DNode[]): AssertionTemplateResult;
replaceChildren(selector: string, children: () => DNode[]): AssertionTemplateResult;
setChildren(selector: string, children: () => DNode[], type?: 'prepend' | 'replace' | 'append'): AssertionTemplateResult;
setProperty(selector: string, property: string, value: any): AssertionTemplateResult;
setProperties(selector: string, value: any | PropertiesComparatorFunction): AssertionTemplateResult;
getChildren(selector: string): DNode[];
getProperty(selector: string, property: string): any;
getProperties(selector: string): any;
replace(selector: string, node: DNode): AssertionTemplateResult;
remove(selector: string): AssertionTemplateResult;
Mocking
您可能已經注意到,在測試部件時,我們主要測試對屬性進行各種修改後,使用者介面是否正確渲染。它們不包含真正的業務邏輯,但您可能想測試例如單擊按鈕後是否呼叫了屬性方法。這個測試不關心方法實際做了什麼,只關心是否正確呼叫了介面。在這種情況下,您可以使用類似 Sinon 庫。
src/widgets/Action.ts
import WidgetBase from '@dojo/framework/widget-core/WidgetBase';
import { v, w } from '@dojo/framework/widget-core/d';
import Button from '@dojo/widgets/button';
import * as css from './styles/Action.m.css';
export default class Action extends WidgetBase<{ fetchItems: () => void }> {
protected render() {
return v('div', { classes: [css.root] }, [w(Button, { onClick: this.handleClick, key: 'button' }, ['Fetch'])]);
}
private handleClick() {
this.properties.fetchItems();
}
}
您可能想測試,當單擊按鈕後是否會呼叫 this.properties.fetchItems
方法。
tests/unit/widgets/Action.ts
const { describe, it } = intern.getInterface('bdd');
import harness from '@dojo/framework/testing/harness';
import { w, v } from '@dojo/framework/widget-core/d';
import { stub } from 'sinon';
describe('Action', () => {
const fetchItems = stub();
it('can fetch data on button click', () => {
const h = harness(() => w(Home, { fetchItems }));
h.expect(() => v('div', { classes: [css.root] }, [w(Button, { onClick: () => {}, key: 'button' }, ['Fetch'])]));
h.trigger('@button', 'onClick');
assert.isTrue(fetchItems.calledOnce);
});
});
在這種情況下,你可以 mock 一個 Action 部件的 fetchItems
方法,該方法將嘗試獲取資料項。然後就可以使用 @button
定位到按鈕,並觸發按鈕的 onClick
事件,然後校驗 fetchItems
方法是否被呼叫過一次。
要了解更多 mocking 資訊,請閱讀 Sinon 文件。
功能測試
與單元測試載入和執行程式碼的流程不同,功能測試在瀏覽器中載入一個頁面並測試應用程式的互動功能。
如果要校驗某個路由對應的頁面內容,可以通過更新連結來簡化測試。
src/widgets/Menu.ts
import WidgetBase from '@dojo/framework/widget-core/WidgetBase';
import { w } from '@dojo/framework/widget-core/d';
import Link from '@dojo/framework/routing/ActiveLink';
import Toolbar from '@dojo/widgets/toolbar';
import * as css from './styles/Menu.m.css';
export default class Menu extends WidgetBase {
protected render() {
return w(Toolbar, { heading: 'My Dojo App!', collapseWidth: 600 }, [
w(
Link,
{
id: 'home', // add id attribute
to: 'home',
classes: [css.link],
activeClasses: [css.selected]
},
['Home']
),
w(
Link,
{
id: 'about', // add id attribute
to: 'about',
classes: [css.link],
activeClasses: [css.selected]
},
['About']
),
w(
Link,
{
id: 'profile', // add id attribute
to: 'profile',
classes: [css.link],
activeClasses: [css.selected]
},
['Profile']
)
]);
}
}
在使用應用程式時,您需要單擊 profile
連結,然後被導航到歡迎使用者頁面。你可以編寫一個功能測試來驗證此行為。
tests/functional/main.ts
const { describe, it } = intern.getInterface('bdd');
const { assert } = intern.getPlugin('chai');
describe('routing', () => {
it('profile page correctly loads', ({ remote }) => {
return (
remote
// loads the HTML file in local node server
.get('../../output/dev/index.html')
// find the id of the anchor tag
.findById('profile')
// click on the link
.click()
// end this action
.end()
// find the h1 tag
.findByTagName('h1')
// get the text in the h1 tag
.getVisibleText()
.then((text) => {
// verify the content of the h1 tag on the profile page
assert.equal(text, 'Welcome Dojo User!');
})
);
});
});
當執行功能測試時,Dojo 會提供一個與頁面互動的 remote
物件。因為載入頁面和與頁面互動是非同步操作,所以必須在測試中返回 remote
物件。
可在命令列中執行功能測試。
命令列
npm run test:functional
這會將 HTML 頁面載入到您計算機中 Chrome 的 remote 例項中,以測試互動功能。
功能測試是非常有用的,它能確保在瀏覽器中,您的程式程式碼能按預期正常工作。
您可以閱讀更多關於 Intern 功能