---
title: "用測試讓你在三小時交出面試作業"
date: 2023-01-25T00:00:00+08:00
publishDate: 2023-01-25T00:00:00+08:00
lastmod: 2023-01-25T11:51:04+08:00
tags: ["心得","經驗","面試","測試"]
toc: true
permalink: "https://blog.aotoki.me/posts/2023/01/25/use-test-to-complete-interview-assignment-in-3-hours/"
language: "zh-tw"
---


因為在 2023 年初就成為天選之人（[2023 年成為外商裁員的天選之人該做些什麼？](https://blog.aotoki.me/posts/2023/01/16/if-you-be-layoff-in-2023-what-can-you-do/)）而開始找工作，運氣很不錯的在獵頭協助跟朋友的內推，我在 1/18 跟 1/19 分別收到兩間公司的作業，但是我不想留到農曆年後才處理，因此決定用空檔趕在連假前完成他們！

<!--more-->

## 面試作業{#assignment}

有兩間公司，題目的難度不太一樣。比較輕鬆的是常見的 Todo List（待辦事項）的後端 CRUD 然而需要用 Golang 或 Python 來實作，我選了 Golang 來實作，因為 Golang 大多不會使用框架，因此不像 Rails 那麼容易，從我的 WakaTime 紀錄來看用了 2.5 小時就解決。

另一間是 IoT 相關的公司，一樣使用 Golang 來解問題但是比較複雜，需要在不使用任何套件的狀況下支援從 JSON 轉成指定的資料格式（像是 Protobuf 之類的）因為之前有相關的經驗所以大概有方向，但也不是太容易的工作，實現到 JSON 會用到的資料型態都支援大概用了 5 小時左右。

> 兩個題目都是 Senior 或以上的職位，每間公司的最終判斷標準不太一樣因此題目不一定會很難，以 CRUD 的題目來說 Junior 應該也要能解，至於資料格式的轉換，則是跟產業有關對 Senior 來說確實是必備的知識。

## 用測試開始{#start-from-test}

我之前提過我不是一定要使用 TDD（Test-Driven Development，測試驅動開發）的人，然而我是認同「使用測試能開發更快」的，這是透過實踐跟練習達成的效果，如果你發現「寫測試更慢」那很可能是測試的方向不對，因為我過去也遇過這樣的問題。

測試其實有很多類型，像是端對端測試（E2E Testing）、單元測試（Unit Test）、整合測試（Integration Test）等等不同類型的測試，每一種類型的測試都有其效果跟目的，要加速完成面試作業的速度，要先知道「需要哪種測試」

CRUD 的作業的要求是三天內完成必須要有單元測試，而 IoT 則是沒有限定時間，並且單元測試是「如果有更好」看起來都是寫單元測試，然而 CRUD 的作業應該是要從「端對端測試」開始下手更好。

之前跟 [LeSS in Action](https://vocus.cc/less-in-action-tw/home) 的講師聊過端對端測試跟單元測試的差異，得到了「範圍大小」的見解，其實是一個很有趣的角度，在 Rails 中如果是寫 Feature Test（基本上就是端對端測試）的狀況下是最容易「快速提高覆蓋率」也就是這類測試會呼叫到大多數的程式（反過來看就是可以看出沒用到的部分）

在 CRUD 的實作中，我們是需要加入非常多檔案的，以 MVC 框架來說就是 Controller、Model、View 等等，整個開發流程其實是破碎的，用端對端測試可以「快速確認成果」是在預期之中。

然而 IoT 的情況比較像是一個套件中的「特定功能」也就是所有的行為都集中在某個檔案，在這種狀況下用「單元測試」針對特定檔案驗證其實是相當合理的。

如果用範圍去看，其實兩種測試只是範圍不同，測試的內容都還是「輸入跟輸出」兩件事情。

## 步進式開發{#dev-step-by-step}

當我們確定測試目標後，就可以先建立一個初始的測試來驗證我們的實作，這邊以 CRUD 和 IoT 的測試都各舉一次例子方便大家想像。

```go
// IoT
func Test_FromJSON(t *testing.T) {
  data, _ := encoder.FromJSON(`null`)
  // Verify the "null" encoded to `0x00`
  if !cmp.Equal([]byte{0x00}, data) {
    t.Error(cmp.Diff([]byte{0x00}, data))
  }
}
```

```go
// CRUD
func Test_GET_Tasks(t *testing.T) {
  // e.g. router := gin.Default()
  res := httptest.NewRecorder()
  req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
  router.ServeHTTP(res, req)

  if !cmp.Equal(`{"data":[]}`, res.Body.String()) {
    t.Error(cmp.Diff(`{"data":[]}`, res.Body.String()))
  }
}
```

在 IoT 的部分，我們是對 `FromJSON()` 方法單獨測試，驗證的是輸出內容依照預期的狀況回傳指定的 `[]byte` 陣列，而 CRUD 則是模擬一個真實的 HTTP 請求，驗證回傳的 JSON 是符合預期的。

由此可見，兩者都是在測試「結果符合題目要求」但一個是完整呼叫 RESTful API 的測試，另外一個則是普通的單元測試。

接下來要開始實作，以 CRUD 為例子大多數情況我們都會想要把資料庫建好，然後定義 Model 接著是 Controller 最後把 View 套上去，然後執行測試驗證，然而我們可以「步進的調整」去實現，受限於篇幅關係以快速用幾段程式碼帶過。

```go
func main() {
  // ...
  r.GET("/tasks", func(c *gin.Context) {
    c.JSON(gin.H{
      "data": []gin.H{},
    })
  })
}
```

要通過測試，其實我們是可以直接寫死回傳的，然而這不利於後續的擴充，因此需要繼續重構。

```go
func GetAllTasks(usecase *TodoUseCase) gin.HandlerFunc {
  return func(c *gin.Context) {
    c.JSON(gin.H{
      "data": []gin.H{},
    })
  }
}

func main() {
  // ...
  r.GET("/tasks", todo.GetAllTasks(todoUseCase))
}
```

這邊我跳過了大概三四個步驟，直接重構到 Dependency Injection（依賴注入）的階段為後面的重構準備，但是可以看出來我們將 Router（路由）的處理分離出來，初步有了 Controller 的雛型，在之後繼續加入其他方法時就能繼續沿用。

```go
type TaskOutput struct {
  ID int `json:"id"`
  Name string `json:"name"`
  Completed bool `json:"completed"`
}

func (uc *TodoUseCase) ListTasks() []TaskOutput {
  output = make([]TaskOutput, 0)

  tasks := uc.repo.AllTasks()
  for _, task := range tasks {
    output = append(output, TaskOutput{
      ID: task.ID,
      Name: task.Name,
      Completed: task.IsCompleted(),
    })
  }

  return output
}

func GetAllTasks(usecase *TodoUseCase) gin.HandlerFunc {
  return func(c *gin.Context) {
    c.JSON(gin.H{
      "data": usecase.ListTasks(),
    })
  }
}
```

再繼續往前進一步就是將寫死的回傳改為呼叫 `TodoUseCase` 的方法，這邊一樣提早了不少步驟，把 Repository 也先放進去，然而可以看到每個步驟都是維持「最小修改」的前提下去修改，不斷地把「寫死」重構成「動態呼叫」的狀態。

至於 Repository 我們在初期也不一定要串接資料庫（題目也說不用）所以如果是 `GET /tasks` 的話，我們甚至可以這樣。

```go
func (s *InMemoryStore) AllTasks() []*domain.Task {
  return []*domain.Task {
    {1, "Todo Item", domain.TaskIncompleted},
  }
}
```

一直到我們實作 `POST /tasks` 的時候，再改由這個 Repository 的保存的資料去讀取即可，假設對於這個「實作流程」非常熟練的話，幾乎能讓每一個動作都沒有多餘的處理，就可以極大的縮減實作的時間。

> 可能會有人疑惑為什麼 Use Case 的回傳是 `TaskOutput` 而不是直接用 `domain.Task` 這是因為輸出的內容不一定是實體（Entity），因此這邊刻意用 `task.IsCompleted()` 來點出這個情境，我們不一定會想回傳所有的實體內容，或者在不同 Use Case 下會要給出不同的結果，因此定義一個 `TaskOutput` 更為合理

這篇文章有點長，實際上要「快速實作」面試作業的技巧並不複雜，就是「減少多餘操作」即可，那麼用測試代替手動驗證、用少量修改減少反覆檢查程式碼跟退回的數量，就可以很快的完成這件事情。

> 我猜可能會有人想知道比較完整的過程，如果有興趣的話可以透過[聯絡表單](https://us4.list-manage.com/contact-form?u=dd3d68032c0510041f1302539&form_id=3e9db24ef48afe5de0ecbe399bcd1e0d)告訴我想看到的形式（文章、影片）等資訊，如果是影片的話順便提供「時間長度、是否接受分段」的資訊會更好，如果完整錄製可能也是要好幾小時，我自己也不一定會看這麼長的影片。

