实现.NET Core时,xUnit可用于创建单元测试,.NET Core团队使用了该产品。xUnit是一个开源实现方案,创建NUnit 2.0的开发人员创建了它。现在,.NET Core命令行界面支持MSTest和xUnit。
提示:
xUnit的文档可参阅https://xunit.github.io/。
Visual Studio测试环境支持其他测试框架。测试适配器,如NUnit、xUnit、Boost(用于C++)、Chutzpah(用于JavaScript)和Jasmine(用于JavaScript)可通过扩展和更新来使用;这些测试适配器与Visual Studio Test Explorer集成。
xUnit是.NET Core中一个杰出的测试框架,也由微软的.NET Core和ASP .NET Core开源代码使用,所以xUnit是本节的重点。
1. 使用xUnit和.NET Core
使用.NET Core应用程序,可以创建xUnit测试,其方式与MSTest测试类似。从命令行,可以使用:
> dotnet new xunit
创建xUnit测试项目。在Visual Studio 2017中,可以选择项目类型xUnit Test Project(.NET Core)。
在示例项目中,测试与以前相同的.NET 标准库UnitTestingSamples。这个库包含之前所示的测试的类型:DeepThought和StringSample。测试项目的名称是UnitTestingSamples.xUnit.Tests。
这个项目需要引用xunit(对于单元测试,是xunit.runner.visualstudio[在Visual Studio中运行测试])和UnitTestingSamples项目(应测试的代码)。为了与.NET Core命令行集成,添加dotnet-xunit的DotNetCliToolReference:
netcoreapp3.1
false
2. 创建Fact属性
创建测试的方式非常类似于之前的方法。在MSTest中,需要给测试类型添加特性注释([TestClass])。但在xUnit中是不必要的。因为会在所有的公共类中搜索测试方法。在xUnit和MSTest中测试方法TheAnswerToTheUltimateQuestionOfLifeTheUniverseAndEverything的差异只是测试方法带Fact特性注释和不同的Assert.Equal方法:
public class DeepThoughtTest
{
[Fact]
public void ResultOfTheAnswerToTheUltimateQuestionOfLifeTheUniverseAndEverything()
{
int expected = 42;
var dt = new DeepThought();
int actual = dt.TheAnswerOfTheUltimateQuestionOfLifeUniverseAndEverything();
Assert.Equal(expected,actual);
}
}
现在使用的Assert类在xUnit名称空间中定义。与MSTest的Assert方法相比,这个类定义了更多的方法,用于验证。例如,不是添加一个特性来指定预期的异常,而是使用Assert.Throws方法,允许在一个测试方法中多次检查异常:
public class StringSampleTest
{
[Fact]
public void GetStringDemoExceptions()
{
var sample = new StringSample(string.Empty);
Assert.Throws(()=>sample.GetStringDemo(null,"a"));
Assert.Throws(() => sample.GetStringDemo("a",null));
Assert.Throws(()=>sample.GetStringDemo(string.Empty,"a"));
}
}
3. 创建Theory特性
xUnit为不需要参数的测试方法定义Fact特性。使用xUnit还可以调用需要参数的单元测试方法:使用Theory特性提供数据,添加一个派生于Data的特性。这样就可以通过一个方法定义多个单元测试了。
在下面的代码片段中,Theory特性应用于GetStringDemoInlineData单元测试方法。StringSample.GetStringDemo方法定义了取决于输入数据的不同路径。如果第二个参数传递的字符串不包含在第一个参数中,就到达第一条路径。如若第二个字符串包含在第一个字符串的前5个字符串中,就到达第二条路径。第三条路径是用else子句到达的。要到达所有不同的路径,3个InlineData特性应用于测试方法。每个特性都定义了4个参数,它们以相同的顺序直接发送到单元测试方法的调用中。特性还定义了被测试方法应该返回的值:
[Theory]
[InlineData("","longer string","nger","removed nger from longer string: lo string")]
[InlineData("init","longer string","string","INIT")]
public void GetStringDemoInlineData(string init,string a,string b,string expected)
{
var sample = new StringSample(init);
string actual = sample.GetStringDemo(a,b);
Assert.Equal(expected,actual);
}
特性InlineData派生于Data特性。除了通过特性直接把值提供给测试方法之外,值也可以来自于属性、方法或类。以下例子定义了一个静态方法,它用IEnumeralbe
public static IEnumerable
单元测试方法现在用MemberData特性改变了。这个特性允许使用返回IEnumerable
[Theory]
[MemberData(nameof(GetStringSampleData))]
public void GetStringDemoMemberData(string init, string a, string b, string expected)
{
var sample = new StringSample(init);
var actual = sample.GetStringDemo(a, b);
Assert.Equal(expected, actual);
}
测试运行结果:
4. 使用Mocking库
下面是一个更复杂的例子:在MVVM应用程序中,为客户端服务库创建一个单元测试。本章的示例代码仅包含该应用程序使用的一个库。这个服务使用依赖注入功能,注入接口IBooksRepository定义的存储库。用于测试AddOrUpdateBookAsync方法的单元测试不应该测试该库,而只测试方法中的功能。对于库,应执行另一个单元测试:下面的代码段显示了类的实现:
public class BooksService : IBooksService
{
private ObservableCollection _books = new ObservableCollection();
public IEnumerable Books => _books;
private IBookRepository _booksRepository;
public BooksService(IBookRepository booksRepository)
{
_booksRepository = booksRepository;
}
public async Task AddOrUpdateBookAsync(Book book)
{
if (book is null)
{
throw new ArgumentNullException(nameof(book));
}
Book updated = null;
if (book.BookId == 0)
{
updated = await _booksRepository.AddAsync(book);
_books.Add(updated);
}
else
{
updated = await _booksRepository.UpdateAsync(book);
if (updated is null)
{
throw new InvalidOperationException();
}
Book old = _books.Where(b => b.BookId == updated.BookId).Single();
int ix = _books.IndexOf(old);
_books.RemoveAt(ix);
_books.Insert(ix,updated);
}
return updated;
}
public Book GetBook(int bookId)
{
return _books.Where(b => b.BookId == bookId).SingleOrDefault();
}
public async Task LoadBooksAsync()
{
if (_books.Count > 0)
{
return;
}
IEnumerable books = await _booksRepository.GetItemAsync();
//_books.Clear();
foreach (var book in books)
{
_books.Add(book);
}
}
}
因为AddOrUpdateBookAsync的单元测试不应该测试用于IBooksRepository的存储库,所以需要实现一个用于测试的存储库。为了简单起见,可以使用一个模拟库自动填充空白。一个常用的模拟库是Moq。对于单元测试项目,添加NuGet包Moq。
注意:
除了使用Moq框架之外,还可以用示例数据实现一个内存中的存储库。在用户界面的设计过程中,可以这么做来处理应用程序的示例数据。
使用xUnit时,每次运行测试都会创建测试类的一个新实例。如果多个测试需要相同的功能,就可以把这个功能移动到构造函数中。如果每次运行测试后需要释放资源,就可以实现IDisposable接口。
在BooksServiceTest类的构造函数中,实例化一个Mock对象,传递泛型参数IBooksRepository。Mock构造函数创建接口的实现代码。因为需要从存储库中得到一些非空结果来创建有用的测试,所以Setup方法定义可以传递的参数,ReturnAsync方法定义了方法存根返回的结果。使用Mock类的Object属性访问模拟对象,并传递它,以创建BooksService类的实例。有了这些设置,就可以实现单元测试:
public class BooksServiceTest : IDisposable
{
public void Dispose()
{
}
private const string TestTitle = "Test Title";
private const string UpdatedTestTitle = "Updated Test Title";
private const string APublisher = "A Publisher";
private BooksService _bookService;
private Book _newBook = new Book
{
BookId = 0,
Title = TestTitle,
Publisher = APublisher
};
private Book _expectedBook = new Book
{
BookId = 1,
Title = TestTitle,
Publisher = APublisher
};
private Book _notInRepositoryBook = new Book
{
BookId = 42,
Title = TestTitle,
Publisher = APublisher
};
private Book _updatedBook = new Book
{
BookId = 1,
Title = UpdatedTestTitle,
Publisher = APublisher
};
public BooksServiceTest()
{
var mock = new Mock();
mock.Setup(repository =>
repository.AddAsync(_newBook)).ReturnsAsync(_expectedBook);
mock.Setup(repository =>
repository.UpdateAsync(_notInRepositoryBook)).ReturnsAsync(null as Book);
mock.Setup(repository=>
repository.UpdateAsync(_updatedBook)).ReturnsAsync(_updatedBook);
_bookService = new BooksService(mock.Object);
}
[Fact]
public void Test1()
{
}
}
实现的第一个单元测试AddOrUpdateBookAsync_ThrowFouNull证明,如果把null传递给AddOrUpdateBookAsync方法,就会抛出ArgumentNullException异常。该实现代码只需要在构造函数中实例化成员变量_booksService,而不需要模拟设置。这个代码示例还说明,单元测试方法可以实现为返回Task的异步方法:
public async Task AddOrUpdateBookAsync_ThrowNullException()
{
//arrange
Book nullBook = null;
//act an assert
await Assert.ThrowsAsync(() =>
_bookService.AddOrUpdateBookAsync(nullBook));
}
单元测试方法AddOrUpdateBookAsync_AddedBookReturnsFromRepository给服务添加了一本新书(变量_newBook),并期望返回_expectedBook对象。在AddOrUpdateBookAsync方法的实现代码中,调用了IBooksRepository的AddAsync方法,因此,可以应用以前给这个方法定义的模拟设置。这个方法的结果应是,返回的Book等于_expectedBook,_expectedBook也需要添加到BooksService的图书集合中:
public async Task AddOrUpdateBookAsync_AddedBookReturnsFromRepository()
{
//arrange in constructory
//act
Book actualAdded = await _bookService.AddOrUpdateBookAsync(_newBook);
//assert
Assert.Equal(_expectedBook, actualAdded);
Assert.Contains(_expectedBook,_bookService.Books);
}
AddOrUpdateBookAsync_UpdateNotExistingBookThrows单元测试证明,尝试更新服务中不存在的图书,应抛出InvalidOperationException异常:
public async Task AddOrUpdateBookAsync_UpdateNotExistingBookThrow()
{
//arrange in constructor
//act and assert
await Assert.ThrowsAsync(()=>
_bookService.AddOrUpdateBookAsync(_notInRepositoryBook));
}
更新图书的常见情形用单元测试AddOrUpdateBookAsync_UpdateBook来处理。这里需要做额外的准备,在更新前,先把图书添加到服务中:
[Fact]
public async Task AddOrUpdateBookAsync_UpdateBook()
{
//arrange
await _bookService.AddOrUpdateBookAsync(_newBook);
//act
Book updateBook = await _bookService.AddOrUpdateBookAsync(_updatedBook);
//assert
Assert.Equal(_updatedBook, updateBook);
Assert.Contains(_updatedBook,_bookService.Books);
}
当使用MVVM模式与基于XAML的应用程序,以及使用MVC模式和基于Web的应用程序时,会降低用户界面的复杂性,减少复杂UI测试的需求。然而,仍有一些场景应该用于UI测试,例如,浏览页面、拖拽元素等。此时应使用Visual Studio的UI测试功能。