单元测试概念
维基百科上的说法,单元测试(Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最小 单位)来进行正确性检验的测试工作。 程序单元是应用的最小可测试部件。在面向对象编程中,最 小单元就是方法,包括基类、抽象类、或者派生类(子类)中的方法。 按照通俗的理解,一个单元 测试判断某个特定场条件下某个特定方法的行为,如斐波那契数列算法,冒泡排序算法。
做单元测试的好处:
- 验证自己或别人的程序是否符合正确的预期
- 有单元测试的习惯,会有助于编写模块化的代码,更符合 6 大软件设计原则的代码
- 单元测试也是文档的一部分,对于其他人,可以更快的理解代码的设计原理
- 有了单元测试,可以在代码频繁修改的情况下,方便的进行测试,有助于代码的编写
- 在单元测试的基础上可以做集成测试,自动化测试
编写单元测试的原则:
- 可重复运行的
- 在内存中运行,没有外部依赖组件(比如说真实的数据库,真实的文件存储等)
- 快速返回结果
- 一个测试方法只测试一个问题
好的程序一定是有一定的测试覆盖率的代码,单元测试是保证代码的质量的方法之一,单元测试关注的 两个重点就是最小可测试单元和代码逻辑
编写的步骤有个基本的规范
- Arrange 用于初始化一些被测试方法需要的参数或依赖的对象。
- Act 方法 用于调用被测方法进行测试。
- Assert 用于验证测试方法是否按期望执行或者结果是否符合期望值
xunit api 简绍
xunit 主要的特点是简洁,初始化和释放资源不需要标记单独的方法,初始化直接放在构造方法里,资 源释放实现 IDisposable 接口,在 Dispose 方法中进行测试的清理工作即可
Fact 标记,有 DisplayName,Skip,Timeout 属性可以设置
[Fact]
public void Test3() {
var i= new A();
var j= i.Sum(2, 3);
Assert.Equal(5,j);
}
Trait 对测试进行分组
[Fact]
[Trait("Category","1")]
public void BeNewWhenCreated()
{
}
[Trait("Category","2")]
public void BeNewWhenCreated()
{
}
Theory 配合 InlineData 进行静态初始数据测试,Theory 也可以设置 DisplayName,Skip,Timeout 属性
[Theory]
[InlineData(4,2)]
[InlineData(3,2)]
public void Test4(int a,int b) {
var i= new A();
var j= i.Sum(a,b);
Assert.Equal(5,j);
}
Theory 配合 MemberData 实现动态的初始数据
MemberData 可以一定程度上解决 InlineData 存在的问题(静态数据),MemberData 支持字段、属性或 方法,需要满足下面两个条件:
- 需要是 public 的
- 需要是 static 的 3 .可以隐式转换为 IEnumerable<object[]> 或者方法返回值可以隐式转换为 IEnumerable<object[]>
[Theory]
[MemberData(nameof(GetData),3)]
public void Test1(int para) {
Assert.True(para>=0);
}
public static IEnumerable<object[]> GetData(int amount=1) {
return Enumerable.Range(0, amount).AsEnumerable().Select(p=>new object[]{p});
}
public class CalculatorTestData
{
private static readonly List<object[]> Data = new() {
new object[]{ 1,2},
new object[]{ 3,4},
};
public static IEnumerable<object[]> GetData => Data;
}
//在测试类里使用
public class UnitTest1 {
[Theory]
[MemberData(nameof(CalculatorTestData.GetData),MemberType =typeof(CalculatorTestData))]
public void Test1(int a,int b) {
Assert.True(a > 0);
Assert.True(b > 0);
}
}
如果返回的是 object[] 类型,不是强类型,可以自定义数据提供测序 TheoryData
//自定义测试数据提供类
public class TestData : TheoryData<int ,string> {
public TestData() {
foreach (var i in Enumerable.Range(1,10)) {
Add(i,i.ToString());
}
}
}
//测试类
public static TestData testData= new ();
[Theory]
[MemberData(nameof(testData))]
public void Test2(int a,string b) {
Assert.True(a>0);
Assert.NotNull(b);
}
Theory 配合 DataAttribute 动态的初始数据,还可以获取到 MethodInfo 信息,这样可以提供测试方法相关的数据
这是最好的一种提供动态测试测试数据的方法,代码量少,在测试类里也不要写什么静态属性
//每个测试方法上加了此特性的都会执行
public class TestData : DataAttribute {
private readonly int _para;
public TestData(int para) {
_para = para;
}
public override IEnumerable<object[]> GetData(MethodInfo testMethod) {
return Enumerable.Range(0, _para).Select(p => {
return new object[] { p };
});
}
}
//使用方式
[Theory]
[TestData(2)]
public void Test1(int para) {
Assert.True(para >= 0);
}
Theory 配合 ClassData 动态的初始数据
internal class TestData : IEnumerable<object[]>{
public IEnumerator<object[]> GetEnumerator() {
yield return new object[] { 1,2 };
yield return new object[] { 3,4 };
}
IEnumerator IEnumerable.GetEnumerator() {
return GetEnumerator();
}
}
[Theory]
[ClassData(typeof(TestData))]
public void Test2(int a,int b) {
Assert.True(a>0);
Assert.True(b>0);
}
ITestOutputHelper 可以在测试中输出一些内容,直接使用控制台输出是没有效果的
public class UnitTest1 {
private readonly ITestOutputHelper _testOutputHelper;
public UnitTest1(ITestOutputHelper testOutputHelper) {
_testOutputHelper = testOutputHelper;
}
[Theory]
[InlineData(4,2)]
[InlineData(3,2)]
public void Test4(int a,int b) {
var i= new A();
var j= i.Sum(a,b);
Assert.Equal(5,j);
_testOutputHelper.WriteLine("测试结束");
}
}
异步支持
[Fact]
public async Task Test1() {
var result= await F(1,2);
Assert.Equal(3,result);
}
private async Task<int> F(int a,int b) {
await Task.Yield();
return a + b;
}
异常断言
[Fact]
public async Task Test1() {
var ex = await Assert.ThrowsAsync<ArgumentException>(async() =>
{
F();
});
Assert.Equal("a", ex.Message);
}
private async void F()
{
throw new ArgumentException();
}
对 private 或受保护的 字段或方法进行测试
- 自己写反射
- 使用类库 ExposedObject
public class A {
private string F() {
return "f";
}
}
[Fact]
public async Task Test1() {
var exposed = Exposed.From( new A());
var result=exposed.F();
Assert.Equal("f",result);
}
xunit 常用的断言
- Assert.Equal() 验证两个参数是否相等,支持字符串等常见类型。同时有泛型方法可用,当比较泛 型类型对象时使用默认的 IEqualityComparer 实现,也有重载支持传入 IEqualityComparer
- 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,或传入 IComparer
- Assert.NotInRange() 与上面的相反
- Assert.Null() 验证对象为空
- Assert.NotNull() 与上面的相反 *Assert.StrictEqual() 判断两个对象严格相等,使用默认的 IEqualityComparer 对象
- Assert.NotStrictEqual() 与上面相反 *Assert.IsType()/Assert.IsType() 验证对象是某个类型 (不能是继承关系)
- Assert.IsNotType()/Assert.IsNotType() 与上面的相反
- Assert.IsAssignableFrom()/Assert.IsAssignableFrom() 验证某个对象是指定类型或指定类型的子 类
- Assert.Subset() 验证一个集合是另一个集合的子集
- Assert.ProperSubset() 验证一个集合是另一个集合的真子集 (集合元素不能完全一样)
- Assert.ProperSuperset() 验证一个集合是另一个集合的真超集
- Assert.Collection() 验证第一个参数集合中所有项都可以在第二个参数传入的 Action 序列中相应 位置的 Action 上执行而不抛出异常。
- Assert.All() 验证第一个参数集合中的所有项都可以传入第二个 Action 类型的参数而不抛出异常 。与 Collection()类似,区别在于这里 Action 只有一个而不是序列。
- Assert.PropertyChanged() 验证执行第三个参数 Action 使被测试 INotifyPropertyChanged 对象 触发了 PropertyChanged 时间,且属性名为第二个参数传入的名称。
- Assert.Throws()/Assert.Throws() Assert.ThrowsAsync()/Assert.ThrowsAsync() 验证测试代码抛 出指定异常(不能是指定异常的子类)如果测试代码返回 Task,应该使用异步方法
- Assert.ThrowsAny() Assert.ThrowsAnyAsync() 验证测试代码抛出指定异常或指定异常的子类 如果 测试代码返回 Task,应该使用异步方法
测试类资源释放
如下测试类,同时执行俩个测试方法
Test1 start2023/9/28 15:43:23 over2023/9/28 15:43:24
Test2 start2023/9/28 15:43:24 over2023/9/28 15:43:25
通过以下代码的结果说明每次运行一个测试方法都是去 new 一个 测试类 ,在测试结束的时候去调用 测试类的 Dispose 方法进行资源释放这样设计的目的是为了保证每次测试方法执行都是一个独立的环 境,保证测试方法的环境的隔离性,每个测试方法都是独立的上下文
public class UnitTest1 :IDisposable{
private readonly ITestOutputHelper _testOutputHelper;
public UnitTest1(ITestOutputHelper testOutputHelper) {
_testOutputHelper = testOutputHelper;
_testOutputHelper.WriteLine("start"+DateTime.Now.ToString());
}
[Fact]
public async Task Test1() {
await Task.Delay(1000);
}
[Fact]
public async Task Test2() {
await Task.Delay(1000);
}
public void Dispose() {
_testOutputHelper.WriteLine("over"+DateTime.Now.ToString());
}
}
在测试方法里共享数据 IClassFixture
如上的设计每个测试方法都是独立的测试环境,如果在测试过程中有数据库链接,读写文件等资源型操 作,多个测试方法都能用到相关的资源,在这种情况下可以把资源单独放入共享的类,减少资源的创建 次数
IClassFixture 在单个测试的所有测试方法上进行共享
//共享的资源在整个测试类的测试方法里共享
public class ShareResource : IDisposable {
public string Content { get; }
//在第一次测试方法使用的时候进行构造,之后的的测试方法不会在进行构造
public ShareResource() {
var stream = new FileStream("C:\\Users\\hbai\\Desktop\\test.txt",FileMode.Open);
var buffer = new Byte[1024];
stream.Read(buffer, 0, buffer.Length);
Content = System.Text.Encoding.UTF8.GetString(buffer);
stream.Close();
stream.Dispose();
}
//在所有相关测试方法都执行结束的时候进行销毁执行
public void Dispose() {
}
}
//测试类继承 IClassFixture<ShareResource>
public class UnitTest1 :IDisposable,IClassFixture<ShareResource> {
private readonly ITestOutputHelper _testOutputHelper;
private readonly ShareResource _fixture;
//传递共享的资源 ShareResource
public UnitTest1(ITestOutputHelper testOutputHelper,ShareResource fixture) {
_testOutputHelper = testOutputHelper;
_fixture = fixture;
_testOutputHelper.WriteLine("start"+DateTime.Now.ToString());
}
[Fact]
public async Task Test1() {
await Task.Delay(1000);
_testOutputHelper.WriteLine(_fixture.Content);
}
[Fact]
public async Task Test2() {
await Task.Delay(1000);
_testOutputHelper.WriteLine(_fixture.Content);
}
public void Dispose() {
_testOutputHelper.WriteLine("over"+DateTime.Now.ToString());
}
}
Collection Fixtures 跨多个测试类的测试方法上进行共享
//把上面的资源 包装到 ICollectionFixture<ShareResource> 里,并写一个如下形式
[CollectionDefinition("shareResource")]
public class DatabaseCollection : ICollectionFixture<ShareResource>
{
}
//测试类上使用 Collection 进行资源共享
[Collection("shareResource")]
public class UnitTest1 :IDisposable {
private readonly ITestOutputHelper _testOutputHelper;
private readonly ShareResource _fixture;
public UnitTest1(ITestOutputHelper testOutputHelper,ShareResource fixture) {
_testOutputHelper = testOutputHelper;
_fixture = fixture;
_testOutputHelper.WriteLine("start"+DateTime.Now.ToString());
}
[Fact]
public async Task Test1() {
await Task.Delay(1000);
_testOutputHelper.WriteLine(_fixture.Content);
}
[Fact]
public async Task Test2() {
await Task.Delay(1000);
_testOutputHelper.WriteLine(_fixture.Content);
}
public void Dispose() {
_testOutputHelper.WriteLine("over"+DateTime.Now.ToString());
}
}
//测试类上使用 Collection 进行资源共享
[Collection("shareResource")]
public class UnitTest2 :IDisposable {
private readonly ITestOutputHelper _testOutputHelper;
private readonly ShareResource _fixture;
public UnitTest2(ITestOutputHelper testOutputHelper,ShareResource fixture) {
_testOutputHelper = testOutputHelper;
_fixture = fixture;
_testOutputHelper.WriteLine("start"+DateTime.Now.ToString());
}
[Fact]
public async Task Test1() {
await Task.Delay(1000);
_testOutputHelper.WriteLine(_fixture.Content);
}
[Fact]
public async Task Test2() {
await Task.Delay(1000);
_testOutputHelper.WriteLine(_fixture.Content);
}
public void Dispose() {
_testOutputHelper.WriteLine("over"+DateTime.Now.ToString());
}
}
BeforeAfterTestAttribute 在测试方法执行前后加入处理逻辑
//拦截测试类
[TestData]
public class UnitTest1 {
public UnitTest1(ITestOutputHelper testOutputHelper) {
}
[Fact]
//拦截测试方法
[TestData]
public void Test1() {
Assert.True(true);
}
}
//类似拦截器,可以拦截测类或测试方法
public class TestData : BeforeAfterTestAttribute {
public override void Before(MethodInfo methodUnderTest) {
}
public override void After(MethodInfo methodUnderTest) {
}
}
断言的扩展
Shouldly 和 Fluent Assertions 都是扩展的断言库,Fluent Assertions 维护更快 相比 xunit 自带 的断言 ,Fluent Assertions 使用起来更为灵活一些
https://fluentassertions.com/introduction
单元测试模拟库
单元测试不要使用真实数据进行测试,可以使用一些模拟库提供的数据进行测试
单元测试关注的两个重点就是最小可测试单元和代码逻辑,但是很多时候测试的方法内部调用了外部的 组件(数据库或网络相关的),在这种情况下,不要试图和数据库或网络去交互,因为此时关注的不是 数据库或网络相关的类或方法,此时需要屏蔽数据库或网络相关的类和方法,使用 stub、mock、fake 等方式,一遍测试的顺利进行
Moq 库
Moq 是.net 平台下的一个非常流行的模拟库,只要有一个接口它就可以动态生成一个对象,底层使用的 是 Castle 的动态代理功能. 它的流行赖于依赖注入模式的兴起,现在越来越多的分层架构使用依赖注 入的方式来解耦层与层之间的关系.最为常见的是数据层和业务逻辑层之间的依赖注入, 业务逻辑层不 再强依赖数据层对象,而是依赖数据层对象的接口,在 IOC 容器里完成依赖的配置.这种解耦给单元测试 带来了巨大的便利,使得对业务逻辑的测试可以脱离对数据层的依赖,单元测试的粒度更小,更容易排查 出问题所在. Moq 可以在编译时动态生成接口的代理对象.大大提高了代码的可维护性,同时也极大减少 工作量(相比可以自己 new 一个接口对象)除了动态创建代理外,Moq 还可以进行行为测试,触发事件 等.
需要注意的是代理接口的字段或方法,不需要特别注意,但是代理对象的方法或字段,就需要是虚方法 或抽象方法,因为它是基于 Castle 动态代理生成的代理对象
//实体
public class MyEntity {
public string Id { get; set; }
public string Bar { get; set; }
}
//仓储接口
public interface IMyRepository<out MyEntity> {
MyEntity GetElementById(string id);
IEnumerable<MyEntity> GetAll();
}
//业务逻辑类
public class MyService {
private readonly IMyRepository<MyEntity> _myRepository;
//依赖接口,方便测试
public MyService(IMyRepository<MyEntity> myRepository) {
_myRepository = myRepository;
}
public MyEntity GetDto(string id) {
if (string.IsNullOrWhiteSpace(id)) return null;
return _myRepository.GetElementById(id);
}
public IEnumerable<MyEntity> GetAllDto() {
return _myRepository.GetAll();
}
}
如下的测试类测试失败,因为 moq 无法模拟 IMyRepository
public class UnitTest1 {
[Fact]
public void Test1() {
var moq = new Mock<IMyRepository<MyEntity>>();
var myService = new MyService(moq.Object);
var entity= myService.GetDto("1");
Assert.NotNull(entity);
}
[Fact]
public void Test2() {
var moq = new Mock<IMyRepository<MyEntity>>();
var myService = new MyService(moq.Object);
var entities= myService.GetAllDto();
Assert.True(entities.Any());
}
}
代理接口的方法 moq 对象的 setup,提供虚拟数据
public class UnitTest1 {
[Fact]
public void Test1() {
var moq = new Mock<IMyRepository<MyEntity>>();
//模拟数据 先指定过滤的方法,在返回指定的数据
//It 静态方法,提供数据过滤的范围,it 提供其他的静态方法进行过滤 比如 It.Is<>(m=>true)
// It.Is<>(m=>true) 只要输入的参数符合条件,才提供虚拟数据
moq.Setup(p => p.GetElementById(It.IsAny<string>())).
// 如果方法有参数,可以在这里传递
// Returns ,也可以多次调用,每次调用返回不同的值
Returns((string id) => new MyEntity() {
Id = id,
Bar = "bar"
});
// moq.Object 获取代理对象
var myService = new MyService(moq.Object);
var entity = myService.GetDto("1");
Assert.NotNull(entity);
Assert.Equal("bar", entity.Bar);
}
[Fact]
public void Test2() {
var moq = new Mock<IMyRepository<MyEntity>>();
//模拟数据
moq.Setup(p => p.GetAll()).Returns(() => new List<MyEntity>() {
new MyEntity(){Id = "1",Bar = "bar1"},
new MyEntity(){Id = "2",Bar = "bar2"}
});
var myService = new MyService(moq.Object);
var entities = myService.GetAllDto();
Assert.True(entities.Any());
}
}
Mock.Of 可以同时 Mock 多个方法
Mock.Of<IMyRepository<MyEntity>>(p => p.GetElementById(It.IsAny<string>()) ==
new MyEntity(){ } && p.GetAll()==new []{new MyEntity()});
Moq 代理接口的属性
public interface IA {
public string Bar { get; set; }
}
[Fact]
public void Test3() {
var mock = new Mock<IA> {
DefaultValue = DefaultValue.Mock
};
mock.Setup(p => p.Bar).Returns("not ok");
//会抛出错误
try {
//对代理对象的属性进行修改没有效果
mock.Object.Bar="oko";
Assert.True(mock.Object.Bar=="oko");
}
catch (Exception e) {
}
//验证成功
//SetupProperty 设置允许修改的属性
mock.SetupProperty(p => p.Bar);
mock.Object.Bar="oko";
Assert.True(mock.Object.Bar=="oko");
}
}
代理对象的虚拟方法或属性
public interface IA {
public Bar Bar { get; set; }
}
public abstract class Bar {
public abstract string Foo { get; set; }
public abstract void F();
}
[Fact]
public void Test3() {
var moq = new Mock<IA>();
//虚拟或抽象属性才能被代理
moq.Setup(p => p.Bar.Foo).Returns("123");
//验证通过
Assert.Equal("123",moq.Object.Bar.Foo);
//虚拟或抽象方法才能被代理
moq.Setup(p => p.Bar.F()).Throws(()=>new Exception());
//验证通过
Assert.Throws<Exception>(moq.Object.Bar.F);
}
[Fact]
public void Test4() {
var moq = new Mock<Bar>();
//虚拟或抽象属性才能被代理
moq.Setup(p => p.Foo).Returns("123");
//验证通过
Assert.Equal("123",moq.Object.Foo);
//虚拟或抽象方法才能被代理
moq.Setup(p => p.F()).Throws(()=>new Exception());
//验证通过
Assert.Throws<Exception>(moq.Object.F);
}
AutoFixture 包 解决 Moq 手动赋值
public interface IA {
public string Bar { get; set; }
public int Foo { get; set; }
public DateTimeOffset Dt { get; set; }
public IB B { get; set; }
}
public interface IB {
public string Bar { get; set; }
}
[Fact]
public void Test1() {
var fixture = new Fixture();
var moqA = new Mock<IA>();
moqA.Setup(p => p.Bar).Returns(fixture.Create<string>());
moqA.Setup(p => p.Foo).Returns(fixture.Create<int>());
moqA.Setup(p => p.Dt).Returns(fixture.Create<DateTimeOffset>());
var moqB = new Mock<IB>();
moqB.Setup(p => p.Bar).Returns(fixture.Create<string>());
moqA.Setup(p => p.B).Returns(moqB.Object);
var tmp = new A();
//测试成功
tmp.F(moqA.Object);
}
AutoFixture 赋值对象
[Fact]
public void Test6() {
var fixture = new Fixture();
//Without 忽略指定的属性
var myEntity1= fixture.Build<MyEntity>().Without(p => p.Id).Create();
myEntity1.Id = "1";
//忽略后通过 do 在赋值
var myEntity2= fixture.Build<MyEntity>().Without(p=>p.Id).Do(p => {
p.Id = "1";
}).Create();
//赋予特定的值
var myEntity3= fixture.Build<MyEntity>().With(p => p.Id,"2").Create();
Assert.True(myEntity1.Id=="1");
Assert.True(myEntity2.Id=="1");
Assert.True(myEntity3.Id=="1");
}
AutoFixture.Xunit 包 解决依赖对象的自动赋值
下面测试方法的参数的值会自动的进行赋值
[Theory]
[AutoData]
public void Test5(string a,int b,DateTimeOffset c,string d) {
}
对象及对象的集合都可以,但是不能给接口直接填充数据
[Theory]
[AutoData]
public void Test6(MyEntity myEntity) {
Assert.NotNull(myEntity);
}
xunit 里使用依赖注入
如果做一个测试需要频繁的去 new 对象,有点麻烦 https://www.cnblogs.com/weihanli/p/14152452.html 对 xunit 在框架层面注入了 asp.net core 的 DI ,可以更方便的进行注入
集成测试
- 利用真实的外部依赖(采用真实的数据库,外部的 Web Service,文件存储系统等)
- 在一个测试里面可能会多个问题(数据库正常确,配置,系统逻辑等)
- 可以在运行较长时间之后才返回测试结果
- 一次集成测试可以跑完完整的一个 use case
- asp.net core 微软提供了很好的继承测试环境