Java单元测试自动化扎实吗?(JUnit/Hamcrest/..)



意图

我正在寻找以下内容:

  • 一种可靠的单元测试方法
    1. 我的方法缺少什么
    2. 我做错了什么
    3. 我在做什么是不必要的
  • 一种自动完成尽可能多任务的方法

当前环境

  • Eclipse作为IDE
  • JUnit作为一个测试框架,集成到Eclipse中
  • Hamcrest作为一个"matchers"库,以获得更好的断言可读性
  • 谷歌Guava进行前提条件验证

当前方法

结构

  • 每个类一个测试类进行测试
  • 分组在静态嵌套类中的方法测试
  • 指定测试行为的测试方法命名+预期结果
  • JavaAnnotation指定的预期异常,而不是在方法名称中

方法

  • 注意null
  • 注意空的列表<E>
  • 注意空字符串
  • 注意空数组
  • 注意被代码更改的对象状态不变量(后置条件)
  • 方法接受记录的参数类型
  • 边界检查(例如Integer.MAX_VALUE等)
  • 通过特定类型记录不变性(例如Google Guava ImmutableList<e>)
  • 。。。这个有单子吗?拥有测试列表的好处示例:
    • 在数据库项目中要检查的内容(例如CRUD、连接、日志记录…)
    • 多线程代码中要检查的内容
    • EJB需要检查的事项

示例代码

这是一个展示一些技巧的人为例子


MyPath.java

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import java.util.Arrays;
import com.google.common.collect.ImmutableList;
public class MyPath {
public static final MyPath ROOT = MyPath.ofComponents("ROOT");
public static final String SEPARATOR = "/";
public static MyPath ofComponents(String... components) {
checkNotNull(components);
checkArgument(components.length > 0);
checkArgument(!Arrays.asList(components).contains(""));
return new MyPath(components);
}
private final ImmutableList<String> components;
private MyPath(String[] components) {
this.components = ImmutableList.copyOf(components);
}
public ImmutableList<String> getComponents() {
return components;
}
@Override
public String toString() {
StringBuilder stringBuilder = new StringBuilder();
for (String pathComponent : components) {
stringBuilder.append("/" + pathComponent);
}
return stringBuilder.toString();
}
}

MyPathTests.java

import static org.hamcrest.Matchers.is;
import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
import static org.hamcrest.collection.IsEmptyCollection.empty;
import static org.hamcrest.collection.IsIterableContainingInOrder.contains;
import static org.hamcrest.core.IsEqual.equalTo;
import static org.hamcrest.core.IsNot.not;
import static org.hamcrest.core.IsNull.notNullValue;
import static org.junit.Assert.assertThat;
import org.junit.Test;
import org.junit.experimental.runners.Enclosed;
import org.junit.runner.RunWith;
import com.google.common.base.Joiner;
@RunWith(Enclosed.class)
public class MyPathTests {
public static class GetComponents {
@Test
public void componentsCorrespondToFactoryArguments() {
String[] components = { "Test1", "Test2", "Test3" };
MyPath myPath = MyPath.ofComponents(components);
assertThat(myPath.getComponents(), contains(components));
}
}
public static class OfComponents {
@Test
public void acceptsArrayOfComponents() {
MyPath.ofComponents("Test1", "Test2", "Test3");
}
@Test
public void acceptsSingleComponent() {
MyPath.ofComponents("Test1");
}
@Test(expected = IllegalArgumentException.class)
public void emptyStringVarArgsThrows() {
MyPath.ofComponents(new String[] { });
}
@Test(expected = NullPointerException.class)
public void nullStringVarArgsThrows() {
MyPath.ofComponents((String[]) null);
}
@Test(expected = IllegalArgumentException.class)
public void rejectsInterspersedEmptyComponents() {
MyPath.ofComponents("Test1", "", "Test2");
}
@Test(expected = IllegalArgumentException.class)
public void rejectsSingleEmptyComponent() {
MyPath.ofComponents("");
}
@Test
public void returnsNotNullValue() {
assertThat(MyPath.ofComponents("Test"), is(notNullValue()));
}
}
public static class Root {
@Test
public void hasComponents() {
assertThat(MyPath.ROOT.getComponents(), is(not(empty())));
}
@Test
public void hasExactlyOneComponent() {
assertThat(MyPath.ROOT.getComponents(), hasSize(1));
}
@Test
public void hasExactlyOneInboxComponent() {
assertThat(MyPath.ROOT.getComponents(), contains("ROOT"));
}
@Test
public void isNotNull() {
assertThat(MyPath.ROOT, is(notNullValue()));
}
@Test
public void toStringIsSlashSeparatedAbsolutePathToInbox() {
assertThat(MyPath.ROOT.toString(), is(equalTo("/ROOT")));
}
}
public static class ToString {
@Test
public void toStringIsSlashSeparatedPathOfComponents() {
String[] components = { "Test1", "Test2", "Test3" };
String expectedPath =
MyPath.SEPARATOR + Joiner.on(MyPath.SEPARATOR).join(components);
assertThat(MyPath.ofComponents(components).toString(),
is(equalTo(expectedPath)));
}
}
@Test
public void testPathCreationFromComponents() {
String[] pathComponentArguments = new String[] { "One", "Two", "Three" };
MyPath myPath = MyPath.ofComponents(pathComponentArguments);
assertThat(myPath.getComponents(), contains(pathComponentArguments));
}
}

问题,措辞明确

  • 是否有一个用于构建单元测试的技术列表?比我上面过于简单的列表更高级的东西(例如,检查null、检查边界、检查预期异常等)可能在要购买的书或要访问的URL中可用?

  • 一旦我有了一个采用特定类型参数的方法,我可以获得任何Eclipse插件来为我的测试生成存根吗?也许使用JavaAnnotation来指定有关该方法的元数据,并让该工具为我实现相关的检查?(例如@MustBeLowerCase,@ShouldBeOfSize(n=3),…)

我觉得必须记住所有这些"QA技巧"和/或应用它们很乏味,而且像机器人一样,我发现复制和粘贴很容易出错,而且当我像上面那样编写代码时,我发现它不能自我记录。诚然,Hamcrest库倾向于专门化测试类型(例如,在使用RegEx的String对象上,在File的对象上,等等),但显然不会自动生成任何测试存根,也不会反映代码及其属性,也不会为我准备一个工具。

请帮我把这件事做得更好。

PS

请不要告诉我,我只是在展示一个愚蠢的代码,它围绕着从静态工厂方法中提供的路径步骤列表创建路径的概念,这是一个完全虚构的例子,但它显示了"一些"参数验证的情况。。。如果我包括一个更长的例子,谁会真正阅读这篇文章

  1. 考虑使用ExpectedException而不是@Test(expected...。这是因为,例如,如果你期望一个NullPointerException,而你的测试在设置中抛出了这个异常(在调用被测方法之前),你的测试就会通过。使用ExpectedException,你将期望放在对被测方法的调用之前,所以不可能发生这种情况。此外,ExpectedException允许您测试异常消息,如果您有两个不同的IllegalArgumentExceptions可能会被抛出,并且您需要检查正确的一个,这将非常有用。

  2. 考虑将测试中的方法与设置和验证隔离开来,这将简化测试审查和维护。当被测类上的方法作为设置的一部分被调用时尤其如此,这可能会混淆哪个是被测方法。我使用以下格式:

    public void test() {
    //setup
    ...
    // test (usually only one line of code in this block)
    ...
    //verify
    ...
    }
    
  3. 阅读书籍:Clean Code,JUnit In Action,Test Driven Development By Example

    Clean Code有一个关于测试的优秀部分

  4. 我看到的大多数示例(包括Eclipse自动生成的)在测试标题中都有测试中的方法。这便于审查和维护。例如:testOfComponents_nullCase。您的示例是我看到的第一个使用Enclosed按测试中的方法对方法进行分组的示例,这真的很好。然而,由于@Before@After不能在封闭的测试类之间共享,因此它增加了一些开销。

  5. 我还没有开始使用它,但Guava有一个测试库:Guava testlib。我还没有机会玩它,但它似乎有一些很酷的东西。例如:NullPointerTest是引号:

  • 一个测试实用程序,用于验证您的方法在其任何参数为null时抛出{@link*NullPointerException}或{@linkUnsupportedOperationException}。若要使用它,必须首先为类使用的参数类型提供有效的默认*值

回顾:我意识到上面的测试只是一个例子,但由于建设性的回顾可能会有所帮助,所以开始吧。

  1. 在测试getComponents时,也要测试空列表情况。另外,使用IsIterableContainingInOrder

  2. ofComponents的测试中,调用getComponentstoString来验证它是否正确处理了各种非错误情况似乎是有意义的。应该有一个没有参数传递给ofComponents的测试。我看到这是用ofComponents( new String[]{})完成的,但为什么不只做ofComponents()呢?需要一个测试,其中null是通过的值之一:ofComponents("blah", null, "blah2"),因为这将抛出一个NPE。

  3. 在测试ROOT时,如前所述,我建议调用ROOT.getComponents一次,并对其进行所有三个验证。此外,ItIterableContainingInOrder会执行not empty、size和contains这三个操作。测试中的is是超常的(尽管它是语言学的),我觉得不值得拥有(IMHO)。

  4. 在测试toString时,我觉得隔离测试中的方法非常有帮助。我会把toStringIsSlashSeparatedPathOfComponents写如下。请注意,我没有使用被测试类中的常量。这是因为IMHO,对被测试类的任何功能更改都会导致测试失败。

    @Test     
    public void toStringIsSlashSeparatedPathOfComponents() {       
    //setup 
    String[] components = { "Test1", "Test2", "Test3" };       
    String expectedPath =  "/" + Joiner.on("/").join(components);   
    MyPath path = MyPath.ofComponents(components)
    // test
    String value = path.toStrign();
    // verify
    assertThat(value, equalTo(expectedPath));   
    } 
    
  5. Enclosed不会运行任何不在内部类中的单元测试。因此testPathCreationFromComponents将不会运行。

最后,使用测试驱动开发。这将确保您的测试以正确的理由通过,并将按预期失败。

我看到您花了很多精力来真正测试您的类。很好!:)

我的意见/问题是:

  • 嘲笑怎么样?你没有提到任何工具
  • 在我看来,你非常关心细节(我并不是说它们不重要!),而忽略了测试类的业务目的。我想这是因为你首先编写代码(是吗?)。我建议采用更多的TDD/BDD方法,并将重点放在测试类的业务职责上
  • 不确定这会给你带来什么:"分组在静态嵌套类中的方法测试">
  • 关于测试存根的自动生成等。简单地说:不要。您最终将测试实现而不是行为

好的,这是我对您的问题的看法:

是否有用于构建单元测试的技术列表

简言之,没有。你的问题是,要为一种方法生成测试,你需要分析它的作用,并在每个地方对每个可能的值进行测试。虽然有测试生成器,但IIRC没有生成可维护的代码(请参阅测试驱动开发参考资料)。

你已经有一个相当好的清单需要检查,我想补充一下:

  • 确保通过方法的所有路径都被覆盖
  • 确保所有重要的功能都包含在一个以上的测试中,我经常使用Parameterized

我发现有一件事真的很有用,那就是问这个方法应该做什么,而不是做什么。这样,你就可以以更开放的心态编写测试了。

我发现另一件有用的事情是减少与测试相关的样板,这样我可以更容易地阅读测试。添加测试越容易越好。我发现Parameterized对此非常好。对我来说,测试的可读性是关键。

所以,以你上面的例子为例,如果我们放弃"只测试一种方法中的一件事"的要求,我们就会得到

public static class Root {
@Test
public void testROOT() {
assertThat("hasComponents", MyPath.ROOT.getComponents(), is(not(empty())));
assertThat("hasExactlyOneComponent", MyPath.ROOT.getComponents(), hasSize(1));
assertThat("hasExactlyOneInboxComponent", MyPath.ROOT.getComponents(), contains("ROOT"));
assertThat("isNotNull", MyPath.ROOT, is(notNullValue()));
assertThat("toStringIsSlashSeparatedAbsolutePathToInbox", MyPath.ROOT.toString(), is(equalTo("/ROOT")));
}
}

我做了两件事,在断言中添加了描述,并将所有测试合并为一个测试。现在,我们可以阅读测试,看到我们实际上有重复的测试。我们可能不需要测试CCD_ 29&amp;is(notNullValue())等。这违反了每个方法一个断言的规则,但我认为这是合理的,因为您在没有减少覆盖率的情况下删除了许多样板。

我可以自动执行检查吗

是的。但我不会用注释来做这件事。假设我们有一个方法,比如:

public boolean validate(Foobar foobar) {
return !foobar.getBar().length > 40;
} 

所以我有一个测试方法,上面写着:

private Foobar getFoobar(int length) {
Foobar foobar = new Foobar();
foobar.setBar(StringUtils.rightPad("", length, "x")); // make string of length characters
return foobar;
}
@Test
public void testFoobar() {
assertEquals(true, getFoobar(39));
assertEquals(true, getFoobar(40));
assertEquals(false, getFoobar(41));
}

当然,上面的方法很容易根据长度将其分解为参数化测试。这个故事的寓意是,你可以像对待非测试代码一样分解你的测试。

因此,根据我的经验,为了回答你的问题,我得出的结论是,通过减少测试中的样板,使用测试的参数化和因子分解的明智组合,你可以做很多事情来帮助所有的组合。作为最后一个例子,这就是我将如何使用Parameterized:实现您的测试

@RunWith(参数化.class)公共静态类OfComponents{@参数公共静态集合数据(){return Arrays.asList(new Object[][]{{new String[]{"Test1","Test2","Test3"},null},{new String[]{"Test1"},null},{null,NullPointerException.class},{new String[]{"Test1",","Test2"},IllegalArgumentException},});}

private String[] components;
@Rule
public TestRule expectedExceptionRule = ExpectedException.none();
public OfComponents(String[] components, Exception expectedException) {
this.components = components;
if (expectedException != null) {
expectedExceptionRule.expect(expectedException);
}
}
@Test
public void test() {
MyPath.ofComponents(components);
}

请注意,上面的内容没有经过测试,可能也没有编译。根据以上内容,您可以将数据作为输入进行分析,并添加(或至少考虑添加)所有内容的组合。例如,您还没有测试{"Test1"、null、"Test2"}。。。

好吧,我会发布两个不同的答案。

正如James Coplien所说,单元测试毫无价值。在这个问题上,我不同意他的观点,但也许你会发现,考虑少进行单元测试而不是搜索自动解决方案会有所帮助。
  • 考虑将理论与DataPoints结合使用。我认为这将大大减少你的问题。此外,使用mock可以帮助您。

  • 最新更新