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

用測試讓你在三小時交出面試作業

因為在 2023 年初就成為天選之人(2023 年成為外商裁員的天選之人該做些什麼?)而開始找工作,運氣很不錯的在獵頭協助跟朋友的內推,我在 1/18 跟 1/19 分別收到兩間公司的作業,但是我不想留到農曆年後才處理,因此決定用空檔趕在連假前完成他們!

面試作業

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

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

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

用測試開始

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

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

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

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

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

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

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

步進式開發

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

1// IoT
2func Test_FromJSON(t *testing.T) {
3  data, _ := encoder.FromJSON(`null`)
4  // Verify the "null" encoded to `0x00`
5  if !cmp.Equal([]byte{0x00}, data) {
6    t.Error(cmp.Diff([]byte{0x00}, data))
7  }
8}
 1// CRUD
 2func Test_GET_Tasks(t *testing.T) {
 3  // e.g. router := gin.Default()
 4  res := httptest.NewRecorder()
 5  req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
 6  router.ServeHTTP(res, req)
 7
 8  if !cmp.Equal(`{"data":[]}`, res.Body.String()) {
 9    t.Error(cmp.Diff(`{"data":[]}`, res.Body.String()))
10  }
11}

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

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

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

1func main() {
2  // ...
3  r.GET("/tasks", func(c *gin.Context) {
4    c.JSON(gin.H{
5      "data": []gin.H{},
6    })
7  })
8}

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

 1func GetAllTasks(usecase *TodoUseCase) gin.HandlerFunc {
 2  return func(c *gin.Context) {
 3    c.JSON(gin.H{
 4      "data": []gin.H{},
 5    })
 6  }
 7}
 8
 9func main() {
10  // ...
11  r.GET("/tasks", todo.GetAllTasks(todoUseCase))
12}

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

 1type TaskOutput struct {
 2  ID int `json:"id"`
 3  Name string `json:"name"`
 4  Completed bool `json:"completed"`
 5}
 6
 7func (uc *TodoUseCase) ListTasks() []TaskOutput {
 8  output = make([]TaskOutput, 0)
 9
10  tasks := uc.repo.AllTasks()
11  for _, task := range tasks {
12    output = append(output, TaskOutput{
13      ID: task.ID,
14      Name: task.Name,
15      Completed: task.IsCompleted(),
16    })
17  }
18
19  return output
20}
21
22func GetAllTasks(usecase *TodoUseCase) gin.HandlerFunc {
23  return func(c *gin.Context) {
24    c.JSON(gin.H{
25      "data": usecase.ListTasks(),
26    })
27  }
28}

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

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

1func (s *InMemoryStore) AllTasks() []*domain.Task {
2  return []*domain.Task {
3    {1, "Todo Item", domain.TaskIncompleted},
4  }
5}

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

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

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

我猜可能會有人想知道比較完整的過程,如果有興趣的話可以透過聯絡表單告訴我想看到的形式(文章、影片)等資訊,如果是影片的話順便提供「時間長度、是否接受分段」的資訊會更好,如果完整錄製可能也是要好幾小時,我自己也不一定會看這麼長的影片。