Welcome back, future Java masters! Up until now, we’ve focused a lot on making our code work. But what happens when your code works, but it’s hard to read, difficult to change, or breaks unexpectedly when you touch it? That’s where the journey from “working code” to “quality code” begins!
In this chapter, we’re going to dive into three interconnected practices that are absolutely crucial for any professional developer: Clean Code, Refactoring, and Unit Testing. These aren’t just fancy terms; they are the bedrock of building robust, maintainable, and production-ready applications. You’ll learn why writing clear, understandable code is paramount, how to improve existing code without breaking it, and how to build confidence in your software with automated tests. Get ready to elevate your coding game!
To make the most of this chapter, you should be comfortable with basic Java syntax, object-oriented programming concepts like classes, objects, methods, and variables, and have a fundamental understanding of how to set up and run a simple Java project (preferably with Maven, as we’ll be using it for dependency management). If you’ve been following along with the previous chapters, you’re perfectly equipped!
What is “Clean Code”?
Imagine reading a book where sentences are jumbled, paragraphs are endless, and character names change without warning. Frustrating, right? Writing “clean code” is the programming equivalent of writing a clear, well-structured, and easy-to-understand book.
Clean code is code that is easy to read, easy to understand, easy to change, and easy to extend. It’s code that minimizes confusion and maximizes clarity for anyone who has to work with it – including your future self!
Why Does Clean Code Matter So Much?
- Collaboration: Software development is rarely a solo sport. Clean code makes it easier for team members to understand and contribute to each other’s work.
- Maintainability: Over 80% of a software’s lifecycle is spent on maintenance (bug fixes, new features). Clean code drastically reduces the effort required for this.
- Reduced Technical Debt: “Technical debt” is like accumulating interest on a loan – it’s the cost of choosing an easy, but poorly designed, solution now, which will cost more in the long run. Clean code helps you avoid this.
- Debugging: When bugs inevitably appear, clean code makes it much faster to pinpoint and fix the problem.
- Extensibility: When requirements change or new features need to be added, clean code allows you to integrate them smoothly without breaking existing functionality.
Key Principles of Clean Code
While there are entire books dedicated to this topic (like Robert C. Martin’s “Clean Code”), here are some foundational principles:
- Meaningful Names: Variables, methods, and classes should have names that clearly describe their purpose.
- Bad:
int x;,void doIt(); - Good:
int customerAge;,void processOrder();
- Bad:
- Small Functions/Methods: Each method should do one thing, and do it well. If a method is longer than 10-15 lines, it’s often a sign it’s doing too much.
- Single Responsibility Principle (SRP): A class or module should have only one reason to change. This means it should have only one primary responsibility.
- Don’t Repeat Yourself (DRY): Avoid duplicating code. If you find yourself writing the same logic multiple times, it’s a candidate for extraction into a reusable method or class.
- Comments (When Necessary): Good code is often self-documenting. Comments should explain why something is done, not what it does (unless the “what” is truly complex and not obvious from the code itself).
Refactoring: Improving Code Without Changing Behavior
Refactoring is the process of restructuring existing computer code without changing its external behavior. Think of it like tidying up your room: you move furniture around, organize your drawers, and throw out clutter, but it’s still your room, and all your belongings are still there and accessible.
Why Refactor?
- Improve Design: Over time, code can become messy. Refactoring helps to improve the internal structure and design of your software.
- Increase Readability: Makes the code easier to understand for yourself and others.
- Reduce Complexity: Breaks down complex logic into simpler, more manageable pieces.
- Prepare for New Features: A well-refactored codebase is much easier to extend with new functionality.
- Find Bugs: The process of refactoring often helps uncover hidden bugs or logical flaws you might not have noticed before.
When to Refactor?
- Before adding a new feature: Clean up the area of code you’re about to modify.
- When fixing a bug: Before diving into the fix, refactor the surrounding code to make it easier to understand the problem.
- During code reviews: Identify areas for improvement.
- When you notice “code smells”: These are indicators in the code that suggest a deeper problem (e.g., long methods, duplicate code, classes with too many responsibilities).
Common Refactoring Techniques
- Extract Method: Turn a code fragment into a new method whose name explains the purpose of the fragment.
- Rename Variable/Method/Class: Change the name of an element to better communicate its purpose.
- Introduce Explaining Variable: Replace a complex expression with a temporary variable that explains the purpose of the expression.
Unit Testing: The First Line of Defense
Unit testing is a software testing method where individual units or components of software are tested. A “unit” could be the smallest testable part of an application, such as a method or a class. The goal is to validate that each unit of the software performs as designed.
Why Unit Test?
- Catch Bugs Early: Unit tests find bugs at the earliest possible stage of development, where they are cheapest and easiest to fix.
- Enable Confident Refactoring: If you have a comprehensive suite of unit tests, you can refactor your code with confidence, knowing that if you break something, a test will fail immediately.
- Provide Living Documentation: Well-written unit tests serve as executable examples of how to use your code and what its expected behavior is.
- Improve Design: Writing tests often forces you to think about the design of your code, leading to more modular and testable components.
- Faster Feedback Loop: Instead of running the entire application to check a small change, you can run a specific unit test in milliseconds.
Introducing JUnit 5
For Java, the de-facto standard for unit testing is JUnit. The latest major version, JUnit 5, is a powerful and flexible testing framework. As of late 2025, JUnit Jupiter (the programming model for JUnit 5) is stable and widely adopted. We’ll use it to write our tests.
A common best practice related to unit testing is Test-Driven Development (TDD). In TDD, you write a failing test first, then write just enough code to make that test pass, and finally refactor your code. This cycle (Red-Green-Refactor) ensures you always have tests and drives a clean design.
Step-by-Step Implementation: Building & Testing a Calculator
Let’s put these concepts into practice. We’ll start with a slightly “messy” Calculator class, refactor it using clean code principles, and then write unit tests for it using JUnit 5.
First, let’s make sure our project is set up to handle JUnit 5. We’ll assume you have a Maven project.
Step 1: Add JUnit 5 to Your Maven Project
Open your pom.xml file. We need to add the JUnit Jupiter API and Engine dependencies.
<!-- Add these to your <dependencies> section in pom.xml -->
<dependencies>
<!-- Existing dependencies... -->
<!-- JUnit Jupiter API (for writing tests) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.10.1</version> <!-- Check Maven Central for latest stable in 2025 if newer than 5.10.1 -->
<scope>test</scope>
</dependency>
<!-- JUnit Jupiter Engine (for running tests) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.10.1</version> <!-- Must match API version -->
<scope>test</scope>
</dependency>
<!-- JUnit Vintage Engine (if you have old JUnit 3/4 tests, otherwise optional) -->
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>5.10.1</version> <!-- Must match API version -->
<scope>test</scope>
</dependency>
</dependencies>
<!-- Also ensure your Maven Surefire Plugin is configured to run JUnit 5 tests -->
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.3</version> <!-- Use a recent version for Surefire -->
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.12.1</version> <!-- Use a recent version for Compiler -->
<configuration>
<source>25</source> <!-- Assuming JDK 25 -->
<target>25</target> <!-- Assuming JDK 25 -->
</configuration>
</plugin>
</plugins>
</build>
Explanation:
- We’ve added
junit-jupiter-apiandjunit-jupiter-engine. Theapiis what you write your tests against, and theengineis what actually runs them. - The
versionfor JUnit 5 is specified as5.10.1. As of late 2025, this is a very recent stable version. Always check Maven Central for the absolute latest stable release if you want to be completely cutting-edge. <scope>test</scope>means these dependencies are only needed for compiling and running tests, not for the main application code.- We’ve also configured the
maven-surefire-plugin(which runs tests) andmaven-compiler-pluginto work with JDK 25. Oracle JDK 25 was released in September 2025, making it the most current non-LTS release, while JDK 21 is the current LTS. For new development, using the latest stable (JDK 25) is a great choice. You can find official documentation for JDK 25 here: docs.oracle.com/en/java/javase/25/.
Run mvn clean install in your project’s root directory to download these dependencies.
Step 2: Our Initial “Messy” Calculator Class
Let’s create a new Java class named Calculator.java in your src/main/java/com/example/app directory (adjust package as needed).
// src/main/java/com/example/app/Calculator.java
package com.example.app;
public class Calculator {
// A method that does too much and has unclear variable names
public double calculateTotal(double val1, double val2, String op, double discountPercent) {
double result;
if (op.equals("add")) {
result = val1 + val2;
} else if (op.equals("sub")) {
result = val1 - val2;
} else if (op.equals("mul")) {
result = val1 * val2;
} else if (op.equals("div")) {
if (val2 == 0) {
throw new IllegalArgumentException("Cannot divide by zero!");
}
result = val1 / val2;
} else {
throw new IllegalArgumentException("Unknown operation: " + op);
}
// Apply discount if any
if (discountPercent > 0) {
result = result * (1 - (discountPercent / 100));
}
return result;
}
public static void main(String[] args) {
Calculator c = new Calculator();
System.out.println("Adding 10 and 5 with no discount: " + c.calculateTotal(10, 5, "add", 0));
System.out.println("Subtracting 10 from 5 with 10% discount: " + c.calculateTotal(10, 5, "sub", 10));
}
}
What’s “messy” here?
calculateTotaldoes too much: it performs arithmetic and applies a discount. (Breaks SRP!)- Variable names like
val1,val2,opare okay but could be more descriptive in context. - The
if-else ifchain for operations is long and could be more modular.
Step 3: Refactoring for Clean Code
Let’s refactor this step-by-step.
Refactoring Step 3.1: Extracting Basic Operations
We can extract the core arithmetic operations into their own, smaller, single-purpose methods. This adheres to the “Small Functions” principle.
Modify Calculator.java:
// src/main/java/com/example/app/Calculator.java
package com.example.app;
public class Calculator {
// New, smaller, single-purpose methods
public double add(double operand1, double operand2) {
return operand1 + operand2;
}
public double subtract(double operand1, double operand2) {
return operand1 - operand2;
}
public double multiply(double operand1, double operand2) {
return operand1 * operand2;
}
public double divide(double operand1, double operand2) {
if (operand2 == 0) {
throw new IllegalArgumentException("Cannot divide by zero!");
}
return operand1 / operand2;
}
// Now, let's refactor calculateTotal to use these
public double calculateTotal(double val1, double val2, String op, double discountPercent) {
double result;
// Refactored to use our new, clean methods
if (op.equals("add")) {
result = add(val1, val2); // Using extracted method
} else if (op.equals("sub")) {
result = subtract(val1, val2); // Using extracted method
} else if (op.equals("mul")) {
result = multiply(val1, val2); // Using extracted method
} else if (op.equals("div")) {
result = divide(val1, val2); // Using extracted method
} else {
throw new IllegalArgumentException("Unknown operation: " + op);
}
// Apply discount if any
if (discountPercent > 0) {
result = result * (1 - (discountPercent / 100));
}
return result;
}
public static void main(String[] args) {
Calculator c = new Calculator();
System.out.println("Adding 10 and 5 with no discount: " + c.calculateTotal(10, 5, "add", 0));
System.out.println("Subtracting 10 from 5 with 10% discount: " + c.calculateTotal(10, 5, "sub", 10));
}
}
Explanation:
- We’ve introduced
add,subtract,multiply, anddividemethods. Each does one specific thing, making them easier to understand, test, and reuse. - The
calculateTotalmethod now calls these new methods, delegating the actual arithmetic. It’s already looking cleaner!
Refactoring Step 3.2: Extracting Discount Logic
The calculateTotal method still handles both the arithmetic and the discount. Let’s extract the discount logic into its own method.
Modify Calculator.java again:
// src/main/java/com/example/app/Calculator.java
package com.example.app;
public class Calculator {
public double add(double operand1, double operand2) {
return operand1 + operand2;
}
public double subtract(double operand1, double operand2) {
return operand1 - operand2;
}
public double multiply(double operand1, double operand2) {
return operand1 * operand2;
}
public double divide(double operand1, double operand2) {
if (operand2 == 0) {
throw new IllegalArgumentException("Cannot divide by zero!");
}
return operand1 / operand2;
}
// New method to apply discount - adheres to SRP!
private double applyDiscount(double amount, double discountPercentage) {
if (discountPercentage < 0 || discountPercentage > 100) {
throw new IllegalArgumentException("Discount percentage must be between 0 and 100.");
}
return amount * (1 - (discountPercentage / 100));
}
// Refactored calculateTotal method
public double calculateTotal(double val1, double val2, String op, double discountPercent) {
double result;
if (op.equals("add")) {
result = add(val1, val2);
} else if (op.equals("sub")) {
result = subtract(val1, val2);
} else if (op.equals("mul")) {
result = multiply(val1, val2);
} else if (op.equals("div")) {
result = divide(val1, val2);
} else {
throw new IllegalArgumentException("Unknown operation: " + op);
}
// Now calling the extracted discount method
if (discountPercent > 0) {
result = applyDiscount(result, discountPercent);
}
return result;
}
public static void main(String[] args) {
Calculator c = new Calculator();
System.out.println("Adding 10 and 5 with no discount: " + c.calculateTotal(10, 5, "add", 0));
System.out.println("Subtracting 10 from 5 with 10% discount: " + c.calculateTotal(10, 5, "sub", 10));
System.out.println("Multiplying 10 by 5 with 50% discount: " + c.calculateTotal(10, 5, "mul", 50));
}
}
Explanation:
- We created a
privatemethodapplyDiscount. It’sprivatebecause it’s an internal helper method for theCalculatorclass; we don’t necessarily want other parts of the application calling it directly. calculateTotalis now much simpler. It determines the base arithmetic result, and then (conditionally) applies the discount using the dedicatedapplyDiscountmethod. This adheres much better to the Single Responsibility Principle.
Our Calculator class is now much cleaner and easier to understand!
Step 4: Writing Unit Tests for Our Refactored Calculator
Now that our code is clean, let’s write some tests to ensure it works correctly and stays that way. We’ll create a test class for Calculator. In a Maven project, test classes typically go into src/test/java/com/example/app.
Create a new file CalculatorTest.java in src/test/java/com/example/app.
// src/test/java/com/example/app/CalculatorTest.java
package com.example.app;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*; // Static import for easy access to assertion methods
public class CalculatorTest {
private Calculator calculator; // Declare an instance of our Calculator
// @BeforeEach runs before *each* test method
@BeforeEach
void setUp() {
calculator = new Calculator(); // Initialize a new Calculator for each test
}
@Test // This annotation marks a method as a test method
@DisplayName("Test basic addition of two positive numbers") // A descriptive name for the test
void testAddPositiveNumbers() {
double expected = 15.0;
double actual = calculator.add(10.0, 5.0);
assertEquals(expected, actual, "Adding 10.0 and 5.0 should be 15.0");
}
@Test
@DisplayName("Test subtraction with a positive and negative number")
void testSubtractPositiveAndNegative() {
double expected = 15.0; // 10 - (-5) = 15
double actual = calculator.subtract(10.0, -5.0);
assertEquals(expected, actual, "Subtracting -5.0 from 10.0 should be 15.0");
}
@Test
@DisplayName("Test division by zero should throw an IllegalArgumentException")
void testDivideByZero() {
// assertThrows verifies that a specific exception is thrown
assertThrows(IllegalArgumentException.class, () -> calculator.divide(10.0, 0.0),
"Dividing by zero should throw IllegalArgumentException");
}
@Test
@DisplayName("Test calculateTotal with addition and a discount")
void testCalculateTotalWithAdditionAndDiscount() {
double expected = 13.5; // (10 + 5) * (1 - 10/100) = 15 * 0.9 = 13.5
double actual = calculator.calculateTotal(10.0, 5.0, "add", 10.0);
assertEquals(expected, actual, 0.001, "Calculation with add and 10% discount should be 13.5"); // Delta for double comparison
}
@Test
@DisplayName("Test calculateTotal with an invalid operation")
void testCalculateTotalWithInvalidOperation() {
assertThrows(IllegalArgumentException.class, () -> calculator.calculateTotal(10.0, 5.0, "unknown", 0),
"Invalid operation should throw IllegalArgumentException");
}
@Test
@DisplayName("Test applyDiscount with valid percentage")
void testApplyDiscountValid() {
// Since applyDiscount is private, we'd typically test it indirectly through public methods.
// However, for demonstration, if it were public, this is how you'd test it.
// For truly private methods, consider if it's better to test the public method that uses it.
// For now, let's assume we temporarily made it public for this specific test case.
// In real-world, if a private method needs direct testing, it might be a sign it should be its own class.
// Let's test it via calculateTotal for now, as that's its public interface use case.
// If we *really* wanted to test private methods, reflection or changing visibility are options,
// but generally discouraged as it couples tests to implementation details.
// So, instead of trying to call applyDiscount directly, we'll ensure calculateTotal covers it.
// The previous test `testCalculateTotalWithAdditionAndDiscount` already covers this.
// Let's add a simple test for the `multiply` method we extracted.
double expected = 50.0;
double actual = calculator.multiply(10.0, 5.0);
assertEquals(expected, actual, "Multiplying 10.0 and 5.0 should be 50.0");
}
@Test
@DisplayName("Test applyDiscount with invalid percentage (negative)")
void testApplyDiscountInvalidNegative() {
// We'll test this through the calculateTotal method, which uses applyDiscount
assertThrows(IllegalArgumentException.class, () -> calculator.calculateTotal(100.0, 0.0, "add", -10.0),
"Negative discount percentage should throw IllegalArgumentException");
}
@Test
@DisplayName("Test applyDiscount with invalid percentage (over 100)")
void testApplyDiscountInvalidOver100() {
assertThrows(IllegalArgumentException.class, () -> calculator.calculateTotal(100.0, 0.0, "add", 110.0),
"Discount percentage over 100 should throw IllegalArgumentException");
}
}
Explanation:
package com.example.app;: Matches the package of ourCalculatorclass.importstatements: Bring in necessary JUnit 5 classes.static org.junit.jupiter.api.Assertions.*lets us useassertEquals,assertThrows, etc., directly withoutAssertions.prefix.@BeforeEach void setUp(): This method runs before every single test method. It’s perfect for setting up a freshCalculatorinstance, ensuring each test starts with a clean slate and no side effects from previous tests.@Test: This annotation tells JUnit that the following method is a test method it should execute.@DisplayName("..."): Provides a human-readable name for the test, which is very helpful when looking at test reports.assertEquals(expected, actual, message): This is an assertion method. It checks ifexpectedandactualvalues are equal. If not, the test fails, and themessageis displayed. Fordoublecomparisons, it’s often good practice to include a delta (e.g.,assertEquals(expected, actual, 0.001)) to account for potential floating-point inaccuracies.assertThrows(ExceptionType.class, () -> { ... }, message): This assertion is used to verify that a specific type of exception is thrown when a certain piece of code is executed. This is crucial for testing error conditions.- Notice how we test the
privateapplyDiscountmethod indirectly by testingcalculateTotalwith discount values. This is generally preferred over using reflection to access private methods, as it tests the public behavior that relies on the private implementation.
Step 5: Running Your Tests
With your pom.xml, Calculator.java, and CalculatorTest.java in place, you can run your tests!
Open your terminal or command prompt, navigate to your project’s root directory (where pom.xml is located), and run:
mvn test
Maven will compile your main code, compile your test code, and then execute all tests. You should see output indicating that all tests passed successfully!
If you’re using an IDE like IntelliJ IDEA or Eclipse, you can usually right-click on the CalculatorTest.java file and select “Run ‘CalculatorTest’”. The IDE will show you a nice graphical report of your test results.
Mini-Challenge: Extend and Test
You’ve done a fantastic job so far! Now, it’s your turn to apply what you’ve learned.
Challenge:
- Add a new method to the
Calculatorclass calledpower(double base, double exponent)that calculatesbaseraised to the power ofexponent. (Hint: Look upMath.pow()in Java’s standard library). - Following the TDD principle, first, write a new unit test in
CalculatorTest.javafor yourpowermethod. Make sure this test fails initially because thepowermethod doesn’t exist yet. - Then, implement the
powermethod inCalculator.javato make your test pass. - Finally, add another test case to
CalculatorTest.javato ensurecalculateTotalcorrectly handles a new operation type, “pow”, which uses your newpowermethod.
Hint:
- For the
powermethod, remember to handle edge cases if you want to be thorough (e.g., 0 to the power of 0, negative exponents, etc.), but for this challenge, focus on positive integers first. - When writing your test for
power, useassertEqualsand provide a clearDisplayName. - Remember to modify the
calculateTotalmethod’sif-else ifchain to include the new “pow” operation.
What to observe/learn:
This challenge reinforces the cycle of writing a failing test, implementing the feature, and then verifying the feature through its public interface. It also demonstrates how unit tests give you confidence when modifying existing code (like calculateTotal) or adding new features.
Common Pitfalls & Troubleshooting
- Not Running Tests Regularly: The biggest pitfall! Tests are only useful if they are consistently run. If you don’t run them, you won’t know if your recent changes broke something.
- Troubleshooting: Integrate
mvn testinto your daily development workflow. Most IDEs can be configured to run tests automatically or with a single click. Make sure your Continuous Integration (CI) pipeline also runs all tests.
- Troubleshooting: Integrate
- Over-Commenting or Under-Commenting:
- Over-commenting: Explaining what the code does when it’s already obvious from clear variable/method names. This leads to stale comments that can become inaccurate.
- Under-commenting: Not explaining why a particular non-obvious design choice was made, or why a complex algorithm is implemented in a specific way.
- Troubleshooting: Strive for self-documenting code through meaningful names and small methods. Use comments sparingly to explain intent, assumptions, or complex algorithms.
- Fear of Refactoring: Developers sometimes avoid refactoring because they’re afraid of breaking working code.
- Troubleshooting: This fear is largely mitigated by a robust suite of unit tests. If you have good test coverage, you can refactor with confidence, knowing that if you introduce a bug, a test will catch it. Refactoring without tests is indeed risky!
- Writing Untestable Code: If your classes are tightly coupled, have too many responsibilities, or rely heavily on external systems without proper abstraction, they become very hard to unit test.
- Troubleshooting: Design your classes with testability in mind from the start. Follow the Single Responsibility Principle. Use dependency injection (a topic for a future chapter!) to provide external dependencies rather than having classes create them directly.
Summary
Congratulations on completing Chapter 18! You’ve taken a significant leap towards becoming a truly professional Java developer. Here are the key takeaways:
- Clean Code is about writing code that is readable, understandable, maintainable, and extensible. It’s crucial for collaboration, reducing technical debt, and improving overall software quality.
- Refactoring is the disciplined process of restructuring existing code without changing its external behavior. It’s done to improve design, readability, and prepare for future changes.
- Unit Testing, using frameworks like JUnit 5, is about testing individual components of your code in isolation. It catches bugs early, provides confidence for refactoring, and acts as living documentation.
- We learned how to set up JUnit 5 in a Maven project (using
junit-jupiter-apiandjunit-jupiter-engineversion5.10.1or later for JDK 25). - We practiced incremental refactoring by extracting methods and applying the Single Responsibility Principle to a
Calculatorclass. - We wrote effective unit tests using
@Test,@BeforeEach,@DisplayName,assertEquals, andassertThrowsto verify the correct behavior of our refactored code.
By embracing Clean Code, Refactoring, and Unit Testing, you’re not just writing code that works; you’re crafting high-quality, resilient, and adaptable software – a hallmark of a true master.
What’s Next?
In the next chapter, we’ll continue to build on this foundation of quality. We’ll explore more advanced testing techniques, including integration testing, and perhaps even touch upon mocking, which is essential for isolating dependencies in unit tests. Get ready to solidify your understanding of building rock-solid applications!