CSSE2002 — Week 5 Notes
Testing
See csse2002 for course logistics.
Today’s outline
- Levels of Testing
- Test Frameworks
- Black Box Testing
- White Box Testing
- 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), orcatch (E5), but is anF1(F2 extends F1) → caught bycatch (F1 e):x += 10.finallyalways runs:x += 2→x = 12. No exception escapes the innertry, soy += 20still runs →y = 20. - F3: directly caught by
catch (F3 e):x += 1.finally:x += 2→x = 3.y += 20runs →y = 20. - F4: matches
catch (F1 e)before reachingcatch (F4 e), sincecatchclauses are checked in order andF4 extends F1— so it’s caught by theF1handler:x += 10.finally:x += 2→x = 12.y += 20runs →y = 20. - E4: none of the inner
catchclauses match (F3/F1/F4are allF-hierarchy,E5is a sibling ofE4’s ancestorE3, not a match) — onlyfinallyruns (x += 2→x = 2), then the exception propagates to the outercatch (Exception e):y += 100→y = 100. - E5: matches
catch (E5 e):y += 1, then re-throws it.finallystill runs:x += 2→x = 2. The re-thrown exception escapes the innertryentirely (skippingy += 20) and is caught by the outercatch (Exception e):y += 100→y = 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 += 10 → x = 11 — g() 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 y → y = 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 bycatch (F1 e)(F3 extends F1):x += 1. No further exception,y += 10runs. Neither outercatchtriggers →x = 1, y = 10. - F2: caught directly by
catch (F2 e):x += 5, then callsg(), which itself throwsF2— this propagates out of the innermosttry(skippingy += 10) to the middlecatch (F2 f):y += 100. →x = 5, y = 100. - E1: none of
F2/F1catch it, butcatch (E1 e)does:y += 2, then re-throws. This isn’t anF2, so the middlecatch (F2 f)doesn’t match — it propagates to the outermostcatch (Exception e):x += 1000. →x = 1000, y = 2. - F1: caught by
catch (F1 e)directly (anF1isn’t anF2):x += 1.y += 10runs. →x = 1, y = 10(same result asF3, since both only match theF1handler).
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
}- When an exception occurs, the rest of the
tryblock is skipped. - The
catchblock catches it and its statements execute. - If nothing in
trythrows,catchis skipped entirely.
throw and throws
throwexplicitly 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 nestedif/elsebranches 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
- Unit testing — check that each “unit” in the project behaves correctly.
- Integration testing — check that components work together and that the interfaces between components work as expected.
- System testing — does the system as a whole work correctly?
- (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:
- Statement coverage — every statement is executed at least once.
- Branch coverage — every branch is tested for both the
trueandfalsecase. - 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 = 2alone covers every line (bothifbodies run). - Branch coverage:
x = 2andx = -1together cover both branches of eachif(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) test → write code until the test passes → refactor → 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”.