C#單元測試面面觀

標題有點標題黨,但相信各位看完這篇文章必定會所收穫,若是以前沒有接觸過單元測試或瞭解不深經過本文都能對單元測試有個全新認識。本文的特色是不脫離實際,所測試的代碼都是常見的模式。html

寫完這篇文章後,我看了一些關於單元測試理論的東西,發現文章中有些好像不太合主流測試理論,因爲理論和使用我的難以完美結合,只能取實用爲本。程序員

另外本文編寫的單元測試都是基於已有代碼進行測試,而不是TDD倡導的現有測試後有能夠工做的代碼,不一樣思想指導下寫出的測試代碼可能不太同樣。正則表達式

 

最近的項目中寫了一個巨長的函數,調試的時候老是發現各類潛在的問題,一遍一遍的F5啓動調試,鍵盤都快按爛了,那個函數還沒跑通。想了想,弄個單元測試頗有必要,說幹就幹。找來xUnit等把單元測試搞了起來。說來這仍是第一次正八經的搞單元測試,想一想有必要把這個過程記錄一下,因而這篇文章就誕生了。算法

進行單元測試代碼編寫的過程當中收穫還真很多,好比把那個巨長的函數重構爲4個功能相對獨立,大小適中的函數。另外寫測試能夠以用戶的角度使用函數,從而發現了幾個以前沒有想到的應該進行邏輯判斷的地方並在程序代碼中加入了if段。其實這些都是單元測試的好處,固然單元測試的利可能不僅這些,總之越早在項目中加入單元測試越是事半功倍。數據庫

這篇文章對單元測試作了一些總結,固然最重要的是記錄了Mocks工具的使用。在此次單元測試以前我對單元測試的瞭解停留在幾個測試框架的測試方法上。拿測試運行器乾的最多的事不是「測試」而是「調試」。即通常都是在一個類及函數不方便啓動程序來調試時,搞一個測試類,用測試運行器的調試功能專門去Debug這個方法。這其實也只是用了測試框架(和測試運行器)很小的一部分功能。編程

在開始正題以前說一說單元測試工具的選擇。如今xUnit.net幾乎成爲了準官方的選擇。xUnit.net配套工具完善,上手簡單初次接觸單元測試是很好的選擇。測試運行器選擇了ResharperxUnit runner插件(Resharper也vs必不可少的插件),我的始終感受VS自帶的測試運行工具遠不如Resharper的好用。Mock框架選擇了大名鼎鼎的RhinoMocks,神同樣的開源Mock框架。json

因爲我是單元測試新手,這也是第一次比較仔細的寫單元測試,最大的體會就是Mock工具要比Test Framework與編寫單元測試代碼的用戶關係更密切。本文將從最簡單的測試開始爭取將全部可能遇到的測試狀況都寫出來,若有不完整也請幫忙指出,若有錯誤請不吝賜教。c#

插播一下,xUnit.net的安裝很簡單,打開Nuget包管理器找到xUnit.net並安裝就能夠了(寫這篇文章是最新正式版是2.0,2.1到了RC),就是一些程序集。RhinoMocks也是同理。Resharper的xUnit Test Runner經過Resharper的Extension Manager(有這麼一個菜單項)來安裝,點擊菜單彈出以下圖的對話框:緩存

圖1框架

寫這段內容時,xUnit.net Test Runner排在顯眼的第一位,點擊ToggleButton切換到Install,點擊安裝就能夠了,完了須要重啓vs。

ps.新版的Resharper Extension Manager基於Nuget實現,我這裏的聯通寬帶連nuget常週期性抽風,有時不得不走代理,速度龜慢。

 

1.最簡單的單元測試

這裏先展現一個最簡單的方法及測試,目的是讓沒有接觸過單元測試的同窗有個直觀印象:

被測方法是一個計算斐波那契數的純計算方法:

public int Fibonacci(int n)
{
    if (n == 1 || n == 2)
    {
        return 1;
    }
    int first = 1;
    int second = 1;
    for (int i = 2; i < n; i++)
    {
        var temp = second;
        second += first;
        first = temp;
    }
    return second;
}

測試方法:

[Fact]
public void Test_Fibonacci_N()
{
    var act = Fibonacci(10);
    var expect = 55;
    Assert.True(act == expect);
}

xUnit最簡單的使用就是在測試方法上標記[Fact],若是使用Resharper Test Runner的話在vs的代碼窗口中能夠看到這樣這樣一個小圓圈,點擊就能夠&ldquo;運行&rdquo;或&ldquo;調式&rdquo;這個測試方法。(其它runner也相似)

圖2

在測試方法所在的類聲明那行前面也有一個這個的圓點,點擊後能夠執行類中全部測試方法。若是測試經過圓點是綠色小球標識,若是不經過會以紅色標記顯示。

另外也能夠打開Resharper的UnitTest窗口,裏面會列出項目中全部的單元測試,也能夠經過這個執行單個或批量測試:

圖3

咱們執行上面的測試,能夠看到下面的結果:

圖4

嗯 ,咱們的測試經過了。有時候咱們還會編寫一些測試,測試相反的狀況,或邊界狀況。如:

[Fact]
public void Test_Fibonacci_N_Wrong()
{
    var act = Fibonacci(11);
    var expect = 55;
    Assert.False(act == expect);
}

在團隊人員配置比較齊全的狀況下,設計測試用例應該是測試人員的工做,程序員按照設計好的測試用例編寫測試方法,對被測試方法進行全方面的測試。

除了上面用到的Assert.True/False,xUnit還提供了以下幾種斷言方法(以2.0版爲準,表格儘可能給這些方法分類排的序,可能不太完整):

斷言 說明
Assert.Equal() 驗證兩個參數是否相等,支持字符串等常見類型。同時有泛型方法可用,當比較泛型類型對象時使用默認的IEqualityComparer<T>實現,也有重載支持傳入IEqualityComparer<T>
Assert.NotEqual() 與上面的相反
Assert.Same() 驗證兩個對象是否同一實例,即判斷引用類型對象是否同一引用
Assert.NotSame() 與上面的相反
Assert.Contains() 驗證一個對象是否包含在序列中,驗證一個字符串爲另外一個字符串的一部分
Assert.DoesNotContain() 與上面的相反
Assert.Matches() 驗證字符串匹配給定的正則表達式
Assert.DoesNotMatch() 與上面的相反
Assert.StartsWith() 驗證字符串以指定字符串開頭。能夠傳入參數指定字符串比較方式
Assert.EndsWith() 驗證字符串以指定字符串結尾
Assert.Empty() 驗證集合爲空
Assert.NotEmpty() 與上面的相反
Assert.Single() 驗證集合只有一個元素
Assert.InRange() 驗證值在一個範圍以內,泛型方法,泛型類型須要實現IComparable<T>,或傳入IComparer<T>
Assert.NotInRange() 與上面的相反
Assert.Null() 驗證對象爲空
Assert.NotNull() 與上面的相反
Assert.StrictEqual() 判斷兩個對象嚴格相等,使用默認的IEqualityComparer<T>對象
Assert.NotStrictEqual() 與上面相反
Assert.IsType()/Assert.IsType<T>() 驗證對象是某個類型(不能是繼承關係)

Assert.IsNotType()/

Assert.IsNotType<T>()

與上面的相反

Assert.IsAssignableFrom()/

Assert.IsAssignableFrom<T>()

驗證某個對象是指定類型或指定類型的子類
Assert.Subset() 驗證一個集合是另外一個集合的子集
Assert.ProperSubset() 驗證一個集合是另外一個集合的真子集
Assert.ProperSuperset() 驗證一個集合是另外一個集合的真超集
Assert.Collection() 驗證第一個參數集合中全部項均可以在第二個參數傳入的Action<T>序列中相應位置的Action<T>上執行而不拋出異常。
Assert.All()

驗證第一個參數集合中的全部項均可以傳入第二個Action<T>類型的參數而不拋出異常

。與Collection()相似,區別在於這裏Action<T>只有一個而不是序列。

Assert.PropertyChanged() 驗證執行第三個參數Action<T>使被測試INotifyPropertyChanged對象觸發了PropertyChanged時間,且屬性名爲第二個參數傳入的名稱。

Assert.Throws()/Assert.Throws<T>()

Assert.ThrowsAsync()/

Assert.ThrowsAsync<T>()

驗證測試代碼拋出指定異常(不能是指定異常的子類)

若是測試代碼返回Task,應該使用異步方法

Assert.ThrowsAny<T>()

Assert.ThrowsAnyAsync<T>()

驗證測試代碼拋出指定異常或指定異常的子類

若是測試代碼返回Task,應該使用異步方法

編寫單元測試的測試方法就是傳說中的3個A,Arrange、Act和Assert。

  • Arrange用於初始化一些被測試方法須要的參數或依賴的對象。

  • Act方法用於調用被測方法獲取返回值。

  • Assert用於驗證測試方法是否定期望執行或者結果是否符合指望值

大部分的測試代碼都應按照這3個部分來編寫,上面的測試方法中只有Act和Assert2部分,對於邏輯內聚度很高的函數,這2部分就能夠很好的工做。像是一些獨立的算法等按上面編寫測試就能夠了。可是若是被測試的類或方法依賴其它對象咱們就須要編寫Arrange部分來進行初始化。下一節就介紹相關內容。

 

2.被測試類須要初始化的狀況

在大部分和數據庫打交道的項目中,尤爲是使用EntityFramework等ORM的項目中,經常會有IRepository和Repository<T>這樣的身影。我所比較贊同的一種對這種倉儲類測試的方法是:使用真實的數據庫(這個真實指的非Mock,通常來講使用不一樣於開發數據庫的測試數據庫便可,經過給測試方法傳入測試數據庫的連接字符串實現),而且相關的DbContext等都直接使用EntityFramework的真實實現而不是Mock。這樣,在IRepository之上的全部代碼咱們均可以IRepository的Mock來做爲實現而不用去訪問數據庫。

若是對於實體存儲到數據庫可能存在的問題感到擔憂,如類型是否匹配,屬性是否有可空等等,咱們也能夠專門給實體寫一些持久化測試。爲了使這個測試的代碼編寫起來更簡單,咱們能夠把上面測試好的IRepository封裝成一個單獨的方法供實體的持久化測試使用。

下面將給出一些示例代碼:

首先是被測試的IRepository

public interface IRepository<T> where T : BaseEntity
{
    T GetById(object id);

    void Insert(T entity);

    void Update(T entity);

    void Delete(T entity);

    IQueryable<T> Table { get; }

    IQueryable<T> TableNoTracking { get; }

    void Attach(T entity);
}

這是一個項目中最多見的IRepository接口,也是最簡單化的,沒有異步支持,沒有Unit of Work支持,但用來演示單元測試足夠了。這個接口的實現代碼EFRepository就不列出來的(用EntityFramework實現這個接口的代碼大同小異)。下面給出針對這個接口進行的測試並分析測試中的一些細節。

public class EFRepositoryTests:IDisposable
{
    private const string TestDatabaseConnectionName = "DefaultConnectionTest";

    private readonly IDbContext _context;
    private readonly IRepository<User> _repository;//用具體的泛型類型進行測試,這個不影響對EFRepository測試的效果

    public EFRepositoryTests()
    {
        _context = new MyObjectContext(TestDatabaseConnectionName);
        _repository = new EfRepository<User>(_context);
    }

    [Fact]
    public void Test_insert_getbyid_table_tablenotracking_delete_success()
    {
        var user = new User()
        {
            UserName = "zhangsan",
            CreatedOn = DateTime.Now,
            LastActivityDate = DateTime.Now
        };
        _repository.Insert(user);
        var newUserId = user.Id;
        Assert.True(newUserId > 0);

        //聲明新的Context,否則查詢直接由DbContext返回而不通過數據庫
        using (var newContext = new MyObjectContext(TestDatabaseConnectionName))
        {
            var repository = new EfRepository<User>(newContext);
            var userInDb = repository.GetById(newUserId);
            user.UserName.ShouldEqual(userInDb.UserName);
        }

        using (var newContext = new MyObjectContext(TestDatabaseConnectionName))
        {
            var repository = new EfRepository<User>(newContext);
            var userInDb = repository.Table.Single(r => r.Id == newUserId);
            user.UserName.ShouldEqual(userInDb.UserName);
        }

        using (var newContext = new MyObjectContext(TestDatabaseConnectionName))
        {
            var repository = new EfRepository<User>(newContext);
            var userInDb = repository.TableNoTracking.Single(r => r.Id == newUserId);
            user.UserName.ShouldEqual(userInDb.UserName);
        }

        _context.Entry(user).State.ShouldEqual(EntityState.Unchanged);
        _repository.Delete(user);

        using (var newContext = new MyObjectContext(TestDatabaseConnectionName))
        {
            var repository = new EfRepository<User>(newContext);
            var userInDb = repository.GetById(newUserId);
            userInDb.ShouldBeNull();
        }
    }

    [Fact]
    public void Test_insert_update_attach_success()
    {
        var user = new User()
        {
            UserName = "zhangsan",
            CreatedOn = DateTime.Now,
            LastActivityDate = DateTime.Now
        };
        _repository.Insert(user);
        var newUserId = user.Id;
        Assert.True(newUserId > 0);

        //update
        using (var newContext = new MyObjectContext(TestDatabaseConnectionName))
        {
            var repository = new EfRepository<User>(newContext);
            var userInDb = repository.GetById(newUserId);
            userInDb.UserName = "lisi";
            repository.Update(userInDb);
        }

        //assert
        using (var newContext = new MyObjectContext(TestDatabaseConnectionName))
        {
            var repository = new EfRepository<User>(newContext);
            var userInDb = repository.GetById(newUserId);
            userInDb.UserName.ShouldEqual("lisi");
        }

        //update by attach&modifystate
        using (var newContext = new MyObjectContext(TestDatabaseConnectionName))
        {
            var repository = new EfRepository<User>(newContext);
            var userForUpdate = new User()
            {
                Id = newUserId,
                UserName = "wangwu",
                CreatedOn = DateTime.Now,
                LastActivityDate = DateTime.Now
            };
            repository.Attach(userForUpdate);
            var entry = newContext.Entry(userForUpdate);
            entry.State.ShouldEqual(EntityState.Unchanged);//assert
            entry.State = EntityState.Modified;
            repository.Update(userForUpdate);
        }

        //assert
        using (var newContext = new MyObjectContext(TestDatabaseConnectionName))
        {
            var repository = new EfRepository<User>(newContext);
            var userInDb = repository.GetById(newUserId);
            userInDb.UserName.ShouldEqual("wangwu");
        }
        _repository.Delete(user);
    }
    
    public void Dispose()
    {
        _context.Dispose();
    }
}

如代碼所示,經過2個測試方法覆蓋了對IRepository方法的測試。在測試類的成員中聲明瞭被測試接口的對象以及這些接口所依賴的成員的對象。這個場景是測試數據倉儲因此這些依賴對象使用真實類型而非Mock(後文會見到使用Mock的例子)。而後在構造函數中對這些成員進行初始化。這些部分都是測試的Arrange部分。即對於全部測試方法通用的初始化信息咱們放在測試類構造函數完成,因測試方法而異的Arrange在每一個測試方法中完成。

測試方法中用的到擴展方法能夠見文章最後一小節。

對於須要清理分配資源的測試類,能夠實現IDisposable接口並實現相應Dispose方法,xUnit.net將負責將構造函數中分配對象的釋放。

xUnit.net每次執行測試方法時,都是實例化一個測試類的新對象,好比執行上面的測試類中的兩個測試測試方法會執行測試類的構造函數兩次(Dispose也會執行兩次保證分配的對象被釋放)。這種設置使每一個測試方法都有一個乾淨的上下文來執行,不一樣測試方法使用同名的測試類成員不會產生衝突。

 

3.避免重複初始化

若是測試方法能夠共用相同的測試類成員,或是出於提升測試執行速度考慮咱們但願在執行類中測試方法時初始化代碼只執行一次,可使用下面介紹的方法來共享同一份測試上下文(測試類的對象):

首先實現一個Fixture類用來完成須要共享的對象的初始化和釋放工做:

public class DbContextFixture: IDisposable
{
    private const string TestDatabaseConnectionName = "DefaultConnectionTest";

    public readonly IDbContext Context;

    public DbContextFixture()
    {
        Context = new MyObjectContext(TestDatabaseConnectionName);
    }

    public void Dispose()
    {
        Context.Dispose();
    }
}

下面是重點,請注意怎樣在測試類中使用這個Fixture:

public class EFRepositoryByFixtureTests : IClassFixture<DbContextFixture>
{
    private readonly IDbContext _context;
    private readonly IRepository<User> _repository;

    public EFRepositoryByFixtureTests(DbContextFixture dbContextFixture)
    {
        _context = dbContextFixture.Context;
        _repository = new EfRepository<User>(_context);
    }

    //測試方法略...
}

測試類實現了IClassFixture<>接口,而後能夠經過構造函數注入得到前面的Fixture類的對象(這個注入由xUnit.net來完成)。

這樣全部測試方法將共享同一個Fixture對象,即DbContext只被初始化一次。

除了在同一個類的測試方法之間共享測試上下文,也能夠在多個測試類之間共享測試上下文:

public class DbContextFixture : IDisposable
{
    private const string TestDatabaseConnectionName = "DefaultConnectionTest";

    public readonly IDbContext Context;

    public DbContextFixture()
    {
        Context = new GalObjectContext(TestDatabaseConnectionName);
    }

    public void Dispose()
    {
        Context.Dispose();
    }
}

[CollectionDefinition("DbContext Collection")]
public class DbContextCollection : ICollectionFixture<DbContextFixture>
{
}

Fixture類和以前如出一轍,此次多了一個Collection結尾的類來實現一個名爲ICollectionFixture<>接口的類。這個類沒有代碼其最主要的做用的是承載這個CollectionDefinition Attribute,這個特性的名字很是重要。

來看一下在測試類中怎麼使用:

[Collection("DbContext Collection")]
public class EFRepositoryCollectionTest1
{
    private readonly IDbContext _context;
    private readonly IRepository<User> _repository;

    public EFRepositoryCollectionTest1(DbContextFixture dbContextFixture)
    {
        _context = dbContextFixture.Context;
        _repository = new EfRepository<User>(_context);
    }

    //測試方法略...
}

[Collection("DbContext Collection")]
public class EFRepositoryCollectionTest2
{
    private readonly IDbContext _context;
    private readonly IRepository<User> _repository;

    public EFRepositoryCollectionTest2(DbContextFixture dbContextFixture)
    {
        _context = dbContextFixture.Context;
        _repository = new EfRepository<User>(_context);
    }

    //測試方法略...
}

在測試類上經過Collection特性標記這個測試類須要Fixture,注意Collection特性構造函數的參數與CollectionDefinition特性構造函數的參數必須徹底匹配,xUnit.net經過這個來進行關聯。標記上[Collection]後就能夠經過構造函數注入得到Fixture對象了,這個與以前就是相同的了。

有幾個測試類就標幾個[Collection],這些測試類將共享相同的Fixture對象。

若是咱們把DbContextCollection的實現改爲:

 

[CollectionDefinition("DbContext Collection")]
public class DbContextCollection : IClassFixture<DbContextFixture>
{
}

結果是EFRepositoryCollectionTest1和EFRepositoryCollectionTest2擁有不一樣的Fixture對象,但在它們類的範圍內這個Fixture是共享的。

 

4.異步方法測試支持

異步編程在C#和.NET中變得原來越流行,庫中不少方法都增長了Async版本,有些新增長的庫甚至只有Async版本的方法(以UWP爲表明)。對異步方法的測試也愈來愈重要,xUnit.net從某個版本(忘了是哪一個了)起開始支持異步方法測試。須要的改動很是簡單就是把返回void的測試方法改爲返回Task並添加async關鍵字變爲異步方法,這樣xUnit.net就能正確的從被測試的異步方法獲取值並完成測試。

好比加入以前用過的IRepository中多了一個異步方法GetByIdAsync,要對這個方法進行單元測試:

Task<T> GetByIdAsync(object id);

異步的測試方法以下:

[Fact]
public async Task Test_get_async()
{
    var userId = 1;
    var user = await _repository.GetByIdAsync(userId);
    Assert.True(user.UserName.Length>0);
}

基本上咱們怎麼去寫異步方法就怎麼去寫異步測試方法。

 

5.給測試方法傳入系列參數

這一小部分是文章快完成時,讀了下xUnit文檔補充上的,在這以前全然不知道xUnit.net還有這麼個功能,看來多寫博客能夠幫助完善知識點中的漏洞,你們共勉。

除了經常使用的[Fact],xUnit還提供一個名爲[Theory]的測試Attribute。xUnit文檔很簡明的解釋二者的不一樣:

Fact所測試的方法結果老是一致的,即它用來測試不變的條件。

Theory測試的方法對一個特定集合中的數據測試結果爲真。

想不出其它例子(個人確沒用過),就給出官方的例子吧。

被測方法:

//判斷一個數是否爲奇數
bool IsOdd(int value)
{
     return value % 2 == 1;
}

測試方法:

[Theory]
[InlineData(3)]
[InlineData(5)]
[InlineData(6)]
public void MyFirstTheory(int value)
{
    Assert.True(IsOdd(value));
}

測試結果:

圖5

對於測試數據集合中的6不是奇數,因此測試失敗。

雖然只有一個測試方法,但xUnit會針對每條的InlineData傳入的數據執行一次測試,這樣能夠很容易看出是哪一條InlineData出了問題就如圖5所示。

修改測試集:

[Theory]
[InlineData(3)]
[InlineData(5)]
[InlineData(7)]
public void MyFirstTheory(int value)
{
    Assert.True(IsOdd(value));
}

這樣測試就能夠順利經過了。

圖6

 

6.Mock初次登場

仍是以實際項目中常見的場景來介紹須要使用Mock的場景,如如今有一個UserService(篇幅緣由只展現部分):

public class UserService : IUserService
{
    private readonly IRepository<User> _userRepository;

    public UserService(IRepository<User> userRepository)
    {
        _userRepository = userRepository;
    }

    public User GetUserById(int userId)
    {
        return _userRepository.GetById(userId);
    }

    public void Create(User user)
    {
        _userRepository.Insert(user);
    }

    public void Update(User user)
    {
        _userRepository.Update(user);
    }

    public void Delete(User user)
    {
	...
    }
}

要測試這個UserService難免會對IRepository產生依賴,因爲在以前的測試中看到Repository已通過完善的測試,因此在測試UserService的時候可使用一個與Repository有相同接口的Stub類,如RepositoryStub,來代替EFRepository供UserService使用,這個類不進行實際的數據訪問,只是按照咱們的測試指望經過硬編碼的方式返回一些值。但每每大型項目中有成百上千的類須要有對應的Mock類用於單元測試,手寫這些xxxMock類是一個很大的工做。因而Mock框架誕生了。

Mock框架(微軟稱作Fakes框架,應該就是一個東西)的做用就是靈活方便的構造出這種Mock類的實例供單元測試方法使用。

Mock,Stub這二者的區分老外們好像一直在討論。大概就是,Stub表示虛擬的對象中存在這些Stub方法使被測試方法能夠正常工做,而Mock不可是虛擬對象中須要提供的方法,還能夠驗證被測對象是否與Mock發生了交互。Mock多是測試不一樣過的緣由,但Stub不會是。經過文中Rhino Mocks的例子能夠仔細體會這兩個概念的不一樣。

好比咱們測試下上面代碼中的GetUserById方法(雖然這個方法很簡單,實際項目中沒有測試的必要,但做爲例子仍是很合適的。)

[Fact]
public void Test_GetUser()
{
    var userRepository = MockRepository.GenerateStub<IRepository<User>>();
    userRepository.Stub(ur => ur.GetById(1)).Return(new User() { UserName = "wangwu" });
    var userService = new UserService(userRepository);
    var userGet = userService.GetUserById(1);

    Assert.Equal("wangwu", userGet.UserName);
}

這多是使用Mock框架最簡單的例子了,GenerateStub方法生成一個」樁「對象,而後使用Stub方法添加一個」樁「方法,使用這個樁對象來構造UserService對象,很顯然測試會順利經過。

例子中Stub方法顯式要求接收1做爲參數(即若是咱們給GetUserById傳入非1的數字測試沒法經過),但被測方法實際上是能夠傳入任意參數的。能夠經過Rhino Mock提供的強大的Arg<T>來改變一下參數約束:

userRepository.Stub(ur => ur.GetById(Arg<int>.Is.Anything)).Return(new User() { UserName = "wangwu" });

這樣就能夠給被測方法傳入任意整數參數,更符合測試語義。Arg<T>類提供了各類各樣對參數約束的函數,以及一個幾乎無所不能的Matches方法,後文還有有介紹。

 

上面用到的只是Mock框架一部分做用,Mock框架更神奇的地方將在下一小節介紹。

 

7.Mock大顯身手 - 測試沒有顯式返回值的方法

前文介紹的大部份內容Assert都是用來判斷被測試方法的返回值。實際項目中還有許多沒有返回值的方法也須要咱們經過測試來保證其中邏輯的正確性。這些沒有返回值的方法有多是將數據保存到數據庫,有多是調用另外一個方法來完成相關工做。

對於將數據保存到數據庫的狀況以前的測試有介紹這裏再也不贅述。對於調用另外一個方法(這裏指調用另外一個類的方法或調用同一個類中方法的測試下一小節介紹)的狀況,咱們經過Mock框架提供的Assert方法來保證另外一個類的方法確實被調用。

這裏以保存用戶方法爲例來看一下測試如何編寫:

public void Create(User user)
{
    _userRepository.Insert(user);
}

如代碼,這個方法沒有返回值,使用以前的Assert方法沒法驗證方法正確執行。因爲單元測試中的userRepository是Mock框架生成的,能夠藉助Rhino Mocks提供的功能來驗證這個方法確實被調用並傳入了恰當的參數。

[Fact]
public void Test_Create_User()
{
    var userRepository = MockRepository.GenerateMock<IRepository<User>>();
    userRepository.Expect(ur => ur.Insert(Arg<User>.Is.Anything));
    var userService = new UserService(userRepository);
    userService.Create(new User() {UserName = "zhangsan"});
    userRepository.VerifyAllExpectations();
}

這個測試代碼和上一小節測試代碼不一樣之處在於使用GenerateMock和Except方法替代了GenerateStub和Stub方法,前者用於指定一個能夠被驗證的指望,然後者只是提供一個虛擬的樁。在代碼的最後經過VerifyAllExpectations方法驗證全部指望都被執行。執行測試沒有意外的話測試能夠正常經過。

給Expect指定的lambda表達式中的Insert方法接受Arg<User>.Is.Anything做爲參數,這正符合被測試函數的要求。若是Create函數中沒有調用IRepository的Insert函數,測試也會失敗:

圖7

這是驗證函數被執行的一種方法,還有另外一種等效的方法,且後者在外觀上更符合以前提到的單元測試的AAA模式:

[Fact]
public void Test_Create_User()
{
    var userRepository = MockRepository.GenerateMock<IRepository<User>>();//這種方法中,這裏使用GenerateMock和GenerateStub均可以
    var userService = new UserService(userRepository);
    userService.Create(new User() {UserName = "zhangsan"});
    userRepository.AssertWasCalled(ur => ur.Insert(Arg<User>.Is.Anything));
}

如代碼所見,這段測試代碼沒有使用Expect設置指望,而是經過AssertWasCalled來驗證一個函數是否被調用。

 

上面大部分例子都使用了Rhino Mocks的GenerateMock<T>()和GenerateStub<T>()靜態方法。Rhino Mocks還經過MockRepository對象的實例方法DynamicMock<T>()和Stub<T>()提供了相同的功能。這二者的最主要區別是,對於Except的驗證,前者只能在靜態方法返回的對象上分別調用VerifyAllExpectations()方法進行驗證,然後者能夠在MockRepository對象上調用VerifyAll()驗證MockRepository中全部的Except。

 

8.測試類內部方法調用

實際測試中還經常會遇到一個方法調用相同類中另外一個方法的這種須要測試的狀況,爲了好描述,假設是C類中的A方法調用了B方法。

先說A和B都是public方法的狀況,正確的測試方法應該是分別測試A,B方法,對於A的測試使用Mock框架生成一個B的Stub方法。

先看一下用來展現的待測方法:

public void Create(User user)
{
    if (IsUserNameValid(user.UserName))
        _userRepository.Insert(user);
}

public virtual bool IsUserNameValid(string userName)
{
    //檢查用戶名是否被佔用
    Debug.WriteLine("IsUserNameValid called");
    return true;
}

在建立用戶以前須要驗證用戶名是否可用,爲此添加了一個IsUserNameValid方法。爲了演示這個方法被標記爲public。值得注意是這仍是一個virtual方法,由於下文咱們要用Rhino Mocks生成這個方法的一個指望,當用Rhino Mocks生成方法的指望時,若是方法不屬於一個接口,則這個方法必須是virtual方法。下面是測試代碼:

[Fact]
public void Test_Create_User_with_innerCall()
{
    var userRepository = MockRepository.GenerateMock<IRepository<User>>();
    userRepository.Expect(ur => ur.Insert(Arg<User>.Is.Anything));
    var userService = MockRepository.GeneratePartialMock<UserService>(userRepository);
    userService.Expect(us => us.IsUserNameValid("zhangsan")).Return(true);

    userService.Create(new User() { UserName = "zhangsan" });
    userRepository.VerifyAllExpectations();
    userService.VerifyAllExpectations();
}

最重要的部分就是經過GeneratePartialMock方法生成了一個userService的對象,而後在上面設置了IsUserNameValid方法的指望。這樣UserService對象中除了IsUserNameValid對象外,其它方法都將使用真實方法,這樣咱們測試的Create方法將調用真實方法而IsUserNameValid是Mock框架生成的。就完成了咱們的需求。

上面介紹了A和B都是public方法的狀況,實際項目中更常見的狀況是A是public方法而B是private方法,即IsUserNameValid是一個private方法:

private bool IsUserNameValid(string userName)
{
    //檢查用戶名是否被佔用
    Debug.WriteLine("IsUserNameValid called");
    return true;
}

對於這種狀況通常能夠經過對A的測試同時驗證B的執行是正確的,即把B做爲A來一塊兒測試,由於這時候沒法單獨使用Mock框架來模擬B方法。因此也要保證在測試方法中傳入的參數可讓A和B都正常執行。

若是private方法很是複雜,也能夠對private方法單獨測試。

對於private方法的測試無法像測試public方法那樣實例化一個對象而後調用方法。須要藉助一個工具來調用private方法,對此微軟在Microsoft.VisualStudio.QualityTools.UnitTestFramework.dll提供了一個PrivateObject類能夠完成這個工做。這個dll位於C:\Program Files (x86)\Microsoft Visual Studio 14.0\Common7\IDE\PublicAssemblies\(根據vs版本不一樣有所不一樣)下,須要手工添加引用。

如被測方法是一個private方法:

private bool IsUserNameValid(string userName)
{
    //檢查用戶名是否被佔用
    Debug.WriteLine("IsUserNameValid called");
    return true;
}

測試代碼能夠這樣寫:

[Fact]
public void Test_IsUserNameValid()
{
    var userService = new UserService(null);
    var userServicePrivate = new PrivateObject(userService);
    var result = userServicePrivate.Invoke("IsUserNameValid","zhangsan");
    Assert.True((bool)result);
}

即便用PrivateObject把被測類包起來,而後經過Invoke方法調用private方法便可。

 

9.Rhino Mocks的高級功能

有了前文和Rhino Mocks的接觸的基礎,這一小節來看一下Rhino Mocks的一些高級功能。

Arg實現參數約束

在前文咱們已經體會到了Arg<T>的強大,Arg<T>.Is.Anything做爲參數就能夠指定Stub方法接受指定類型的任意參數。Arg還能夠進行更多的參數限制,當被測試方法給指望方法傳入的參數不符合參數約束時,驗證指望會失敗最終將致使測試不經過。下面的表格來自Rhino Mocks官方文檔,其中列出了Arg支持的大部分約束。(博主翻譯並按照最新的3.6.1版整理了下)

Arg<T>.Is  
 

Equal(object)

NotEqual(object)

參數相等或不等
 

GreaterThan(object)

GreaterThanOrEqual(object)

LessThan(object)

LessThanOrEqual(object)

大於,大於等於,小於,小於等於比較
 

Same(object)

NotSame(object)

比較引用是否相同
  Anything 任意參數
 

Null

NotNull

參數爲空或不爲空
  TypeOf 參數爲泛型參數指定的類型
Arg<T>.List  
  OneOf(IEnumerable) 肯定參數是指定集合中的一個
  Equal(IEnumerable) 參數列表與指定列表相同
  Count(AbstractConstraint) 肯定參數集合有指定數量的符合約束的元素
  Element(int, AbstractConstraint) 參數集合中指定位置的元素複合一個約束
  ContainsAll(IEnumerable) 肯定參數集合包含全部的指定元素
  IsIn(object) 肯定指定元素屬於參數集合(參數須要爲IEnumerable)
Arg<T>.Ref() 指定ref參數
Arg<T>.Out() 指定out參數
Arg.Text  
 

StartsWith(string)

EndsWith(string)

Contains(string)

參數字符串以指定字符串開始或以指定字符串結束或包含指定字符串
  Like(string regex) 參數匹配指定正則表達式
Arg.Is() Arg<T>.Is.Equal()等價
Arg<T>.Matches()  
  Argt<T>.Matches(Expression) 參數匹配一個lambda表達式指定的約束
  Argt<T>.Matches(AbstractConstraint) 用於不支持lambda的C#版本,之內置的約束類型指定參數約束

表中大部分方法和xUnit.net支持的Assert很相似。重點來看一下其中最強大的Matches方法:

userRepository.Expect(ur => ur.Insert(Arg<User>.Matches(u=>
                                                u.UserName.Length>2 && u.UserName.Length<12&&
                                                u.Birthday>DateTime.Now.AddYears(-120)&&
                                                Regex.IsMatch(u.QQ,"^\\d[5,15]#"))));

這個複雜的Matches方法參數限制了指望函數接受的參數符合一些列條件。

 

WhenCalled--另外一個」bug「般的存在

在Stub、Expect等方法的調用鏈上有一個名爲WhenCalled的方法,它用來指定當樁方法或指望方法被執行時所執行的操做。這裏面能夠幹不少不少事。好比:

userRepository.Stub(ur => ur.GetById(Arg<int>.Is.Anything))
    .Return(new User() { UserName = "wangwu" })
    .WhenCalled(mi =>
    {
        //能夠修改樁方法的參數和返回值,還能夠獲取方法信息
        var args = mi.Arguments;
        var methodInfo = mi.Method;
        var returnVal = mi.ReturnValue;

        //能夠設置本地變量,供下面的代碼使用
        getByIdCalled = true;
    });

能夠用設置的變量來判斷方法樁是否被執行:

Assert.True(getByIdCalled);

 

判斷方法執行次數

有時候不僅須要判斷指望方法是否被執行,還要判斷執行的次數。Rhino Mocks的AssertWasCalled方法的重載提供了這個功能:

userRepository.AssertWasCalled(ur => ur.Insert(Arg<User>.Is.Anything),c=>c.Repeat.Once());

這樣Insert方法應該只被執行1次測試才能夠經過。除此還有Twice(),Never(),AtLeastOnce()及Times(int)等其它方法用來指定不一樣的次數。

AssertWasCalled第二個參數的類型Action<T>中的T(即lambda表達式參數)是IMethodOptions<T>類型,除了能夠經過Repeat屬性的方法設置執行次數約束外還有其它方法,大部分方法能夠經過其它途徑進行等價設置,還有一些已通過時就再也不贅述了。

 

10.UWP中的單元測試

上文的例子都是在.NET Framework 4.5的程序集中進行的,對於全部使用.NET Framework的項目類型都適用,好比Winform/WPF,ASP.NET MVC等等。對於UWP這樣基於Windows Runtime平臺的程序因爲上文使用的RhinoMocks不能用於UWP,因此須要另外尋找可用的Mock Framework。另外當前版本的用於Resharper的xUnit.net Test Runner在UWP環境不能啓動用於執行測試代碼的測試程序,須要使用xUnit.net用於vs的Test Runner,並且xUnit.net和Test Runner都要使用最新的的2.1 rc才能正常啓動一個程序用於執行測試代碼。

在UWP中測試項目是一個可執行的程序,測試代碼在這裏面運行。而不像傳統.NET項目的測試只須要依附於一個普通的程序集。在UWP執行測試代碼若是涉及到如Windows.Storage這種與設備相關的代碼是須要以應用的身份去調用的。因此單元測試項目做爲一個可執行項目是必要的。

 找來找去可選的真很少,一個是微軟自家的Microsoft Fakes,另外一個是Telerik的JustMock。前者沒找到怎麼用,放棄(感受微軟vs裏的測試工具一直不怎麼好用)。後者是一個商業工具(有免費版),暫時拿來玩玩吧。由於前文把各類測試場景也都介紹的差很少了,這裏就直接給出一個例子,並看一下JustMock與RhinoMocks的細節不一樣。

被測代碼好像是來自國外一個開源的庫,實在記不清從哪&ldquo;借鑑&rdquo;來的了。

public async Task ClearInvalid()
{
    var validExtension = storage.GetFileExtension();
    var folder = await storage.GetFolderAsync().ConfigureAwait(false);

    var files = await folder.GetFilesAsync();

    foreach (var file in files.Where(x => x.FileType == validExtension))
    {
        var loadedFile = await storage.LoadAsync<CacheObject>(file.DisplayName).ConfigureAwait(false);

        if (loadedFile != null && !loadedFile.IsValid)
            await file.DeleteAsync();
    }
}

這裏一段UWP用於清除無效緩存項,來看一下測試代碼:

[Fact]
public async Task Can_ClearInvalid_Success()
{
    var fileName = "testfile";
    
    var storage = Mock.Create<IStorageHelper>();
    Mock.Arrange(()=>storage.GetFileExtension()).Returns(".json");
    var file1 = Mock.CreateLike<StorageFileFake>(sf => sf.FileType == ".json" && sf.DisplayName == fileName);
    var file2 = Mock.CreateLike<StorageFileFake>(sf => sf.FileType == ".json" && sf.DisplayName == "fileNoInCache");
    var file3 = Mock.CreateLike<StorageFileFake>(sf => sf.FileType == ".xml" && sf.DisplayName == "fileOtherType");
    
    var folder = ApplicationData.Current.LocalFolder;//Partial Mock
    Mock.ArrangeLike<StorageFolder>(folder,sf=>sf.GetFilesAsync()==
        Task.FromResult(new List<IStorageFile>() {file1,file2,file3} as IReadOnlyList<StorageFile>).AsAsyncOperation());
    Mock.ArrangeLike(storage,s => s.GetFolderAsync()==Task.FromResult(folder));

    var cacheObj = Mock.CreateLike<CacheObject>(co => co.IsValid == false);
    Mock.Arrange(() => storage.LoadAsync<CacheObject>(Arg.AnyString)).OccursAtLeast(2);
    Mock.Arrange(() => storage.LoadAsync<CacheObject>(Arg.Is(fileName))).Returns(Task.FromResult(cacheObj));

    Mock.Arrange(()=>file1.DeleteAsync()).MustBeCalled();

    var cacheManager = new TemporaryCacheManager(storage);
    await cacheManager.ClearInvalid();

    storage.Assert();
    file1.Assert();
}

Storage類因爲特殊緣由(反正那種實如今UWP中的類都同樣),不能經過Mock.Create來建立,而是使用了一個真實的對象,而後經過JustMock建立Partial Mock的方式給這個Storage對象增長一些虛擬的方法。

至於其餘方法,能夠經過下面這個RhinoMocks和JustMock對比(按個人理解,有錯請指正)的表得知用法:

RhinoMocks JustMock
MockRepository.GenerateStub<T>() Mock.CreateLike<T>()
mock.Stub() Mock.ArrangeLike<T>()
MockRepository.GenerateMock<T>() Mock.Create<T>()
mock.Except() Mock.Arrange()
MockRepository.GeneratePartialMock<T>() 直接建立真實對象,並Arrange()模擬方法
mock.VerifyAllExpectations() mock.Assert()
Arg<T> Arg
AssertWasCalled()//其實不太同樣 MustBeCalled()
c=>c.Repeat.XXX() OccursAtLeast(times)

當前這段測試代碼並不能正確運行,由於2.1RC版本的xUnit runner for vs和JustMock 2015Q2好像不太兼容,總會報少System.Core缺失啥的錯誤。

 

11.經過擴展方法進行Assert

nopCommerce項目中給單元測試準備的一系列擴展方法用起來也很方便,能夠把Act和Assert合併到一行,必定程度上提升代碼的可讀性。

原代碼是基於NUnit的,我把它們改爲了支持xUnit.net的放在下面供須要的童鞋參考。

public static class TestExtensions
{
    public static T ShouldNotBeNull<T>(this T obj)
    {
        Assert.NotNull(obj);
        return obj;
    }

    public static T ShouldEqual<T>(this T actual, object expected)
    {
        Assert.Equal(expected, actual);
        return actual;
    }

    public static void ShouldEqual(this object actual, object expected, string message)
    {
        Assert.Equal(expected, actual);
    }

    public static Exception ShouldBeThrownBy(this Type exceptionType, Action testDelegate)
    {
        return Assert.Throws(exceptionType, testDelegate);
    }

    public static void ShouldBe<T>(this object actual)
    {
        Assert.IsType<T>(actual);
    }

    public static void ShouldBeNull(this object actual)
    {
        Assert.Null(actual);
    }

    public static void ShouldBeTheSameAs(this object actual, object expected)
    {
        Assert.Same(expected, actual);
    }

    public static void ShouldBeNotBeTheSameAs(this object actual, object expected)
    {
        Assert.NotSame(expected, actual);
    }

    public static T CastTo<T>(this object source)
    {
        return (T)source;
    }

    public static void ShouldBeTrue(this bool source)
    {
        Assert.True(source);
    }

    public static void ShouldBeFalse(this bool source)
    {
        Assert.False(source);
    }

    public static void SameStringInsensitive(this string actual, string expected)
    {
        Assert.Equal(actual,expected,true);
    }
}

 

 

其它平臺.NET Core及Xamarin沒搞過,不瞭解。就寫到這吧。歡迎指正。謝謝。

 

轉載請保留原連接

相關文章
相關標籤/搜索