现代模拟工具与黑魔法
权力腐蚀的例子
现代模拟工具对我们处理遗留代码的能力产生的积极影响,以及使用这些工具可能带来的负面影响。
2012 年 9 月 10 日
在 有效地处理遗留代码 中,Michael Feathers 将遗留代码定义为没有自动化测试的代码。2010 年的某个时候,我被介绍了 JMockIt,并了解了它如何让我们编写看似违反 Java 语义的自动化测试。例如,可以在测试执行期间替换静态方法。如果我使用 Michael Feathers 建议的“旧”风格,我会做一些类似引入实例委托的事情,以便能够编写我想要编写的测试。现在,使用现代模拟工具,我可以跳过这一步。我最初的反应是惊奇,因为我认为这是一种方法,可以打开难以测试的现有代码,并更快地编写一些东西,而且在现有代码中少做一些修改。
快进到 2011 年底,我在柏林教授一门课程,并被要求在用户组做一次演讲(你可以在这里看到那个演讲)。在那个视频中,你可以看到我笨拙地试图使用 JMockIt 对一些遗留代码进行测试,最后我设法做到了,而没有实际更改底层代码。你在视频中看不到的是,第二天在课堂上,我们拿到了生成的代码,并对其应用了更传统的遗留重构技术,然后重写了基于 JMockIt 的测试。结果令人震惊,我相信它们不言自明。以下是对该故事的完整再现,其中去掉了大部分笨拙的部分,我希望如此。
惯犯
要开始,这里有一些代码供你参考
public static BigDecimal convertFromTo(String fromCurrency, String toCurrency) { Map<String, String> symbolToName = currencySymbols(); if (!symbolToName.containsKey(fromCurrency)) throw new IllegalArgumentException(String.format( "Invalid from currency: %s", fromCurrency)); if (!symbolToName.containsKey(toCurrency)) throw new IllegalArgumentException(String.format( "Invalid to currency: %s", toCurrency)); String url = String.format("http://www.gocurrency.com/v2/dorate.php?inV=1&from=%s&to=%s&Calculate=Convert", toCurrency, fromCurrency); try { HttpClient httpclient = new DefaultHttpClient(); HttpGet httpget = new HttpGet(url); HttpResponse response = httpclient.execute(httpget); HttpEntity entity = response.getEntity(); StringBuffer result = new StringBuffer(); if (entity != null) { InputStream instream = entity.getContent(); InputStreamReader irs = new InputStreamReader(instream); BufferedReader br = new BufferedReader(irs); String l; while ((l = br.readLine()) != null) { result.append(l); } } String theWholeThing = result.toString(); int start = theWholeThing.lastIndexOf("<div id=\"converter_results\"><ul><li>"); String substring = result.substring(start); int startOfInterestingStuff = substring.indexOf("<b>") + 3; int endOfIntererestingStuff = substring.indexOf("</b>", startOfInterestingStuff); String interestingStuff = substring.substring( startOfInterestingStuff, endOfIntererestingStuff); String[] parts = interestingStuff.split("="); String value = parts[1].trim().split(" ")[0]; BigDecimal bottom = new BigDecimal(value); return bottom; } catch (Exception e) { throw new RuntimeException(e); } }
表面上,这段代码以一种相当含糊的方式抓取了货币汇率信息,但这段代码的真正目的是讨论糟糕的编码选择及其对我们理解、测试和维护这段代码的能力的影响。
测试挑战
在深入研究该方法本身之前,首先观察到这是一个静态方法。在 Java、C#、C++(以及许多其他语言)中,静态方法始终在编译(或链接)时绑定。这意味着调用静态方法的代码直接耦合到这些方法。方法的选择和调用机制选择得过早,无法轻松地让测试设置一个环境,使它们能够转移到测试控制的东西。可以使用 Michael Feathers 所谓的“链接缝合线”在运行时调用不同的静态方法。在 Java 的情况下,你可以确保具有静态方法的不同版本的类在类路径中更早地可用。下面将对此进行更多介绍。
验证
此方法首先执行一些基本验证。它确认作为方法参数提供的符号确实存在。为此,它调用同一个类中的另一个方法。
Map<String, String> symbolToName = currencySymbols(); if (!symbolToName.containsKey(fromCurrency)) throw new IllegalArgumentException(String.format( "Invalid from currency: %s", fromCurrency)); if (!symbolToName.containsKey(toCurrency)) throw new IllegalArgumentException(String.format( "Invalid to currency: %s", toCurrency));
必需
获取已知符号的方法是静态的,并且在同一个类中。这使得在语言内部无法模拟它或以其他方式不执行验证。虽然始终执行验证似乎是一个好主意,但它确实增加了测试负担,这使得测试在某种程度上更加脆弱,因为它们依赖于随着时间的推移可能发生变化的更多东西。这仅仅是触及了表面。似乎始终在测试其他东西时测试验证会使验证检查更加彻底,事实上,这种测试通常代表重复而不是更深入的测试;它增加了体积,而没有增加价值。
我只想验证解析是否正确。正如所写,验证和解析必须一起完成,以特定的顺序。这个顺序看起来很合理;为了有输入,我需要有效的货币。但是,虽然这可能看起来像一个必要的约束,但解析中没有任何东西依赖于有效的货币,也不应该依赖于有效的货币。它只关心要解析的文本是否遵循某种形式,而不是该形式的一部分恰好还包括有效的货币符号。因此,验证的业务不必要地渗透到下一步。我认为这是一个不必要的时序耦合的例子。虽然解析在时间上确实遵循验证,但因此不应该遵循验证必须是允许我们验证解析的必要条件。给定的测试知道它正在测试什么。例如,它知道它想要解析的输入是否有效。因此,在解析之前强制执行验证与代码的编写方式无关。它对于解析来说不是必需的。
最后一点值得强调,因为它是一个常见的误解。根据我的经验,大多数人会建议在解析之前进行验证是必要的。从某种意义上说,它是必要的;为了获得一些有效的输入,我需要在真实系统中使用有效的货币符号。但是,为了测试的目的,我不需要真实系统,因此通过验证的要求实际上是偶然的。如果我们检查验证是否有效,并且我们检查解析是否有效,那么我们编写的代码是否有可能不起作用?我认为不会,所以我没有看到进行完全集成的功能测试的必要性。有人可能不同意我的观点。在一个真实的项目中,我可能会编写一个,因为争论会比仅仅编写一个自动检查花费更多时间。但是,将事物分解成越来越小的部分既是一项基本技能,又需要多年的学习。但是,即使你不同意我的观点,我希望我们能同意的是,如果我能独立于验证验证解析,那么检查事情会更容易。在 通用系统思维导论 中,Weinberg 描述了 计算平方定律,你可能更熟悉它作为“分而治之”。这段代码显然没有遵循任何这样的规则,不必要的、偶然的耦合将使事情变得相对困难。
调用 currencySymbols()
如上所述,调用静态方法是一个问题,但更大的问题是,该方法使用 HttpClient 对系统进行了调用,因此调用它需要互联网连接。这个调用是必要的,或者至少被验证使用。这个静态方法的存在是否与这个方法的其他部分本质上相关,还是偶然相关?不要将“按原样编写”与“必要”混淆。
传统选项
为了解决这些问题,我们可以做一些事情
- 通过使用 Sprout Class 或 Extract Class 将验证提取到它自己的类中
- 使此类中的所有方法都非静态且非私有,并使用 测试子类 使测试更容易
- 使用实例委托 - 静态方法保留,但内部它们调用对象上的实例方法
这些解决方案解决了底层的语言设计问题(不可覆盖的静态内容是它们的设计方式,但并非从根本上必要,请考虑 Smalltalk、JavaScript、Self 等)。无论如何,选择哪一个取决于许多因素,包括起点、现有代码对该类的依赖程度等。
HttpClient
接下来,代码使用 HttpClient 读取网页
HttpClient httpclient = new DefaultHttpClient(); HttpGet httpget = new HttpGet(url); HttpResponse response = httpclient.execute(httpget); HttpEntity entity = response.getEntity();
直接使用
这段代码直接使用 HttpClient,并使用 new 启动。继承是 Java 中耦合的最高形式,紧随其后的是调用 new 。正如这段代码所写,没有语言定义的方法可以避免使用这个类。你可以使用链接缝合线,通过不同的 jar 文件(或类路径)进行测试。在紧急情况下,我会考虑这样做,但如果我能访问代码并更改它,或者,正如我们将看到的,访问我所说的第四代模拟工具,例如 JMockIt (Java 开源)、powermock(Java 开源)或 Isolator.Net(商业,.Net)
违反依赖倒置原则
在这个例子中,业务领域是货币转换,但业务逻辑直接使用 HttpClient。这违反了 依赖倒置原则。在这种情况下,如果有人想获得货币转换,正如代码所写,尝试这样做会引入对需要连接到互联网的类的直接、编译时耦合。高级内容依赖于低级细节。这段代码不会像倾向于颠倒这种依赖关系的替代方案那样好地老化。
解决这个问题
解决这个问题的选项与验证相同:引入实例方法、发芽一个类等。但是,还有一个更深层次的问题。这段代码不仅直接依赖于互联网连接,正如我们即将看到的,它返回 HTML,必须对其进行解析。我们关心的是转换率,而不是解析 HTML,但为了获得我们想要的东西,我们必须经过多个技术层,并且在所有这些之后,我们必须处理 HTML。
文件 I/O
HttpClient 使 InputStream 可用,然后将其读取到完成
StringBuffer result = new StringBuffer(); if (entity != null) { InputStream instream = entity.getContent(); InputStreamReader irs = new InputStreamReader(instream); BufferedReader br = new BufferedReader(irs); String l; while ((l = br.readLine()) != null) { result.append(l); } } String theWholeThing = result.toString();
嵌入
与前面的部分一样,这段代码直接嵌入到方法中,使其更难忽略。
复杂性和重复
这段代码并不特别复杂,但要了解它在做什么,你必须阅读它。改进沟通的一种方法是减少对它的需求,代码也是如此。正如这段代码所写,你必须阅读它才能理解它。如果它在它自己的方法或类中,并有一个好名字,那么它可能更容易忽略。由于我们倾向于阅读代码而不是编写代码,因此我们可以做任何事情来减少阅读代码的需求,这都是对项目生命周期的明智投资。
需要完成方法
这段代码是嵌入的,正如所写,它必须每次都执行才能练习代码。这是时序耦合的另一个例子。正如上一节中所暗示的那样,如果它以一种可以更好地理解它的方式组织起来,那么当我们试图检查的东西与读取流的内容没有直接关系时,它可能更容易摆脱它。
解析
现在流已转换为字符串,是时候解析结果了
int start = theWholeThing.lastIndexOf("<div id=\"converter_results\"><ul><li>"); String substring = result.substring(start); int startOfInterestingStuff = substring.indexOf("<b>") + 3; int endOfIntererestingStuff = substring.indexOf("</b>", startOfInterestingStuff); String interestingStuff = substring.substring( startOfInterestingStuff, endOfIntererestingStuff); String[] parts = interestingStuff.split("="); String value = parts[1].trim().split(" ")[0];
第三节与第一节相同...
此时你可能会注意到我像一张跳针唱片一样重复。这段代码包含了上面提到的所有问题:它必须按原样执行,它违反了依赖倒置原则,它必须被阅读才能理解。
SRP 违反
这个方法是违反单一职责原则的典型例子。它做了很多不同的事情,每件事都有不同的改变原因和时间。事实上,在这个方法的原始形式中,我访问了一个网站,该网站变得不可用,所以我不得不把它改成另一个网站来获取我想要的信息。这在几个地方造成了问题,说明了违反 SRP 和 DIP 的问题。我不仅需要访问不同的位置(HttpStuff),而且我获取了不同的 HTML(解析),并且我不得不为不同的 URL(再次是 HttpStuff)进行操作。
深入
现代的模拟工具使我上面提到的很多事情变得无关紧要;至少表面上是这样。与其先尝试在生产代码中修复这些问题,不如让我们通过尝试通过自动单元测试来执行这个方法来深入研究。
设置 - 练习代码
一个起点是简单地尝试使用空参数或“合理”的值来执行有问题的代码。由于该领域是货币转换,并且该方法接受两种货币,这似乎是一个合理的起点。
这是一个简单地执行代码的自动单元测试的开始。我的目标是完成这个方法。需要注意的是,虽然这段代码可以在有网络连接的情况下运行,但我写这个测试的时候已经关闭了我的连接,以确保我的测试不需要网络连接才能运行。
@Test public void returnExpectedConversion_v1() { CurrencyConversion.convertFromTo("USD", "EUR"); }
如果网络已启用,这将起作用,但如果没有,代码将生成一个java.net.UnknownHostException。然而,它实际发生的地方是在CurrencyConversion.currencySymbols方法中,而不是在我们关心的方法中。使用旧的工具,这需要一些工作,但对于我们这篇文章中选择的工具来说,情况并非如此:JMockIt
通过验证
这是测试的第二个版本,它通过了第一个异常。
@Test public void returnExpectedConversion_v2() { new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } }; CurrencyConversion.convertFromTo("USD", "EUR"); } private Map<String, String> mapFrom(String... keyValuePairs) { Map<String, String> result = new ConcurrentHashMap<String, String>(); for (int i = 0; i < keyValuePairs.length; ++i) result.put(keyValuePairs[i], keyValuePairs[i]); return result; }
运行这个,抛出了相同的异常,UnknownHostException,但异常现在是在测试中的方法中,而不是在被调用的方法中。这是一个改进。这允许代码通过验证,但如何实现呢?
new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } };
注意使用NonStrictExpectations创建的匿名内部类?这种形式告诉 JMockIt 对 CurrencyConversion类做一些事情(在这种情况下替换一个静态方法)。内部 {} 中的代码是标准的 Java 实例初始化器。在该实例初始化器中执行的代码告诉 JMockIt 替换执行的方法,currencySymbols。这是一个对类进行部分模拟的例子;我们替换了类中的一个方法,这样每次调用它时,它都会返回分配给继承字段“result”的值。
这涉及一些黑魔法。JMockIt 做了一些 Java 字节码魔法,这段代码使用 JMockIt DSL 来实现这一点。要使它工作,需要将 JMockIt jar 文件添加到类路径中。如果你确保它在 JUnit 的 jar 文件之前列出,就足以使它发生。JMockIt 使用在其 MANIFEST.MF 文件中注册的 JavaAgent 来使这一切自动化。
处理客户端
现在代码在尝试读取网页的部分代码上失败了。
HttpClient httpclient = new DefaultHttpClient(); HttpGet httpget = new HttpGet(url); HttpResponse response = httpclient.execute(httpget); HttpEntity entity = response.getEntity();
这段代码稍微复杂一些,因为它嵌入在方法中,但我们可以解决这个问题。然而,这样做会需要更多工作。
- 前两行调用new。我们需要使这些对 new 运算符的使用返回一个我们控制的类。
- 下一行在HttpClient上调用execute方法,所以我们需要控制它。
- 最后一行在HttpResponse上调用getEntity方法,它是前一行的返回值,所以这总体上更加复杂。
以下是一种一举解决这三个问题的方法。
new NonStrictExpectations() { DefaultHttpClient httpclient; HttpResponse response; HttpEntity entity; { httpclient.execute((HttpUriRequest) any); result = response; response.getEntity(); result = entity; entity.getContent(); result = bais; } };
在这个NonStrictExpectations的使用中,第一行没有传入参数,这意味着我们正在以某种方式处理实例,而不是静态内容。这个匿名内部类有三个字段:DefaultHttpClient、HttpResponse、HttpEntity。这些类在 Java 类加载器中完全被替换,只针对这个测试。这意味着,例如,调用 new DefaultHttpClient 将返回 JMockIt 创建的类的实例,而不是在 HttpClient jar 中找到的版本。
这是一个我称之为动态链接缝合线的例子。它是一个像 Michael Feathers 在他的书中讨论的链接缝合线,通常是通过构建脚本/文件魔法来实现的。然而,与使用构建脚本不同,这是使用一个库,这使得它可以在 Java 代码“内部”语言中使用,而不是在语言之外。
这段代码替换了这三个类,但用什么替换呢?
- HttpClient.execute方法将始终返回 response,它是 JMockIt 创建的 HttpResponse 子类的实例。
- HttpResponse.getEntity方法将始终返回 entity,它是 JMockIt 创建的 HttpEntity 子类的实例。
- HttpEntity.getContent方法将始终返回“bais”,我们将在下一节中看到。
不要误解,这是强大的东西。事实上,我有一个个人经验法则:如果我认为某件事很酷,那么它可能不适合实际开发。JMockIt 使我的“酷蜘蛛感应”过度兴奋。
处理文件 I/O
底层代码需要流的内容。为了创建它,测试使用一些标准的 Java 魔法来创建一个内存流。
final ByteArrayInputStream bais = new ByteArrayInputStream( "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>" .getBytes());
这从一个字符串创建一个内存中的ByteArrayInputStream。我怎么知道要放什么字符串?我不得不对底层代码进行逆向工程。即便如此,这也使代码能够执行。
整合
以下是一个测试作为单个方法,而不是像迄今为止那样被分解。
@Test public void returnExpectedConversion_v3() throws Exception { final ByteArrayInputStream bais = new ByteArrayInputStream( "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>" .getBytes()); new NonStrictExpectations() { DefaultHttpClient httpclient; HttpResponse response; HttpEntity entity; { httpclient.execute((HttpUriRequest) any); result = response; response.getEntity(); result = entity; entity.getContent(); result = bais; } }; new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } }; BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); }
一方面,这相当令人印象深刻。我们设法在不接触代码的情况下端到端地执行了代码。如果我们想编写一些使用这个类的代码,我们现在有了一种方法。通常,在尝试接触一些代码之前,我们需要先对其进行测试,这样我们就知道在进行更改后是否破坏了任何东西。这被称为特征测试。像这样的写得不好的代码通常会使这样做变得非常困难。虽然它仍然很困难,但至少我们能够在不接触生产代码的情况下做到这一点。在代码周围进行特征测试可以使重构更安全。
所以我们完成了,对吧?
错了。
回归传统
我们解决了症状,而不是原因
请注意,JMockIt 使得实现近乎黑魔法成为可能,但我们可以做得更好吗?如果我们尝试,投入的时间是否值得?在本节中,我们从同一个方法开始,对代码进行一些所谓的遗留重构,使其可以使用更传统的工具进行测试,在本例中是手工编写的测试替身。然后我们将进行比较,做出观察,然后看看什么将成为可能。
引入实例委托
静态方法的一个典型问题是它们不能被覆盖。为了解决这个问题,我们可能只需让类使用所有实例方法。但是,让我们假设在这个例子中,我们需要维护向后兼容性,所以我们需要保留静态方法(根据我的经验,这并非牵强附会)。
虽然我可能会尝试先进行测试,但事实上,我有一个现有的 JMockIt 测试,所以我会简单地进行必要的更改。我会通过将 CurrencyConversion 复制到一个新的包中来做到这一点,该包的名称添加了 v2(这个博客的源代码是从源代码生成的,所以我需要保留原始版本)。
要引入一个实例委托器。
- 引入类的静态实例。
- 将静态方法复制到实例方法中(你需要创建新的方法名称,因为静态方法和实例方法不能具有相同的名称和签名)。
- 将静态方法更改为在内部静态实例上调用实例方法。
以下是一种方法(内部实例的延迟初始化是故意的,如果你担心线程问题,我们可以使用双重检查锁定,或者只是使方法同步)。
private static CurrencyConversion instance; private static CurrencyConversion getInstance() { if (instance == null) { instance = new CurrencyConversion(); } return instance; } public static BigDecimal convertFromTo(String fromCurrency, String toCurrency) { return getInstance().convert(fromCurrency, toCurrency); } public static Map<String, String> currencySymbols() { return getInstance().getAllCurrencySymbols(); }
提取一些方法
有很多机会提取一些方法,所以这里是在进行了一些方法提取后 v2/CurrencyConversion 的一个版本。
public BigDecimal convert(String fromCurrency, String toCurrency) { validateCurrencies(fromCurrency, toCurrency); try { String result = getPage(fromCurrency, toCurrency); String value = extractToValue(result); return new BigDecimal(value); } catch (Exception e) { throw new RuntimeException(e); } } protected void validateCurrencies(String fromCurrency, String toCurrency) { Map<String, String> symbolToName = currencySymbols(); if (!symbolToName.containsKey(fromCurrency)) throw new IllegalArgumentException(String.format( "Invalid from currency: %s", fromCurrency)); if (!symbolToName.containsKey(toCurrency)) throw new IllegalArgumentException(String.format( "Invalid to currency: %s", toCurrency)); } protected String extractToValue(String result) { String theWholeThing = result; int start = theWholeThing.lastIndexOf("<div id=\"converter_results\"><ul><li>"); String substring = result.substring(start); int startOfInterestingStuff = substring.indexOf("<b>") + 3; int endOfIntererestingStuff = substring.indexOf("</b>", startOfInterestingStuff); String interestingStuff = substring.substring( startOfInterestingStuff, endOfIntererestingStuff); String[] parts = interestingStuff.split("="); return parts[1].trim().split(" ")[0]; } protected String getPage(String fromCurrency, String toCurrency) throws URISyntaxException, IOException, HttpException { String url = String.format("http://www.gocurrency.com/v2/dorate.php?inV=1&from=%s&to=%s&Calculate=Convert", toCurrency, fromCurrency); HttpClient httpclient = new DefaultHttpClient(); HttpGet httpget = new HttpGet(url); HttpResponse response = httpclient.execute(httpget); HttpEntity entity = response.getEntity(); StringBuffer result = new StringBuffer(); if (entity != null) { InputStream instream = entity.getContent(); InputStreamReader irs = new InputStreamReader(instream); BufferedReader br = new BufferedReader(irs); String l; while ((l = br.readLine()) != null) { result.append(l); } } return result.toString(); }
这是一个针对这个类的最后一个测试的版本(位于 v2 包中)。这个测试通过了。
@Test public void returnExpectedConversion_v4() throws Exception { final ByteArrayInputStream bais = new ByteArrayInputStream( "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>" .getBytes()); new NonStrictExpectations() { DefaultHttpClient httpclient; HttpResponse response; HttpEntity entity; { httpclient.execute((HttpUriRequest) any); result = response; response.getEntity(); result = entity; entity.getContent(); result = bais; } }; new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } }; BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); }
测试子类
现在我将使用手工编写的测试替身而不是 JMockIt 来重写这个测试。我不会展示所有中间步骤,我只展示最终结果。
class CurrencyConversion2_testingSubclass extends CurrencyConversion { @Override public void validateCurrencies(String fromCurrency, String toCurrency) { } @Override public Map<String, String> getAllCurrencySymbols() { return mapFrom("USD", "EUR"); } @Override public String getPage(String fromCurrency, String toCurrency) { return "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>"; } } @Test public void returnExpectedConversion_v5() throws Exception { CurrencyConversion original = CurrencyConversion.reset(new CurrencyConversion2_testingSubclass()); BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); CurrencyConversion.reset(original); }
这个测试产生了与 JMockIt 测试相同的结果,它手工完成了大部分工作。
请注意,为了使这成为可能,我们需要允许设置和重置新创建的 Singleton 类。
public static CurrencyConversion reset(CurrencyConversion other) { CurrencyConversion original = instance; instance = other; return original; }
观察
实际花费时间
这种引入实例委托器、可覆盖的静态单例和几个提取方法的技术可能看起来相当多。实际上,这种类型的更改很快。有多快?对于这个例子来说,我使用 IntelliJ 花了 3 分钟。Eclipse 也需要相同的时间。在 vi 中,可能需要 1 分钟(好吧,可能不是,但我确实在 Eclipse、IntelliJ 甚至 Visual Studio 中使用 vi 插件)。无论如何,一旦你练习了,它就很快。如果类一开始没有使用所有静态方法,我们本可以避免很多这种情况,但这是一个常见的问题,所以知道如何处理它是一个很好的通用技术。
是的,但是...
一个常见的担忧是,将所有这些提取方法设为 protected 会怎么样?这并不困扰我,因为我认为可测试性比设计更重要。这实际上是一个错误的二分法,但它听起来很有争议,所以我喜欢这么说。事实上,许多这些“受保护的方法”足够复杂,需要单独的类。接下来,引入依赖倒置,突然我连接了受我控制的依赖对象,它们具有可覆盖的方法,检查这个整体流程的各个部分变得轻而易举。分而治之。
第一次测试与第二次测试
以下是两个测试,用于比较。
使用 JMockIt
@Test public void returnExpectedConversion_v4() throws Exception { final ByteArrayInputStream bais = new ByteArrayInputStream( "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>" .getBytes()); new NonStrictExpectations() { DefaultHttpClient httpclient; HttpResponse response; HttpEntity entity; { httpclient.execute((HttpUriRequest) any); result = response; response.getEntity(); result = entity; entity.getContent(); result = bais; } }; new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } }; BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); }
使用手工编写的模拟和重构
class CurrencyConversion2_testingSubclass extends CurrencyConversion { @Override public void validateCurrencies(String fromCurrency, String toCurrency) { } @Override public Map<String, String> getAllCurrencySymbols() { return mapFrom("USD", "EUR"); } @Override public String getPage(String fromCurrency, String toCurrency) { return "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>"; } } @Test public void returnExpectedConversion_v5() throws Exception { CurrencyConversion original = CurrencyConversion.reset(new CurrencyConversion2_testingSubclass()); BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); CurrencyConversion.reset(original); }
如果你必须支持测试代码,你更喜欢哪个版本?等等,先别回答。
第一次测试仍然通过
我想重申,即使我对生产代码进行了一些更改,JMockIt 测试仍然通过。这对我很感兴趣。事实上,如果你从 JMockIt 如何操作类加载器的角度考虑,这是有道理的。即便如此,它仍然很酷。
如果我们再次尝试使用 JMockIt 会怎样
为什么停在这里?如果我们花时间重写 JMockIt 测试,同时考虑对生产代码所做的更改,会发生什么?
@Test public void returnExpectedConversion_final() throws Exception { new NonStrictExpectations(CurrencyConversion.class) { CurrencyConversion c; { c.validateCurrencies(anyString, anyString); c.getPage(anyString, anyString); result = "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>"; } }; BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); }
现在你想要维护哪个?
请注意,一些重构使最终的 JMockIt 测试变得更好。事实上,编写这个版本的测试所需的重构量小于我所做的重构量。仅仅提取方法就足以极大地改善 JMockIt 测试。但是请注意,JMockIt 没有强迫这样做。不使用模拟工具或使用像 Mockito 这样的工具会强迫我进行一些重构才能使这个类能够进行测试。
闭环反馈
当人们以开环方式工作时会发生什么?也就是说,你编写了一些代码,然后像 JMockIt 这样的工具使你能够在事后编写测试,而无需为糟糕的决定而苦恼?我之所以问,是因为这个例子展示了这种情况。原始代码是一团糟,JMockIt 允许我编写一个特征测试来执行代码。这很好。但是,它并没有强迫我解决原始问题。从某种意义上说,它也让我避免了不得不修复一团糟的痛苦。如果我们拿走了强大的工具,那么我们必须清理混乱才能编写测试。请注意,当我们清理代码时,它仍然是一团糟,但它比以前好,重构后的代码也暗示了其他改进。此外,也许下次有人编写代码时,他们可能会学到一些东西,也许,也许他们不会再犯同样的错误,或者至少不会那么频繁。
虽然没有直接关系,但这里有一篇很棒的文章可以阅读,它与反馈的想法有关:Joshua Foer 的“心灵游戏大师的秘密”。这篇文章讨论的一个要点是提高技能需要什么:失败。也就是说,我们需要犯错误,然后从错误中学习。当工具让我们无法从错误中学习时会发生什么?我担心强大的工具可能会减轻或消除与失败相关的痛苦,从而导致停滞。如果我们不总是努力犯新的愚蠢错误,那么我们实际上并没有在学习,对吧?
结论
我是否认为我们不应该使用像 JMockIt 这样的工具?不。我认为 JMockIt 是一个很棒的工具,我曾经在需要它强大的功能的情况下使用过它。但是,在新的开发过程中使用它怎么样?我对此有两种想法。一方面,我希望在工作时使用最强大的工具。尽管我最喜欢的模拟库是 Mockito,但它不如 JMockIt 强大。另一方面,JMockIt 实际上需要更多的纪律才能有效地使用,因为它允许我留下更多混乱,仍然可以完成我想要完成的事情。
也许这是一个无关紧要的问题。我通常练习测试驱动开发。虽然我没有使用 JMockIt 在真实项目中练习 TDD 的经验,但我使用 Mockito 进行了练习,我认为我并没有过度使用它。我仍然存在有时过于聪明或狡猾的问题,但我不能把责任归咎于工具。我可以说我看到其他团队使用 Mockito 和 Moq(Mockito 在 C# 中的等效工具)过度使用它们。但是,当我看到这种情况时,这些团队是在大量开发之后才编写测试的——这不是工具的错。似乎如果有人有纪律以小步前进,在开发过程中编写测试(希望是首先),那么 JMockIt 的强大功能就不会造成任何问题。另一方面,如果 JMockIt 的强大功能将成为问题,那么可能存在更深层次的问题需要担心。
无论如何,我鼓励你尝试使用这些工具,并亲身体验拥有如此强大的功能是否有助于或阻碍你的学习。
重大修订
2012 年 9 月 10 日:首次发布
2012 年 5 月 25 日:初稿