因為在 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
更為合理
這篇文章有點長,實際上要「快速實作」面試作業的技巧並不複雜,就是「減少多餘操作」即可,那麼用測試代替手動驗證、用少量修改減少反覆檢查程式碼跟退回的數量,就可以很快的完成這件事情。
我猜可能會有人想知道比較完整的過程,如果有興趣的話可以透過聯絡表單告訴我想看到的形式(文章、影片)等資訊,如果是影片的話順便提供「時間長度、是否接受分段」的資訊會更好,如果完整錄製可能也是要好幾小時,我自己也不一定會看這麼長的影片。