---
url: >-
  /blog-ocean-world/unleash-the-magic-of-type-script-with-ts-rest-practical-experience.md
description: 紀錄一下使用 ts-rest 實際開發的點點滴滴
---

![unleash-the-magic-of-type-script-with-ts-rest](/unleash-the-magic-of-type-script-with-ts-rest-practical-experience.webp){.cover}

# 前後端不再打架，讓 ts-rest 發揮 TypeScript 的魔法吧！（實務篇）

距離上次[概念篇](/blog-ocean-world/unleash-the-magic-of-type-script-with-ts-rest)也快一年了，時間過得真快。%(´・ω・\`)%

本文延續概念篇內容，紀錄一些實作筆記，大家若有更好的做法還請不吝指教。%(\*´∀\`)~♥%

技術堆疊以 Vue、NestJS 為例，相關套件版本如下：

* @nestjs/core：`11.1.5`
* @ts-rest/core：`3.52.1`
* @ts-rest/nest：`3.52.1`
* zod：`3.25.76`

::: tip
ts-rest 預計 `^3.53.0` 開始支援 [`Standard Schema`](https://github.com/standard-schema/standard-schema) 驗證，只要支援此規範的驗證器（例：[Zod v4](https://zod.dev/v4)、[Valibot](https://valibot.dev/guides/introduction/)、[ArkType](https://arktype.io/)）的驗證器都可以用來定義合約資料，不限於 Zod 了！%✧⁑｡٩(ˊᗜˋ\*)و✧⁕｡%
:::

本文分成 3 個主要部分：

1. 通用概念

   不分前後端的注意事項與心得

2. 前端

   前端 ts-rest 應用，包含初始化 Client、狀態與結果資料、表單資料驗證等

3. 後端

   後端 ts-rest 應用，包含定義 API 合約、驗證資料、處理請求等

讓我們開始吧！%♪( ◜ω◝و(و%

::: tip 不定期更新
本文為 ts-rest 應用筆記，會隨著時間推移而變化、更新，甚至過時，我會盡快更新，還請大家多多包涵。%(´,,•ω•,,)%
:::

## 通用概念

### jsonQuery

ts-rest 提供的 [`jsonQuery`](https://ts-rest.com/contract/overview#json-query-parameters) 參數可以讓你輕鬆處理 REST API 的 query string。

一般來說 REST API 的 query string 會被視為字串，其中數字、布林與矩陣可能會出現歧異。

啟用 `jsonQuery` 就可以讓 query string 中的資料使用 JSON 形式發送，這樣就可以避免以上問題。

舉個栗子。%( ´ ▽ \` )ﾉ🌰%

#### 數字與布林

若 URL 為 `/posts?take=10&draft=false`，伺服器會取得 `{ take: "10", draft: "false" }`，可以看到內容都是字串。

你可能會說：Zod 可以用 `z.coerce.number()`、`z.coerce.boolean()` 轉換啊？

的確可以轉換沒錯，但這裡有個[隱藏的小地雷](https://github.com/colinhacks/zod/issues/1630)，就是字串的 `"true"` 與 `"false"`，經過 `z.coerce.boolean()` 都會變成 `true`！%╭(°A ,°\`)╮%

::: tip Zod v4 之 `z.stringbool()`
感謝社群的一隻狐狸大大補充

Zod v4 新增了 [`stringbool`](https://zod.dev/api?id=stringbool)，可以正確處理字串的布林值，v4 讚讚！%(/≧▽≦)/%
:::

當然可以自定義轉換器解決，不過若啟用 `jsonQuery`（前端後端都要開），伺服器會取得 `{ take: 10, draft: false }`，就不用特別轉換了。%(≖‿ゝ≖)✧%

#### 矩陣

若 URL 為 `/posts?tags=js&tags=ts`，伺服器會拿到 `['js', 'ts']`，但若 `tags` 只有一個呢？

URL 變為 `/posts?tags=cod`，

這樣伺服器取到的參數可能會變成 `{ tags: 'cod' }`，而不是 `{ tags: ['cod'] }`！ %Σ(ˊДˋ;)%

若啟用 `jsonQuery`，則 URL 會變成 `/posts?tags=["js","ts"]`，就沒有以上分歧的問題了。%✧⁑｡٩(ˊᗜˋ\*)و✧⁕｡%

路人：「如果 client 端不是使用 ts-rest client，是不是可能有一樣的問題？」

鱈魚：「你說的沒錯 ...%(›´ω\`‹ )%，如果使用端不是用 JSON Query Parameters 發送，還是要乖乖的把合約內的驗證器與轉換器寫好寫滿了 %( ˘･з･)%」

### ClientInfer、ServerInfer 傻傻分不清楚？

ts-rest 本身提供了很方便的 [Type Helper 工具](https://ts-rest.com/contract/type-helpers)，可以輕鬆提取合約內的型別。

不過有分 ClientInfer 與 ServerInfer，看的出來一個是 Client 視角，一個是 Server 視角，所以具體差在哪呢？%(\*´･д･)%

簡單來說就是進去與出來的資料型別不同，假設合約這麼寫：

```ts
export const collectionDataContract = contract.router({
  find: {
    method: 'GET',
    path: '/v1/collection-data',
    query: z.object({
      age: z.string().transform(Number),
    }),
    responses: {
      200: collectionDataSchema.array(),
    },
    summary: '取得 collection-data',
  },
})
```

可以看出 `age` 輸入型別是 `string`，但輸出型別是 `number`。

現在讓我們實際看看 ClientInfer 與 ServerInfer 取出來的 `age`

```ts
// type 為 string
type ClientInput = ClientInferRequest<typeof collectionDataContract>['find']['query']['age']
// type 為 number
type ServerInput = ServerInferRequest<typeof collectionDataContract>['find']['query']['age']
```

對 Request 來說，client 是提供端（輸入至合約），而 server 是接收端（取得合約輸出）。

所以得到 `ClientInput` 為 `string`，而 `ServerInput` 為 `number`。

不難發現只有在輸入與輸出型別不同才會有明顯差別，否則兩者型別會相同。

以上就是 ClientInfer 與 ServerInfer 具體差別，有沒有比較清楚了呢？%ԅ(´∀\` ԅ)%

## 前端

### 初始化 Client

如同[文件描述](https://ts-rest.com/client/fetch)，使用 `initClient` 即可以將合約轉換成可直接呼叫的 Client。

可以考慮多一層封裝，提供基礎設定值：

```ts
import type { AppRouter } from '@ts-rest/core'
import { initClient } from '@ts-rest/core'

const baseUrl = import.meta.env.VITE_API_BASE_URL ?? ''

export function useClient<T extends AppRouter>(
  router: T,
  options?: Parameters<typeof initClient>[1],
) {
  return initClient(router, {
    baseUrl,
    baseHeaders: {},
    jsonQuery: true,
    ...options,
  })
}
```

Client 會在網頁中到處出現，希望不要重複建立，避免不必要的記憶體浪費。

可以存在某個變數並 `export` 給其他檔案使用，不過這樣每次微調 `options` 都要建立共用變數有點麻煩（命名好難 %(。-\`ω´-)%），使用 memoization 方式處理會更簡單。

::: tip 甚麼是 memoization
[先前的文章](/blog-program/improve-speed-using-memoization)曾經聊過，有興趣的朋友可以來看看
:::

這裡用 `lodash-es` 提供的 `memoize` 實作：

```ts
import hash from 'object-hash'

export const useClient = memoize(
  <T extends AppRouter>(
    router: T,
    options?: Parameters<typeof initClient>[1],
  ) => initClient(router, {
    baseUrl,
    baseHeaders: {},
    jsonQuery: true,
    ...options,
  }),
  (router, options) => hash({ router, options }),
)
```

`hash` 是為了計算快取用的 key，這裡使用 [`object-hash`](https://www.npmjs.com/package/object-hash) 處理，大家可以換成自己喜歡的實作。%ヾ(◍'౪\`◍)ﾉﾞ%

需要授權的 API 需要在 Authorization Header 提供 Access Token 才能存取，讓我們加入自動加入 Authorization Header 的功能：

```ts
import { initClient, tsRestFetchApi } from '@ts-rest/core'

/** 自動加入 Authorization Header */
export const useClient = memoize(
  <T extends AppRouter>(
    router: T,
    options?: Parameters<typeof initClient>[1],
  ) => initClient(router, {
    baseUrl,
    baseHeaders: {},
    jsonQuery: true,
    api: async (args) => {
      const { accessToken } = useAuthStore()

      return tsRestFetchApi({
        ...args,
        headers: {
          Authorization: `Bearer ${accessToken}`,
          ...args.headers,
        },
      })
    },
    ...options,
  }),
  (router, options) => hash({ router, options }),
)
```

`api` 參數可以讓我們自定義請求如何送出。%(ゝ∀・)b%

說到 Access Token，就一定有 Refresh Token，讓我們加入 401 時自動 Refresh Token 邏輯：

```ts
import { authContract } from '@project-code/shared'

/** 自動加入 Authorization Header 且 401 時自動 refresh */
export const useClient = memoize(
  <T extends AppRouter>(
    router: T,
    options?: Parameters<typeof initClient>[1],
  ) => initClient(router, {
    baseUrl,
    jsonQuery: true,
    api: async (args) => {
      const authStore = useAuthStore()
      let accessToken = authStore.accessToken

      let res = await tsRestFetchApi({
        ...args,
        headers: {
          Authorization: `Bearer ${accessToken}`,
          ...args.headers,
        },
      })

      // 只有 401 需要處理 refreshToken
      if (res.status !== 401) {
        return res
      }

      const authApi = initClient(authContract, {
        baseUrl,
        ...options,
      })

      const result = await authApi.refresh()
      if (result.status === 200) {
        accessToken = result.body.accessToken
        authStore.setAccessToken(accessToken)
      }

      /** 再發送一次剛剛被 401 的請求 */
      res = await tsRestFetchApi({
        ...args,
        headers: {
          Authorization: `Bearer ${accessToken}`,
          ...args.headers,
        },
      })
      if (res.status === 401) {
        // 可加入要求使用者登入邏輯
      }

      return res
    },
    ...options,
  }),
  (router, options) => hash({ router, options }),
)
```

這時候有個小問題，若有請求同時發出，就會重複 refresh 很多次。

甚麼？你說你們後端不在意？那就跳...還是來加個基本處理好了。%(・∀・)９%

使用 Vue 的 `ref` 與 VueUse 的 [`until`](https://vueuse.org/shared/until/) 實現基本版：

```ts
/** 用於防止多次 refresh */
const isRefreshing = ref(false)

/** 自動加入 Authorization Header 且 401 時自動 refresh */
export const useClient = memoize(
  <T extends AppRouter>(
    router: T,
    options?: Parameters<typeof initClient>[1],
  ) => initClient(router, {
    baseUrl,
    jsonQuery: true,
    api: async (args) => {
      const authStore = useAuthStore()

      await until(isRefreshing).toBe(false)
      let res = await tsRestFetchApi({
        ...args,
        headers: {
          Authorization: `Bearer ${authStore.accessToken}`,
          ...args.headers,
        },
      })

      // 只有 401 需要處理 refreshToken
      if (res.status !== 401) {
        return res
      }

      // 防止多次 refresh
      if (!isRefreshing.value) {
        const refreshClient = initClient(authContract, {
          baseUrl,
          ...options,
        })

        isRefreshing.value = true
        const result = await refreshClient.refresh()
        isRefreshing.value = false

        if (result.status === 200) {
          authStore.setAccessToken(result.body.accessToken)
        }
      }

      await until(isRefreshing).toBe(false)
      /** 再發送一次剛剛被 401 的請求 */
      res = await tsRestFetchApi({
        ...args,
        headers: {
          Authorization: `Bearer ${authStore.accessToken}`,
          ...args.headers,
        },
      })
      if (res.status === 401) {
        // 可加入要求使用者登入邏輯
      }

      return res
    },
    ...options,
  }),
  (router, options) => hash({ router, options }),
)
```

這樣可以處理基本 9 成常見的情境了。%◝( •ω• )◟%

二次封裝後可以很輕鬆地加入各種自定義邏輯，有需求的話還可以加入 Queue 等等花式功能，這個就看實際需求而定了。%੭ ˙ᗜ˙ )੭%

眼尖的朋友可能還有注意文件有提到 [Query Client](https://ts-rest.com/client/vue-query-v4)，實際上就是 Vue Query（Tanstack Query）。

Vue Query 內建資料快取、背景更新、自動重試等等強大功能，推薦大家可以玩玩看。

與一般的 Client 差別在初始化與參數稍微不同而已，所以 Query Client 的封裝同理：

```ts
import { initQueryClient } from '@ts-rest/vue-query'

/** 自動加入 Authorization Header 且 401 時自動 refresh */
export const useQueryClient = memoize(
  <T extends AppRouter>(
    router: T,
    options?: Parameters<typeof initQueryClient>[1],
  ) => initQueryClient(router, {
    baseUrl,
    baseHeaders: {},
    jsonQuery: true,
    api: async (args) => {
      // ...
    },
    ...options,
  }),
  (router, options) => hash({ router, options }),
)
```

可以注意到 ts-rest 支援同時使用兩種 Client，這讓實務開發上更有彈性

資料邏輯很單純的網頁使用一般的 Client 即可，有複雜需求的部分再使用 Query Client。

正所謂小朋友才做選擇，我兩種都要！%ヽ(●\`∀´●)ﾉ%

如此可以讓專案保持簡單、乾淨，更不容易出現協作斷層。%( ‧ω‧)ノ╰(‧ω‧ )%

最後讓我們來點測試，確認一下 `memoize` 有正常工作：

`/apps/admin-web/src/common/api.test.ts`

```ts
import { initContract } from '@ts-rest/core'
import { describe, expect, it } from 'vitest'
import { useClient } from './api'

const baseContract = initContract().router({
  create: {
    method: 'GET',
    path: '/v1/collection-data',
    responses: {},
    summary: '建立 collection-data',
  },
}, {
  pathPrefix: '/api',
})

describe('api', () => {
  describe('useClient', () => {
    it(`相同設定應取得同一個實例`, () => {
      const client1 = useClient(baseContract)
      const client2 = useClient(baseContract)

      expect(client1).toBe(client2)
    })

    it(`不同設定應取得不同實例`, () => {
      const client1 = useClient(baseContract)
      const client2 = useClient(baseContract, {
        baseUrl: '/custom',
        baseHeaders: { a: 'a', b: 'b' },
      })

      expect(client1).not.toBe(client2)
    })

    it(`同樣設定但順序不同，也應取得同一個實例`, () => {
      const client1 = useClient(baseContract, {
        baseUrl: '/custom',
        baseHeaders: { a: 'a', b: 'b' },
      })
      const client2 = useClient(baseContract, {
        baseHeaders: { b: 'b', a: 'a' },
        baseUrl: '/custom',
      })

      expect(client1).toBe(client2)
    })
  })
})
```

以上程式碼可以[在此取得](https://github.com/Codfisher/blog-ts-rest-practice/blob/main/apps/admin-web/src/common/api.ts)。

### 狀態與結果資料

ts-rest 回應的結果會將 HTTP Code 包含在資料內，其結構範例如下：

```txt
{
  status: 200,
  body: {
    name: 'cod'
  },
  header: {}
}
```

假設 API 合約為：

```ts
const c = initContract()

export const accountContract = c.router({
  create: {
    method: 'POST',
    path: '/v1/accounts',
    body: z.object({
      username: z.string(),
      password: z.string(),
      name: z.string(),
      description: z.string().optional(),
    }),
    responses: {
      200: z.object({
        id: z.string(),
      }),
      400: z.object({
        reason: z.enum([
          'username-duplicate',
        ]).optional(),
        message: z.string(),
      }),
      401: c.noBody(),
      403: c.noBody(),
    },
    summary: '建立 account',
  },
})
```

可以根據 `status` 取得具體內容，例如：

```ts
const accountApi = useClient(accountContract)

const result = await accountApi.create({})

if (result.status === 200) {
  const newId = result.body.id
  // 提示成功建立使用者
}

if (result.status === 400) {
  if (result.body.reason === 'username-duplicate') {
    // 處理重複的使用者名稱
  }
  else {
    // 處理其他錯誤
  }
}
```

這樣前後端只要約定好 `status` 或列舉資料（像是 `reason` 欄位），就可以精準處理各種業務邏輯了。%( ´ ▽ \` )ﾉ%

當然如果不需要關心細節，直接比較數字即可：

```ts
const accountApi = useClient(accountContract)

const result = await accountApi.create({})

if (result.status >= 400) {
  // 提示建立使用者失敗
}
```

### 表單資料驗證

若 UI 套件的 Form 可以使用驗證器，可以直接與取得合約內的驗證器進行表單驗證，以 Nuxt UI Form 為例：

```vue
<script setup lang="ts">
import type { FormSubmitEvent } from '@nuxt/ui'
import { accountContract } from '@project-code/shared'
import * as z from 'zod'

const schema = accountContract.create.body
type Schema = z.output<typeof schema>

const state = reactive<Partial<Schema>>({
  email: undefined,
  password: undefined
})

const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
  toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
  console.log(event.data)
}
</script>

<template>
  <UForm
    :schema="schema"
    :state="state"
    class="space-y-4"
    @submit="onSubmit"
  >
    <UFormField label="Email" name="email">
      <UInput v-model="state.email" />
    </UFormField>

    <UFormField label="Password" name="password">
      <UInput v-model="state.password" type="password" />
    </UFormField>

    <UButton type="submit">
      Submit
    </UButton>
  </UForm>
</template>
```

若 Form 不能直接使用驗證器（Zod schema、ArkType type 等等），也不是大問題，來個轉換器就行。

以我常用的 Quasar 為例，可以參考此[實作](https://github.com/Codfisher/blog-ts-rest-practice/blob/main/apps/admin-web/src/common/zod-validator.ts)。

::: tip 感謝優秀的同事們
此轉換器是公司的夥伴們開發，我幫忙寫測試與文件，感謝他們的努力！%(´,,•ω•,,)%
:::

## 後端

### 合約 + 測試 = 更可靠的 API

後端實作合約基本上就和概念篇相同，實作上最重要的部分是可以拿合約寫 e2e 測試，這樣只要測試有過，API 基本上不會有錯惹。%ԅ(´∀\` ԅ)%

所以要怎麼拿合約寫 e2e 測試呢？

概念如下：

1. 將資料輸入合約，取得 API 請求資料（例如 url、query string、body data）
2. 利用第 1 步取得的資料，使用測試工具（例如 Supertest）發送請求

接下來以 [`collection-data` 合約](https://github.com/Codfisher/blog-ts-rest-practice/blob/main/packages/shared/src/collection-data/contract.ts)為例，一步一步來吧。

建立一個將 ts-rest 合約轉換成 API 請求資料的工具：

`apps\api-server\src\common\utils\utils-ts-rest.ts`

```ts
type ExtractQuery<T> = T extends { query: infer U } ? U : never
type ExtractBody<T> = T extends { body: infer U } ? U : never

interface UseContractReturn<Query, Body> {
  url: string;
  method: Lowercase<AppRoute['method']>;
  query: Query;
  body: Body;
}

/** 協助取出合約 API 指定 API 內容
 *
 * 主要用於 e2e 測試
 */
export function useContract<
  Route extends AppRouteQuery,
  Data extends ClientInferRequest<Route>,
>(
  route: Route,
  data?: Data
): UseContractReturn<ExtractQuery<Data>, ExtractBody<Data>>
export function useContract<
  Route extends AppRouteMutation,
  Data extends ClientInferRequest<Route>,
>(
  route: Route,
  data?: ClientInferRequest<Route>
): UseContractReturn<ExtractQuery<Data>, undefined>
export function useContract<
  Route extends AppRouteQuery | AppRouteMutation,
>(
  route: Route,
  data?: ClientInferRequest<Route>,
): any {
  const {
    method,
    path,
  } = route

  const url = path.replace(
    /:(\w+)/g,
    (match, key) => data?.params?.[key],
  )

  if (data && 'body' in data) {
    return {
      url,
      method: method.toLowerCase() as Lowercase<AppRoute['method']>,
      query: data?.query,
      body: data,
    }
  }

  return {
    url,
    method: method.toLowerCase() as Lowercase<AppRoute['method']>,
    query: data?.query,
  }
}
```

測試工具使用 Supertest，建立一個將合約資料透過 Supertest 發送請求的工具：

`apps/api-server/src/resource/collection-data/collection-data.e2e.ts`

```ts
import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core'
import type { IncomingHttpHeaders } from 'node:http'
import {
  collectionDataContract,
} from '@ts-rest-practice/shared'
import request from 'supertest'
import { useContract } from '../../common/utils/utils-ts-rest'

// 這裡不可能出現其他 Contract，所以可以簡短命名
type Contract = typeof collectionDataContract
type ContractRequest = ClientInferRequest<Contract>

interface ApiOptions {
  headers?: IncomingHttpHeaders;
}

/** e2e 測試之合約轉換層
 *
 * 將合約轉換成 API 呼叫，這樣就可以直接拿合約內容進行 e2e 測試
 *
 * 也就是說只要合約有變動，這裡也要跟著變動，這樣就可以確保合約的正確性
 *
 * 同時只要 e2e 測試通過，即表示合約正確，可以交付
 *
 * 另一個附加好處是如果其他資源的 e2e 測試有依賴此資源，也可以直接拿去重複使用
 *
 * @param server 測試環境中的 HTTP server 主體
 * @param globalOptions 額外的選項。同 method 內的 options，method 內的 options 會覆蓋這裡的 options
 */
export function createCollectionDataApi(
  server: any,
  globalOptions?: ApiOptions,
) {
  return {
    async create<
      Code extends keyof Contract['create']['responses'] = 200,
    >(
      data: ContractRequest['create']['body'],
      code = 200 as Code,
      options?: ApiOptions,
    ) {
      const { url, method } = useContract(collectionDataContract.create, {
        body: data,
      })

      const { body, statusCode, headers } = await request(server)[method](url)
        .set({
          ...globalOptions?.headers,
          ...options?.headers,
        })
        .send(data)

      if (code !== statusCode) {
        throw new Error(`${statusCode} : ${JSON.stringify(body, null, 2)}`)
      }

      return {
        headers,
        body: body as ClientInferResponseBody<
          typeof collectionDataContract.create,
          Code
        >,
      }
    },

    // 其他方法同理
  }
}
```

最後就針對此資源建立 e2e 測試（以下為參考，實作可以替換成自己喜歡的方式）：

```ts
type CollectionDataContract = ClientInferRequest<typeof collectionDataContract>

describe('collectionData e2e', () => {
  /** 使用記憶體版本 MongoDB 運行測試 */
  let mongodb: MongoMemoryReplSet
  let mongoConnection: Connection
  let app: INestApplication
  let server: ReturnType<INestApplication['getHttpServer']>

  let collectionDataApi: ReturnType<typeof createCollectionDataApi>

  beforeAll(async () => {
    // ...啟動 Server 與 DB

    collectionDataApi = createCollectionDataApi(server)
  }, 60000)

  /** 測試結束，關閉 DB */
  afterAll(async () => {
    // ...
  })

  async function clearAll() {
    // ...
  }
  /** 每次測試開始前與結束時，都清空 collection 資料，以免互相影響 */
  beforeEach(async () => await clearAll())
  afterEach(async () => await clearAll())

  function createCollectionData(
    params?: Partial<CollectionDataContract['create']['body']>,
  ) {
    const expectData: CollectionDataContract['create']['body'] = {
      name: 'test',
      description: '安安',
      ...params,
    }

    return collectionDataApi.create(expectData)
  }

  describe('建立 collection-data', () => {
    it('全部參數', async () => {
      const expectData: Required<CollectionDataContract['create']['body']> = {
        name: 'name',
        description: 'description',
        remark: 'remark',
      }

      const { body: result } = await collectionDataApi.create(expectData)

      expect(result).toMatchObject(pick(
        expectData,
        ['name', 'description', 'remark'],
      ))
    })
  })

  describe('取得 collection-data', () => {
    it('取得指定筆數資料', async () => {
      await Promise.allSettled([
        createCollectionData(),
        createCollectionData(),
      ])

      const { body: result } = await collectionDataApi.find({ limit: 1 })
      expect(result.total).toBe(2)
      expect(result.data).toHaveLength(1)
    })
  })

  describe('取得指定 collection-data', () => {
    it('取得指定資料', async () => {
      const { body: data } = await createCollectionData()
      const { body: newData } = await collectionDataApi.findOne(data.id)

      expect(newData).toEqual(data)
    })
  })

  describe('更新指定 collection-data', () => {
    it('修改 name 為 cod', async () => {
      const { body: data } = await createCollectionData()
      const { body: newData } = await collectionDataApi.update(data.id, {
        name: 'cod',
      })

      expect(newData.name).toBe('cod')
    })
  })

  describe('刪除指定 collection-data', () => {
    it('刪除資料後，find 無法取得', async () => {
      const { body: data } = await createCollectionData()
      await collectionDataApi.remove(data.id)
      const { body: result } = await collectionDataApi.find()

      expect(result.data).toHaveLength(0)
    })
  })
})
```

為了防止篇幅過長，這裡只展示部分測試內容，完整測試程式碼可以[在此查看](https://github.com/Codfisher/blog-ts-rest-practice/blob/main/apps/api-server/src/resource/collection-data/collection-data.e2e.spec.ts)。

理論上測試內容只要覆蓋合約內的所有 API 與業務邏輯之測試案例，就可以確保 API 正常交付惹。

至於測試案例怎麼列？這個就是一門藝術了。%(◜௰◝)%

## 總結 🐟

感謝大家看到這裡，以上為使用 ts-rest 實際開發的點點滴滴，內容與觀念會隨著時間推移而變化，所以本文會不定期更新。

特別感謝公司優秀的同事們，導入 ts-rest、專案結構、使用設計與實作，很多靈感都來自同事們的提問與建議，非常感謝大家。%( ‧ω‧)ノ╰(‧ω‧ )%

也感謝前端社群的 Danny 大大推薦 ts-rest 這個好東西，有任何問題還請大家不吝指教。%(\*´∀\`)~♥%

本文完整程式碼可以[在此取得](https://github.com/Codfisher/blog-ts-rest-practice)。
