.net測試篇之Moq框架簡單使用
系列目錄
Moq庫簡介及安裝
Moq簡介
Moq是.net平臺下的一個非常流行的模擬庫,只要有一個介面它就可以動態生成一個物件,底層使用的是Castle的動態代理功能.
它的流行賴於依賴注入模式的興起,現在越來越多的分層架構使用依賴注入的方式來解耦層與層之間的關係.最為常見的是資料層和業務邏輯層之間的依賴注入,業務邏輯層不再強依賴資料層物件,而是依賴資料層物件的介面,在IOC容器裡完成依賴的配置.
這種解耦給單元測試帶來了巨大的便利,使得對業務邏輯的測試可以脫離對資料層的依賴,單元測試的粒度更小,更容易排查出問題所在.
大家可能都知道,資料層的介面往往有很多方法,少則十幾個,多則幾十個.我們如果在單元測試的時候把介面切換為假實現,即使實現類全是空也需要大量程式碼,並且這些程式碼不可重用,一旦介面層改變不但要更改真實資料層實現還要修改這些專為測試做的假實現.這顯然是不小的工作量.
幸好有Moq,它可以在編譯時動態生成介面的代理物件.大大提高了程式碼的可維護性,同時也極大減少工作量.
除了動態建立代理外,Moq還可以進行行為測試,觸發事件等.
Moq安裝
Moq安裝非常簡單,在Nuget裡面搜尋moq,第一個結果便是moq框架,點選安裝即可.
Moq簡單使用
本示例中要使用到的程式碼如下
public class MyDto { public string Name { get; set; } public int Age { get; set; } } public interface IDataBaseContext<out T> where T:new() { T GetElementById(string id); IEnumerable<T> GetAll(); IEnumerable<T> GetElementsByName(string name); IEnumerable<T> GetPageElementsByName(string name, int startPage = 0, int pageSize = 20); IEnumerable<T> GetElementsByDate(DateTime? startDate, DateTime? endDate); } public class MyBll { private readonly IDataBaseContext<MyDto> _dataBaseContext; public MyBll(IDataBaseContext<MyDto> dataBaseContext) { _dataBaseContext = dataBaseContext; } public MyDto GetADto(string id) { if (string.IsNullOrWhiteSpace(id)) return null; return _dataBaseContext.GetElementById(id); } }
MyDto為業務層和資料層互動的物件,IDataBaseContext為資料層介面,MyBll為我們的業務邏輯層
我們要測試的是業務邏輯層的程式碼.這裡業務邏輯類並沒有無參建構函式,如果手動建立起來非常麻煩,裡面的坑前面說過.下面看如何使用Moq來模擬一個IDataBaseContext物件
我們編寫以下測試類
[Test] public void SimpleTest() { var moq = new Mock<IDataBaseContext<MyDto>>(); MyBll bll = new MyBll(moq.Object); var result = bll.GetADto(null); Assert.Null(result); }
由於bll的GetADto如果傳的引數是null或者空就會返回一個null物件,因些返回的結果是Null,以上測試會通過.
這裡我們首先建立了一個moq物件,它的Object屬性就是我們要模擬的IDataBaseContext
Moq基本配置
我們再為MyBll新增以下方法
public IEnumerable<MyDto> GetDtos(string name)
{
if (string.IsNullOrWhiteSpace(name)) return null;
var dtos = _dataBaseContext.GetElementsByName(name);
return dtos;
}
我們編寫如下測試方法
[Test]
public void ShouldReturn_A_Collection_Of_Dtos()
{
var moq = new Mock<IDataBaseContext<MyDto>>();
MyBll bll = new MyBll(moq.Object);
var dtos = bll.GetDtos("sto");
}
以上測試方法呼叫了bll的GetDtos方法,我們知道GetDtos內部呼叫了資料訪問介面的GetElementsByName方法,我們在除錯模式下看看返回的結果是什麼.
它返回了一個空集合,實際上不管我們提供的是什麼樣的字串,它都返回一個空集合,這是預設行為,因為_dataBaseContext.GetElementsByName
並不知道我們的真實邏輯是什麼.
這樣很顯然並不是總能滿足我們的要求,很多時候我們在測試業務邏輯層的時候需要具體的資料,然後才能繼續往下走.
比如以下方法,我們獲取資料庫裡的所有資料,然而通過一系列邏輯進行過濾,最終返回過濾後的結果.
public IEnumerable<MyDto> GetAllDtos()
{
var all = _dataBaseContext.GetAll().ToList();
if (!all.Any()) return Enumerable.Empty<MyDto>();
//一系列邏輯...
var filteredDtos = all.Where(a => a.Age > 20);
var orderDtos = filteredDtos.OrderBy(a => a.Name);
return orderDtos;
}
如果是預設行為(呼叫模擬的介面方法,引用物件返回null,集合返回空,簡單物件返回預設值),則程式碼很快就返回了,if下面的業務邏輯測不到了.下面我們看下如何配置介面方法的返回值
這裡其實主要用到了 新建moq物件的setup
方法,我們可以在setup裡設定方法,屬性的值.
[Test]
public void ShouldReturn_A_Collection_Of_Dtos()
{
var moq = new Mock<IDataBaseContext<MyDto>>();
moq.Setup(a => a.GetAll()).Returns(new List<MyDto>
{
new MyDto{Name="baidu",Age=15},
new MyDto{Name="sto",Age=32},
new MyDto{Name="zto",Age=24},
new MyDto{Name="yto",Age=12}
});
MyBll bll = new MyBll(moq.Object);
var dtos = bll.GetAllDtos().ToList();
dtos.Should().HaveCount(2);
dtos.Select(a => a.Name).Should().BeInAscendingOrder();
}
我們看以上程式碼,我們我們讓資料訪問介面的代理物件返回一個MyDto型別集合,一共四個元素,由我們的業務可知,我們只要年齡大於20的元素,並且名字按正序排列.因此以上測試應該返回成功,實際上也是測試通過了.
帶引數的方法設定
以上的GetAll是不帶引數的,帶引數的方法我們可以顯式的指定一個引數,我們也可以使用Moq框架提供的方法來模糊指定引數,比如我們可以指定方法是任意字元,任意數字,任意範圍的數字等.
我們再看前面的一個方法
public MyDto GetADto(string id)
{
if (string.IsNullOrWhiteSpace(id)) return null;
return _dataBaseContext.GetElementById(id);
}
這個方法接收一個型別為字串的id,只要字串不是空字串或者null時我們都返回一個MyDto物件.
測試方法如下
[Test]
public void ShouldReturn_A_Dto_If_QueryBy_Id_With_Valid_Parameter()
{
var moq = new Mock<IDataBaseContext<MyDto>>();
moq.Setup(a => a.GetElementById(It.IsAny<string>())).Returns(new MyDto());
MyBll bll = new MyBll(moq.Object);
var dto = bll.GetADto("afakeid");
dto.Should().NotBeNull();
}
這裡我們使用到了Moq裡的It.Is方法,這個方法接受一個Func<T,bool>型別的委託,我們的條件是不管它是一個什麼樣的string,總是返回一個new MyDto();
[warning]注意這裡配置的是Moq物件(即moq.Object)的方法返回值,而不是bll物件的方法的返回值,如果我們傳入的字串是空字串,則GetADto直接返回了null,資料訪問物件就沒機會執行了.
It裡面還有很多靜態方法,用於指定數字是否是否在某一範圍,物件是否是列表中的物件,字串是否滿足正則等.語義都非常明確,大家可以自己研究一下.
指定引數的配置
以上使用到了It.IsAny方法.It裡面還有一個Is方法,接受一個Func<T,bool>型別委託,用於指定物件為滿足特定條件的物件,而不是任意物件.
Bll層新增以下方法
public bool IsVip(string id)
{
if (string.IsNullOrWhiteSpace(id)) return false;
var dto = _dataBaseContext.GetElementById(id);
if (dto?.Name?.Contains("sto")) return true;
return false;
}
我們判斷一個dto是否是vip,如果傳入id為null返回false,如果不是則獲取一個物件,如果物件的名字包含sto關鍵字則返回true
比如我們知道id為9527的物件為sto,因此它是個vip,我們的測試方法如下
[Test]
public void ShouldReturn_True_If_Id_Is_9527()
{
var moq = new Mock<IDataBaseContext<MyDto>>();
moq.Setup(a => a.GetElementById(It.Is<string>(t => t.Trim() == "9527"))).Returns(new MyDto { Name = "sto", Age = 24 });
MyBll bll = new MyBll(moq.Object);
bool isVip = bll.IsVip("9527");
Assert.True(isVip);
}
以上測試通過.
MOCk.Of
我們以上僅配置了介面代表的一個方法,有時候需要配置多個,這樣需要多個Setup,這時候我們可以使用Mock.Of,注意Mock.Of創建出來的是一個代理物件,而不是一個mock物件.
[Test]
public void MockOf_Test()
{
var obj = Mock.Of<IDataBaseContext<MyDto>>(a =>a.GetAll()==new List<MyDto>(){new MyDto()}
&&a.GetElementById(It.IsAny<string>())==new MyDto()
&&a.GetElementsByName(It.IsAny<string>())==new MyDto[3]);
var all = obj.GetAll();
var one = obj.GetElementById("s");
var some = obj.GetElementsByName("somename");
Assert.Multiple(() =>
{
Assert.AreEqual(1, all.Count());
Assert.NotNull(one);
Assert.AreEqual(3, some.Count());
});
}
以上測試會通過.
注意以上的xxx==xxx並不是比較兩個物件,Mock利用它進行賦值
很多初接觸單元測試的朋友看完以上程式碼後可能感覺一臉懵,完全不理解利用moq在dao層生成一些看似無意義的假資料有什麼意義,其實大家要明白單元測試的目的是什麼,單元測試是以程式碼塊為基礎(通常是一個方法),測試這一個單元邏輯的正確性,在dao層,我們只關心這一層拿到資料後的處理邏輯.很多朋友可能知道ef可以搭建記憶體伺服器來模擬真實資料庫,這樣也同樣不依賴於外部的資料庫.其實大家也可以這樣做,也可以不這樣而使用moq來模擬一個數據庫連線上下文物件.因為在單元測試裡,真實的資料是什麼樣的並不是首要關心的問題,而是這個程式碼單元邏輯的正確性.如果是做整合測試,我們則需要模擬一個真實環境,這個時候我們就需要使用記憶體伺服器甚至使用外部伺服器.當然,如果要做壓力測試,我們還需要模擬產品執行時真實的物理環境,網路環境等條件(當然,很多時候直接在真實的執行環境進行測試了).總之我們要搞清楚不同的測試要解決什麼樣的問題,要達到什麼樣的目的,剩下的才是工具框架的使用.