单人纸牌(Elevens Lab)活动5:使用断言进行测试

陈 欣发布

导言

在前面的活动中,你设计了CardDeck这些class。在完成之后,你被要求创立object来测试class的每个method。在本活动中,我们将学到更严谨的测试方法并介绍Java的assert语句。

探索

测试的目的何在?程序员都会犯错误。这些错误从误读算法或问题规范(这种错误的性质是最严重的)到简单的拼写错误。程序测试的目的在于找出尽可能多的错误。让我们考虑测试的种种方面。

有效和有条理的测试

我们该如何测试程序?显然,我们需要运行程序以查看它的行为是否和预期一致。不过真正的测试需要做得更多,即所谓的系统性。程序员不应该随机挑选测试案例,而应该按照更容易发现错误的模式。例如,程序员应该选择那些能够涉及程序各个部分的测试。如果程序的一些代码没有被执行到,错误就有可能隐藏其中。好的测试对程序员来说也应该方便。测试应该易于运行,尽可能的简单,但也有暴露编程错误的复杂度。简单的测试也会让识别编程错误变得容易。

在本活动中,我们会专注于寻找两种类型的错误:

  • 不一致,例如程序的两个部分对变量值有不同的预期
  • 常见的粗心错误,例如数字差一或者将操作符误用为另一个(如该用>时用了<

我们使用断言进行全部的测试。断言是一种boolean表达式,在程序运行正确时为true。我们要将所有的测试放到一个专门的测试class里,其main method会执行测试。

Java中的断言

我们的测试代码使用Java的assert语句。这一语句有如下形式:

assert booleanExpression : StringExpression;

如果booleanExpression的值为true,程序将继续执行下面的语句。如果booleanExpression的值为false,程序则抛出AssertionError异常并打印StringExpression。于此同时还会打印出堆栈跟踪(stack trace)(我们将在之后解释)。以下是使用assert的一个例子。

Card c1 = new Card("ace", "hearts", 1);
Card c2 = new Card("ace", "hearts", 1);
assert c1.matches(c2) : "Duplicate cards do not match.";

此处的代码将创建两个Card object,然后检查它们是否包含相同的信息。如果c1.matches(c2)返回true,将会继续执行后续的语句。然而,当程序有误并造成c1.matches(c2)返回false时,会抛出AssertionError异常并打印Duplicate cards do not match.这句消息。

断言在默认情况下是被禁用的。使用它们需要带上-ea(enable assertions,启用断言)命令行开关。例如,以下命令将执行CardTestermain method并启用断言。

java -ea CardTester

整理class Card中的测试

我们现在继续考虑class Card的测试。Card有一个构造函数和好几个method(suitrankpointValuematchestoString)。我们的测试必须涵盖全部这些。

首先,我们创建名为CardTester.java的文件,文件名反映了代码的用途。其main method会以创建一系列用于测试的Card object开始。

Card c1 = new Card("ace", "hearts", 1);
Card c2 = new Card("ace", "hearts", 1);
Card c3 = new Card("ace", "hearts", 2);
Card c4 = new Card("ace", "spades", 1);
Card c5 = new Card("king", "hearts", 1);
Card c6 = new Card("queen", "clubs", 3);

头两张牌是一模一样的。c3c4c5c1各有一个不同的实例变量值。这种带有一个不同值的牌能够帮助我们识别复制粘贴错误(例如suit的函数体从rank直接不加更改的复制过来)。最后一张牌与其他牌的所有值都不同。

我们先开始测试从Card中读取信息的method(accessor)。这些测试单单检查在使用完全不同信息实例化object之后,储存的信息是那些在构造函数中所提供的。注意字符串消息里含有每个断言所涉及的特定值。

assert c1.rank().equals("ace") : "Wrong rank: " + c1.rank();
assert c1.suit().equals("hearts") : "Wrong suit: " + c1.suit();
assert c1.pointValue() == 1 : "Wrong point value: " + c1.pointValue();
assert c6.rank().equals("queen") : "Wrong rank: " + c6.rank();
assert c6.suit().equals("clubs") : "Wrong suit: " + c6.suit();
assert c6.pointValue() == 3 : "Wrong point value: " + c6.pointValue();

接下来让我们测试Cardmatches method。两张牌当且仅当有相同的大小、花色和点数值才会相匹配。matches极有可能通过一些比较和&&来实现。常见的错误有上面提到的复制粘贴错误或者用||代替了&&。将c1与其他所有牌进行匹配测试会揭示这些种类的错误。

assert c1.matches(c1) : "Card doesn't match itself: " + c1;
assert c1.matches(c2) : "Duplicate cards aren't equals: " + c1;
assert !c1.matches(c3) : "Different cards are equal: " + c1 + ", " + c3;
assert !c1.matches(c4) : "Different cards are equal: " + c1 + ", " + c4;
assert !c1.matches(c5) : "Different cards are equal: " + c1 + ", " + c5;
assert !c1.matches(c6) : "Different cards are equal: " + c1 + ", " + c6;

最后我们再次在两个不同object上测试toString

assert c1.toString().equals("ace of hearts (point value = 1)") : "Wrong toString: " + c1;
assert c6.toString().equals("queen of clubs (point value = 3)") : "Wrong toString: " + c6;

如果所有测试都通过了,我们将输出一条消息提示这一点。

System.out.println("All tests passed!");

系统化测试

对于扑克牌,最复杂的数据结构也仅仅是字符串。但在测试包含更丰富内容的class时,一步一脚印是非常重要的。例如对于class Deck而言,先测试一张牌的牌组,然后再过渡到两张不同牌的牌组会很有条理。

但这些测试挤在一起不会乱套么?就像你之前做过的一些编程练习一样,把一大长串语句分成几个子method是很好的思路。这能够减小测试程序的尺寸,一些断言语句也可以被重用。class DeckTestermain method可以是下面这样:

public static void main(Stringp[] args) {
  test1CardDeck();
  test2CardDeck();
  testShuffle();
  System.out.println("All tests passed!");
}

练习

  1. 给定的目录中有四个子目录Buggy1Buggy2Buggy3Buggy4,每个子目录的内容是一个带有不同错误的class Deck。这些出错的牌组已经被编译好,且不带有源文件,仅有名为Deck.class的可执行字节码文件。这些错误包括非法移动一行语句,或把一个符号替换成另一个,如0替换成1>替换成<。使用所提供的DeckTester程序逐一测试它们:
    java -ea DeckTester 或 直接运行DeckTester.bat
    对四个不同牌组的测试均会造成AssertionError异常,以及输出为什么会造成错误的信息。对每次出现的错误,记下带错class Deck的哪一method或构造函数包含错误,并根据所学推断错误的原因。查看在活动4中完成的class Deck可能会对此有所帮助。
    注:Buggy1测试可能会有类似以下的输出:

    ... >java -ea DeckTester 
    Exception in thread "main" java.lang.AssertionError: isEmpty is false for an empty deck.
       at DeckTester.testEmpty(DeckTester.java:98)
       at DeckTester.test1CardDeck(DeckTester.java:28)
       at DeckTester.main(DeckTester.java:12)

    最后三行输出即是堆栈跟踪,说明:

    • AssertionError发生在第98行,testEmpty method中
    • testEmpty method在第28行被test1CardDeck method调用时
    • test1CardDeck method在第12行被main method调用时

    将你的结论记录在下:
    Buggy1:
    出错的构造函数或method名:
    描述可能的错误:

    Buggy2:
    出错的构造函数或method名:
    描述可能的错误:

    Buggy3:
    出错的构造函数或method名:
    描述可能的错误:

    Buggy4:
    出错的构造函数或method名:
    描述可能的错误:

  2. 现在检查Buggy5目录。该目录中有一个带有多种错误的Deck.java文件。利用DeckTester来帮助你找到错误。修正每个错误直至class Deck能通过所有测试。
    注意在运行DeckTester的过程中你可能会遇到AssertionError以外的其他运行时错误。这时将一张牌牌组测试和两张牌牌组测试的顺序调换可能会有帮助,像这样:

    public static void main(Stringp[] args) {
      test2CardDeck();   // order swapped
      test1CardDeck();   // order swapped
      testShuffle();
      System.out.println("All tests passed!");
    }

陈 欣

AADPS创始人

0 条评论

发表回复