如何為一個老舊類別的笨重函式加上單元測試?
這兩年才開始學習單元測試,所以它對我來說還算很新的玩意兒,這一年嚐試導入手邊一些小專案,愈試,就愈覺的這真是一個很棒的工具。慢慢的,也要開始讓部門開發人員學習自行導入單元測試,所以寫了一些範例和內容,有什麼不對的地方,還請多多指教。
因為部門大部分維運的專案,幾乎都有七、八年以上歷史,而且許多專案都不是我們原生開發的,因此今天做內訓講解單元測試時,理所當然會聽到的問題:如何在既有的類別中,為一個笨重的函式加上單元測試?這些既有函式多半有許多呼叫端,而且有時候還會有「不可知」的呼叫端,因此要改變它們的結構是一種令人備感壓力的行為,而且加上單元測試又收不到人日,所以到底如何可以 叫馬兒快跑還不吃草 盡快加上單元測試又不怕影響到既有的呼叫端?再加上部門開發人員對於重構和物件導向,其實都還只有基礎認知和不多的經驗,怎麼導入單元測試,其實有些困擾。
先看看經典的笨重函式雛型。以下範例當然是簡化版本,而且為了方便說明,還做了些物件導向的分工。實際情況常常是一個函式有百餘行乃至於三、四百行,參數大多是用弱型別的 DataTable、Array!但下面的範例還是具有一定的代表性:
Public Class ElephantClass
Public Function SaveAction(entity As Order) As Boolean
'verify order's property.
If entity.CustomerId = String.Empty Then
Throw New NullReferenceException("CustomerId can't null.")
End If
'..... do more verify action and set default value to entity property.
'transfer Oracle Command object.
Dim cmd As New OracleCommand()
cmd.CommandText = "Update Orders Set CustomerId = :CustomerId"
cmd.Parameters.Add("CustomerId", OracleDbType.Varchar2, entity.CustomerId, ParameterDirection.Input)
'call dbhelper to insert/update database.
Dim db As New DbHelper()
Return db.RunCommand(cmd)
End Function
End Class
Public Class Order
Property CustomerId As String
End Class
Public Class DbHelper
Public Function RunCommand(cmd As OracleCommand) As Boolean
Dim conn As New OracleConnection("connection string")
cmd.Connection = conn
Dim trans = conn.BeginTransaction()
Try
cmd.ExecuteNonQuery()
trans.Commit()
Return True
Catch ex As Exception
trans.Rollback()
Throw
End Try
End Function
End Class
我們要加上單元測試的對象是 SaveAction 函式,第一個要先思考,我們要測試什麼東東?同事的回答很容易預期:
- 驗證屬性的地方。請注意,範例只有一個 CustomerId,實際上這裡可能就有近百行程式碼,要驗證很多屬性,甚至有些必須轉發給 DB 的 Package 來驗證。
- 寫入 DB。
第 2 點其實是一個常見誤區!在 SaveAction 中,寫入 DB 的動作是透過 DbHelper 類別執行,所以我們要測試的案例,到底是寫入 DB 的行為,還是準備寫入 DB 之前所建立的 OracleCommand?如果是寫入 DB 的行為,測試對象應該是 DbHelper 的 RunCommand 函式,而不是 SaveAction 函式!所以要驗證的應該是在 SaveAction 中,轉換成 OracleCommand 後,所帶的 CommandText 和參數名稱、參數值,是否符合預期才對。
對於 SaveAction 函式,單元測試前,其實應該要實施非常多的重構,可能至少都要做 Introduce Local/Constant、Inline Temp、Promote to Parameter、Consolidate Conditional Expression、Encapsulate Field、Extract Method,乃至於 Extract Class、Move Method 都有可能,但是別忘了一開始的前提:要快(因為沒人日可收)還要不影響既有呼叫端(包含不可知的入口),所以挑重點做,甚至不要做重構,只是透過屬性注入(Property Injection)進行微調。
這裡分享的是我覺得相對簡單、快速的方法:
- 把明顯可以切割的區塊抽成獨立函式,甚至是獨立類別。
- 把外部相依的部分,透過屬性注入,進行一定程度的解耦。
在 SaveAction 函式中,我們明顯可以看到有三個區塊相對獨立:
- 驗證傳入參數值。
- 把 Order 轉換成 OracleCommand。
- 調用 DbHelper.RunCommand 函式執行寫入資料庫。
在此案例中,我們把驗證傳入參數當做 SaveAction 的主要任務,保留在函式裡。這雖然不適切,但是把百行上下的程式抽離出去,有其明顯的風險(光是抽出來的方法要傳入之參數可能就高達十餘個),更何況實際程式碼中,裡面還有許多特殊的商業邏輯和外部元件相依存在,因此我們必須在一個有限的範圍中(時間、風險),選擇相對可行的方式,所以選擇把最大區塊保留下來,當做函式的主要任務。
第二個區塊,把 Order 轉換成 OracleCommand,我們用很簡單的 Extract Method 拉出去,只要傳入一個 Order 的 Instance,回傳一個 OracleCommand 就可以快速搞定,甚至可透過泛型 + 反射的技巧,變成一定程度可通用的轉換 Entity 為 OracleCommand 工具類別。範例程式就沒做這麼複雜了,直接 Extract Method、Move Method to New Class:
Public Shared Function GetOracleCommand(entity As Order) As OracleCommand
Dim cmd As New OracleCommand()
cmd.CommandText = "Update Orders Set CustomerId = :CustomerId"
cmd.Parameters.Add("CustomerId", OracleDbType.Varchar2, entity.CustomerId, ParameterDirection.Input)
Return cmd
End Function
End Class
第三個調用 DbHelper.RunCommand 的地方,因為已經是呼叫第三方元件了,似乎是沒有再抽離的必要,但我們要思考一個問題:當我要測試 SaveAction 的主要任務時,都會調用 DbHelper.RunCommand 函式,把測試資料寫入 DB,這樣絕對有問題啊!如果現在環境不能和資料庫建立連線怎麼辦?而且寫入這些測試資料,一定會汙染系統,這是絕對不可以做的行為!
所以我們必須先讓 SaveAction 在單元測試時,不使用 DbHelper,但是這在現有的函式中做不到,所以退而求其次,不然就是讓 SaveAction 函式在調用 DbHelper.RunCommand 時,不會真的去存取資料庫。這似乎可行,因為我們可以把 DbHelper 改用函式傳入參數取代,這種方式又叫參數注入(Parameter Injection),但是這會動到函式的簽章,會造成所有呼叫端必須對應調整,違反限制條件,當然不可行!
沒關係,我們有第二個解法,改用屬性注入(Property Injection),在 ElephantClass 中加入一個 MyDbHelper 屬性,然後取值時,若呼叫端沒有設定(= Nothing),就建立一個新的 DbHelper 實例,否則就直接回傳呼叫端所設定之 DbHelper:
Public Class ElephantClass
Private _myDbHelper As IDbHelper
Public Property MyDbHelper() As IDbHelper
Get
If _myDbHelper Is Nothing Then
_myDbHelper = New DbHelper()
End If
Return _myDbHelper
End Get
Set(ByVal value As IDbHelper)
_myDbHelper = value
End Set
End Property
Public Function SaveAction(entity As Order) As Boolean
'verify order's property.
If entity.CustomerId = String.Empty Then
Throw New NullReferenceException("CustomerId can't null.")
End If
'..... do more verify action and set default value to entity property.
'transfer Oracle Command object.
Dim cmd As OracleCommand = TransferHelper.GetOracleCommand(entity)
'call dbhelper to insert/update database.
Return MyDbHelper.RunCommand(cmd)
End Function
End Class
這個解法挺好,我們可以在不動到函式簽章的情況下,把第三方元件變成由呼叫端自行傳入,或者保留原邏輯由 SaveAction 函式自行新建,但請注意,這樣並沒有解決如何讓 RunCommand 不要真的去存取資料庫問題喔!要解決這個問題,我們必須用到抽取介面(Extract Interface)的動作,另外我們可以把抽出來的介面放在 DbHelper.vb 裡面,讓檔案隻數不會增加,而這個 Extract Interface 的動作,除了要重新編譯 DbHelper 所屬的專案外,完全沒有其他風險,所以可以放心的抽出來:
Function RunCommand(ByVal cmd As OracleCommand) As Boolean
End Interface
Public Class DbHelper
Implements IDbHelper
Public Function RunCommand(cmd As OracleCommand) As Boolean Implements IDbHelper.RunCommand
Dim conn As New OracleConnection("connection string")
cmd.Connection = conn
Dim trans = conn.BeginTransaction()
Try
cmd.ExecuteNonQuery()
trans.Commit()
Return True
Catch ex As Exception
trans.Rollback()
Throw
End Try
End Function
End Class
最後,我們建立單元測試,並在單元測試中建立一個假的 DbHelper,實作 IDbHelper,在 RunCommand 函式中,固定回傳 True(因為我們要測的並不是更新資料庫的動作是否成功),然後在單元測試程式在執行測試前,先把假的 DbHelper 設定給測試對象(ElephantClass)的屬性中,再調用 SaveAction,這樣就可以順利測試驗證屬性的相關內容了:
Imports Microsoft.VisualStudio.TestTools.UnitTesting
Imports ElephantClassCanTestUsingPropertyInjection
Imports Oracle.DataAccess.Client
<TestClass()> Public Class TestElephantClass
<TestMethod()> _
Public Sub TestSaveAction_VerifyEntityProperty()
'arrange
Dim target As New ElephantClass()
Dim fakeDb As New FakeDbHelper()
Dim testOrder As New Order()
testOrder.CustomerId = "Leo"
target.MyDbHelper = fakeDb
Dim expected As Boolean = True
'actual
Dim actual = target.SaveAction(testOrder)
'assert
Assert.AreEqual(expected, actual)
'also use Assert.IsTrue(actual)
End Sub
<TestMethod()> _
<ExpectedException(GetType(System.NullReferenceException), "沒有錯就奇怪了!")> _
Public Sub TestSaveAction_VerifyEntityProperty_Null()
'arrange
Dim target As New ElephantClass()
Dim fakeDb As New FakeDbHelper()
Dim testOrder As New Order()
testOrder.CustomerId = String.Empty
target.MyDbHelper = fakeDb
Dim expected As Boolean = True
'actual
Dim actual = target.SaveAction(testOrder)
'assert
Assert.AreEqual(expected, actual)
'also use Assert.IsTrue(actual)
End Sub
End Class
Public Class FakeDbHelper
Implements IDbHelper
Public Function RunCommand(cmd As OracleCommand) As Boolean Implements IDbHelper.RunCommand
Return True
End Function
End Class
另外,因為我們把 Oracle 轉成 OracleCommand 的動作抽到獨立的類別函式中,所以我們也可以輕易的去對他做測試囉,這部份就請大家自行玩玩看。
--------
沒什麼特別的~
不過是一些筆記而已