1. 程式人生 > 其它 >單元測試佈道之一:定義、分類與策略

單元測試佈道之一:定義、分類與策略

目錄

在開始之前

即便從業若多年,不寫單元測試的開發人員並不少見。關於單元測試的相關知識和實踐網上連篇累牘,無須從零開始陳述,本系列預計三四章,本單為序,部分內容來自網上資料整理,後續內容新增自行編寫的內容,出處見於文章末尾,請自行取用。

什麼是單元測試

關於測試概念非常多,在進行定義之前有必要先對測試進行分類,避免大家使用相同術語表達不同的意思。

測試的分類

軟體測試從不同的角度審視有著不同的分類方式,比如按測試方法有“黑盒”、“白盒”之分,按按測試方向有功能、效能、安全、相容性、穩定性測試。開發人員關注按階段分類的測試

,列舉如下。

  • 單元測試:檢查程式碼判斷是否有問題
  • 整合測試:測試模組和模組的連線有沒有問題
  • 系統測試:測試軟體的整個整體。功能,安全,效能等等測試
  • 驗收測試:甲方或者客戶來驗收這個軟體是不是它要的軟體,協助驗收

單元策略在開發階段完成。面對繁多的分類方式,Google 有自己的命名:小型測試、中型測試和大型測試。

可以看到 Google 所謂小型測試就是單元測試,我們引入其定義。

單元測試的定義

單元測試是指對軟體中的最小可測試單元進行檢查和驗證,是最低級別的測試活動。開發者編寫的一小段程式碼,用於檢驗被測程式碼的一個很小的、很明確的功能是否正確。通常而言,一個單元測試是用於判斷某個特定條件(或者場景)下某個特定函式的行為。

  • 驗證程式碼與設計相符合;
  • 跟蹤需求與設計的實現;
  • 發現設計和需求中存在的缺陷;
  • 發現在編碼過程中引入的錯誤。

單元測試與其他測試的區別

單元測試與整合測試的區別

  • 測試物件不同:單元測試物件是實現了具體功能的程式單元;整合測試物件是概要設計規劃中的模組及模組間的組合。
  • 測試方法不同:單元測試中的主要方法是基於程式碼的白盒測試;整合測試中主要使用基於功能的黑盒測試。
  • 測試時間不同:整合測試晚於單元測試。
  • 測試內容不同:單元測試主要是模組內程式的邏輯、功能、引數傳遞、變數引用、出錯處理及需求和設計中具體要求方面的測試;整合測試主要驗證各個介面、介面之間的資料傳遞關係,及模組組合後能否達到預期效果。

單元測試與系統測試的區別

  • 單元測試屬於白盒測試,從開發者的角度出發,關注的是單元的具體實現、內部邏輯結構和資料流向;系統測試屬於黑盒測試,從使用者角度出發,證明系統已滿足使用者的需要。
  • 單元測試使問題及早暴露,便於定位解決,屬於早期測試;系統測試是一種後期測試,定位錯誤比較困難。
  • 單元測試允許多個被測單元同時進行測試;系統測試時基於需求規格說明書。

單元測試的必要性

因為場景覆蓋、邏輯不自閉甚至低階錯誤等諸多因素導致程式碼很難編寫一次就正確執行,這就需要單元測試存在。而專案複雜度和程式碼量日益增長,手工迴歸測試時間越來越長,這就需要單元測試來兜底。

如果讀者經歷開發維護過沒有單元測試的中型專案,很難同意程式碼很難不變成臭不可聞的 shit mountain:不敢輕易重構,新增功能小心翼翼避免觸碰到不知道在哪裡的隱匿邏輯引入問題。而測試人員更是叫苦連天,迴歸一輪下來時間久,線上問題層出不窮。

測試金字塔

一個健康、快速、可維護的測試組合應該是這樣的:寫許多小而快的單元測試,適當寫一些更粗粒度的測試,寫很少高層次的端到端測試。

大量單元測試作為金字塔基底,在此之上是一些整合測試,再往上是自動化相關測試。

  • 越靠近金字塔底部,測試組織起來越快,開展的成本越低
  • 越靠近金字塔頂部,測試組織起來越慢,開展的成本越高

來自微軟的統計資料:bug 在單元測試階段被發現,平均耗時3.25小時,如果漏到系統測試階段,要花費11.5小時。

在開發階段發現 bug,其解決成本遠遠比上線之後暴露問題要低得多得多。

程式碼的可測試性

截止目前為止我們都在推廣形而上學的內容,從現在開始,我們以 dotnet 相關示例說明可測試性相關內容。要保證每個元件的正確性以及可以校驗變化,其實是希望將程式碼的質量保障提前,保證每個元件在開發階段能夠測試;而想要每個元件能夠測試,在設計過程中,就要保證每個模組是可以測試的,而這就是可測試性。

單元測試不僅用來測試程式碼功能,還可以用來測試程式碼設計,不好寫單測的程式碼都是不好的程式碼。

好的測試容易寫、可讀、可靠、快速。我們在設計以及編寫程式碼時,必須將可測試性納入考量,在定義可測試性時不妨先看反模式

未決行為/非確定性

// BAD
public class PowerTimer
{
	public String GetMeridiem()
	{
		var time = DateTime.Now;
		if (time.Hour >= 0 && time.Hour < 12)
		{
			return "AM";
		}
		return "PM";
	}
}

public class PowerTimerTest
{
	[Fact]
	public void get_meridiem_before_12_return_am()
	{
		// HOW?
		// Assert.Equal(new PowerTimer().GetMeridiem(), "AM");
	}
}

DateTime.Now 本質上是一個隱藏的輸入,在程式執行期間或測試執行之間可能會更改,對其呼叫將產生不同的結果。引入方法引數可以修復該 API:

public class PowerTimer
{
	public String GetMeridiem(DateTime time)
	{
		if (time.Hour >= 0 && time.Hour < 12)
		{
			return "AM";
		}
		return "PM";
	}
}

public class PowerTimerTest
{
	[Fact]
	public void get_meridiem_before_12_return_am()
	{
		var time = new DateTime(2021, 6, 15, 1, 0, 0);
		Assert.Equal(new PowerTimer().GetMeridiem(time), "AM");
	}
}

直接依賴於實現

// BAD
public class DepartmentService
{
	private CacheManager _cacheManager = new CacheManager();

	public List<Department> GetDepartmentList()
	{
		List<Department> result;
		if (_cacheManager.TryGet("department-list", out result))
		{
			return result;
		}
        // ...
	}
}


public class CacheManager
{
	public bool TryGet<T>(string key, out T value)
	{
        // ...

假設 CacheManager 直接去訪問 redis 或 memcached 之類的快取,就沒辦法進行單元測試了。解開緊密耦合的依賴,注入物件能修復該 API。

public class DepartmentService
{
	private CacheManager _cacheManager;
	
	public DepartmentService(CacheManager cacheManager)
	{
		_cacheManager = cacheManager;
	}

	public List<Department> GetDepartmentList()
	{
		List<Department> result;
		if (_cacheManager.TryGet("department-list", out result))
		{
			return result;
		}

		// ...
	}
}

額外地說:"依賴注入" 是廣義概念,並不限於 Microsoft.Extensions.DependencyInjection、Autofat、Castle 之類框架和其使用。

全域性變數/單例模式

像 C# 等高階語言中沒有全域性變數,但單例模式是存在的,它是全域性變數的另一種形式。

// BAD
public class UserService
{
	public User CreateUser(string name)
	{
        var id = GlobalCounter.Instance.NextId();
		var user = new User(id, name);
        // ...
	}
}


public class GlobalCounter
{
    private static readonly GlobalCounter Instance = new GlobalCounter();
    
	public long NextId()
	{
        // ...

單例模式同樣依賴於真實的依賴關係,並在元件之間引入了不必要的緊密耦合,但並不是說不能使用單例模式。注入物件能修復該 API。

public class UserService
{
    private readonly GlobalCounter _globalCounter;
    public UserService(GlobalCounter globalCounter) 
    {
        _globalCounter = globalCounter;
    }
    
	public User CreateUser(string name)
	{
        var id = _globalCounter.NextId();
		var user = new User(id, name);
        // ...
	}
}

靜態方法/函式

靜態方法是不確定性或副作用行為的另一個潛在來源。它們可以輕鬆引入緊密耦合,並使我們的程式碼不可測試。ASPNET MVC 中 HttpContext 是密封(sealed )類,完全沒有可測試性。微軟先後引入了 HttpContextBaseHttpContextWrapper 來補救,並最終在 ASPNET Core 中將其拋棄。

// BAD
public void GetPageTitle()
{
    if (HttpContext.Current.User.Identity.IsAuthenticated)
    {
        Page.Title = "Home page for " + HttpContext.User.Identity.Name;
    }
    else
    {
        Page.Title = "Home page for guest user.";
    }
}

當然並不是說不能使用靜態方法/函式,靜態方法/函式不該依賴於外部環境,系統時間,網路等。

// BAD
public static bool CheckNodejsInstalled()
{
    return Environment.GetEnvironmentVariable("PATH").Contains("nodejs", StringComparison.OrdinalIgnoreCase);
}

傳遞引數能修復該 API。

public static bool CheckNodejsInstalled(string path)
{
    return path != null && path.Contains("nodejs", StringComparison.OrdinalIgnoreCase);
}

複雜繼承

如果父類需要 mock 某個依賴才能進行單元測試,那其派生類在編寫單元測試的時候,都要 mock 這個依賴物件。理論上層次越深 mock 工作越多,其實這也是高耦合的一種體現,使得很難編寫單元測試。

abstract class Issue
{
	public Issue(Content content)
	{
        // do stuff with content
	}
}

class RegularIssue : Issue
{
	public RegularIssue(Content content, Plan plan)
		: base(content)
	{
        // do stuff with plan
	}
}

class SignificantIssue : RegularIssue
{
	public SignificantIssue(Content content, Plan plan, Bug bug)
		: base(content, plan)
	{
         // do stuff with bug
	}
}

也許我們只想測試 SignificantIssue 的部分功能,但是構造其例項需要引入不相關的依賴,雖然你可能將其置空了事,但如果父類進行了很嚴謹的非空檢查甚至是型別檢查,測試恐怕並不是那麼容易。

高耦合程式碼

耦合度高的程式碼很難找到單元測試的切入點,也很難寫出高效的測試程式碼。單元測試像是花盆裡的沙子,保證可測試的過程要求我們很好的拆分程式碼,它會降低土壤的粘度(耦合性)

私有方法

私有方法無法測試,如果希望被測試則應考慮設計的合理性。對於 dotnet 專案來說,InternalsVisibleTo 可以幫助我們測試內部類,後文會略有篇幅描述。

單元測試策略

如果進行單元測試,這裡推薦自底向上或孤立的單元測試策略。

  • 自底向上的單元測試:先對最底層的基本單元進行測試,模擬呼叫該單元的單元做驅動模組。然後再對上面一層進行測試,用下面已被測試過的單元做樁模組。依此類推,直到測試完所有單元。
  • 孤立單元測試:不考慮每個單元與其它單元之間的關係,為每個單元設計樁模組或驅動模組。每個模組進行獨立的單元測試。

這裡引入了一些術語像"樁(stub)"之類,後文會有篇幅描述,可以先使用 mock/fake 作為替代理解。

Newtonsoft.Json 中最基本的物件是 JToken,其繼承結構如下:

Newtonsoft.Json.Linq.JToken 
├── Newtonsoft.Json.Linq.JContainer 
│   ├── Newtonsoft.Json.Linq.JArray 
│   ├── Newtonsoft.Json.Linq.JConstructor 
│   ├── Newtonsoft.Json.Linq.JObject 
│   └── Newtonsoft.Json.Linq.JProperty 
└── Newtonsoft.Json.Linq.JValue 
    └── Newtonsoft.Json.Linq.JRaw 

UML 圖更直觀

該圖作於 2013 年,仍適用於當前版本的 json.net,可見基設計之穩定。

Src/Newtonsoft.Json.Tests/Linq 中展示了相關的測試實現

  • JTokenTests.cs:最基本的測試,不依賴其他實現
  • JValueTests.cs:使用 JToken 測試 JValue 的方法,
  • JArrayTests.cs:使用 JValue 測試 JArray 的方法
  • JObjectTests.cs:使用 JValue 測試 JObject 的方法,少量使用 JProperty
  • JConstructorTests.cs:使用 JTokenJValue 測試 JConstructor 的方法
  • JRawTests.cs:使用 JToken 測試 JRaw 的方法

單元測試誤區

現代開發框架做了很多工作使得組織專案變得容易,但不應深度開發框架中的高階特性。

如果脫離開發框架無法做單元測試,就說明程式碼已經不具備可測試性。大量藉助開發框架進行單元測試,會矇蔽團隊的眼睛,讓團隊成員看不到程式碼邊的有多糟糕。

部分參考