现代模拟工具与黑魔法

权力腐蚀的例子

现代模拟工具对我们处理遗留代码的能力产生的积极影响,以及使用这些工具可能带来的负面影响。

2012 年 9 月 10 日


Photo of Brett L. Schuchert

Brett 目前在 Thoughtworks 工作,他在多个领域和学科拥有多年经验,包括培训(从 1985 年开始)、基于解决方案的咨询(从 1992 年开始)和教练(从 2000 年开始)。他喜欢开发和培训/教练,每 4-6 年在两者之间切换重点。


有效地处理遗留代码 中,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 ClassExtract 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 日:初稿