7

我正在从事一个个人项目(意味着干净的源代码,没有遗留依赖项)并尝试遵循有关单元测试、依赖项管理等的最佳实践。

我公司的代码库中到处都是这样的代码:

public Response chargeCard(CreditCard card, Money amount) {
  if(Config.isInUnitTests()) {
      throw new IllegalStateException("Cannot make credit card API calls in unit tests!");
  }
  return CreditCardPOS.connect().charge(card, amount);
}

这里的目标是主动防止在测试期间执行危险代码/具有外部依赖关系的代码。如果单元测试做错事,我喜欢快速失败的概念,但我不喜欢这个实现有几个原因:

  • Config它为散布在我们代码库中的静态类留下了隐藏的依赖关系。
  • 它改变了测试和实时行为之间的控制流,这意味着我们不一定要测试相同的代码。
  • 它将外部依赖项添加到配置文件或其他一些状态保持实用程序。
  • 它看起来很丑:)

可以通过更好的依赖意识来避免我们在公司代码库中使用它的相当多的地方,我正在尝试这样做,但仍有一些地方我仍在努力摆脱实现isInUnitTests()方法。

使用上面的信用卡示例,我可以isInUnitTests()通过将其正确包装在一个可模拟的CardCharger类或类似的东西中来避免对每笔费用的检查,但是虽然更干净,但我觉得我只将问题提升了一个级别 - 怎么做我阻止单元测试构造一个真实的实例CardCharger而不检查创建它的构造函数/工厂方法?

  • isInUnitTests()代码味道吗?
  • 如果是这样,我怎样才能强制单元测试不达到外部依赖项?
  • 如果不是,那么实现这种方法的最佳方法是什么,以及何时使用/避免它的良好做法是什么?

为了澄清,我试图阻止单元测试访问不可接受的资源,如数据库或网络。我完全支持像依赖注入这样的测试友好模式,但是如果一个粗心的开发人员(即我)可能违反它们,那么好的模式将毫无用处 - 在单元测试完成它们所要做的事情的情况下,快速失败对我来说似乎很重要不应该,但我不确定最好的方法。

4

6 回答 6

6

isInUnitTests() 是代码异味吗?

是的,毫无疑问,您有很多方法可以避免将代码耦合到单元测试。没有正当理由拥有这样的东西。

如果是这样,我怎样才能强制单元测试不达到外部依赖项?

您的测试必须只验证一个代码单元并为外部依赖项创建模拟或存根。

您的代码似乎是 Java,其中包含大量成熟的模拟框架。研究一下现有的,然后选择更喜欢你的。

编辑

如何防止单元测试构造 HTTPRequest 的真实实例而不检查创建它的构造函数/工厂方法

您应该使用依赖注入器来解决您的实例依赖关系,因此您将永远不需要使用 aif来测试您是否正在测试,因为在您的“真实”代码中您注入了完整的功能依赖项并且在您的测试中您注入模拟或存根。

举一个更严肃的例子,比如信用卡充电器(一个经典的单元测试示例)——如果测试代码甚至可以访问真实的信用卡充电器而不触发大量异常,这似乎是一个非常糟糕的设计。我认为在这样的情况下,仅仅相信开发人员总是会做正确的事情是不够的

同样,您应该注入外部依赖项作为信用卡充电器,因此在您的测试中您将注入一个假的。如果某些开发人员不知道这一点,我认为贵公司需要的第一件事是为该开发人员提供培训,并进行一些结对编程来指导该过程。

无论如何,我明白你的意思,让我告诉你我经历过的类似情况。

有一个应用程序经过一些处理后发送了一堆电子邮件。这些邮件不能在除了live之外的任何其他环境中发送,否则会是个大问题。

在我开始开发这个应用程序之前,有好几次开发人员“忘记”了这个规则,并且在测试环境中发送了电子邮件,导致了很多问题。依靠人类记忆来避免此类问题并不是一个好的策略。

为了避免这种情况再次发生,我们所做的是添加一个配置设置来指示是否发送真实的电子邮件。如您所见,问题比在单元测试中执行或不执行更广泛。

但是,没有什么可以替代通信,开发人员可能会在他的开发环境中为此设置设置不正确的值。你永远不会 100% 安全。

简而言之:

  • 第一道防线是沟通和培训。
  • 你的第二道防线应该是依赖注入。
  • 如果您觉得这还不够,您可以添加第三道防线:配置设置以避免在测试/开发环境中执行真正的逻辑。没有错。但是请不要命名它IsInUnitTest,因为问题比这更广泛(您还希望避免在开发人员机器上执行此逻辑)
于 2013-07-28T02:01:48.510 回答
0

AngularJS 单元测试文档实际上在描述开发人员处理单元测试的不同方式方面做得非常出色。

在他们概述的四种不同方法中,他们推荐的一种是涉及使用依赖注入的一种,这样您的生产代码流程与您的测试流程相同。唯一的区别是您传递到代码中的对象会有所不同。请注意,Claudio 使用术语“存根”来指代您传递给方法的对象,以充当您在生产中使用的占位符:

public Document getPage(String url, AbstractJsoup jsoup) {
  return jsoup.connect(url).get();
}

我的 Java 有点生疏,所以考虑一下您可能需要实际做一些时髦的事情来完成这项工作,例如检查 jsoup 对象的类型或将其转换为您的测试 Jsoup 对象或您的生产 Jsoup 对象,这可能会失败使用依赖注入的全部目的。因此,如果你在谈论一种动态语言,比如 JavaScript 或其他一些松散类型的函数式编程语言,依赖注入会让事情变得非常干净。

但是在 Java 中,除非您控制依赖关系并且可以使用设计模式(如策略模式)来传递不同的具体实现,否则执行您正在做的事情可能会更容易,特别是因为您不控制 Jsoup 代码。但是,您可能会检查它们是否有可用作占位符的可用存根,因为某些库开发人员确实会编写存根。

如果您不拥有代码,另一种选择可能是使用 Factory 类来获取所需的对象,具体取决于您在第一次实例化它时设置的标志。这似乎是一个不太理想的解决方案,因为您仍然在该 Factory 对象中设置一个全局标志,这可能会对您可能不会尝试测试的事物产生影响。用于单元测试的依赖注入的优点是,您必须在测试时显式传入测试存根,并在您希望方法执行其编写的任务时显式传入生产对象。一旦方法完成执行,测试就结束了,任何调用它的生产过程都会自动在生产模式下运行它,因为它会注入生产对象。

于 2013-07-28T02:10:07.747 回答
0

更新

这是我发布此问题后不久的一个想法,但是我现在确信这不是一个好计划。将它留在这里以供后代使用,但请参阅我的最新答案以了解我最终所做的事情。


我不确定这是正确的做法,但我的一个想法,至少解决了我的第一个和第三个反对意见,来自确定代码是否作为单元测试的一部分运行

您可以通过直接检查执行堆栈来避免存储有关您是否处于单元测试中的外部状态,如下所示:

/**
 * Determines at runtime and caches per-thread if we're in unit tests.
 */
public static class IsInUnitTest extends ThreadLocal<Boolean> {
    private static final ImmutableSet<String> TEST_PACKAGES = 
                                       ImmutableSet.of("org.testng");

    @Override
    protected Boolean initialValue() {
        for(StackTraceElement ste : Thread.currentThread().getStackTrace()) {
            for(String pkg : TEST_PACKAGES) {
                if(ste.getClassName().startsWith(pkg)) {
                    return true;
                }
            }
        }
        return false;
    }
}

这里的主要优点是我们不存储任何状态;我们只需检查堆栈跟踪 - 如果跟踪包含测试框架包,我们就在单元测试中,否则我们不在。它并不完美——特别是如果你使用相同的测试框架进行集成或其他更宽松的测试,它可能会导致误报——但避免外部状态似乎至少是一个小小的胜利。

好奇别人怎么看这个想法。

于 2013-07-28T02:13:08.760 回答
0

我从未见过采取措施主动阻止在单元测试下运行的代码访问外部资源的系统。这个问题从来没有出现过。你对在它有的地方工作表示最深切的同情。

有没有办法控制用于单元测试的类路径,使访问外部资源所需的库不可用?如果类路径上没有 JSoup 和 JDBC 驱动程序,则尝试使用它们的代码测试将失败。您将无法以这种方式排除 JDK 级别的类SocketURLConnection但它可能仍然有用。

如果您使用 Gradle 运行测试,那将相当简单。如果您使用的是 Maven,也许不是。我认为没有任何方法可以在 Eclipse 中使用不同的类路径进行编译和测试。

于 2013-07-28T12:06:24.700 回答
0

好吧,您可以使用Abstract Factory Pattern虽然(可能不适合调用它Abstract Factory Pattern)来实现相同的干净方式。

C# 中的示例:

public class CardChargerWrapper{
    public CardChargerWrapper(
        NullCardCharger nullCharger
        , TestUnitConfig config){
        // assign local value
        this.charger = new CardCharger();
    }
    CardCharger charger;
    NullCardCharger nullCharger;
    TestUnitConfig config;

    public Response Charge(CreditCard card, Money amount){
        if(!config.IsUnitTest()) { return charger.connect().charge(card, amount); }
        else { return NullCardCharger.charge(card, amount); }
    }
}

编辑:更改 CardChargerWrapper 以使用硬编码实例CardCharger而不是注入它。

注意:您可以更改NullCardCharger为类似MockCardChargerOfflineCardCharger用于记录目的的内容。

再次注意:您可以更改CardChargerWrapper' 的构造函数以适应。NullCardCharger例如,您可以将其注入属性,而不是构造函数注入。与 相同TestUnitConfig

编辑:关于是否调用IsUnitTest()一个好主意:

这实际上取决于您的业务视角以及您如何进行测试。正如许多人所说,尚未经过测试的代码的正确性是不可信的。它不可靠。在旁注中,我更喜欢IsChargeRealCard()会比IsUnitTest().

假设我们unit test在我们的上下文中取出,至少您仍然需要在测试环境中进行集成测试。你可能想测试类似的东西:

  1. 我想测试信用卡验证(是否真实等)。
  2. 我想测试一下支付方式,看看卡是否被扣款。作为一个过程,而不是作为一个真正的信用卡支付。

对于第二点,我认为最好的办法是创建一个模拟credit card charger来记录交易。这是为了确保充电正确。它将在测试和开发服务器中进行。

那么,如何CardChargerWrapper帮助这种情况呢?

现在使用 CardChargerWrapper,您可以:

  1. 切换NullCardCharger到任何模拟卡充电器以增强您的单元测试。
  2. CardChargerWrapper可以确保所有使用的班级IsUnitTest在充真卡之前先进行检查。
  3. 您的开发人员需要使用CardChargerWrapper而不是CardCharger,以防止单元测试的开发错误。
  4. 在代码审查期间,您可以发现是否CardCharger在其他类中使用而不是CardChargerWrapper. 这是为了确保不会泄漏代码。
  5. 我不确定,但似乎您可以将主项目的引用隐藏到真实的CardCharger. 这将进一步保护您的代码。
于 2013-07-29T04:50:29.967 回答
0

如果 [isInUnitTest()是反模式],我如何仍然强制单元测试不达到外部依赖项?

我现在有一个我相当满意的解决方案,它可以确保在没有明确启用外部依赖项的情况下不能在测试环境中使用它们。所有依赖于外部资源(HTTP 请求、数据库、信用卡处理器等)的类都将配置类作为其参数之一,该配置类包含初始化这些对象所需的设置。在真实环境中,传入一个真实的 Config 对象,其中包含他们需要的数据。在测试环境中,传入一个mock,如果不显式配置mock,对象将无法构造/连接。

例如,我有一个 Http 连接实用程序:

public class HttpOp {
  public HttpOp(Config conf) {
    if(!conf.isHttpAllowed()) {
      throw new UnsupportedOperationException("Cannot execute HTTP requests in "+
          getClass()+" if HTTP is disabled.  Did you mean to mock this class?");
    }
  }
  ....
}

在单元测试中,如果您尝试运行构造HttpOp对象的代码,则会引发异常,因为Config除非明确设置,否则模拟不会返回 true。在需要这样做的功能测试中,您可以明确地这样做:

@Test
public void connect() {
  State.Http httpState = mock(State.Http.class);
  when(httpState.isEnabled()).thenReturn(true);
  RemoteDataProcessor rdp = new RemoteDataProcessor(new HttpOp(httpState));
  ...

}

当然,这仍然取决于Config在测试环境中被正确地模拟,但现在我们正好有一个危险点要寻找,审阅者可以快速验证Config对象被模拟并相信只有显式启用的实用程序才能访问。同样,现在只需要告诉新团队成员一个问题(“总是Config在测试中模拟”),他们现在可以确信自己不会意外地从信用卡中扣款或向客户发送电子邮件。

于 2013-10-04T04:35:34.273 回答