Unit Testing with JUnit
Introduction to Unit Testing
Definition: Unit testing involves testing individual units of source code, such as a single function, method, or class, in isolation from the rest of the system.
Core Purpose: To ensure code correctness in a quick, automated, and repeatable manner.
Mechanism: The developer provides specific inputs and defines expected outputs. The testing framework automatically checks if the actual output matches the expected output.
Isolation Strategy: It is essential to test system components independently whenever possible. If a complex system is tested as a whole and fails, it is difficult to isolate whether the failure occurred in the logic, the dependencies, or the test itself.
Granularity: Testing begins with very small "units" to isolate issues quickly. These simple tests are then aggregated to build a cohesive, reliable system.
The JUnit Framework
Overview: JUnit is a widely used testing framework for Java. The lecture focuses on JUnit 5 (with some mentions of JUnit 6).
The Console Launcher: While many developers use Integrated Development Environments (IDEs) like Eclipse, the instructor uses the Console Launcher to demystify the process. It demonstrates that JUnit is simply another program that executes test cases and prints formatted output.
Tooling and Environment:
Integrated tutorials are available for IDE setups.
For manual usage, the Console Launcher JAR file must be downloaded and placed in the project directory.
Version Fidelity: The lecture mentions version in slides, though version is the latest cited current version. Minor version differences are generally negligible for standard features.
Requirements for Writing JUnit Tests
Access Modifier: Test methods must not be
private. The JUnit launcher needs to be able to find and access the method; hiding it prevents the test from running.Return Type: JUnit tests must not return a value (they must be
void). Returning values often indicates that testing logic is being combined or delegated, which leads to lower-quality, non-isolated tests.Annotation: Methods must be marked with the
@Testnotation so the launcher can identify them as test cases.Isolation: Each test should be perfectly isolated; nothing should be passed out of the test method.
JUnit Assertions
Conceptual Goal: Assertions allow the developer to state a condition that must be true for the test to pass.
Common Assertions:
assertEquals(X, Y): Asserts that the expected value () equals the actual result () returned by the action.assertNotEquals(X, Y): Asserts that two values are not equal.assertTrue(condition): Asserts that the logical condition evaluates to true.assertFalse(condition): Asserts that the logical condition evaluates to false (often used instead of inverting a condition insideassertTrue).assertNull(object): Asserts that a reference is null.assertNotNull(object): Asserts that a reference is not null.assertThrows(exception.class, executable): Asserts that a specific block of code throws a specific exception.
Setup and Lifecycle Annotations
Code Reuse: To avoid writing repetitive setup code (e.g., populating a data structure for every single test), JUnit provides lifecycle hooks:
@BeforeEach: Runs a specific block of code before every individual test method. Useful for resetting data structures or states.@BeforeAll: Runs once before all tests in the class begin.@AfterEach: Runs after every individual test method (useful for cleanup like closing files).@AfterAll: Runs once after all tests in the class have finished.
Metadata:
@DisplayName: Allows the developer to provide a descriptive string for the test. This is superior to comments as it appears in the output reports, helping identify failed tests without looking at the source code.
The AAA Rule for High-Quality Tests
Arrange: Set up the objects and environment needed for the test (e.g., instantiate a class, configure a fake database).
Act: Execute the specific functionality being tested (e.g., call the
remove()method).Assert: Verify that the action produced the expected result (e.g., check if the size of the structure decreased or if the element is gone).
Best Practices:
One Act per Test: Avoid multiple actions in one test. Multiple acts make it harder to identify which specific operation caused a failure.
Simplicity: Tests should be as simple as possible to avoid introducing bugs into the test suite itself.
Isolation from External Dependencies: Tests should not rely on external databases or files. Instead, create small "fake" or "mock" versions to maintain isolation.
Unit vs. Integration Testing
Unit Tests: Focused on the smallest possible part of the program in isolation.
Integration Tests: Conducted after units are verified. These tests check if multiple units work together correctly when combined into a whole system.
Testing Strategies and Categories
Expected Cases: The "main path" or "happy path" where inputs are standard (e.g., ).
Edge Cases: Inputs at the outer limits of valid ranges, such as empty or full data structures, very large numbers, or the very start/end of a string.
Boundary Cases: Specific points at the edge of a logic gate, such as testing one second before midnight, exactly at midnight, and one second after midnight.
Equivalence Classes: Grouping similar inputs. If a function works for the number , it is likely to work for and . Testing one representative from the "positive integer" class is more efficient than testing all integers.
Exhaustive Testing: Testing every single possible input. This is only possible for very small domains, such as an
enumwith four values.LLMs in Testing: Large Language Models (LLMs) are useful for generating boring "boilerplate" code, such as repetitive test names and annotations, but they must be checked by humans to ensure they actually contain valid assertions.
Example: The Spell Checker
Scenario: A spell checker that returns
nullif a word contains numbers, strips punctuation, and converts words to lowercase before querying a database.Target Method:
bool containsNumbers(String word)Brainstormed Test Cases:
Standard Case: "Hello" \u2192 False.
Positive Case: "H3llo" \u2192 True.
Edge Case (Positioning): "1abc", "abc1", or "a1bc" to ensure the scanner doesn't just check the start or end.
Edge Case (Empty): "" \u2192 Useful for catching off-by-one errors in loops.
Null Case:
nullinput \u2192 Might return false or throw an exception depending on company policy.Symbolic Case: Checking symbols like or . These might be treated as symbols to be stripped rather than numbers.
Terminal Commands for JUnit
Compiling:
javac -cp .;junit-platform-console-standalone-1.12.0.jar ExampleTest.java(Note: The separator is ";" on Windows and ":" on Linux/macOS).Executing:
java -jar junit-platform-console-standalone-1.12.0.jar execute --select-class ExampleTest(Note: The.path may require quotes on Linux to handle spaces).
Questions & Discussion
Q: If you have multiple assertions in one test and one fails, how does JUnit tell you? A: It will give you a single "X" (failure) for the whole test case. This is why you should not put many unrelated tests into one test; it makes tracking the specific failure difficult. It is better to have separate, isolated tests for separate conditions.
Q: Can a test depend on
@BeforeAllsetup? A: Yes, the setup methods are considered part of the test unit. However, you must be careful. For example, testing aremove()method requires aninsert()method to have worked first. In this case, you should verify theinsert()andhasElement()methods independently before relying on them for more complex tests likeremove().Q: Should we test symbols like or ? A: This is a "Test Oracle" problem. You should consult the client or requirements. Likely, these are treated as symbols and stripped out, but identifying these edge cases early is valuable.