蒼時弦也
蒼時弦也
資深軟體工程師
發表於

測試準備 - Clean Architecture in TypeScript

這篇文章是 Clean Architecture in TypeScript 系列的一部分,你可以透過 Leanpub 提前閱讀內容。

前面的內容我們都注重在功能的實現,然而使用 AI 開發使用測試來確保功能的穩定也是非常重要的,至少我們不會因為產生的程式碼有一些預期外的改動,而破壞整個功能的完整性。

模型注入

透過 AI 我們已經把依賴注入的部分處理完畢,然而在使用 AI 時還是直接在 LlmAssistantService 這個物件初始化 AI SDK 的物件,這對測試非常不友善,因此我們需要將模型也改為依賴注入的方式進行提供。

AI SDK 也有提供測試工具,然而使用 Cloudflare 的 Vitest Pool 時,因為 Cloudflare Worker 不支援 node:http 會無法測試,但我們仍有辦法解決這個問題

接下來的修改 AI 還不太擅長,因此我們採用手動處理的方式速度會更快。

首先,修改 src/container.ts 登記 AI 模型。

 1import { createOpenAI, OpenAIProvider } from '@ai-sdk/openai';
 2import { type LanguageModel } from 'ai';
 3
 4import { LlmAssistantService, LanguageModelToken } from '@/service/LlmAssistantService';
 5// ...
 6
 7export const LanguageModelProviderToken = Symbol('LanguageModelProviderToken');
 8
 9// Register Infrastructure dependencies
10container.register<OpenAIProvider>(LanguageModelProviderToken, {
11	useValue: createOpenAI({
12		apiKey: env.OPENAI_API_KEY,
13		baseURL: env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
14	})
15});
16
17container.register<LanguageModel>(LanguageModelToken, {
18	useFactory: (container) => {
19		const openai = container.resolve<OpenAIProvider>(LanguageModelProviderToken);
20		return openai(env.OPENAI_MODEL);
21	}
22});

我們將原本 LlmAssistantService 中的 createOpenAIopenai(env.OPENAI_MODEL) 移動到 src/container.ts 中統一管理,因為 AI SDK 有提供 LanguageModel 的介面,也很好處理注入上的設定。

接下來更新 src/service/LlmAssistantService.ts 的部分,將原本使用 openai(env.OPENAI_MODEL) 相關實作,改為使用依賴注入的 this.model 進行替換。

 1@injectable()
 2export class LlmAssistantService implements AssistantService {
 3  constructor(
 4  	@inject(LanguageModelToken) private readonly model: LanguageModel,
 5    @inject(CartRepositoryToken) private readonly cartRepository: CartRepository,
 6    @inject(ProductRepositoryToken) private readonly productRepository: ProductRepository
 7  ) {}
 8
 9  async ask(chatId: string, messages: ChatMessage[]): Promise<string> {
10    // Convert our ChatMessage entities to the format expected by AI SDK
11    const aiMessages = messages.map(msg => ({
12      role: msg.role as 'user' | 'assistant',
13      content: msg.content
14    }));
15
16	const { text } = await generateText({
17      model: this.model,
18      // ...
19    )}
20  }
21}

這樣一來,我們在測試環境就可以抽換 LanguageModel 為我們想要的物件,也不會受到 AI SDK 的 node:http 相容性影響。

Vitest 安裝

我們初期是使用 create-cloudflare 來初始化專案的,理論上 Vitest 已經會設定好,不過我們會需要針對像是 tsconfig.json 的 Path Alias 做一些處理,另一方面還可以讓 AI 幫我們先做一些簡單的初始化準備。

開始之前,如果已經安裝過 vitest 可以先修改 package.json

1{
2  // ...
3  "test": "vitest --run"
4}

把 Vitest 預設改為 --run 模式,這樣才不會在使用 Claude Code 或者其他工具時,因為 Watch Mode 卡住,而是讓 AI 自動依照工具的輸出繼續調整。

我們可以對 Claude Code 下達這樣的指示:

Update vitest.config.mts to add path alias, and update `test/index.spec.ts` to use integration tests to verify the index is available.

基本上就會自動把 tsconfig.json 設定過的 Path Alias 問題解決,並且將原本 Hello World 的測試改為檢查首頁的 HTML 是否有正常被渲染出來。

這樣我們就初步準備好測試的環境,可以繼續進一步的設定。

Mock Repository

我們可以在測試環境加入 Mock Repository 來代替真實的 Repository 行為,雖然使用 Cloudflare 的 Vitest Pool Worker 也能夠模擬 KV 環境,但是要產生測試資料跟重設還是相對不方便,這個階段可以嘗試用 Claude Code 來產生初步的實作。

 1Let's setup a testing environment:
 2
 31. Update vitest config to use `test/setup.ts`, config mock repositories, use `beforeEach` to reset the database before each test.
 4   - Use `test/setup.ts` to configure the mock repositories.
 5   - Use `beforeEach` to reset the database state before each test.
 62. ONLY add mock repositories, MUST implementation in the `test/support`
 73. Test Cart / Chat api with mock repositories, SHOULD be `test/cart.spec.ts` and `test/chat.spec.ts`
 8
 9DO NOT add any test related to creating method, only GET methods.
10MUST use intergration tests for Cloudflare workers, you can reference the `index.spec.ts` file.

上述的指示已經修過好幾個版本,是相對穩定的做法。

相比軟體的實作,目前還有許多專案沒有設定好測試,或者正確的對測試設定,因此 AI 也有很高的機率使用錯誤的方式,此時就需要給更明確的指示。

實際上產生出來的版本還是差強人意,而且根本是「有測試就好」的狀態

這邊可以先這樣處理,主要是節省我們進行初步的測試環境搭建為主,後續我們會一點一點修正這些問題,變成可行的測試狀態,也讓後續 AI 在實作時能參考這些做法。