C#單元測試面面觀


標題有點標題黨,但相信各位看完這篇文章一定會所收獲,如果之前沒有接觸過單元測試或了解不深通過本文都能對單元測試有個全新認識。本文的特點是不脫離實際,所測試的代碼都是常見的模式。

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

另外本文編寫的單元測試都是基於已有代碼進行測試,而不是TDD倡導的現有測試后有可以工作的代碼,不同思想指導下寫出的測試代碼可能不太一樣。

 

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

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

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

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

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

插播一下,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沒搞過,不了解。就寫到這吧。歡迎指正。謝謝。

 

轉載請保留原鏈接

 

 

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM