停止为可测试性设计






4.78/5 (43投票s)
2006年7月30日
9分钟阅读

126020
一篇介绍使代码更具可测试性的不同技术的文章。
目录
引言
Nunit Framework 每月下载量超过 35,000 次(参见此处),这表明测试驱动开发以及拥有自动化测试的价值是值得的。即使是微软也加入了进来,并分发了自己的测试框架(我们将在本文的示例中使用这个框架)。有许多关于测试驱动开发的文章,但一旦你开始编写测试,你就会注意到你开始设计你的代码使其可测试。在本文中,我们将看到用于创建可测试代码的这些技术会不必要地使你的代码复杂化,从而难以维护。这与测试驱动开发的“创建最简单有效的东西”的理念背道而驰。
隔离以验证
单元测试的目标是隔离程序的每个部分并证明各个部分是正确的。尽管这个目标很容易理解,但实现起来却相当困难。这就是 Mock 和 Mocking Frameworks 的用武之地。有关详细解释,请参阅 Mark Seemann 的文章 Mock Objects to the Rescue! Test Your .NET Code with NMock。
Mock 的基本思想是拦截代码的正常流程并插入虚拟对象。这些对象并非真的“虚拟”,它们的行为可以被编程和验证;这包括验证传入的参数,返回不同的值,以及验证预期的调用是否真的发生了。所有这些都很好,并且会起作用,但这会给我们留下一个问题:为了拦截正常流程,我们需要以某种方式将真实对象替换为 Mock 对象。
常规做法是改变程序的設計以允许对象交換。我们将看到这会导致创建虚拟方法或新的接口仅仅为了测试。
让我们看一个例子:我们正在创建一个 ATM,我们将实现一个执行现金取款的方法。
这是实现此功能的一种方法。
namespace ATM
{
public class Teller
{
public bool Withdraw(Account account, decimal amount)
{
Transaction transaction = new Transaction(account, -amount);
transaction.Execute();
if (transaction.IsSuccessful)
{
Dispose(amount);
return true;
}
return false;
}
}
}
这看起来简单易懂,但我们能独立测试它吗?我们如何隔离 Transaction
?
隔离事务
我们要做的第一件事是编写我们的 Mock Transaction
,一个不真正访问数据库的。为此,我们将为 Transaction
创建一个接口并创建我们自己的 MockTransaction
。在下面的示例中,我们将创建一个手动 Mock,但你可以使用任何 Mock Framework。
using System;
namespace ATM
{
public interface ITransaction
{
void Execute();
bool IsSuccessful { get; }
}
}
以及我们的 MockTransaction
using ATM;
namespace ATM.Test
{
public class MockTransaction : ITransaction
{
public Account account;
public decimal amount;
public int executeCalledCounter = 0;
public MockTransaction(Account account, decimal amount)
{
// Save arguments for later validation
this.account = account;
this.amount = amount;
}
#region ITransaction Members
public void Execute()
{
// save number of times this has been called
executeCalledCounter++;
}
public bool IsSuccessful
{
// always be successful
get { return true; }
}
#endregion
}
}
如果我们能在 Teller.Withdraw()
中将 Transaction
的创建替换为 MockTransaction
,那么我们就可以运行测试并验证我们的代码在事务成功时是否运行良好。我们将要看到的这些技术是依赖注入或控制反转技术的一部分。
提取和覆盖模式
这是隔离 Transaction
的最简单方法。我们将创建命令提取到一个虚拟方法中,然后为测试覆盖此方法。这是我们现在的代码。注意 ITransaction
和 CreateTransaction
虚拟方法的使用。
namespace ATM
{
public class Teller
{
public bool Withdraw(Account account, decimal amount)
{
ITransaction transaction = CreateTransaction(account, -amount);
transaction.Execute();
if (transaction.IsSuccessful)
{
Dispose(amount);
return true;
}
return false;
}
// We moved the creation statment to a virtual method
// so that we are able to inject a Mock Transaction
protected virtual ITransaction CreateTransaction(Account account,
decimal amount)
{
return new Transaction(account, amount);
}
}
}
为了注入我们自己的 MockTransaction
,我们将不得不创建一个派生自 Teller
的新类。在这个类中,我们将覆盖创建方法,并用我们自己的 MockTransaction
替换它。这是我们的测试
using Microsoft.VisualStudio.TestTools.UnitTesting;
using ATM;
namespace ATM.Test
{
public class TellerForTest : Teller
{
// We need to reference the mock to validate our tests
public MockTransaction mockTransaction;
// Make sure that our Mocked Version of Transaction
// is created instead of the real one
protected override ITransaction CreateTransaction(Account account,
decimal amount)
{
this.mockTransaction = new MockTransaction(account,amount);
return this.mockTransaction;
}
}
[TestClass]
public class TestTeller
{
[TestMethod]
public void TestCanWithdraw()
{
TellerForTest teller = new TellerForTest();
Account account = new Account();
bool actual = teller.Withdraw(account,100);
Assert.AreEqual(true, actual);
// check that transaction was called
Assert.IsNotNull(teller.mockTransaction);
// check that correct values where passed
Assert.AreSame(account, teller.mockTransaction.account);
Assert.AreEqual(-100, teller.mockTransaction.amount);
// check that Execute was called
Assert.AreEqual(1, teller.mockTransaction.executeCalledCounter);
}
}
}
对于这种模式,我们在生产代码中创建了一个接口和一个虚拟方法,并在测试中创建了一个 Mock 对象和一个派生类。当只有一个地方创建 Transaction
时,此方法效果很好,但如果我们有多个地方创建事务,代码就会重复(许多 CreateTransaction
方法)。在这种情况下,更好的方法是使用抽象工厂。
引入抽象工厂
为了更好地插入 Mock 对象,我们可以使用抽象工厂的变体。我们的代码将如下所示。我们将有一些创建者类来实际创建 ITransaction
,以及一个 Factory 来调用正确的 Creator
并返回我们新创建的 ITransaction
。具有更改创建者的方法的 Factory 可能如下所示
namespace ATM
{
public class TransactionFactory
{
// our creator knows how create an ITransaction
static ITransactionCreator creator;
public static ITransactionCreator Creator
{
set { creator = value; }
}
public static ITransaction CreateTransaction(Account account,
decimal amount)
{
// ask the creator to give us a new ITransaction
return creator.Create(account, -amount);
}
}
}
我们的创建者接口
namespace ATM
{
public interface ITransactionCreator
{
ITransaction Create(Account account, decimal amount);
}
}
我们将有一个默认创建者
namespace ATM
{
internal class TransactionCreator : ITransactionCreator
{
#region ITransactionCreator Members
public ITransaction Create(Account account, decimal amount)
{
return new Transaction(account, amount);
}
#endregion
}
}
对于我们的测试,我们需要一个 MockCreator
using ATM;
namespace ATM.Test
{
public class MockTransactionCreator : ITransactionCreator
{
// We need to reference the mock to validate our tests
public MockTransaction transaction;
#region ITransactionCreator Members
public ITransaction Create(Account account, decimal amount)
{
transaction = new MockTransaction(account,amount);
return transaction;
}
#endregion
}
}
我们的代码现在将调用 Factory。注意 TransactionFactory.CreateTransaction
调用。
namespace ATM
{
public class Teller
{
public bool Withdraw(Account account, decimal amount)
{
ITransaction transaction =
TransactionFactory.CreateTransaction(account, -amount);
transaction.Execute();
if (transaction.IsSuccessful)
{
Dispose(amount);
return true;
}
return false;
}
}
}
我们的测试现在将告诉我们的 Factory 使用 MockCreator
。请注意,我们现在使用普通的 Teller
。
using Microsoft.VisualStudio.TestTools.UnitTesting;
using ATM;
namespace ATM.Test
{
[TestClass]
public class TestTeller
{
[TestMethod]
public void TestCanWithdraw()
{
Teller teller = new Teller();
Account account = new Account();
// insert our own Transaction
MockTransactionCreator creator = new MockTransactionCreator();
TransactionFactory.Creator = creator ;
bool actual = teller.Withdraw(account,100);
Assert.AreEqual(true, actual);
// check that transaction was called
Assert.IsNotNull(creator.transaction);
// check that correct values where passed
Assert.AreSame(account, creator.transaction.account);
Assert.AreEqual(-100, creator.transaction.amount);
// check that Execute was called
Assert.AreEqual(1, creator.transaction.executeCalledCounter);
}
}
}
尽管我们完成了测试,但我们还没有完成,因为我们的 TransactionFactory
默认不知道使用 TransactionCreator
,但我将其留给读者作为练习。
对于这种模式,我们在生产代码中创建了一个 Factory、两个接口、一个 Creator
类和一个仅用于测试的属性(TransactionFactory.Creator
需要交换创建者类),并在我们的测试中创建了一个 Mock 对象和创建者。(某些实现需要额外的配置来加载默认的 TransactionCreator
。)
隔离的代价
使用上述方法,我们可以测试我们的代码**以及**获得代码低相关性的额外好处,这意味着我们可以轻松地更改 Transaction
类。拥有如此高的灵活性是有代价的。让我们看看这个代价是多少。
我们现在有许多与具体类完全对应的接口,并且需要在两者上都进行更改。我们还更改了实现和设计。这意味着要增加 3-8 个类!我们实际上创建了一个框架来帮助我们隔离代码。这并不完全是 YAGNI(你永远不会需要它),这是测试驱动开发中的一个实践,它认为我们应该做最简单的事情来使测试通过。使用抽象工厂会导致更多的错误,并使代码更难理解、更难调试、更难维护,这一切都是为了一个我们**只需要**用于测试的功能。一定有更好的办法。我们的业务功能应该驱动我们创建更复杂的代码,而不是我们的测试。
但是,我们如何才能隔离 Transaction
类呢?我们如何将其创建替换为创建 Mock 对象?
在这里,一些现代工具非常有用。
使用 TypeMock.NET 隔离我们的代码
这就是 TypeMock.NET 发挥作用的地方。TypeMock 有一个新想法:与其使用 POJO 和 OO 方法来隔离我们的代码,不如使用其他技术来隔离我们的代码。利用 TypeMock.NET 的魔力,我们现在可以在不改变设计的情况下隔离和替换我们的 Transaction
类。我们所要做的就是告诉框架,当调用 new Transaction()
时,将其替换为 Mock 实例。
这是一个隔离 Transaction
并调用 Mock 代码的示例。
using Microsoft.VisualStudio.TestTools.UnitTesting;
using ATM;
using TypeMock;
namespace ATM.Test
{
[TestClass]
public class TestTeller
{
[TestMethod]
public void TestCanWithdraw()
{
Account account = new Account();
// Set Expectations (see note 1)
using (RecordExpectations record = RecorderManager.StartRecording())
{
// We expect the a new Transaction to be created
// with these arguments (see note 2)
Transaction mockTransaction = new Transaction(account, -100);
record.CheckArguments(); // (see note 3)
// We expect Execute to be called on the future object
// (see note 4)
mockTransaction.Execute();
// We will return a successfull transaction (see note 5)
record.ExpectAndReturn(mockTransaction.IsSuccessful, true);
}
// Lets run our tests
Teller teller = new Teller();
bool actual = teller.Withdraw(account, 100);
Assert.AreEqual(true, actual);
// make sure that all expected methods where called
MockManager.Verify();
}
[TestCleanup]
public void MyTestCleanup()
{
// Stop mocking
MockManager.CleanUp();
}
}
}
解释
- 在 using 块中,所有方法都将被记录和 Mock。
- 我们创建一个 Transaction,这意味着我们将 Mock 下一个将被创建的 Transaction。
- 我们还验证了传递给构造函数的参数是我们的账户,并且我们正在从账户中取出 100 美元。
- 由于我们不想实际执行事务,因此我们 Mock 了 Execute 方法和
IsSuccessful
属性。 - 在这个测试中,我们将检查我们的代码在事务成功时是否运行正常。
细心的读者可能会注意到,我们没有向我们正在测试的代码传递任何 Mock 对象,这是因为我们不需要。TypeMock.NET 将在需要时自动加载 Mock 对象。
最好的部分是我们的生产代码没有改变,并且易于理解,同时我们可以轻松地对其进行测试。
但这只是故事的一部分。假设我们无法创建我们的 Transaction(构造函数是私有的)。我们如何隔离它?
这时反射 Mock 就派上用场了。
using Microsoft.VisualStudio.TestTools.UnitTesting;
using ATM;
namespace ATM.Test
{
[TestClass]
public class TestTeller
{
[TestMethod]
public void TestCanWithdraw()
{
Account account = new Account();
// We expect the a new Transaction to be created
Mock transactionMock = MockManager.Mock(typeof(Transaction));
// We expect the Transaction to be created with these arguments
transactionMock.ExpectConstructor().Args(account, -100);
// We expect Execute to be called on the future object
transactionMock.ExpectCall("Execute");
// We will return a successfull transaction
transactionMock.ExpectGet("IsSuccessful", true);
Teller teller = new Teller();
bool actual = teller.Withdraw(account, 100);
Assert.AreEqual(true, actual);
MockManager.Verify();
}
[TestCleanup()
public void MyTestCleanup()
{
// Stop mocking
MockManager.CleanUp();
}
}
}
在这里,我们做了和上面一样的操作,但是使用了反射 Mock,因为它们是反射的,我们传入方法名作为字符串,这使得我们能够 Mock 私有方法,但随后我们会失去 IntelliSense 和重构的能力。
模拟静态方法
使用 TypeMock.NET,我们可以轻松地隔离静态方法和密封类,这是使用常规技术几乎不可能完成的任务。假设我们为了性能决定使用一个 TransactionPool
来为我们提供 Transactions
。我们的代码现在看起来如下
namespace ATM
{
public class Teller
{
public bool Withdraw(Account account, decimal amount)
{
Transaction transaction =
TransactionPool.GetTransaction(account, -amount);
transaction.Execute();
if (transaction.IsSuccessful)
{
Dispose(amount);
return true;
}
return false;
}
}
}
我们仍然可以使用 TypeMock 进行如下测试
using Microsoft.VisualStudio.TestTools.UnitTesting;
using ATM;
using TypeMock;
namespace ATM.Test
{
[TestClass]
public class TestTeller
{
[TestMethod]
public void TestCanWithdraw()
{
Account account = new Account();
// Set Expectations
using (RecordExpectations record = RecorderManager.StartRecording())
{
// We expect the a Transaction to be fetched
// from the pool created with these arguments
// TypeMock will automatically return a mocked type.
Transaction mockTransaction =
TransactionPool.GetTransaction(account, -100);
record.CheckArguments();
// We expect Execute to be called on the future object
mockTransaction.Execute();
// We will return a successfull transaction
record.ExpectAndReturn(mockTransaction.IsSuccessful, true);
}
// Lets run our tests
Teller teller = new Teller();
bool actual = teller.Withdraw(account, 100);
Assert.AreEqual(true, actual);
// make sure that all expected methods where called
MockManager.Verify();
}
[TestCleanup]
public void MyTestCleanup()
{
// Stop mocking
MockManager.CleanUp();
}
}
}
模拟一系列调用
TypeMock.NET 甚至更进一步,可以一次性隔离一系列调用,这是使用普通 Mock Frameworks 所需的大量代码更改才能实现的。假设我们决定我们的 TransactionPool
可以从一个 Singleton DataAccess
访问。我们的代码现在看起来如下
namespace ATM
{
public class Teller
{
public bool Withdraw(Account account, decimal amount)
{
// Notice that we are calling a chain of methods
Transaction transaction =
DataAccess.Instance.GetTransactionPool().GetTransaction(account,
-amount);
transaction.Execute();
if (transaction.IsSuccessful)
{
Dispose(amount);
return true;
}
return false;
}
}
}
我们仍然可以使用 TypeMock 进行如下测试
using Microsoft.VisualStudio.TestTools.UnitTesting;
using ATM;
using TypeMock;
namespace ATM.Test
{
[TestClass]
public class TestTeller
{
[TestMethod]
public void TestCanWithdraw()
{
Account account = new Account();
// Set Expectations
using (RecordExpectations record = RecorderManager.StartRecording())
{
// We expect the a Transaction to be fetched
// from the pool created with these arguments
// TypeMock will automatically return a mocked type.
Transaction mockTransaction =
DataAccess.Instance.GetTransactionPool().GetTransaction(account,
-100);
record.CheckArguments();
// We expect Execute to be called on the future object
mockTransaction.Execute();
// We will return a successful transaction
record.ExpectAndReturn(mockTransaction.IsSuccessful, true);
}
// Lets run our tests
Teller teller = new Teller();
bool actual = teller.Withdraw(account, 100);
Assert.AreEqual(true, actual);
// make sure that all expected methods where called
MockManager.Verify();
}
[TestCleanup]
public void MyTestCleanup()
{
// Stop mocking
MockManager.CleanUp();
}
}
}
我们可以看到,尽管我们隔离了代码,但仍然可以轻松地进行重构,同时保持测试不变。如果将来我们的代码需要更改 Transaction 的能力,例如客户要求一个可以处理平面文件而不是数据库的 ATM,我们可以将代码重构为使用上述技术之一。在大多数情况下,我们甚至不必更改测试代码。
这个魔法是如何工作的
TypeMock.NET 使用面向切面的编程,这是一种新的方法论,允许混合类并在不同位置连接它们。TypeMock 实际上所做的是隔离真实代码,并决定何时运行真实代码,何时运行 Mock 代码。使用 Profiler API,TypeMock.NET 监控代码的创建和使用,然后对代码进行仪器化,以便在需要时进行隔离。要了解其工作原理,TypeMock.NET 提供了一个 Trace,显示了所有实例和 Mock 类型的方法调用。
结论
尽管创建接口和使用 Factory 被认为是良好的 O/O 实践,但如果过度使用,它会使我们的代码混乱。我们应该能够决定生产代码的最佳设计,而无需更改代码以使其可测试。过去这是不可能的,但随着 TypeMock.NET 等现代工具的出现,这是可行的,所以我们应该停止自欺欺人,没有必要更改我们的设计来使代码可测试。生产代码和功能应该驱动我们的设计,**而不是**我们的测试。这将为我们节省数小时的开发和维护不必要代码的时间。
我当然希望讨论这个观点。
参考文献
澄清一下,我与 TypeMock.NET 有关联,但这只是因为我真心相信它和它背后的方法论。
修订历史
2006年7月30日
- 首次发帖。