很好回答的问题。
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 测试的角度来看这并不重要。