创建私有构造函数进行测试是不好的实践吗?

8

我遇到了一些Java代码,其中公共构造函数调用了一个包私有构造函数,并使用一堆new运算符来创建新对象。

public class Thing {

    //public
    public Thing(String param1, int paramm2) {
        this(param1, param2, new Dependency1(), new Dependency2());
    }

    //package-private for the sake of testing
    Thing(String param1, int param2, Dependency1 param3, Dependency2 param4) {
        this.memberVar1 = param1;
        this.memberVar2 = param2;
        this.memberVar3 = param3;
        this.memberVar4 = param4;
    }


    //...rest of class...
}

在我看来,这种做法是错误的,因为你编写代码是为了测试它,而不是编写正确的代码。我认为另外两个选项(我能想到的)要么是创建一个工厂,要么是使用PowerMockito在适当的位置注入一个新对象。就我个人而言,我会按照下面的方式编写。

public class Thing {

    //public
    public Thing(String param1, int paramm2) {
        this.memberVar1 = param1;
        this.memberVar2 = param2;
        this.memberVar3 = new Dependency1();
        this.memberVar4 = new Dependency2();
    }

    //...rest of class...
}

什么是最佳实践/正确的方法来实现这个?

7
最好的方法是使用依赖注入。第一个解决方案可以视为穷人版的依赖注入,但至少具有使代码可测试的优点。我更喜欢正确且经过测试的代码,而不是看起来更漂亮但未经过测试、难以测试且可能不正确且难以安全维护的代码。 - JB Nizet
1
严格来说,出于测试目的向类添加私有或包私有方法或构造函数从未“错误”。Joshua Bloch在《Effective Java, 2nd Ed》中也这样说过。私有成员不是您的类导出的API的一部分,对您的类的客户端没有任何伤害。可测试性很重要。 - scottb
2个回答

7
通常情况下,在任何发布的内容中包含特定于测试的代码都不是一个好习惯(但也有例外,所以不要过于认真地阅读)。以下是一些原因:
1. 外部人员可能会以你从未预料到的方式使用测试构造函数,破坏一切,因为他们可能没有阅读文档表明它是用于测试的,或者开发人员忘记了对其进行记录。
假设你想编写一些有用的扩展功能来扩展Thing,但是在Thing的公共/受保护API中找不到符合你要求的内容。然后,你找到了这个似乎允许你所期望的内容的包私有构造函数,只是后来发现它破坏了你的代码。你仍然不能做你想做的事情,并且你浪费了时间探索API的一部分,而这部分并没有得到回报。任何这样做的人都会对API留下负面印象,并且不太可能向其他人推荐它。
2. 重构包名称将会破坏一些东西。
由于Java的默认可见性方式,这个测试代码对生产代码中发生的重构不是很具有弹性。测试代码只能在同一包中调用该构造函数。如果包被重命名,调用它的测试代码将无法访问它,导致编译错误。当然,对于开发代码和测试代码的人来说,这是一个容易解决的问题,但即使没有添加这个小烦恼,重构已经不是一件有趣的事情了。如果一堆人以前能够成功地使用包私有内容来满足他们的需求,那么现在所有他们的代码也都被破坏了。
当然,有些情况下很难编写既可以在测试环境又可以在生产环境中运行的代码(例如只有在应用程序联网时才运行的函数)。在这种情况下,依赖注入可能会帮助你,但是如果可以避免牺牲功能覆盖范围或添加你从未打算让其他开发人员看到的API钩子而避免更复杂的测试方案,那么简单的测试总是最好的。

1
忘记记录文档 - Ben
我试图对那些认为“代码就是文档”的可怕人们友好一些 ;) - CodeBlind

2
我知道StackExchange上已有关于这个主题的好讨论,但是我的谷歌运气不好。我发现的重复内容并没有什么启示性。
- 仅在测试期间使用特殊构造函数是否有代码异味? - https://softwareengineering.stackexchange.com/questions/239087/using-2-constructors-one-that-injects-dependencies-and-one-that-instantiates-th 个人来说,我在生产环境中看到了三种类型的测试代码。
- 分支逻辑 - if (isTesting) foo() else bar(); - 测试方法 - Object foo(); 由测试代码调用,但从未在生产环境中调用。 - 测试构造函数 - Foo(Object dependency1, Object dependency2) 由测试代码调用,但从未在生产环境中调用。
前两种类型在多个方面都是有害的。
- 可读性降低了,因为代码更长、更复杂。业务逻辑可以被测试逻辑掩盖。 - 可维护性降低了,因为代码有更多的职责。更新测试可能需要对生产代码进行更改。 - 在最好的情况下,这个测试代码是生产中的死代码。在最坏的情况下,客户端意外地在生产中执行此测试代码,导致不可预测的结果。
当然,第三种类型也可能会存在上述问题;但我相信所谓的测试构造函数可以减轻或消除其他类型的生产测试代码固有的问题。
- 添加构造函数显然会使代码变长(至少稍微变长),但它不必增加复杂性:如果我们构造函数链接,如此处示例中,我们有一个构造默认值和另一个仅分配字段的构造函数。这种分离可以比单个构造函数更不复杂。如果我们遵循通常接受的做法,在构造函数中不实现业务逻辑,那么我们就应该没有掩盖业务逻辑的风险。 - 再次假设我们的“测试”构造函数被链接到生产构造函数,并且除了分配之外不实现任何逻辑,则代码实际上并没有为类添加职责:它仍然实例化和分配依赖项,但作为单独的方法而不是单个方法。没有测试更改会破坏我们的生产代码的机会。 - 在上述假设下,我们的测试构造函数不是死代码:它实际上在生产中被调用,从客户端调用的任何链接构造函数。因此,执行此第二个构造函数(使用适当的参数)的客户端可以期望与链接构造函数相同的契约。
在构造函数内部调用new通常是不好的,原因与依赖注入通常是好的相同。然而,在某些情况下,类的依赖项有明显的合理默认实现,并且在构造函数中实例化这些依赖项为客户端提供了最简单的可能接口。在这些情况下,我认为链式一个第二个构造函数是适当的,该构造函数不强制使用任何默认值,无论第二个构造函数是否促进测试。

作为奖励,链式构造函数的做法鼓励面向接口编程,因为第二个构造函数让你考虑除了默认值之外的其他依赖项实现。

我的谷歌运气也不太好,所以我才发帖。我也不喜欢重复。 - Busch

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接