将私有方法公开以对其进行单元测试...好主意吗?

Posted

技术标签:

【中文标题】将私有方法公开以对其进行单元测试...好主意吗?【英文标题】:Making a private method public to unit test it...good idea? 【发布时间】:2011-10-27 21:41:32 【问题描述】:

版主注意: 这里已经发布了 39 个答案(有些已被删除)。在您发布您的答案之前,考虑是否可以在讨论中添加一些有意义的东西。你很可能只是在重复别人已经说过的话。


我偶尔会发现自己需要在一个类中公开一个私有方法,只是为了为其编写一些单元测试。

通常这是因为该方法包含在类中的其他方法之间共享的逻辑,并且自己测试逻辑更整洁,或者另一个可能的原因是我想测试同步线程中使用的逻辑而不必担心关于线程问题。

其他人发现自己这样做是因为我真的不喜欢这样做吗?我个人认为奖金超过了将方法公开的问题,该方法并没有真正提供课堂之外的任何服务......

更新

感谢大家的回答,似乎引起了人们的兴趣。我认为普遍的共识是测试应该通过公共 API 进行,因为这是使用类的唯一方式,我同意这一点。我上面提到的几个我会这样做的案例是不常见的情况,我认为这样做的好处是值得的。

但是,我可以看到每个人的观点,它不应该真的发生。再考虑一下,我认为更改代码以适应测试是一个坏主意-毕竟我认为测试在某种程度上是一种支持工具,如果愿意的话,将系统更改为“支持支持工具”是公然的不好的做法。

【问题讨论】:

“如果你愿意,将系统更改为‘支持支持工具’,这是明显的坏习惯”。嗯,是的,有点。但是 OTOH 如果您的系统不能很好地使用已建立的工具,那么可能是系统出了问题。 这不是***.com/questions/105007/do-you-test-private-method的副本吗? 不是答案,但您始终可以通过反射访问它们。 不,你不应该暴露它们。相反,您应该通过其公共 API 测试您的类 行为 是否符合其应有的行为。如果暴露内部真的是唯一的选择(我怀疑),那么你至少应该让访问器包受到保护,这样只有同一个包中的类(你的测试应该是)才能访问它们。 是的。为方法提供包私有(默认)可见性以使其可从单元测试中访问是完全可以接受的。像 Guava 这样的库甚至提供了一个 @VisibileForTesting 注释来创建这样的方法 - 我建议你这样做,以便正确记录该方法不是 private 的原因。 【参考方案1】:

很好回答的问题。 IHMO,来自@BlueRaja 的优秀answer - Danny Pflughoeft 是最好的之一。

很多答案建议只测试公共接口,但恕我直言 这是不现实的——如果一个方法做了需要 5 个步骤的事情, 您需要分别测试这五个步骤,而不是一起测试。 这需要测试所有五种方法,其中(测试除外) 否则可能是私有的。


首先,我想强调“我们是否应该公开私有方法以对其进行单元测试”这个问题是一个客观正确答案取决于多个参数的问题。 所以我认为在某些情况下我们不需要,而在其他情况下我们应该


将私有方法设为公共方法或将私有方法提取为另一个类(新的或现有的)中的公共方法?

这很少是最好的方法。 单元测试必须测试一个 API 方法/函数的行为。 如果您测试调用属于同一组件的另一个 public 方法的 public 方法,则不要单元测试该方法。您同时测试多个 public 方法。 因此,您可能会重复测试、测试夹具、测试断言、测试维护以及更普遍的应用程序设计。 随着测试价值的降低,编写或维护它们的开发人员通常会失去兴趣。

为了避免所有这些重复,在许多情况下,更好的解决方案是private 方法作为public 方法提取到新的或现有的方法中,而不是创建private 方法public 方法类。 它不会造成设计缺陷。 它将使代码更有意义,并且类不那么臃肿。 此外,有时private 方法是类的例程/子集,而行为更适合特定结构。 最后,它还使代码更具可测试性,避免重复测试。 我们确实可以通过在自己的测试类和客户端类的测试类中对 public 方法进行单元测试来防止测试重复,我们只需模拟依赖关系。

模拟私有方法?

虽然可以使用反射或 PowerMock 等工具,但我认为这通常是绕过设计问题的一种方法。private 成员未设计为向其他类公开。 测试类是另一个类。所以我们应该对它应用相同的规则。

模拟被测对象的公共方法?

您可能需要将修饰符 private 更改为 public 以测试该方法。 然后为了测试使用这个重构的公共方法的方法,你可能想通过使用工具作为 Mockito (spy concept) 来模拟重构的 public 方法,但类似于模拟 private 方法,我们应该避免模拟对象正在测试中。

Mockito.spy() 文档说明了这一点:

创建一个真实对象的间谍。间谍调用真正的方法,除非它们被 > > 存根。

应谨慎使用真正的间谍,偶尔使用,例如,当 处理遗留代码。

根据经验,使用spy() 通常会降低测试质量及其可读性。 此外,由于被测对象既是模拟对象又是真实对象,因此更容易出错。 这通常是编写无效验收测试的最佳方式。


这是我用来决定 private 方法应该保留 private 还是重构的准则。

案例 1) 如果此方法被调用一次,则永远不要创建 private 方法 public。 这是单个方法的private 方法。因此,您永远不能重复调用一次的测试逻辑。

案例 2) 如果private 方法被多次调用,您应该想知道是否应该将private 方法重构为public 方法。

如何决定?

private 方法不会在测试中产生重复。 -> 保持方法 private 不变。

private 方法会在测试中产生重复。也就是说,您需要重复一些测试,为每个使用private 方法对public 方法进行单元测试的测试断言相同的逻辑。 -> 如果重复处理可能会成为提供给客户端的API的一部分(没有安全问题,没有内部处理等...),private方法提取为@987654352 @ 新类中的方法。 -> 否则,如果重复处理还没有成为提供给客户端的 API 的一部分(安全问题、内部处理等...),不要扩大可见性private 方法到public。 您可以保持不变或将方法移到 private 包类中,该类永远不会成为 API 的一部分,也永远不会被客户端访问。


代码示例

这些示例依赖于 Java 和以下库:JUnit、AssertJ(断言匹配器)和 Mockito。 但我认为整体方法也适用于 C#。

1) private 方法不会在测试代码中创建重复的示例

这是一个Computation 类,它提供了执行某些计算的方法。 所有公共方法都使用mapToInts() 方法。

public class Computation 

    public int add(String a, String b) 
        int[] ints = mapToInts(a, b);
        return ints[0] + ints[1];
    

    public int minus(String a, String b) 
        int[] ints = mapToInts(a, b);
        return ints[0] - ints[1];
    

    public int multiply(String a, String b) 
        int[] ints = mapToInts(a, b);
        return ints[0] * ints[1];
    

    private int[] mapToInts(String a, String b) 
        return new int[]  Integer.parseInt(a), Integer.parseInt(b) ;
    


这里是测试代码:

public class ComputationTest 

    private Computation computation = new Computation();

    @Test
    public void add() throws Exception 
        Assert.assertEquals(7, computation.add("3", "4"));
    

    @Test
    public void minus() throws Exception 
        Assert.assertEquals(2, computation.minus("5", "3"));
    

    @Test
    public void multiply() throws Exception 
        Assert.assertEquals(100, computation.multiply("20", "5"));
    


我们可以看到private 方法mapToInts() 的调用不会复制测试逻辑。 这是一个中间操作,它不会产生我们需要在测试中断言的特定结果。

2) private 方法在测试代码中创建不需要的重复的示例

这是一个MessageService 类,它提供了创建消息的方法。 所有public 方法都使用createHeader() 方法:

public class MessageService 

    public Message createMessage(String message, Credentials credentials) 
        Header header = createHeader(credentials, message, false);
        return new Message(header, message);
    

    public Message createEncryptedMessage(String message, Credentials credentials) 
        Header header = createHeader(credentials, message, true);
        // specific processing to encrypt
        // ......
        return new Message(header, message);
    

    public Message createAnonymousMessage(String message) 
        Header header = createHeader(Credentials.anonymous(), message, false);
        return new Message(header, message);
    

    private Header createHeader(Credentials credentials, String message, boolean isEncrypted) 
        return new Header(credentials, message.length(), LocalDate.now(), isEncrypted);
    


这里是测试代码:

import java.time.LocalDate;

import org.assertj.core.api.Assertions;
import org.junit.Test;

import junit.framework.Assert;

public class MessageServiceTest 

    private MessageService messageService = new MessageService();

    @Test
    public void createMessage() throws Exception 
        final String inputMessage = "simple message";
        final Credentials inputCredentials = new Credentials("user", "pass");
        Message actualMessage = messageService.createMessage(inputMessage, inputCredentials);
        // assertion
        Assert.assertEquals(inputMessage, actualMessage.getMessage());
        Assertions.assertThat(actualMessage.getHeader())
                  .extracting(Header::getCredentials, Header::getLength, Header::getDate, Header::isEncryptedMessage)
                  .containsExactly(inputCredentials, 9, LocalDate.now(), false);
    

    @Test
    public void createEncryptedMessage() throws Exception 
        final String inputMessage = "encryted message";
        final Credentials inputCredentials = new Credentials("user", "pass");
        Message actualMessage = messageService.createEncryptedMessage(inputMessage, inputCredentials);
        // assertion
        Assert.assertEquals("Aç4B36ddflm1Dkok49d1d9gaz", actualMessage.getMessage());
        Assertions.assertThat(actualMessage.getHeader())
                  .extracting(Header::getCredentials, Header::getLength, Header::getDate, Header::isEncryptedMessage)
                  .containsExactly(inputCredentials, 9, LocalDate.now(), true);
    

    @Test
    public void createAnonymousMessage() throws Exception 
        final String inputMessage = "anonymous message";
        Message actualMessage = messageService.createAnonymousMessage(inputMessage);
        // assertion
        Assert.assertEquals(inputMessage, actualMessage.getMessage());
        Assertions.assertThat(actualMessage.getHeader())
                  .extracting(Header::getCredentials, Header::getLength, Header::getDate, Header::isEncryptedMessage)
                  .containsExactly(Credentials.anonymous(), 9, LocalDate.now(), false);
    


我们可以看到private 方法createHeader() 的调用在测试逻辑中创建了一些重复。createHeader() 确实创建了我们需要在测试中断言的特定结果。 我们断言 3 次标头内容,而应该需要一个断言。

我们还可以注意到,方法之间的断言重复很接近,但没有必要与 private 方法具有特定逻辑相同: 当然,根据private方法的逻辑复杂度,我们可以有更多的不同。 此外,每次我们在MessageService 中添加一个新的public 方法调用createHeader(),我们都必须添加这个断言。 另请注意,如果createHeader() 修改了其行为,所有这些测试也可能需要修改。 毫无疑问,这不是一个很好的设计。

重构步骤

假设我们处于可以接受 createHeader() 作为 API 一部分的情况。 我们将从重构MessageService 类开始,将createHeader() 的访问修饰符更改为public

public Header createHeader(Credentials credentials, String message, boolean isEncrypted) 
    return new Header(credentials, message.length(), LocalDate.now(), isEncrypted);

我们现在可以测试单一的这个方法:

@Test
public void createHeader_with_encrypted_message() throws Exception 
  ...
  boolean isEncrypted = true;
  // action
  Header actualHeader = messageService.createHeader(credentials, message, isEncrypted);
  // assertion
  Assertions.assertThat(actualHeader)
              .extracting(Header::getCredentials, Header::getLength, Header::getDate, Header::isEncryptedMessage)
              .containsExactly(Credentials.anonymous(), 9, LocalDate.now(), true);


@Test
public void createHeader_with_not_encrypted_message() throws Exception 
  ...
  boolean isEncrypted = false;
  // action
  messageService.createHeader(credentials, message, isEncrypted);
  // assertion
  Assertions.assertThat(actualHeader)
              .extracting(Header::getCredentials, Header::getLength, Header::getDate, Header::isEncryptedMessage)
              .containsExactly(Credentials.anonymous(), 9, LocalDate.now(), false);


但是我们之前为使用createHeader() 的类的public 方法编写的测试呢? 差别不大。 事实上,我们仍然很恼火,因为这些public 方法仍然需要针对返回的标头值进行测试。 如果我们删除这些断言,我们可能不会检测到关于它的回归。 我们应该能够自然地隔离这个处理,但我们不能,因为createHeader() 方法属于被测试的组件。 这就是为什么我在回答开头解释说,在大多数情况下,我们应该赞成在另一个类中提取private 方法,而不是将访问修饰符更改为public

所以我们介绍HeaderService

public class HeaderService 

    public Header createHeader(Credentials credentials, String message, boolean isEncrypted) 
        return new Header(credentials, message.length(), LocalDate.now(), isEncrypted);
    


我们将createHeader() 测试迁移到HeaderServiceTest

现在 MessageService 定义为 HeaderService 依赖:

public class MessageService 

    private HeaderService headerService;

    public MessageService(HeaderService headerService) 
        this.headerService = headerService;
    

    public Message createMessage(String message, Credentials credentials) 
        Header header = headerService.createHeader(credentials, message, false);
        return new Message(header, message);
    

    public Message createEncryptedMessage(String message, Credentials credentials) 
        Header header = headerService.createHeader(credentials, message, true);
        // specific processing to encrypt
        // ......
        return new Message(header, message);
    

    public Message createAnonymousMessage(String message) 
        Header header = headerService.createHeader(Credentials.anonymous(), message, false);
        return new Message(header, message);
    


MessageService 测试中,我们不再需要断言每个标头值,因为这已经过测试。 我们只想确保 Message.getHeader() 返回 HeaderService.createHeader() 返回的内容。

例如,这里是新版本的createMessage() 测试:

@Test
public void createMessage() throws Exception 
    final String inputMessage = "simple message";
    final Credentials inputCredentials = new Credentials("user", "pass");
    final Header fakeHeaderForMock = createFakeHeader();
    Mockito.when(headerService.createHeader(inputCredentials, inputMessage, false))
           .thenReturn(fakeHeaderForMock);
    // action
    Message actualMessage = messageService.createMessage(inputMessage, inputCredentials);
    // assertion
    Assert.assertEquals(inputMessage, actualMessage.getMessage());
    Assert.assertSame(fakeHeaderForMock, actualMessage.getHeader());

注意assertSame() 用于比较标头的对象引用而不是内容。 现在,HeaderService.createHeader() 可能会改变它的行为并返回不同的值,从 MessageService 测试的角度来看这并不重要。

【讨论】:

哇,对于一些简单的测试来说,这是很多开销。那么我(谢天谢地,我的公司也是)所做的是将测试放在与测试代码相同的包中,仅在并行目录结构中,并使要测试的方法包私有。无需更改公共接口,但仍提供完整的可测试性。清洁和简单,当然是 IMO。【参考方案2】:

当您要测试的某个方法中有复杂的逻辑时,这是一个很好的指标,表明该类违反了单一职责原则。

一个好的解决方案是:

    为原始方法的功能创建一个接口。 在一个类中实现并测试该接口。 将接口注入到原始类中。

【讨论】:

【参考方案3】:

就个人而言,我在测试私有方法时也遇到了同样的问题,这是因为一些测试工具是有限的。 如果有限的工具不响应您的需求,则您的设计被有限的工具驱动是不好的,而是更改工具而不是设计。 因为你要C#我不能提出好的测试工具,但是Java有两个强大的工具:TestNG和PowerMock,你可以找到对应的.NET平台的测试工具

【讨论】:

【参考方案4】:

这一切都与实用主义有关。您的单元测试在某种程度上是代码的客户端,为了实现良好的代码覆盖率,您需要使代码非常可测试。如果测试代码非常复杂,您的解决方案将是一个潜在的失败,以便您能够设置必要的极端情况,而无需对您的代码进行有效的公共接缝。使用 IoC 也有助于解决这个问题。

【讨论】:

【参考方案5】:

最近我在重构一个大方法(> 200 行)时也有同样的想法。我成功地将大方法拆分为每个逻辑步骤的小方法,因此很容易推理。

当谈到重构出来的小私有方法时,我想我是否应该单独测试它们,因为如果我只测试公共方法,我还在测试大方法,测试根本没有从重构中受益

经过一番思考后,我有了一些认识:

    如果所有的小方法都是私有的,不能被其他人重用, 也许我做错了什么:我没有向右走 从代码中抽象出来,我只是拆分大方法 将它们视为线条/字符串,而不是像心理障碍 当我找到正确的小方法时(我再次重构, 完全改变小方法),并移动小方法 进入另一个公开为公共方法的类供其他人使用, 现在我可以测试它们(我应该测试它们,它们将被更多地使用 并值得关注)

总结:

我还有很多小的私有方法,但是他们共享了很多公共方法,而且小方法真的很小(3-4行,主要是函数调用),但是我不会测试它们 , 我现在只需要在另一个类中测试共享的公共方法

【讨论】:

以上是关于将私有方法公开以对其进行单元测试...好主意吗?的主要内容,如果未能解决你的问题,请参考以下文章

将验证/测试数据与训练数据混合是个好主意吗?

我们可以为了方法级别的单元测试而将方法的访问说明符从私有更改为默认吗

我应该测试私有方法还是仅测试公共方法? [关闭]

我应该对私有/受保护方法进行单元测试吗

使用私有继承隐藏实现是个好主意吗?

如何对在 Swift 中调用的私有方法进行单元测试