CSSE2002 — Week 5 Notes

Testing

See csse2002 for course logistics.

Today’s outline

  1. Levels of Testing
  2. Test Frameworks
  3. Black Box Testing
  4. White Box Testing
  5. Test Driven Development (TDD)

Testing

See java-testing — levels of testing, regression testing, black-box vs white-box testing (smoke tests, boundary tests, equivalence classes, code coverage), and TDD.

JUnit

See java-junit@Test, @Before/@After, Assert methods, and testing for expected exceptions.

Debugging

Practical for week 5: using the IntelliJ debugger to find and fix bugs in a small provided codebase of chat bots (ChatBot implementations) — reinforcing java-junit (the provided tests are what reveal each bug) and java-exceptions (RecursiveFibonacci’s bug manifests as a StackOverflowError).

This practical is hands-on IntelliJ-debugger work rather than a computable Q&A, so there are no interactive checks here — just each bug and its fix, as a static reference/answer key.

Setup: a provided .zip (from Blackboard) contains ChatBot.java (the shared interface) plus several bot implementations and their JUnit tests, to be placed under a debugging/ package in src//test/ source roots. Running the tests initially shows 37 tests total, 24 failing. Ground rule: no modifying code (implementation or tests) until the bug is actually found via the debugger — no debug-print-statement shortcuts.

public interface ChatBot {
    String replyTo(String username, String message);
}

Bug 1 — MockBot

MockBot should reply to any message with the message repeated in alternating caps — letters up to and including 'O' lowercase, letters after 'O' uppercase:

private String mock(String message) {
    StringBuilder result = new StringBuilder();
    for (char letter : message.toCharArray()) {
        if (letter > 'O') {
            result.append(Character.toUpperCase(letter));
        } else {
            result.append(Character.toLowerCase(letter));
        }
    }
    return result.toString();
}

The bug is that the original message’s case is never normalised first — letter > 'O' compares the character’s existing case-sensitive code point directly, so any already-lowercase letter after 'o' (lowercase) in the alphabet compares incorrectly against the uppercase 'O' boundary. Fixing it means normalising the message to uppercase before iterating, so the comparison is always against consistent-case letters:

private String mock(String message) {
    StringBuilder result = new StringBuilder();
    for (char letter : message.toUpperCase().toCharArray()) {
        if (letter > 'O') {
            result.append(Character.toUpperCase(letter));
        } else {
            result.append(Character.toLowerCase(letter));
        }
    }
    return result.toString();
}

Bug 2 & 3 — GuessingGameBot

A bot that replies “yes”/“no” to guess higher <n> / guess lower <n> against a secret number, and “you win!”/“you lose!” for guess equal <n>. Two independent bugs:

Bug 2 — message splitting. split(String) breaks a message into words on spaces:

protected static List<String> split(String input) {
    List<String> output = new ArrayList<>();
    int index = 0, newIndex;
    while (index != -1) {
        newIndex = input.indexOf(' ', index + 1);
        if (newIndex == -1) {
            output.add(input.substring(index));
        } else {
            output.add(input.substring(index, newIndex));
        }
        index = newIndex;
    }
    return output;
}

Bug 3 — comparison logic. The higher/lower cases compare the wrong way:

case "higher" -> {
    int guess = Integer.parseInt(words.get(2));
    return guess > SECRET_NUMBER ? "yes" : "no";
}
case "lower" -> {
    int guess = Integer.parseInt(words.get(2));
    return guess < SECRET_NUMBER ? "yes" : "no";
}

Bug 2 fix: substring(index) includes the character at index itself, but after finding a space, index is reassigned directly to that space’s position (newIndex) — so the next word’s substring starts with a leading space. Fix: advance past the space (newIndex + 1), and break once the last word has been added (since index would otherwise become -1 from newIndex, which is already handled by the while condition, but the assignment ordering still needs correcting):

while (index != -1) {
    newIndex = input.indexOf(' ', index + 1);
    if (newIndex == -1) {
        output.add(input.substring(index));
        break;
    } else {
        output.add(input.substring(index, newIndex));
    }
    index = newIndex + 1;
}

Bug 3 fix: the >/< are swapped — a "guess higher" should return "yes" when the guess is lower than the secret (i.e. the secret is higher than the guess), and vice versa:

case "higher" -> {
    int guess = Integer.parseInt(words.get(2));
    return guess < SECRET_NUMBER ? "yes" : "no";
}
case "lower" -> {
    int guess = Integer.parseInt(words.get(2));
    return guess > SECRET_NUMBER ? "yes" : "no";
}

Bug 4 & 5 — MultiplayerGuessingGameBot

Extends GuessingGameBot to let multiple users each submit one guess, with the game’s host finishing it to reveal a ranked leaderboard. Two independent bugs:

Bug 4 — host check inverted:

if (message.equals("finish game")) {
    if (username.equals(host)) {
        return "You do not have a game running.\n" +
                "Host must finish game.";
    }
    return results();
}

Bug 5 — leaderboard rank off-by-one:

private String results() {
    // ...
    int rank = 0;
    for (Map.Entry<String, Integer> entry : entries) {
        builder.append("\n").append(rank).append(") ")...
        rank++;
    }
    return builder.toString();
}

Bug 4 fix: the condition is backwards — a non-host finishing the game should get the “you do not have a game running” message, not the host:

if (!username.equals(host)) {
    return "You do not have a game running.\n" +
            "Host must finish game.";
}
return results();

Bug 5 fix: rankings should start at 1, not 0:

int rank = 1;

Bug 6 & 7 — CommitBot

Lets a user generate a random commit message (commit new), save the last-generated one (commit save), and reload it later (commit load). Two independent bugs:

Bug 6 — generated message never recorded as “last”:

if (message.endsWith("new")) {
    try {
        String commit = generateMessage();
        return commit;
    } catch (IOException | URISyntaxException e) {
        return "IOException trying to fetch commit message";
    }
}

Bug 7 — load reads from the wrong map:

if (message.endsWith("load")) {
    if (!lastMessages.containsKey(username)) {
        return "No saved message to load";
    }
    return lastMessages.get(username);
}

Bug 6 fix: commit new never stores the generated message into lastMessages, so commit save (which reads from lastMessages) always sees a stale (or missing) value:

String commit = generateMessage();
lastMessages.put(username, commit);
return commit;

Bug 7 fix: commit load should read the saved message, not the last generated one — otherwise load just repeats whatever was most recently generated, ignoring commit save entirely:

if (message.endsWith("load")) {
    if (!savedMessages.containsKey(username)) {
        return "No saved message to load";
    }
    return savedMessages.get(username);
}

Bug 8 — RecursiveFibonacci (bonus)

Tests throw a StackOverflowError — a recursive method’s base case is either missing or unreachable, so it recurses forever (until the call stack runs out of memory).

The recursive calculate(n, x) only has a base case for n == 1. When x > 1, the recursive subtraction steps by more than 1 at a time, so n can skip straight past 1 into negative numbers — n == 1 is never reached, and the recursion never terminates.

Two possible fixes (given \(\mathcal{F}(1) = \mathcal{F}(2) = 1\)):

// Preferred: widen the existing base case so it can't be skipped over.
public static int calculate(int n, int x) {
    if (n <= 2) {
        return 1;
    }
    // ...
}
// Alternative: add an explicit n == 0 (or n < 1) base case.
public static int calculate(int n, int x) {
    if (n == 0) { // or n < 1
        return 0;
    }
    // ...
}

The first (n <= 2) is preferred: the second approach still lets recursive calls that violate the method’s own precondition (n >= 1) run to completion instead of eliminating them.

Bug 9 — CachedFibonacci (challenge)

Gives incorrect results for larger Fibonacci numbers. The cache moves the most-recently-used entry to the front of two parallel arrays (cacheKeys/cacheValues) each lookup:

int key = cacheKeys[index], value = cacheValues[index];
for (int i = index - 1; i >= 0; --i) {
    cacheKeys[i + 1] = cacheKeys[i];
    cacheValues[i + 1] = cacheValues[i];
}
cacheKeys[0] = key;
cacheValues[0] = value;

After shuffling the found entry to the front of the cache, index (the variable tracking where the entry was found) is never updated to reflect its new position (0) — so subsequent logic that relies on index still thinks the entry lives at its old position:

cacheKeys[0] = key;
cacheValues[0] = value;
index = 0;

Exceptions

Applied class exercises for week 5, tracing execution through nested try/catch/finally blocks — reinforcing java-exceptions. Uses this exception hierarchy throughout:

class E1 extends Exception {};
class E2 extends E1 {};
class E3 extends E1 {};
class E4 extends E3 {};
class E5 extends E3 {};

class F1 extends Exception {};
class F2 extends F1 {};
class F3 extends F1 {};
class F4 extends F1 {};

On the HTML site, fill in each blank with your answer (as a quoted string, in the form x, y, e.g. "12, 20"), then click Run Code to check it. In the PDF, the Working callout is shown as a static answer key instead (interactive checking needs a browser).

Question 1

x and y are member variables, both starting at 0:

public void exercise1() {
    try {
        try {
            f();
            y += 10;
        } catch (F3 e) {
            x += 1;
        } catch (F1 e) {
            x += 10;
        } catch (F4 e) {
            x += 100;
        } catch (E5 e) {
            y += 1;
            throw e;
        } finally {
            x += 2;
        }
        y += 20;
    } catch (Exception e) {
        y += 100;
    }
}

What are x, y after exercise1() is called, if f() throws each of the following?

f() throws F2

f() throws F3

f() throws F4

f() throws E4

f() throws E5

  • F2: not caught by the inner catch (F3), catch (F4), or catch (E5), but is an F1 (F2 extends F1) → caught by catch (F1 e): x += 10. finally always runs: x += 2x = 12. No exception escapes the inner try, so y += 20 still runs → y = 20.
  • F3: directly caught by catch (F3 e): x += 1. finally: x += 2x = 3. y += 20 runs → y = 20.
  • F4: matches catch (F1 e) before reaching catch (F4 e), since catch clauses are checked in order and F4 extends F1 — so it’s caught by the F1 handler: x += 10. finally: x += 2x = 12. y += 20 runs → y = 20.
  • E4: none of the inner catch clauses match (F3/F1/F4 are all F-hierarchy, E5 is a sibling of E4’s ancestor E3, not a match) — only finally runs (x += 2x = 2), then the exception propagates to the outer catch (Exception e): y += 100y = 100.
  • E5: matches catch (E5 e): y += 1, then re-throws it. finally still runs: x += 2x = 2. The re-thrown exception escapes the inner try entirely (skipping y += 20) and is caught by the outer catch (Exception e): y += 100y = 1 + 100 = 101.

Does exercise1 need a throws declaration? No — every exception f() might throw is either caught by the inner try or (for the re-thrown E5) by the outer try. A throws declaration is only needed for checked exceptions that escape uncaught.

Question 2 — bonus

public void exercise2() throws ... {
    try {
        y += g();
    } catch (E3 e) {
        x += 100;
    }
}

public int g() throws ... {
    try {
        f();
        y += 10;
        return 400;
    } catch (E1 e) {
        x += 1;
    } finally {
        x += 10;
    }
    return 1;
}

f() throws E1

f() throws E2

f() throws E3

Whichever of E1/E2/E3 is thrown, g()’s catch (E1 e) matches (all are E1 or subclasses), so x += 1, then finally runs x += 10x = 11g() never reaches return 400 or return 1 explicitly inside the catch, so it falls through to the final return 1. Back in exercise2, y += g() adds that 1 to yy = 1. The exception never escapes g(), so exercise2’s own catch (E3 e) never runs (x stays at 11, not 111).

The throws declarations on exercise2 and g would differ depending on which exception type f() actually throws (only checked exceptions that can escape uncaught need declaring).

Question 3 — bonus

g() throws F2 (unconditionally, when called):

public void exercise3() throws ... {
    try {
        try {
            try {
                f();
                x += 100;
            } catch (F2 e) {
                x += 5;
                g();
            } catch (F1 e) {
                x += 1;
            } catch (E1 e) {
                y += 2;
                throw e;
            }
            y += 10;
        } catch (F2 f) {
            y += 100;
        }
    } catch (Exception e) {
        x += 1000;
    }
}

f() throws F3

f() throws F2

f() throws E1

f() throws F1

  • F3: not caught by catch (F2 e), but is caught by catch (F1 e) (F3 extends F1): x += 1. No further exception, y += 10 runs. Neither outer catch triggers → x = 1, y = 10.
  • F2: caught directly by catch (F2 e): x += 5, then calls g(), which itself throws F2 — this propagates out of the innermost try (skipping y += 10) to the middle catch (F2 f): y += 100. → x = 5, y = 100.
  • E1: none of F2/F1 catch it, but catch (E1 e) does: y += 2, then re-throws. This isn’t an F2, so the middle catch (F2 f) doesn’t match — it propagates to the outermost catch (Exception e): x += 1000. → x = 1000, y = 2.
  • F1: caught by catch (F1 e) directly (an F1 isn’t an F2): x += 1. y += 10 runs. → x = 1, y = 10 (same result as F3, since both only match the F1 handler).

Reference material

Java Exceptions

Introduced in 2026-03-19-exceptions (Lecture, Week 4).

What is an exception?

An unexpected event that occurs during program execution, e.g.:

System.out.println(5 / 0); // ArithmeticException: / by zero (NOT Infinity, unlike floating-point division)

Common causes: invalid user input, loss of network connection, physical limitations (e.g. out of disk space), code errors, opening an unavailable file.

When an exception occurs, Java creates an exception object containing information about the failure (Effective Java, Item 75: include failure-capture information in detail messages).

Exception hierarchy

Throwable
├── Error
└── Exception
    ├── RuntimeException (unchecked)
    └── IOException (checked)

RuntimeException — unchecked

Caused by a programming error; the compiler does not force you to handle these:

  • Improper use of an API → IllegalArgumentException
  • Null pointer access → NullPointerException
  • Out-of-bounds array access → ArrayIndexOutOfBoundsException
  • Dividing by 0 → ArithmeticException

IOException — checked

Checked by the compiler at compile-time — the programmer is prompted (via throws) to handle these:

  • Opening a file that doesn’t exist → FileNotFoundException
  • Reading past the end of a file → EOFException
  • A connection attempt to a remote host fails → ConnectException

try/catch

Place code that might throw inside try; every try is followed by a catch:

int result;
try {
    result = 5 / 0;
} catch (ArithmeticException e) {
    System.out.println(e); // print out the error message
    result = Integer.MAX_VALUE; // fallback value
}
  1. When an exception occurs, the rest of the try block is skipped.
  2. The catch block catches it and its statements execute.
  3. If nothing in try throws, catch is skipped entirely.

throw and throws

  • throw explicitly throws a single exception:
public static void checkEntry(int age) {
    if (age < 18) {
        throw new IllegalArgumentException("Access denied");
    }
}
  • throws (in a method declaration) declares the exception types a method might produce, so callers are prompted to handle them:
public static void findFile() throws FileNotFoundException {
    File newFile = new File("file.txt");
    FileInputStream stream = new FileInputStream(newFile);
}

finally

Runs regardless of whether an exception was thrown; optional.

try {
    // code
} catch (ExceptionType1 e1) {
    // catch block
} finally {
    // always executes
}

Worked example — what does this print?

try {
    System.out.println("Good Morning");
    throw new FileNotFoundException();
    System.out.println("the earth says"); // never reached
} catch (Exception e) {
    System.out.println("hello");
} finally {
    System.out.println("world");
}

Prints Good Morning, hello, world — the throw immediately skips the rest of try (so "the earth says" never prints), catch runs since FileNotFoundException is an Exception, and finally always runs last.

Custom exceptions

Extend Exception (or a subclass) to define your own:

public class ItemNotFoundException extends Exception {
    public ItemNotFoundException() {
        super("Item not found");
    }
    public ItemNotFoundException(String message) {
        super(message);
    }
}

public class Store {
    private List<String> items;

    public Store() {
        items = new ArrayList<>();
        items.add("apple");
        items.add("banana");
        items.add("orange");
    }

    public String findItem(String itemName) throws ItemNotFoundException {
        for (String item : items) {
            if (item.equals(itemName)) {
                return item;
            }
        }
        throw new ItemNotFoundException("Item '" + itemName + "' not found in the list.");
    }
}

Java JUnit

Introduced in 2026-03-26-testing (Lecture, Week 5). See java-testing for the broader testing concepts JUnit is used to implement.

What is JUnit?

An open-source test automation framework for Java — one of many xUnit frameworks (JUnit, NUnit, CPPUnit, PyUnit…). This course uses JUnit 4. Depending on how software is structured, unit-testing frameworks like JUnit can also be used for other kinds of automated testing (the term “unit testing” is sometimes loosely used to mean any automated test).

Writing tests

Tests are defined in classes — conventionally one test class per real class. @Test marks a method as a test JUnit should run; test methods take no parameters and return void, and should be named for what they check.

public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() { return name; }
    public int getAge() { return age; }
}
import org.junit.Test;
import static org.junit.Assert.assertEquals;

public class PersonTest {
    @Test
    public void testGetAge() {
        Person p1 = new Person("Jack", 10);
        assertEquals(10, p1.getAge());
    }

    @Test
    public void testGetName() {
        Person p1 = new Person("Jack", 10);
        assertEquals("Jack", p1.getName());
    }
}

@Before and @After

Each test method should be independent, but common setup (e.g. object creation) can be factored out:

public class PersonTest {
    private Person person;

    @Before
    public void setUp() {
        person = new Person("Jack", 10);
    }

    @After
    public void tearDown() {
        person = null;
    }
    // ...
}

@Before runs before each test; @After runs after each test.

Assert

Some static methods on Assert:

  • assertEquals — asserts two objects are equal.
  • assertArrayEquals — asserts two object arrays are equal.
  • assertFalse / assertTrue — asserts a condition is false/true.
  • assertSame / assertNotSame — asserts two objects do/don’t refer to the same object.

Checking for exceptions

Don’t catch the exception yourself — tell the test runner which exception type to expect via @Test(expected = ...), and let it catch it:

@Test(expected = EOFException.class)
public void testExceptions() throws EOFException {
    callThatShouldThrowEOFException();
}

Note the .class after the exception type is required.

Black box vs white box JUnit tests

  • Black box: write tests purely from the specification/Javadoc, without looking at the implementation, then apply smoke tests / boundary tests / equivalence classes (see java-testing) to decide what to check — tests must not violate the method’s preconditions.
  • White box: design tests using knowledge of the internal logic, aiming to cover branches, loops, and edge cases (e.g. targeting 100% branch coverage) — e.g. for a calculateDiscount(amount, isMember) method with nested if/else branches on both parameters, white-box tests are written to exercise every branch combination (amount ≤ 0; member above/at-or-below 100; non-member above/at-or-below 100).

Java Testing

Introduced in 2026-03-26-testing (Lecture, Week 5).

Levels of testing

  1. Unit testing — check that each “unit” in the project behaves correctly.
  2. Integration testing — check that components work together and that the interfaces between components work as expected.
  3. System testing — does the system as a whole work correctly?
  4. (User) acceptance testing — do users of the system agree that the system does what it’s supposed to do?

These form a pyramid, from many low-level unit tests at the base up to a few acceptance tests at the top.

Regression testing

During development, testing helps answer two questions: does the new stuff work, and have we broken things that used to work (regressed)? Regression testing ensures old features keep working as new features are introduced.

Black box vs white/glass box testing

  • Black box — the software has inputs and outputs to test, but the implementation is unknown to you; you test according to the specification (see java-specification) — what it’s supposed to do.
  • White/glass box — testing designed with knowledge of the internal implementation, so tests can pay special attention to cases where the implementation is complicated.

Black box techniques

Smoke tests — test the common-case functionality first (“can it run?”), to decide whether more rigorous testing is worthwhile. The name comes from electronics testing: plug in a board, turn on the power — if you see smoke, stop.

Boundary tests — investigate extremes and corner cases, since that’s where bugs often occur:

Input type Try
Whole number 0, -1, minimum value, maximum value
Floating point 0, -1, NaN, infinity
Collection (array, list, set…) empty, one element, large
Reference type null
Resource (file, link) non-existent resource

Equivalence classes — groups of inputs that the system treats the same way. E.g. for getCurrentPassengers() on a bus with capacity 80: Valid-Empty (0), Valid-Normal (1-79), Valid-Full (80), Invalid (anything else). The corresponding boundary tests probe each class’s edges: -1, 0, 1 (around empty), 79, 80, 81 (around full capacity) — giving a minimal boundary set of -1, 0, 1, 79, 80, 81.

White box technique: code coverage

Of all the ways a program could run, how many are covered by the tests? Three levels, from weakest to strongest:

  1. Statement coverage — every statement is executed at least once.
  2. Branch coverage — every branch is tested for both the true and false case.
  3. Path coverage — every distinct path through the code is traversed.

Example:

public void register(int x) {
    if (x > 0) {
        positives += 1;
    }
    if (x % 2 == 0) {
        evens += 1;
    }
}
  • Statement coverage: x = 2 alone covers every line (both if bodies run).
  • Branch coverage: x = 2 and x = -1 together cover both branches of each if (true/false).
  • Path coverage: needs all 4 combinations of the two conditions: x=2 (true, true), x=1 (true, false), x=-2 (false, true), x=-1 (false, false).

Loops make exhaustive path coverage infeasible — a loop like for (int i = 0; i < 100; i++) { if (f(i, k)) { j++; } } has \(2^{100}\) paths (over a billion tests/second would still take ~40.2 trillion years, about 2900 times the age of the universe). Instead, path coverage for loops is approximated: treat 0, 1, and 2-or-more iterations (of a loop or recursive call) as equivalent (“engineers’ induction: one, two, three — that’s good enough for me”).

Test Driven Development (TDD)

Originates from the Agile manifesto and Extreme Programming. Cycle: write a (failing) testwrite code until the test passesrefactor → write the next test. Developers write small test cases for every feature based on their initial understanding, and only modify/write new code when a test fails (avoiding duplicated test scripts) — the tests determine when code is “ready”.