1 of 73

Principles of Software Construction: Objects, Design, and Concurrency��Test case designJonathan Aldrich, Charlie Garrod, Jeremy Lacomis

1

17-214/514

2 of 73

Learning Goals

Explain different coverage metrics and when they are appropriate

Write test cases to achieve line and branch coverage for a function

Deliberately decide when and how to use or combine structural and specification-based testing

2

17-214/514

3 of 73

Today

  • Specifications
  • Specification vs. Structural testing
  • Testing Strategies
    • Structural Testing: Statement, branch, path coverage; limitations
    • Specification Testing: Boundary value analysis, combinatorial testing, decision tables
  • Writing testable code & good tests

3

17-214/514

4 of 73

Specifications and testing are closely related

Q: What exactly do you test when given a method?

  • What it claims to do: specification testing – the contract (last week)
  • What it does: structural testing – coverage

4

17-214/514

5 of 73

Structural Testing

5

17-214/514

6 of 73

Structural Testing: a closer look

Takes into account the internal mechanism of a system (IEEE, 1990).

  • Approaches include tracing data and control flow through a program

6

17-214/514

7 of 73

Case Study

Assume various Wallets

public interface Wallet {

boolean pay(int cost);

int getValue();

}

7

17-214/514

8 of 73

DebitWallet.pay()

What should we test in this code?

public boolean pay(int cost) {

if (cost <= this.money) {

this.money -= cost;

return true;

}

return false;

}

8

17-214/514

9 of 73

DebitWallet.pay()

public boolean pay(int cost) {

if (cost <= this.money) {

this.money -= cost;

return true;

}

return false;

}

new DebitWallet(100).pay(10);

9

17-214/514

10 of 73

DebitWallet.pay()

public boolean pay(int cost) {

if (cost <= this.money) {

this.money -= cost;

return true;

}

return false;

}

new DebitWallet(0).pay(10);

10

17-214/514

11 of 73

CreditWallet.pay()

How about now?

public boolean pay(int cost, boolean useCredit) {

if (useCredit) {

if (this.credit + cost <= this.maxCredit) {

this.credit += cost;

return true;

}

}

if (cost <= this.cash) {

this.cash -= cost;

return true;

}

return false;

}

11

17-214/514

12 of 73

CreditWallet.pay()

Exercise: think about as many test scenarios as you can

public boolean pay(int cost, boolean useCredit) {

if (useCredit) {

if (enoughCredit) {

return true;

}

}

if (enoughCash) {

return true;

}

return false;

}

12

17-214/514

13 of 73

CreditWallet.pay()

public boolean pay(int cost, boolean useCredit) {

if (useCredit) {

if (enoughCredit) {

return true;

}

}

if (enoughCash) {

return true;

}

return false;

}

Test case

useCredit

enough Credit

enough Cash

Result

Coverage

1

T

T

-

Pass

--

13

17-214/514

14 of 73

CreditWallet.pay()

public boolean pay(int cost, boolean useCredit) {

if (useCredit) {

if (enoughCredit) {

return true;

}

}

if (enoughCash) {

return true;

}

return false;

}

Test case

useCredit

enough Credit

enough Cash

Result

Coverage

1

T

T

-

Pass

--

2

F

-

T

Pass

--

3

F

-

F

Fails

Statement

14

17-214/514

15 of 73

Coverage

We have tested every statement; are we done?�Depends on desired coverage:

  • Provide at least one test for distinct types of behavior
  • Typically on control flow paths through the program
  • Statement, branch, basis paths, MC/DC

15

17-214/514

16 of 73

Structures in Code

16

17-214/514

17 of 73

Control-Flow of CreditCard.pay()

public boolean pay(int cost, boolean useCredit) {

if (useCredit) {

if (enoughCredit) {

return true;

}

}

if (enoughCash) {

return true;

}

return false;

}

17

17-214/514

18 of 73

Control-Flow of CreditCard.pay()

public boolean pay(int cost, boolean useCredit) {

if (useCredit) {

if (enoughCredit) {

return true;

}

}

if (enoughCash) {

return true;

}

return false;

}

useCredit

enough Credit

pay w/credit

true

true

enough

Cash

pay w/cash

fail

false

false

false

true

18

17-214/514

19 of 73

Control-Flow of CreditCard.pay()

useCredit

enough Credit

pay w/credit

true

true

enough

Cash

pay w/cash

fail

false

false

false

true

Test case

useCredit

enough Credit

enough Cash

Result

Coverage

1

T

T

-

Pass

--

2

F

-

T

Pass

--

3

F

-

F

Fails

Statement

19

17-214/514

20 of 73

Control-Flow of CreditCard.pay()

useCredit

enough Credit

pay w/credit

true

true

enough

Cash

pay w/cash

fail

false

false

false

true

Test case

useCredit

enough Credit

enough Cash

Result

Coverage

1

T

T

-

Pass

--

2

F

-

T

Pass

--

3

F

-

F

Fails

Statement

20

17-214/514

21 of 73

Control-Flow of CreditCard.pay()

useCredit

enough Credit

pay w/credit

true

true

enough

Cash

pay w/cash

fail

false

false

false

true

Test case

useCredit

enough Credit

enough Cash

Result

Coverage

1

T

T

-

Pass

--

2

F

-

T

Pass

--

3

F

-

F

Fails

Statement

21

17-214/514

22 of 73

CreditWallet.pay()

public boolean pay(int cost, boolean useCredit) {

if (useCredit) {

if (enoughCredit) {

return true;

}

}

if (enoughCash) {

return true;

}

return false;

}

Test case

useCredit

enough Credit

enough Cash

Result

Coverage

1

T

T

-

Pass

--

2

F

-

T

Pass

--

3

F

-

F

Fails

Statement

4

T

F

T

Pass

Branch

22

17-214/514

23 of 73

Path Coverage

We have seen every condition … what else is missing?

23

17-214/514

24 of 73

Path Coverage

We have seen every condition … but not every path.

  • 3 conditions, each with two values = 8 permutations
  • Some permutations are impossible
  • Still one path left

24

17-214/514

25 of 73

Control-Flow of CreditCard.pay()

Paths:

  • {true, true}: pay w/credit
  • {false, true}: pay w/cash
  • {false, false}: fail

useCredit

enough Credit

pay w/credit

true

true

enough

Cash

pay w/cash

fail

false

false

false

true

25

17-214/514

26 of 73

Control-Flow of CreditCard.pay()

Paths:

  • {true, true}: pay w/credit
  • {false, true}: pay w/cash
  • {false, false}: fail
  • {true, false, true}: pay w/cash�after failing credit

useCredit

enough Credit

pay w/credit

true

true

enough

Cash

pay w/cash

fail

false

false

false

true

26

17-214/514

27 of 73

Control-Flow of CreditCard.pay()

Paths:

  • {true, true}: pay w/credit
  • {false, true}: pay w/cash
  • {false, false}: fail
  • {true, false, true}: pay w/cash�after failing credit
  • {true, false, false}: try credit, but�fail, and no cash

useCredit

enough Credit

pay w/credit

true

true

enough

Cash

pay w/cash

fail

false

false

false

true

27

17-214/514

28 of 73

CreditWallet.pay()

public boolean pay(int cost, boolean useCredit) {

if (useCredit) {

if (enoughCredit) {

return true;

}

}

if (enoughCash) {

return true;

}

return false;

}

Test case

useCredit

enough Credit

enough Cash

Result

Coverage

1

T

T

-

Pass

--

2

F

-

T

Pass

--

3

F

-

F

Fails

Statement

4

T

F

T

Pass

Branch

5

T

F

F

Fails

(Basis) paths

28

17-214/514

29 of 73

BitCoinWallet.pay()

public boolean pay(int cost) {

int currValue;

while ((currValue = getValue()) < cost) {

// Just wait.

}

this.btc -= cost / currValue;

return true;

}

public int getValue() {

return (int)

(this.btc * Math.pow(2, 20*Math.random()));

}

29

17-214/514

30 of 73

Control-flow of BitCoinWallet.pay()

What are all the paths?

BTC value�enough?

pay w/btc

true

false

30

17-214/514

31 of 73

Control-flow of BitCoinWallet.pay()

What are all the paths?

  • {true}
  • {false, true}
  • {false, false, true}
  • {false, false, false, true}
  • ...

BTC value�enough?

pay w/btc

true

false

31

17-214/514

32 of 73

Control-flow of BitCoinWallet.pay()

Perfect “general” path coverage is elusive

But “adequate” coverage criteria exist:

  • Basis paths: each path must cover one new edge
    • {true} and {false, true} are sufficient
    • As is just {false, true}
  • Loop adequacy: iterate each loop zero, one, and 2+ times

BTC value�enough?

pay w/btc

true

false

32

17-214/514

33 of 73

More Coverage

Many more criteria exist:

  • For branches with multiple conditions
    • Modified Condition/Decision Coverage is quite popular
  • For loops
    • Boundary Interior Testing
  • Branch coverage is by far the most common

33

17-214/514

34 of 73

Test Coverage?

34

17-214/514

35 of 73

Coverage and Quality

if a ≤ 1

x = a - 1

y = z / x

else

x = 5

Question 1: Is there a defect?

then

35

17-214/514

36 of 73

Coverage and Quality

if a ≤ 1

x = a - 1

y = z / x

else

x = 5

Question 2: Can we achieve 100% statement coverage and miss the defect?

then

36

17-214/514

37 of 73

Coverage and Quality

if a ≤ 1

x = a - 1

y = z / x

then

else

x = 5

Question 3: Can we achieve 100% branch coverage and miss the defect?

37

17-214/514

38 of 73

Test your Understanding: Old Midterm Question

  1. Write the minimum number of tests to achieve full line coverage, but not full branch coverage.

  • What additional tests are necessary for full branch coverage, if any?

  • Would testing with full branch coverage ensure that any division-by-zero problem be found?

38

17-214/514

39 of 73

Outline

  • Structural Testing Strategies
  • Writing testable code & good tests
  • Specification Testing Strategies

39

17-214/514

40 of 73

Testability

40

17-214/514

41 of 73

Coding like the tour the france

public boolean foo() {

try {

synchronized () {

if () {

} else {

}

for () {

if () {

if () {

if () {

if ()

{

if () {

for () {

}

}

}

} else {

if () {

for () {

if () {

} else {

}

if () {

} else {

if () {

}

}

if () {

if () {

if () {

for () {

}

}

}

} else {

}

}

} else {

}

}

}

}

}

if () {

}

41

17-214/514

42 of 73

Writing Testable Code

What is the problem with this?

public boolean hasHeader(String path) throws IOException {

List<String> lines = Files.readAllLines(Path.of(path));

return !lines.get(0).isEmpty()

}

// complete control-flow coverage!

hasHeader(“cards.csv”) // true

42

17-214/514

43 of 73

Writing Testable Code

What is the problem with this?

public boolean hasHeader(String path) throws IOException {

List<String> lines = Files.readAllLines(Path.of(path));

return !lines.get(0).isEmpty()

}

// to achieve a ‘false’ output without having a test input file:

try {

Path tempFile = Files.createTempFile(null, null);

Files.write(tempFile,"\n".getBytes(StandardCharsets.UTF_8));

hasHeader(tempFile.toFile().getAbsolutePath()); // false

} catch (IOException e) {

e.printStackTrace();

}

43

17-214/514

44 of 73

Writing Testable Code

Exercise: rewrite to make this easier

  • And: what would you test?

public boolean hasHeader(String path) throws IOException {

List<String> lines = Files.readAllLines(Path.of(path));

return !lines.get(0).isEmpty()

}

44

17-214/514

45 of 73

Writing Testable Code

Aim to write easily testable code

  • Which is almost by definition more modular

public List<String> getLines(String path) throws IOException {

return Files.readAllLines(Path.of(path));

}

public boolean hasHeader(List<String> lines) {

return !lines.get(0).isEmpty()

}

// Test:

// - hasHeader with empty, non-empty first line

// - getLines (if you must) with null, real path

45

17-214/514

46 of 73

Writing Testable Code

What is the problem with this?

public String[] getHeaderParts(List<String> lines) {

if (!lines.isEmpty()) {

String header = lines.get(0);

if (header.contains(",")) {

return header.split(",");

} else {

return new String[0];

}

} else {

return null;

}

}

46

17-214/514

47 of 73

Writing Testable Code

Split functionality into easily testable units

public String getFirstLine(List<String> lines) {

if (!lines.isEmpty()) {

return lines.get(0);

} else {

return null;

}

}

public String[] getHeaderParts(String header) {

if (header.contains(",")) {

return header.split(",");

} else {

return new String[0];

}

}

47

17-214/514

48 of 73

Coding like the tour the france

public boolean foo() {

try {

synchronized () {

if () {

} else {

}

for () {

if () {

if () {

if () {

if ()

{

if () {

for () {

}

}

}

} else {

if () {

for () {

if () {

} else {

}

if () {

} else {

if () {

}

}

if () {

if () {

if () {

for () {

}

}

}

} else {

}

}

} else {

}

}

}

}

}

if () {

}

48

17-214/514

49 of 73

Testing Coverage Practices

Coverage is useful, but no substitute for your insight

  • Cannot capture all paths
    • Especially beyond “unit”
    • Write testable code
  • You may be testing buggy code
    • (add regression tests)
  • Aim for at least branch coverage
    • And think through scenarios that demand more

49

17-214/514

50 of 73

Good Testing Practices

50

17-214/514

51 of 73

Good Testing Practices

  1. Tests Should Be Fast
  2. Tests Should Be Simple
  3. Test Shouldn’t Duplicate Implementation Logic
  4. Tests Should Be Readable
  5. Tests Should Be Deterministic
  6. Make Sure They’re Part of the Build Process
  7. Distinguish Between The Many Types of Test Doubles and Use Them Appropriately (to be discussed in a later lecture)
  8. Adopt a Sound Naming Convention for Your Tests
  9. Don’t Couple Your Tests With Implementation Details

51

17-214/514

52 of 73

Quiz: Which Good Testing Practices does this Test Violate?

public String[] getHeaderParts(String header) {

if (header.contains(",")) {

return header.split(",");

} else {

return null;

}

}

@Test

public void testGetHeaderParts() {

for (String header : List.of("line", "", "one,two")) {

String[] parts = getHeaderParts(header);

if (header.contains(",")) assertNull(parts);

else assertEqual(header.split(","), parts.length);

}

}

bit.ly/214f23q5

52

17-214/514

53 of 73

How to Improve?

public String[] getHeaderParts(String header) {

if (header.contains(",")) {

return header.split(",");

} else {

return null;

}

}

@Test

public void testGetHeaderParts() {

for (String header : List.of("line", "", "one,two")) {

String[] parts = getHeaderParts(header);

if (header.contains(",")) assertNull(parts);

else assertEqual(header.split(","), parts.length);

}

}

53

17-214/514

54 of 73

Keep tests simple, small

public String[] getHeaderParts(String header) {

if (header.contains(",")) {

return header.split(",");

} else {

return null;

}

}

@Test

public void testGetHeaderPartsNoComma() {

String[] parts = getHeaderParts("line");

assertNull(parts);

}

@Test

...

54

17-214/514

55 of 73

Avoid repeating implementation

public String[] getHeaderParts(String header) {

if (header.contains(",")) {

return header.split(",");

} else {

return null;

}

}

@Test

public void testGetHeaderPartsSplit() {

String line = "one,two";

assertEqual(line.split(","), getHeaderParts(line))

}

@Test

...

55

17-214/514

56 of 73

Good Testing Practices

  • Tests Should Be Fast
  • Tests Should Be Simple
  • Test Shouldn’t Duplicate Implementation Logic
  • Tests Should Be Readable
  • Tests Should Be Deterministic
  • Make Sure They’re Part of the Build Process
  • Distinguish Between The Many Types of Test Doubles and Use Them Appropriately (to be discussed in a later lecture)
  • Adopt a Sound Naming Convention for Your Tests
  • Don’t Couple Your Tests With Implementation Details

56

17-214/514

57 of 73

Let’s critique some tests

57

17-214/514

58 of 73

Outline

  • Structural Testing Strategies
  • Writing testable code & good tests
  • Specification Testing Strategies

58

17-214/514

59 of 73

Specification-Based Testing

59

17-214/514

60 of 73

Back to Specification Testing

What would you test differently in this situation?

  • Previously identified five paths through the code.
    • Are there still five given only specification?
  • Should we test anything new?

/** Pays with credit if useCredit is set and enough

* credit is available; otherwise, pays with cash if

* enough cash is available; otherwise, returns false.

*/

public boolean pay(int cost, boolean useCredit);

60

17-214/514

61 of 73

Back to Specification Testing

What would you test differently in this situation?

  • “if useCredit is set and enough credit is available”:
    • Test both true, either/both false
  • “pays with cash if enough cash is available; otherwise”:
    • Test true, false
  • Could to this with as few as three test cases

/** Pays with credit if useCredit is set and enough

* credit is available; otherwise, pays with cash if

* enough cash is available; otherwise, returns false.

*/

public boolean pay(int cost, boolean useCredit);

61

17-214/514

62 of 73

Specification Testing

We need a strategy to identify plausible mistakes

62

17-214/514

63 of 73

Specification Testing

We need a strategy to identify plausible mistakes

  • Random: avoids bias, but inefficient
    • Yet potentially very valuable, because automatable
    • Not for today

63

17-214/514

64 of 73

Boundary Value Testing

We need a strategy to identify plausible mistakes

  • Boundary Value Testing: errors often occur at boundary conditions
    • E.g.:

/** Returns true and subtracts cost if enough � * money is available, false otherwise.

*/

public boolean pay(int cost) {

if (cost < this.money) {

this.money -= cost;

return true;

}

return false;

}

64

17-214/514

65 of 73

Boundary Value Testing

We need a strategy to identify plausible mistakes

  • Boundary Value Testing: errors often occur at boundary conditions
    • Identify equivalence partitions: regions where behavior should be the same
      • cost <= money: true, cost > money: false
      • Boundary value: cost == money

/** Returns true and subtracts cost if enough � * money is available, false otherwise.

*/

public boolean pay(int cost) {

if (cost < this.money) {

this.money -= cost;

return true;

}

return false;

}

65

17-214/514

66 of 73

Boundary Value Testing

We need a strategy to identify plausible mistakes

  • Boundary Value Testing: errors often occur at boundary conditions
    • Select: a nominal/normal case, a boundary value, and an abnormal case
    • Useful for few categories of behavior (e.g., null/not-null) per value
  • Test: cost < credit, cost == credit, cost > credit,� cost < cash, cost == cash, cost > cash

/** Pays with credit if useCredit is set and enough

* credit is available; otherwise, pays with cash if

* enough cash is available; otherwise, returns false.

*/

public boolean pay(int cost, boolean useCredit);

66

17-214/514

67 of 73

Equivalence Class Testing

Cannot try every single value -> group them where we expect similarities, select one representative each

cost: <0, 0, 1, >1, MAX_INT

useCredit: true, false

available credit: 0, >1, > 100, MAXINT

available cash: 0, >1, > 100, MAXINT

/** Pays with credit if useCredit is set and enough

* credit is available; otherwise, pays with cash if

* enough cash is available; otherwise, returns false.

*/

public boolean pay(int cost, boolean useCredit);

67

17-214/514

68 of 73

Combining Test Inputs

Works for both both boundary values and equivalence classes

Weak: Create a test to cover every class at least once

Strong: Create a test for every combination of classes for different values

    • Captures bugs in interactions between (risky) inputs
    • Rarely need to test pairs of “invalid” values (cost too high for credit & cash)

Combinatorial: Cover only pairwise/threewise interactions

/** Pays with credit if useCredit is set and enough

* credit is available; otherwise, pays with cash if

* enough cash is available; otherwise, returns false.

*/

public boolean pay(int cost, boolean useCredit);

68

17-214/514

69 of 73

Decision Tables

We need a strategy to identify plausible mistakes

  • Decision Tables
    • You’ve seen one already
    • Enumerate condition options
      • Leave out impossibles
      • Identify “don’t-matter” values
    • Useful for redundant input domains

Test case

useCredit

enough Credit

enough Cash

Result

1

T

T

-

Pass

2

F

-

T

Pass

3

F

-

F

Fails

4

T

F

T

Pass

5

T

F

F

Fails

69

17-214/514

70 of 73

Specification Tests

So what is the right granularity?

  • It depends
  • We are still aiming for coverage
    • Just of specifications, and their innumerable implementations
    • BVA (& its cousins), decision tables tend to provide good coverage

70

17-214/514

71 of 73

Structural Testing vs. Specification Testing

You will typically have both code & (prose) specification

  • Test specification, but know that it can be underspecified
  • Test implementation, but not to the point that it cannot change
  • Use testing strategies that leverage both
    • There is a fair bit of overlap; e.g., BVA yields useful branch coverage

71

17-214/514

72 of 73

Further Testing Strategies

Many more aspects, some later in this course:

  • Stubbing/Mocking, to avoid testing dependencies
    • We’ll loop back to this
  • Integration testing: scenarios that span units
    • With unit testing one should not test for an expected usage scenario
      • e.g., in HW2: that everything gets called from Main
    • This lets one make some simplifying assumptions
      • e.g., that every card is seen equally often
  • Beyond correctness: performance, security

72

17-214/514

73 of 73

Summary

Testing comprehensively is hard

  • Tailor to your task: specification vs. structural testing
    • Do not assume unstated specifications for HW 2; spend your energy wisely
  • Pick a strategy, or a few
    • Be systematic; defend your decisions
  • Tomorrow’s recitation covers:
    • Unit testing in Java and TypeScript
    • Branch coverage and specification-based testing

73

17-214/514