【问题标题】:Where to test an object returned by a private method?在哪里测试私有方法返回的对象?
【发布时间】:2016-08-20 05:42:26
【问题描述】:

我是 TDD 的新手,在编写看似简单的单元测试时遇到了设计问题。

被测方法做了两件事:

  1. 调用将对象 A 转换为对象 B 的私有方法
  2. 调用另一个将对象 B 作为参数传递的私有方法

类似这样的:

public void doStuff(A objectA) { B objectB = convertToB(objectA); processB(objectB); }

现在,我在哪里测试转换是否正确完成? 流行的观点是使用 TDD 不需要使用 PowerMock 或其他库来测试私有方法。我引用 Practical Unit Testing with JUnit and Mockito 一书:

您可以并且可能应该做的第一件事就是避免这种情况。如何?通过遵循 TDD 方法。想想当您首先编写测试代码时私有方法是如何实现的。答案 也就是说,它们是在重构阶段创建的,这意味着它们的内容完全被测试覆盖 (假设您确实遵循 TDD 规则,并且仅在测试失败时才编写代码)。在这样的 在这种情况下,“应该以某种方式测试的未经测试的私有方法”没有问题,因为 这样的方法根本不存在。

我的下一个想法是使用 ArgumentCaptor 并验证是否使用正确的参数调用了 processB()。但同样,processB() 也是私有的,所以不能这样做。

当然,可以做很多技巧来使我的类可测试——将objectB 保存在类字段中或公开我的私有方法之一。但这会恶化,而不是改进我的设计。

所以,我在这种情况下的问题是:测试转换方法的正确方法是什么?哪些设计改进可以使此代码可测试?

编辑:添加一个真实示例以更好地了解问题:

public class EmailSender {

    public EmailResult send(Email email) {
        MultivaluedMapImpl formData = prepareFormData(email);
        EmailResult emailResult = processEmailRequest(formData);
    }    

    private MultivaluedMapImpl prepareFormData(Email email) {
        MultivaluedMapImpl formData = new MultivaluedMapImpl();
        formData.add(FROM_KEY, email.getSender());
        email.getRecipients().stream().forEach((recipient) -> {
            formData.add(TO_KEY, recipient);
        });
        formData.add(SUBJECT_KEY, email.getSubject());
        formData.add(TEXT_KEY, email.getText());

        return formData;
    }

    private EmailResult processEmailRequest(MultivaluedMapImpl formData ) {
        Client client = Client.create();
        client.addFilter(new HTTPBasicAuthFilter("api", "API_KEY"));
        WebResource webResource = client.resource(API_URL);
        ClientResponse clientResponse = webResource.type(MediaType.APPLICATION_FORM_URLENCODED).
           post(ClientResponse.class, formData);
        String resultString = clientResponse.getStatusInfo().getFamily().toString();
        EmailResult emailResult = resultString.equals("SUCCESSFUL") ? EmailResult.SUCCESS : EmailResult.FAILED;
        return emailResult;
    }
}

这里prepareFormData()对应上例中的转换方式。我尝试测试的是转换是否正确。

【问题讨论】:

    标签: unit-testing junit mocking tdd


    【解决方案1】:

    TDD 背后的理念是测试功能,而不是方法。所以问题

    测试转换方法的正确方法是什么?

    如果你想使用 TDD 方法,这不是你应该问的。

    哪些设计改进可以使此代码可测试?

    这是一个更好的问题。要对doStuff(A objectA) 进行适当的测试,您需要回到规范:doStuff 应该做什么?事实上,它的返回类型是 void,这让事情变得有点难以想象,但我们假设它做了以下事情之一:

    1. 与外部系统(文件、数据库、url 等)交互
    2. 修改内部系统的状态(变量)

    第一种情况,可以通过mock外部系统,验证mock交互来进行验证;第二,我们应该能够直接验证结果。无论如何,您需要确定一个特定的结果(我们称之为 C)和一种针对每种输入 A 对其进行测试的方法。您的测试应该具有以下结构:

    • doStuff(A1) 应该产生结果 C1
    • doStuff(A2) 应该产生结果 C2

    结果 C 始终由对象 B 决定,而对象 B 又由对象 A 决定。因此,如果 convertToB() 被破坏,则测试结果不应与预期值对应并失败。因此,您的转换方法将被测试覆盖。

    编辑:

    我将使用您提供的真实示例来说明我的观点。

    1) 首先,Client 是一个外部依赖项,因此必须模拟以进行正确的单元测试。为此,您需要摆脱静态依赖 Client client = Client.create()` 并将其替换为构造函数或 setter 注入。这是一个详细的example 怎么做。

    2) 现在我们可以模拟客户端了:

    Client mockClient = Mockito.mock(Client.class, Mockito.RETURN_DEEP_STUBS);
    WebResource mockWebResource = Mockito.mock(WebResource.class);
    Mockito.doReturn(mockWebResource).when(mockClient).resource(Mockito.anyString()); //assuming API_URL is a string
    EmailSender sender = new EmailSender(mockClient);
    

    3) 准备一个具体的测试用例:

    // actual email details 
    Email email = new Email();
    email.setSender("john@domain.com");
    email.setRecipients("chris@domain.com", "bob@domain.com");
    //etc.  
    

    4) 执行测试代码

    sender.send(email);
    

    5) 验证结果

    // capture parameter
    ArgumentCaptor<MultivaluedMapImpl> argument = ArgumentCaptor.forClass(MultivaluedMapImpl.class.class);
    Mockito.verify(mockWebResource, Mockito.times(1)).post(Mockito.any(Class.class), argument.capture());
    Assert.assertEqual(email.getSender(), argument.getValue().get(FROM_KEY);
    Assert.assertEqual(email.getRecipients(), argument.getValue().get(TO_KEY);
    // etc.
    

    请注意,您返回的SUCCESSFAILED 结果无关紧要,因为它不是EmailSender 类的责任,而是Client 类的责任,因此不应在EmailSenderTest 中进行测试。

    【讨论】:

    • 您的回答很有道理,但它是否涵盖了所有情况?示例:被测类发送电子邮件。它将其域的电子邮件对象转换为外部服务 API 中指示的格式。结果:C1 为“SENT”,C2 为“ERROR”。假设我的转换方法包含错误,它将“john@domain.com”转换为“chris@domain.com”。外部 API 仍然可以发送它并返回 C1 - “SENT”。我将假设一切都经过测试,并将错误留在代码中。我错过了什么吗?
    • @LimboExile 如果在此示例中邮件的收件人很重要,则结果 C 应包含此信息,因此 C1 应为“SENT to john@domain.com”。一般来说,你的测试用例越详细越好。
    • 但我不决定结果 C。它来自我无法控制的外部服务。此外,如果我将输入数据添加到结果中,则只有在将电子邮件发送给错误的收件人后才能进行检查。
    • @LimboExile 在测试中你必须“决定结果 C”,因为如果你不这样做,那么你的测试是不可重复的(参见 F.I.R.S.T. 原则)。在外部系统的情况下,这可以使用模拟来实现。如果您提供更多有关 processB() 的详细信息,我将很乐意向您展示。
    • 我在一个不太抽象的示例中提供了详细信息。我在之前的评论中想说的是,即使我决定结果 C,我的选择也会受到外部系统的限制。
    【解决方案2】:

    这是(未修改的)EmailSender 类的完整(且有效)测试集:

    import javax.ws.rs.core.*;
    import com.sun.jersey.api.client.*;
    import com.sun.jersey.api.client.WebResource.Builder;
    import static email.EmailSender.*;
    import mockit.*;
    import static org.junit.Assert.*;
    import org.junit.*;
    
    public class EmailSenderTest {
        @Tested EmailSender emailSender;
        @Mocked Client emailClient;
        @Mocked ClientResponse response;
        Email email;
    
        @Before
        public void createTestEmail() {
            email = new Email();
            email.setSender("john@domain.com");
            email.setRecipients("chris@domain.com", "bob@domain.com");
            email.setSubject("Testing");
            email.setText("Just a test");
        }
    
        @Test
        public void successfullySendEmail() {
            new Expectations() {{
                response.getClientResponseStatus(); result = ClientResponse.Status.OK;
            }};
    
            EmailResult result = emailSender.send(email);
    
            new Verifications() {{
                // Verifies correct API URL and media type:
                Builder bldr = emailClient.resource(API_URL).type(
                    MediaType.APPLICATION_FORM_URLENCODED);
    
                // Verifies correct form data:
                MultivaluedMap<String, String> formData;
                bldr.post(ClientResponse.class, formData = withCapture());
                assertEquals(email.getSender(), formData.getFirst(FROM_KEY));
                assertEquals(email.getRecipients(), formData.get(TO_KEY));
                assertEquals(email.getSubject(), formData.getFirst(SUBJECT_KEY));
                assertEquals(email.getText(), formData.getFirst(TEXT_KEY));
            }};
    
            assertSame(EmailResult.SUCCESS, result);
        }
    
        @Test
        public void failToSendEmail() {
            new Expectations() {{
                response.getClientResponseStatus();
                result = ClientResponse.Status.NOT_FOUND;
            }};
    
            EmailResult result = emailSender.send(email);
    
            // No need to repeat here the verification for URL, form data, etc.
            assertSame(EmailResult.FAILED, result);
        }
    }
    

    这两个测试应该足以完全覆盖被测类。此外,他们应该能够检测到EmailSender 实现中可能存在的任何错误。

    请注意,每个测试都涵盖两种“业务场景”之一:a) 电子邮件由收到“成功”结果的电子邮件客户端发送,或 b) 电子邮件已发送但电子邮件客户端收到“失败”结果某种结果。第一个测试还检查发送电子邮件过程中的重要细节。所有这些事情都是由被测单元的要求决定的。上述要求之一是与外部 Client 依赖项的正确交互,该依赖项正在被模拟。

    【讨论】:

      【解决方案3】:

      如果您已经编写了一个方法,而现在您正在尝试对其进行测试,那么您并不是在练习 TDD。使用 TDD,您首先编写测试。现在您不会遇到难以测试的方法的问题,因为您编写的任何方法都已经有了测试。

      【讨论】:

      • 我没有写测试,所以我发布了被测系统的部分功能,现在我问我应该如何写测试:)
      • 这不能回答问题:(
      猜你喜欢
      • 2011-01-03
      • 1970-01-01
      • 2018-04-09
      • 2016-01-27
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2018-12-09
      相关资源
      最近更新 更多