- 前言
- 正文
- 消除重复构造
- 测试类内部状态共享
- 跨测试类状态共享
- 结论
前言
编写单元测试时,我们经常需要针对某个类的方法进行测试。如果要测试的是实例方法,那么就要先构造该类的实例,然后通过这个实例来调用目标方法。
然而,在测试类中直接构造被测试类的实例时,可能会遇到几个常见问题:
- 构造过程复杂:某些类的实例构造起来麻烦,我们不希望在每个测试方法中都重复这个复杂的构造过程。(当然,提取独立方法可以缓解)
- 重复构造:即使构造不复杂,在每个测试方法中都创建一次实例,也会产生不必要的重复代码。
- 状态依赖与方法顺序:被测试类的多个方法可能会操作同一份内部状态(数据),且这些方法的调用往往存在特定的业务顺序。如:方法 B 的执行依赖于方法 A 对数据状态的修改。
- 跨类状态共享:有时我们需要测试多个协同工作类,这些类共同操作同一份共享数据。在测试中模拟这种协作关系也是一个挑战。
正文
消除重复构造
通过构造函数可以为每个测试方法提供独立的测试上下文。
public class MockUser
{public int Id { get; set; }public string Name { get; set; }public short Age { get; set; }
}[TestCaseOrderer(ordererTypeName: "Blog.Core.WebApi.Test.PriorityOrderer", ordererAssemblyName: "Blog.Core.WebApi.Test")]
public class XUnitInitDemo
{private readonly MockUser _mockUser;public XUnitInitDemo(){_mockUser = new MockUser() { Id = 1, Name = "Qanx", Age = 18 };}[Fact, TestPriority(1)]public void Test1(){Assert.Equal(1, _mockUser.Id); // true_mockUser.Id = 2;}[Fact, TestPriority(2)]public void Test2(){Assert.Equal(1, _mockUser.Id); // trueAssert.Equal(2, _mockUser.Id); // false}
}
这里 TestPriority 特性是标识测试方法执行的顺序。可以发现即使在 Test1 方法中改变了 mockUser 对象的值,但在Test2 方法中通过断言可以的判断 mockUser 对象的 Id 仍然是 1。
通过调试测试方法可以发现,每个测试方法的执行前都执行了一次测试类的无参构造函数 XUnitInitDemo。
xUnit 框架默认为每个测试方法创建一个新的测试类实例,再执行该测试方法。
既然每个测试方法都是基于新的测试类实例,那么每次执行测试方法时都会通过构造函数初始化创建一个新的 MockUser 实例,所以通过构造函数创建的的实例是测试方法内部共享的。
如果一个测试类中的测试方法非常多,每个方法都初始化要测试类的实例,那么在整个测试过程中,会创建若干个要测试类的实例,我们以通过实现 IDisposable 接口来释放资源:
public class MockUser : IDisposable
{public int Id { get; set; }public string Name { get; set; }public short Age { get; set; }public void Dispose(){Console.WriteLine("模拟释放");}
}[TestCaseOrderer(ordererTypeName: "Blog.Core.WebApi.Test.PriorityOrderer", ordererAssemblyName: "Blog.Core.WebApi.Test")]
public class XUnitInitDemo : IDisposable
{private readonly MockUser _mockUser;public XUnitInitDemo(){_mockUser = new MockUser() { Id = 1, Name = "Qanx", Age = 18 };}[Fact, TestPriority(1)]public void Test1(){Assert.Equal(1, _mockUser.Id); // true_mockUser.Id = 2;}[Fact, TestPriority(2)]public void Test2(){Assert.Equal(1, _mockUser.Id); // trueAssert.Equal(2, _mockUser.Id); // false}public void Dispose(){_mockUser.Dispose();}
}
通过调试可以发现,每个测试方法执行结束后,都会执行 Dispose 方法清理资源。
测试类内部状态共享
在有些场景下,我们希望测试类的一组测试方法能够复用相同的实例资源(如:数据库连接上下文、依赖注入容器、Reids 实例等),或者这一组测试方法能够操作同一个实例(如:测试文件操作,test_write() 向文件中写入内容、test_read() 读取刚写入的内容、test_truncate() 清空刚写入的内容)。这些测试存在顺序依赖,且后面一个测试会依赖于前一个测试对实例状态所做的改变。
public class MockUser : IDisposable
{public MockUser(){Id = 1;Name = "Qanx";Age = 18;}public int Id { get; set; }public string Name { get; set; }public short Age { get; set; }public void Dispose(){Console.WriteLine("模拟释放");}
}[TestCaseOrderer(ordererTypeName: "Blog.Core.WebApi.Test.PriorityOrderer", ordererAssemblyName: "Blog.Core.WebApi.Test")]
public class XUnitInitDemo : IClassFixture<MockUser>
{private readonly MockUser _mockUser;public XUnitInitDemo(MockUser mockUser){_mockUser = mockUser;}[Fact, TestPriority(1)]public void Test1(){Assert.Equal(1, _mockUser.Id); // true_mockUser.Id = 2;}[Fact, TestPriority(2)]public void Test2(){Assert.Equal(1, _mockUser.Id); // false}[Fact, TestPriority(3)]public void Test3(){Assert.Equal(2, _mockUser.Id); // true}[Fact, TestPriority(4)]public void Test4(){Assert.Equal(2, 2); // true}
}
通过调试可以知道,MockUser 类的实例是在 XUnitInitDemo 测试类的实例构造之前初始化的,这里有一些条件限制:
MockUser必须要有一个无参的构造函数,IClassFixture<TFixture>会通过无参构造函数初始化MockUser对象MockUser只会初始化一次- 如果实现了
IDisposable接口,会在XUnitInitDemo测试方法全部执行之后,调用对应的Dispose方法
跨测试类状态共享
在有些场景下,我们需要在不同的测试类中使用相同的实例。在进行集成测试时尤为常见,如数据库上下文、依赖注入容器、Redis、日志拦截器等等。
先模拟定义一个数据库上下文对象:
public class DatabaseFixture : IDisposable
{public int DBId = 0;public DatabaseFixture(){// 初始化数据库连接或其他资源DBId = 1; // 模拟数据库 ID}public void Dispose(){// 清理数据库连接或其他资源}
}
然后定义一个测试集合(Collection),并指定上面定义的测试夹具(Fixture)DatabaseFixture:
[CollectionDefinition("SharedContext")]
public class SharedContextCollection : ICollectionFixture<DatabaseFixture>
{
}
这个类首先通过 [CollectionDefinition("SharedContext")] 特性声明了一个名为SharedContext的测试集合。然后通过标记接口 ICollectionFixture<DatabaseFixture> 指定该集合中的所有测试类都可以共享 DatabaseFixture 实例。
注意:
- 这个类本身不需要任何代码,也不会被直接实例化
- 只是一个“定义类”,主要用来告诉 xUnit 如何管理和分配共享资源的
DatabaseFixture类在测试集合只会被初始化、释放一次
最后就是使用共享的资源:
[Collection("SharedContext")]
public class Class1Tests
{private readonly DatabaseFixture _fixture;public Class1Tests(DatabaseFixture fixture){_fixture = fixture;}[Fact]public void Test2(){_fixture.DBId = 2;}[Fact]public void Test3(){_fixture.DBId = 3;}
}[Collection("SharedContext")]
public class Class2Tests
{private readonly DatabaseFixture _fixture;public Class2Tests(DatabaseFixture fixture){_fixture = fixture;}[Fact]public void Test4(){_fixture.DBId = 4;}[Fact]public void Test5(){_fixture.DBId = 5;}
}
通过断点调试可以发现,每个测试方法中都正确改变了 DBId 的值,并且改变的值被带到了下一个测试方法(即时不同的测试类)。
需要共享
DatabaseFixture实例的测试类,必须要放入DatabaseFixture共享的集合中,在这里也就是SharedContext集合,通过[Collection("SharedContext")]特性即可定义测试类归属的集合。
这样,所有标记[Collection("SharedContext")]的测试类都会共用同一个 DatabaseFixture 实例,实现资源的复用,避免重复初始化和释放。
结论
- 构造函数适用于每个测试独立上下文,且能够消除重复
IClassFixture适合测试类内共享资源ICollectionFixture适合用于跨类共享的资源,如:数据库上下文、Reids、依赖注入容器等
