Resource Safety, Unit Testing, and Maintainable Project Structure
try-with-resources, writing real JUnit tests, debugging technique, and organizing a Java project so it stays maintainable as it grows.
What you'll learn
- Use try-with-resources to guarantee a resource is closed even on failure
- Write a JUnit test with meaningful assertions covering both success and failure cases
- Organize a multi-class Java project into a clear, conventional package structure
Prerequisites
Explanation
Any resource that must eventually be released — a file handle, a network connection, a database connection — implements AutoCloseable, and try-with-resources (try (var reader = new BufferedReader(new FileReader(path))) { ... }) guarantees .close() is called automatically when the block exits, whether it completes normally or throws — this is both safer and shorter than a manual try { ... } finally { resource.close(); }, since it's impossible to forget the close call or accidentally skip it on one exit path. Multiple resources can be declared in the same try-with-resources, separated by semicolons, and they're closed in reverse order of declaration.
JUnit is the standard framework for automated Java tests. A test method is annotated @Test and typically follows arrange-act-assert: set up the inputs and any objects under test, call the method being tested, then assert the result with methods like assertEquals(expected, actual), assertTrue(condition), or assertThrows(SomeException.class, () -> methodThatShouldThrow()). A genuinely useful test suite covers both the expected, successful path and the failure/edge cases — a test file with only "happy path" tests gives false confidence, since bugs disproportionately hide in the edge cases (empty input, a boundary value, an invalid argument) nobody bothered to test.
Debugging a failing Java program effectively starts with reading the stack trace from the top down: the first line names the exception type and message, and the lines below it — each an at ClassName.methodName(FileName.java:lineNumber) — trace the exact call chain that led there, with the line closest to the top being where the exception was actually thrown. Reproducing a bug with the smallest possible input, adding a targeted System.out.println or a debugger breakpoint at the suspected point of divergence, and forming a specific hypothesis before changing code (rather than randomly editing until something appears to work) are the habits that separate efficient debugging from guesswork.
A maintainable project structure groups related classes into packages by feature or layer (com.example.enrollment, com.example.grading) rather than dumping every class into one default package, keeps test files in a parallel src/test/java tree mirroring the main source structure, and favors small classes with a single, clear responsibility over large classes that do many unrelated things — the same discipline this course has been building all along (encapsulation, favoring composition, coding to an interface) is what keeps a real, growing Java codebase navigable months later.
Example
The arrange-act-assert test shape, and try-with-resources' guaranteed-cleanup behavior, both modeled in JS.
// Modeling try-with-resources: cleanup is guaranteed even when the body throws.
class Resource {
constructor(name) { this.name = name; this.closed = false; }
close() { this.closed = true; console.log(this.name + " closed"); }
}
function withResource(name, action) {
const resource = new Resource(name);
try {
return action(resource);
} finally {
resource.close(); // ALWAYS runs, mirroring try-with-resources' guarantee
}
}
// Arrange-act-assert, the shape every JUnit @Test method follows:
function testAdd() {
// arrange
const a = 2, b = 3;
// act
const result = a + b;
// assert
if (result !== 5) throw new Error("expected 5, got " + result);
console.log("testAdd passed");
}
testAdd();Try it yourself
Make the action inside withResource throw, and confirm the resource is still closed (check the console output order).
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Guided exercise
Guided exercise
Model try-with-resources for MULTIPLE resources: write withTwoResources(nameA, nameB, action) that creates two Resource-like objects, runs action(a, b), and guarantees BOTH are closed (in reverse order: b then a) even if action throws. Track closes in a shared array so the test can verify the order.
Checks: closes resources in reverse order on success · (supporting check) · both resources close in reverse order even when the action throws
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Independent exercise
Independent exercise
Model a small JUnit-style test runner: write runTests(tests) where tests is an array of {name, fn} objects; fn() throws on failure, returns normally on success. runTests should return {passed, failed} counts, running EVERY test even if one throws (don't let one failing test stop the rest, mirroring how a real test suite reports every test's result).
Checks: correctly counts a mix of passing and failing tests · handles an empty test list · one failing test does not prevent later tests from running
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Guided local lab
Add Exception Handling and Automated Unit Tests to a Structured Java Project
Runs on your computerExtend the Loan domain from this module with real input validation (custom exceptions) and a real JUnit 5 test suite, organized in the conventional src/main + src/test layout.
Required tools
- JDK (21 LTS or newer)
- Apache Maven (3.9+ (or Gradle 8+, adjust commands accordingly))
- A terminal (any)
Setup
- Create a Maven project: `mvn archetype:generate -DgroupId=com.visaspark.library -DartifactId=library-loans-tested -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false`.
- Add the JUnit 5 dependency to pom.xml (junit-jupiter, test scope) and configure maven-surefire-plugin for JUnit 5, or use your own build setup if you're not using Maven.
- Place production classes under src/main/java/com/visaspark/library/, and tests under src/test/java/com/visaspark/library/, mirroring the same package structure.
Project structure
library-loans-tested/
pom.xml
src/
main/java/com/visaspark/library/
Borrowable.java
Book.java
LoanValidationException.java
Loan.java
test/java/com/visaspark/library/
LoanTest.javaStarter files
src/main/java/com/visaspark/library/LoanValidationException.java
package com.visaspark.library;
public class LoanValidationException extends RuntimeException {
// TODO: add a constructor(String message) that calls super(message)
}
src/main/java/com/visaspark/library/Loan.java
package com.visaspark.library;
public class Loan {
private final Borrowable item;
private final String borrowerName;
public Loan(Borrowable item, String borrowerName) {
// TODO: throw LoanValidationException if item is null or borrowerName is null/blank
// TODO: otherwise assign both fields
this.item = item;
this.borrowerName = borrowerName;
}
public String summary() {
return borrowerName + " borrowed " + item.describe();
}
}
src/test/java/com/visaspark/library/LoanTest.java
package com.visaspark.library;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class LoanTest {
// TODO: @Test method: constructing a Loan with a valid item and borrower name succeeds,
// and summary() contains the borrower's name.
// TODO: @Test method: constructing a Loan with a null item throws LoanValidationException
// (use assertThrows).
// TODO: @Test method: constructing a Loan with a blank borrower name ("", or " ")
// throws LoanValidationException.
}
Requirements
- LoanValidationException extends RuntimeException with a message-taking constructor.
- Loan's constructor validates both arguments and throws LoanValidationException with a specific, useful message for each invalid case.
- LoanTest has at least 3 @Test methods: one success case and at least two distinct failure cases, each asserting the correct exception type.
- All tests pass when run via the build tool's test command.
Commands to run
Run the full test suite
mvn testCompile and package (also re-runs tests)
mvn package
Expected behavior
`mvn test` compiles the project and runs LoanTest, reporting all tests passed (BUILD SUCCESS), with the test output showing at least 3 tests executed and 0 failures.
Verify it yourself
mvn testExpected: BUILD SUCCESS, with a summary line reporting 3 or more tests run and 0 failures/errors
mvn test -Dtest=LoanTest#aTestMethodNameYouWroteExpected: Runs just that one test method and reports it passing
Troubleshooting
- `package org.junit.jupiter.api does not exist` — The JUnit 5 dependency is missing from pom.xml, or the surefire plugin isn't configured for JUnit 5 — check both.
- Tests run but assertThrows reports the wrong exception type or none at all — Confirm the validation check happens BEFORE any field assignment in the constructor, and that it actually throws LoanValidationException, not a different exception type or a silent return.
- `BUILD FAILURE` with a NullPointerException inside a test, not an assertion failure — This usually means a test called a method on an object that was never constructed successfully — check the test's arrange step ran without throwing when it wasn't supposed to.
Stuck? Get a hint.
Extension challenge
Add a fourth test asserting that the exception's message actually contains a useful, specific string (e.g. assertTrue(exception.getMessage().contains("borrowerName"))) by capturing the thrown exception from assertThrows' return value, rather than only checking the exception type.
When you've verified this locally, use the "Mark lesson complete" button below to record your progress.
Common mistakes
- Manually closing a resource in a finally block instead of using try-with-resources -- easy to get subtly wrong (forgetting a null-check on the resource, or closing in the wrong order) compared to letting the compiler generate it correctly.
- Writing only 'happy path' tests and skipping failure/edge cases -- a test suite with no failure-case coverage gives false confidence, since the actual bugs usually live in the edge cases nobody tested.
- Changing code randomly while debugging instead of forming a specific hypothesis first -- reproducing the smallest failing case and reading the full stack trace top-to-bottom is almost always faster than trial-and-error edits.
Knowledge check
Takeaway
try-with-resources removes an entire class of resource-leak bugs by generating correct cleanup automatically. A trustworthy test suite deliberately covers failure and edge cases, not just the happy path. Effective debugging starts with reading the stack trace, not editing code at random.
Summary
try-with-resources guarantees AutoCloseable resources are closed on every exit path, in reverse declaration order. JUnit @Test methods follow arrange-act-assert and should cover both success and failure paths. A conventional package structure (src/main, src/test, feature-based packages) keeps a growing project maintainable.
References
Your notes
Notes save automatically.
Finished this lesson?
Mark it complete to track your progress and schedule a future review.
AI tutor
The optional AI tutor isn't enabled in this deployment. All lessons, exercises, quizzes, and search work fully without it.