Moles:借助闭包实现工具辅助的环境隔离
1. 引入示例
让我们考虑一个在2000年1月1日抛出异常的C#方法:
public static class Y2KChecker {
public static void Check() {
if (DateTime.Now == new DateTime(2000,1,1))
throw new ApplicationException("y2k bug!");
}
}
测试这个方法存在问题,因为程序依赖于
DateTime.Now
,而它又依赖计算机时钟,具有环境依赖性和不确定性。而且,
DateTime.Now
是静态属性,无法使用虚方法重写在测试时提供不同实现。这体现了单元测试中的隔离问题:直接调用数据库API、与Web服务通信的程序难以进行单元测试,因为它们的逻辑依赖于环境。
如果.NET允许在运行时重新定义静态方法的含义,我们可以通过用其他值替换
DateTime.Now
的计算值来解决测试问题,但以下代码无法编译:
// does not compile: DateTime.Now cannot be set
DateTime.Now = new DateTime(2000, 1, 1);
Moles可以解决这个问题。Mole类型提供了一种机制来绕过任何.NET方法。当一个方法被绕过,该方法的所有调用都会转发到一个替代实现,而原始方法不会被实际调用。Moles代码生成器创建了一个类型
MDateTime
,它有一个可设置的属性
NowGet
,可以将其设置为一个不接受参数并返回
DateTime
值的委托实例。
// attach delegate to the mole property to redirect DateTime.Now
// to return January 1st of 2000
MDateTime.NowGet = () => new DateTime(2000, 1, 1);
Y2KChecker.Check();
设置
MDateTime.NowGet
后,所有对
DateTime.Now
属性获取器的调用都会被重定向到用户提供的委托,这通过代码插桩实现。使用Visual Studio单元测试框架时,需要添加
[HostType("Moles")]
属性来启用代码插桩。
[TestMethod]
[HostType("Moles")] // run with code instrumentation
public void Y2kCheckerTest() {
...
}
Pex是一个用于.NET的自动化白盒测试生成工具,支持参数化单元测试。以下方法通过
[PexMethod]
属性被识别为参数化单元测试:
[PexMethod]
public void Y2kCheckerTest(DateTime time) {
// hook to the method to redirect DateTime.Now
MDateTime.NowGet = () => time;
Y2KChecker.Check();
}
Pex通过符号跟踪测试输入,发现路径条件
time == new DateTime(2000,1,1)
,并生成两个传统单元测试:
[TestMethod]
[HostType("Moles")]
public void Y2kCheckerTest01() {
Y2kCheckerTest(new DateTime(1, 1, 1));
}
[TestMethod]
[HostType("Moles")]
public void Y2kCheckerTest02() {
Y2kCheckerTest(new DateTime(2000, 1, 1));
}
没有Moles,Pex无法构造覆盖代码所有情况的测试用例。
2. 背景知识:委托、Lambda表达式和闭包
Moles框架利用了.NET委托、C#匿名方法、Lambda表达式和闭包。
在.NET中,委托允许以完全类型安全的方式将函数指针表示为值并进行调用。委托值除了包含实际的函数指针,还可能包含一个对象引用,通常作为实例方法的接收者对象。委托类型指定了方法调用的返回类型和参数类型,并且有名称。例如:
delegate string D(int x, int y);
假设有一个类包含以下方法:
string Compute(int x, int y) {
return (x+y).ToString();
}
创建委托实例的方式类似于C#中的构造函数调用:
D d = new D(this.Compute);
在C#中,调用委托的语法与调用常规方法类似,
d(23,42)
将调用
this.Compute(23,42)
。
从C# 2.0开始支持匿名方法,允许内联
Compute
方法:
D d = delegate(int x, int y) {
return (x+y).ToString();
};
从C# 3.0开始支持Lambda表达式,可进一步简化代码:
D d = (x, y) => (x+y).ToString();
匿名方法或Lambda表达式可以引用外部方法作用域内定义的局部变量。例如:
int offset = 42;
D d = (x, y) => (x + y + offset).ToString();
此时,C#编译器会生成一个闭包类来保存所有引用的局部变量,以下是编译器生成的等效代码:
Closure c = new Closure();
c.offset = 42;
D d = new D(c.Body);
class Closure {
public int offset;
public string Body(int x, int y) {
return (x + y + offset).ToString();
}
}
在C#中,匿名方法或Lambda表达式的主体可以修改捕获的局部变量,并且更改对同一变量的所有其他引用都是可见的,即C#中的闭包捕获变量,而不仅仅是值。
3. 我们的方法:Moles
3.1 代码插桩和底层绕过库
Moles框架基于扩展反射代码插桩框架来实现方法调用的绕过,扩展反射也被Pex和CHESS使用。代码插桩细节对用户隐藏,用户只需与生成的高级mole类型交互。
插桩过程如下:在每个方法的开头插入一些代码,该代码查询底层绕过库,看是否有绕过委托附加到该特定方法。如果有,则调用绕过委托;否则,执行正常代码。
例如,对于以下方法:
string Compute(int x, int y) {
return (x + y).ToString();
}
插桩后的代码类似于以下伪代码:
string Compute(int x, int y) {
// obtain identifier of this method
Method thisMethod = this.GetType().GetMethod(
"Compute", new Type[]{typeof(int), typeof(int)});
// query if a detour has been attached
Delegate detour = _Detours.GetDetour(this, thisMethod);
if (detour != null) {
// pseudo-code; actual implementation avoids boxing
return (string)_Detours.InvokeDetour(
detour, this,
new object[] { x, y });
}
// else execute normal code
return (x + y).ToString();
}
上述代码使用了一个底层绕过库
_Detours
,它管理所有附加的绕过委托,并提供查询和调用绕过委托的功能。
public class _Detours {
// attaching and detaching
public static void AttachDetour(
object receiver, Method method,
Delegate detourDelegate) {
...
}
public static void DetachDetour(
object receiver, Method method) {
...
}
// quering and invoking
public static Delegate GetDetour(
object receiver, Method method) {
...
}
public static object InvokeDetour(
Delegate detour,
object receiver, object[] args) {
...
}
}
插桩后的实际代码使用“不安全”的.NET指令避免了类型转换和装箱,提高了效率。
3.2 为类型安全生成Mole类型
通过通用绕过库附加和分离moles时,编译时无法保证类型安全。为了确保以类型安全的方式进行操作,Moles框架生成专门的代码。这不仅保证了类型安全,还使开发者无需使用.NET反射API构造方法标识符,并支持Visual Studio编辑器的自动代码完成。
对于每个类型
t
,会生成一个mole类型
Mt
。对于
t
中的每个方法,包括.NET属性的隐式getter和setter方法,以及.NET事件的添加和移除方法,都会生成可设置的mole属性。生成的类型
Mt
放在类型
t
的子命名空间中,命名空间后缀为
.Moles
,生成的类型放在单独的程序集中。
-
静态方法和构造函数
:对于类型
t中的每个静态方法,参数类型为T1, T2, ..., Tn,返回类型为U,在类型Mt中会生成一个可设置的静态属性,委托类型类似如下:
delegate U Func (T1, T2, ..., Tn)
实例构造函数的隐式
this
参数成为第一个参数
T1
。属性名以静态方法名开头,后面追加参数的短类型名,可能还会有一个数字以确保所有生成的属性名唯一。例如,
MDateTime.NowGet
的生成实现如下:
static Func/*delegate DateTime Func()*/ NowGet {
set { // property setter has implicit ’value’ parameter
Method method = typeof(DateTime).GetMethod("get_Now");
if (value == null)
_Detours.DetachDetour(null, method);
else
_Detours.AttachDetour(null, method, value);
}
}
-
所有实例的实例方法
:对于类型
t中的每个实例方法,显式参数类型为T1, T2, ..., Tn,返回类型为U,在嵌套类型Mt.AllInstances中会生成一个可设置的静态属性,委托类型类似如下:
delegate U Func (t, T1, T2, ..., Tn)
例如,
Compute
方法的生成静态属性
ComputeInt32Int32
如下:
static Func/*delegate string Func(int,int)*/ ComputeInt32Int32 {
set { // property setter has implicit ’value’ parameter
Method method = typeof(ComputeType).GetMethod("Compute",
new Type[] { typeof(int), typeof(int) });
if (value == null)
_Detours.DetachDetour(null, method);
else
_Detours.AttachDetour(null, method, value);
}
}
这里
AttachDetour
和
DetachAttach
的第一个参数为
null
,表示该绕过适用于所有实例。
-
特定实例的实例方法
:可以根据
this参数的值将绕过委托分配给不同的调用。Mt类型可实例化,每个Mt实例与一个t实例关联,并为t的所有实例方法提供可设置的实例属性。对于类型t中的每个实例方法,参数类型为T1, T2, ..., Tn,返回类型为U,在类型Mt中会生成一个可设置的实例属性,委托类型类似如下:
delegate U Func (T1, T2, ..., Tn)
例如,
Compute
方法的生成属性
ComputeInt32Int32
如下:
Func/*delegate string Func(int,int)*/ ComputeInt32Int32 {
set { // property setter has implicit ’value’ parameter
Method method = typeof(ComputeType).GetMethod("Compute",
new Type[] { typeof(int), typeof(int) });
if (value == null)
_Detours.DetachDetour(this.Instance, method);
else
_Detours.AttachDetour(this.Instance, method, value);
}
}
这里
AttachDetour
和
DetachDetour
的第一个参数为
this.Instance
,表示该绕过仅适用于与当前mole实例关联的特定实例。
以下是一个mermaid流程图,展示方法调用时的处理流程:
graph TD;
A[方法调用] --> B{是否有绕过委托};
B -- 是 --> C[调用绕过委托];
B -- 否 --> D[执行正常代码];
3.3 形式化
Moles框架受Pex启发,Pex使用动态符号执行来跟踪程序执行的控制流和数据流,以收集路径条件。Moles增加了附加和分离绕过委托的能力,并修改了方法调用的含义。
我们考虑以下操作:
| 操作类型 | 操作指令 |
| ---- | ---- |
| 通用计算、赋值和条件控制流 |
assign x := f(y1, y2, ..., yn)
if(x) goto PC
|
| 附加和分离moles |
mole M := (y, N)
mole (x, M) := (y, N)
unmole M
unmole (x, M)
|
| 调用和返回 |
x := call M(y1, y2, ..., yn)
ret y
|
程序
P
有两个函数:
startP : PROC →N
将方法映射到其第一条指令的偏移量,
instr P : N →INSTR
将每个偏移量映射到关联的指令。程序从偏移量0开始。
程序语义由初始程序状态
initP : HEAP × STACK
和单条指令执行函数
stepP : HEAP × STACK →HEAP×STACK
描述。堆将值映射到值,栈是栈帧的序列,每个栈帧由当前程序计数器和局部变量到值的映射组成。
初始程序状态:
initP = ({→}, [(0, {→})])
通用计算、赋值和条件控制流的执行规则如下:
stepP (H, R ++ (PC , L))
where (instr P (PC ) = assign x := f(y1, y2, ..., yn))
= (H, R ++ (PC + 1, L ⊕{x →ˆf(L(y1, y2, ..., yn))}))
where ˆf is the built-in function computing f
stepP (H, R ++ (PC , L))
where (instr P (PC ) = if(x) goto PC ′)
=
(H, R ++ (PC ′, L)) if L(x) is true
(H, R ++ (PC + 1, L)) otherwise
如果当前指令是赋值操作,不改变堆,增加程序计数器,并将计算的值赋给局部变量;如果是条件控制流操作,根据条件值改变程序计数器。
Moles:借助闭包实现工具辅助的环境隔离
3.3 形式化(续)
下面详细解释这些操作的具体含义和执行过程。
-
通用计算、赋值和条件控制流
-
赋值操作
:
assign x := f(y1, y2, ..., yn)指令将内置函数f计算得到的值赋给变量x。在执行stepP函数时,堆H保持不变,程序计数器PC加 1,并且将计算得到的值存储在局部变量映射L中。例如,如果f是一个简单的加法函数,assign x := f(y1, y2)就相当于x = y1 + y2。 -
条件分支操作
:
if(x) goto PC指令根据变量x的值进行条件分支。如果x的值为真(在程序的语义中),则将程序计数器跳转到指定的PC位置;否则,程序计数器加 1 继续执行下一条指令。
-
赋值操作
:
-
附加和分离 moles
-
附加 mole 闭包
:
mole M := (y, N)指令将一个 mole 闭包(y, N)附加到方法M上。这意味着当调用方法M时,控制将转移到闭包(y, N)所代表的函数。对于特定实例的方法,mole (x, M) := (y, N)指令将闭包附加到实例x的方法M上。 -
分离 mole
:
unmole M指令将附加到方法M上的 mole 闭包分离,使得方法M恢复正常调用。同样,unmole (x, M)指令将特定实例x的方法M上的 mole 闭包分离。
-
附加 mole 闭包
:
-
调用和返回
-
方法调用
:
x := call M(y1, y2, ..., yn)指令调用方法M,并将返回值存储在变量x中。在调用过程中,如果有 mole 闭包附加到方法M上,控制将转移到闭包所代表的函数;否则,执行方法M的正常代码。 -
返回操作
:
ret y指令从方法调用中返回,将值y作为返回值。
-
方法调用
:
以下是一个简单的示例,展示这些操作的组合使用:
// 初始状态
initP = ({→}, [(0, {→})])
// 假设程序指令序列
instr P = {
0: assign x := 1,
1: assign y := 2,
2: mole M := (y, N), // 附加 mole 闭包
3: x := call M(y),
4: unmole M, // 分离 mole 闭包
5: ret x
}
// 执行过程
stepP (initP)
// 第一步:assign x := 1
// 堆不变,PC 变为 1,L = {x → 1}
// 状态变为 ({→}, [(1, {x → 1})])
stepP (({→}, [(1, {x → 1})]))
// 第二步:assign y := 2
// 堆不变,PC 变为 2,L = {x → 1, y → 2}
// 状态变为 ({→}, [(2, {x → 1, y → 2})])
stepP (({→}, [(2, {x → 1, y → 2})]))
// 第三步:mole M := (y, N)
// 堆更新,记录 M 与 (y, N) 的关联,PC 变为 3
// 状态变为 ({M → (y, N)}, [(3, {x → 1, y → 2})])
stepP (({M → (y, N)}, [(3, {x → 1, y → 2})]))
// 第四步:x := call M(y)
// 如果 M 有 mole 闭包,调用闭包 (y, N),并将结果存储在 x 中
// 假设闭包返回 3,PC 变为 4,L = {x → 3, y → 2}
// 状态变为 ({M → (y, N)}, [(4, {x → 3, y → 2})])
stepP (({M → (y, N)}, [(4, {x → 3, y → 2})]))
// 第五步:unmole M
// 堆更新,移除 M 与 (y, N) 的关联,PC 变为 5
// 状态变为 ({→}, [(5, {x → 3, y → 2})])
stepP (({→}, [(5, {x → 3, y → 2})]))
// 第六步:ret x
// 返回 x 的值 3
通过这种形式化的描述,我们可以精确地定义 Moles 框架的操作语义,使得开发者能够更好地理解和使用 Moles 来进行环境隔离和测试。
总结
Moles 框架为解决单元测试中的环境隔离问题提供了一种有效的方法。通过代码插桩和底层绕过库,Moles 能够将方法调用重定向到用户指定的委托,从而绕过依赖于环境的组件,使得测试用例可以在隔离的环境中执行。
-
核心优势
-
环境隔离
:Moles 允许开发者在测试时绕过依赖于系统环境的方法,如
DateTime.Now,使得测试用例不依赖于实际的系统状态,提高了测试的可重复性和稳定性。 - 类型安全 :通过生成专门的 mole 类型,Moles 确保了在附加和分离 detour 委托时的类型安全性,同时简化了开发者的操作,支持 Visual Studio 编辑器的自动代码完成。
- 与 Pex 集成 :Moles 与 Pex 等自动化测试工具紧密集成,使得 Pex 能够生成覆盖代码所有情况的测试用例,提高了测试的覆盖率和质量。
-
环境隔离
:Moles 允许开发者在测试时绕过依赖于系统环境的方法,如
-
使用建议
- 合理使用 detour 委托 :在使用 Moles 时,应该谨慎选择需要绕过的方法,并确保 detour 委托的实现符合测试的需求。避免过度使用 detour 委托,以免影响代码的可维护性。
- 结合其他测试技术 :Moles 可以与其他测试技术,如单元测试框架、集成测试框架等结合使用,以实现更全面的测试覆盖。
- 注意性能问题 :虽然 Moles 的代码插桩经过优化,但在某些情况下,过多的插桩可能会影响程序的性能。因此,在实际使用中应该注意性能问题,并进行必要的优化。
总的来说,Moles 是一个强大的工具,能够帮助开发者解决单元测试中的环境隔离问题,提高测试的效率和质量。通过合理使用 Moles,开发者可以更加轻松地编写高质量的测试用例,确保代码的可靠性和稳定性。
超级会员免费看
924

被折叠的 条评论
为什么被折叠?



