SECTION 1
Historical Context & Motivation
Imagine finishing a large Java project, running it, and discovering that something broke—but you have no idea where the problem started. Before automated testing frameworks existed, developers relied on manually running their programs, inspecting output with their eyes, and inserting System.out.println() statements everywhere to check values. This was slow, unreliable, and easy to forget. The need for a better approach led to the creation of unit testing—a method where small, individual pieces of code are tested automatically by other code.
The story of JUnit is closely tied to the rise of test-driven development (TDD) and the broader movement toward writing cleaner, more reliable software. Kent Beck, a software engineer known for pioneering extreme programming, created SUnit for the Smalltalk language in the late 1990s. He then partnered with Erich Gamma to adapt the concept for Java during a transatlantic flight, and JUnit was born. Today, JUnit is the most widely used testing framework in the Java ecosystem.
1994 • SUnit for Smalltalk
1997 • JUnit Is Born
2006 • JUnit 4 Released
@Test, making tests much easier to write and read. This version dominated Java testing for over a decade.2017 • JUnit 5 (Jupiter)
The central question JUnit answers is straightforward: how can we verify that our code does what we expect, quickly and automatically, every single time we make a change? Instead of manually checking your program's output, you write small test methods that run themselves and report pass or fail. This lesson introduces the core building blocks of JUnit so you can start structuring your own tests.
SECTION 2
Core Principles & Definitions
Before writing your first test, you need to understand the key ideas that make JUnit work. A unit test is a small piece of code that checks whether a specific method or function behaves correctly. The word "unit" refers to the smallest testable part of your program—usually a single method. JUnit provides a structured way to write, organize, and run these tests so you don't have to do it by hand.
Test Class
Calculator, your test class is named CalculatorTest.Test Method
@Test that runs a specific check. Each test method should test one behavior and have a descriptive name like testAddTwoPositiveNumbers().Assertions
assertEquals() and assertTrue() that compare the expected result to the actual result. If they don't match, the test fails.Annotations
@Test, @BeforeEach, and @AfterEach that tell JUnit how and when to run each method.Test Runner
KEY TAKEAWAY
SECTION 3
Visual Explanation — Anatomy of a JUnit Test
The diagram below shows the structure of a typical JUnit 5 test class and how its parts connect. Pay attention to how the annotations, test methods, and assertions work together in a clear, repeatable pattern.
@BeforeEach setup method (yellow border), multiple @Test methods (cyan border) with assertion calls inside, and an optional @AfterEach teardown method (pink border).Notice the pattern: JUnit runs @BeforeEach before every single test method, executes the @Test method, and then runs @AfterEach afterward. This means each test starts with a clean slate, so one test cannot accidentally affect another. This isolation is one of the most important ideas in unit testing—each test should be independent and repeatable.
SECTION 4
How JUnit Works — The Test Lifecycle
Understanding the test lifecycle helps you predict exactly when each part of your test class runs. JUnit 5 follows a strict sequence for every test method it discovers. Think of it as a recipe that JUnit repeats for each test: set up, test, tear down.
The Execution Order
- @BeforeAll — Runs once before all tests in the class (must be
static). Use it for expensive setup like opening a database connection. - @BeforeEach — Runs before each individual test method. Use it to create fresh objects so tests don't share state.
- @Test — The actual test method. Contains assertions that check expected vs. actual values.
- @AfterEach — Runs after each test method. Use it to release resources or reset variables.
- @AfterAll — Runs once after all tests finish (must be
static). Use it for final cleanup.
Common Assertion Methods
Assertions are the heart of every test. They are the lines of code that actually check whether your program produced the right result. JUnit 5 provides several assertion methods in the Assertions class. Each one compares an expected value (what you think should happen) against an actual value (what really happened when your code ran).
| Assertion Method | What It Checks | Example |
|---|---|---|
assertEquals(a, b) | a equals b | assertEquals(10, calc.add(4, 6)) |
assertTrue(condition) | condition is true | assertTrue(list.isEmpty()) |
assertFalse(condition) | condition is false | assertFalse(str.contains("error")) |
assertNull(object) | object is null | assertNull(map.get("missing")) |
assertNotNull(object) | object is not null | assertNotNull(user.getName()) |
assertThrows(Ex.class, () -> ...) | An exception is thrown | assertThrows(ArithmeticException.class, () -> calc.divide(1, 0)) |
Common JUnit 5 assertion methods and their usage
SECTION 5
The Arrange-Act-Assert Pattern
Professional developers follow a pattern called Arrange-Act-Assert (AAA) to keep every test method clean and readable. This pattern divides each test into three distinct sections. First, you arrange the objects and data you need. Next, you act by calling the method you want to test. Finally, you assert that the result matches your expectation.
Following the AAA pattern makes your tests self-documenting. Anyone reading your test can quickly see what setup is needed, what action is being tested, and what result is expected. If a test fails, the pattern makes it easy to identify whether the problem is in the setup, the method call, or the expectation itself.
Naming Tip
testDivideByZeroThrowsException() is much more helpful than test3(). When a test fails, a descriptive name instantly tells you what went wrong.SECTION 6
Worked Example — Testing a Calculator Class
Let's walk through a complete example. Suppose you have a simple Calculator class with an add() method and a divide() method. We will write a JUnit 5 test class that verifies both methods work correctly, including handling edge cases like division by zero.
Building a JUnit Test Class Step by Step
- 1
Step 1 — Write the Class Under Test
First, here is the class we want to test. It has two methods:add(int a, int b)returns the sum, anddivide(int a, int b)returns the integer quotient. Ifbis 0, it throws anArithmeticException.public class Calculator { public int add(int a, int b) { return a + b; } public int divide(int a, int b) { return a / b; } } - 2
Step 2 — Create the Test Class with Imports
Create a new file calledCalculatorTest.java. Import the JUnit 5 annotations and the static assertion methods. Thestaticimport lets you callassertEquals()directly without a class prefix.import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; - 3
Step 3 — Arrange with @BeforeEach
Declare aCalculatorinstance variable and use@BeforeEachto create a fresh instance before each test. This ensures no test relies on leftover state from a previous test.Calculator calc; @BeforeEach void setUp() { calc = new Calculator(); } - 4
Step 4 — Write the First Test (Act & Assert)
Add a@Testmethod that callscalc.add(2, 3)and asserts the result equals 5. The first argument toassertEqualsis the expected value; the second is the actual value returned by your code.@Test void testAddPositiveNumbers() { assertEquals(5, calc.add(2, 3)); } - 5
Step 5 — Test an Edge Case (Exception Handling)
UseassertThrowsto verify that dividing by zero throws anArithmeticException. The second argument is a lambda expression (a short inline function) that calls the method we expect to fail. If no exception is thrown, the test fails.@Test void testDivideByZeroThrowsException() { assertThrows(ArithmeticException.class, () -> calc.divide(10, 0)); } - 6
Step 6 — Run and Interpret Results
Run the test class in your IDE (right-click → Run Tests). JUnit will execute both test methods. A green checkmark means the test passed; a red X means it failed. If all tests pass, you can be confident these behaviors are correct. If you later changeCalculator, re-running the tests will instantly reveal if you broke something.✓testAddPositiveNumbers— PASSED | ✓testDivideByZeroThrowsException— PASSED
SECTION 7
JUnit Best Practices vs. Common Mistakes
Writing tests is only helpful if you write them well. Poorly structured tests can be confusing, fragile, or even give you false confidence. The table below compares best practices with common mistakes that new testers often make.
| Best Practice ✓ | Common Mistake ✗ | Why It Matters |
|---|---|---|
| One assertion concept per test method | Stuffing 10 assertions into one test | When a multi-assertion test fails, you can't immediately see which check failed without digging into the error message. |
| Use descriptive method names | Naming tests test1(), test2() | Good names serve as documentation. When a test fails, the name should tell you what broke. |
Use @BeforeEach for shared setup | Duplicating setup code in every test | Reduces code duplication. If the constructor changes, you only update one place. |
| Test edge cases (null, 0, empty) | Only testing the "happy path" | Bugs often hide in unusual inputs. Testing edge cases catches problems before users do. |
| Keep tests independent | Tests that depend on each other's order | JUnit does not guarantee execution order. Tests that share state can pass individually but fail together. |
Best practices vs. common mistakes in JUnit testing
KEY TAKEAWAY
SECTION 8
Connection to Advanced Testing
The JUnit basics you've learned here are just the starting point. As your projects grow larger, you'll encounter more sophisticated testing techniques that build on these foundations. Understanding where basic JUnit fits in the bigger picture will help you appreciate why mastering the fundamentals matters.
| Feature | JUnit Basics (This Lesson) | Advanced JUnit & Beyond |
|---|---|---|
| Test scope | Single methods (unit tests) | Integration tests, end-to-end tests spanning multiple classes |
| Test data | Hardcoded values in each test | Parameterized tests with @ParameterizedTest that run the same test with many data sets |
| Dependencies | Real objects | Mock objects (using Mockito) that simulate dependencies like databases |
| Test organization | Flat list of test methods | Nested tests with @Nested to group related tests hierarchically |
| Code coverage | Manual inspection | Code coverage tools (JaCoCo) that measure which lines your tests exercise |
How JUnit basics connect to advanced testing concepts
As you move forward, you'll discover that the Arrange-Act-Assert pattern and the annotation-based lifecycle you learned today carry over directly to these advanced techniques. Parameterized tests still use assertions; mock objects still follow the AAA pattern; nested tests still use @BeforeEach. Mastering the basics now gives you a solid foundation for everything that comes next.
SECTION 9
Practice Problems
Question 1: CONCEPTUAL
@BeforeEach method is useful in a JUnit test class. What would happen if you did not use one and instead created objects directly inside each test method?Show answer
@BeforeEach method runs before every test, ensuring each test starts with a fresh, identical setup. Without it, you would need to duplicate the same object-creation code in every test method. While this would technically work, it leads to code duplication—if the constructor changes, you'd have to update every test individually. Using @BeforeEach keeps your test class DRY (Don't Repeat Yourself) and guarantees that no test is accidentally reusing a modified object from a previous test.Question 2: BASIC CALCULATION
StringHelper with a method int countVowels(String s) that returns the number of vowels in a string. Write a JUnit test method that verifies countVowels("hello") returns 2. Use the AAA pattern.Show answer
StringHelper object. In the Act step, call helper.countVowels("hello") and store the result. In the Assert step, use assertEquals(2, result). The complete method would be: @Test void testCountVowelsHello() { StringHelper helper = new StringHelper(); int result = helper.countVowels("hello"); assertEquals(2, result); }. The string "hello" contains the vowels 'e' and 'o', so 2 is the expected value.Question 3: INTERMEDIATE
class BankAccountTest { BankAccount acct = new BankAccount(100); @Test void testWithdraw() { acct.withdraw(30); assertEquals(70, acct.getBalance()); } @Test void testWithdrawAgain() { acct.withdraw(50); assertEquals(50, acct.getBalance()); } }Show answer
BankAccount is created once as a field and shared between tests. If testWithdraw() runs first, the balance drops to 70, and testWithdrawAgain() would start with 70 instead of 100—causing it to fail (70 − 50 = 20, not 50). Fix: move the creation into a @BeforeEach method so each test gets a fresh account with a balance of 100. Problem 2: the second test's expected value of 50 only makes sense if the starting balance is 100, which highlights the importance of test independence. With @BeforeEach void setUp() { acct = new BankAccount(100); }, both tests would pass reliably regardless of execution order.Question 4: APPLIED
GradeBook that has a method String getLetterGrade(int score) which returns "A" for scores 90–100, "B" for 80–89, "C" for 70–79, "D" for 60–69, and "F" for below 60. It throws an IllegalArgumentException for scores below 0 or above 100. Write at least three test methods that cover different boundary conditions.Show answer
@Test void testScoreOf90ReturnsA() { assertEquals("A", gb.getLetterGrade(90)); } — tests the lower boundary of an A. (2) @Test void testScoreOf89ReturnsB() { assertEquals("B", gb.getLetterGrade(89)); } — tests the upper boundary of a B, right below the A cutoff. (3) @Test void testNegativeScoreThrowsException() { assertThrows(IllegalArgumentException.class, () -> gb.getLetterGrade(-1)); } — tests the invalid input boundary. Testing at boundaries (90 vs. 89, 0 vs. −1, 100 vs. 101) is critical because off-by-one errors are among the most common bugs in conditional logic.Question 5: CRITICAL THINKING
System.out.println() calls to check my output, and that works fine." Give at least three specific reasons why JUnit tests are more effective than print-statement debugging, and describe a realistic scenario where the print-statement approach would fail to catch a bug.Show answer
SUMMARY
Lesson Summary
Summary
JUnit is Java's standard framework for writing unit tests—small, automated checks that verify individual methods work correctly. Every test class follows a predictable lifecycle controlled by annotations: @BeforeAll runs once at the start, @BeforeEach runs before every test, @Test marks actual test methods, @AfterEach cleans up after each test, and @AfterAll runs once at the end.
Inside each test method, assertions like assertEquals(), assertTrue(), and assertThrows() compare expected values to actual results. Structuring every test with the Arrange-Act-Assert pattern keeps your tests readable, maintainable, and independent from one another. By writing descriptive test names, testing edge cases, and keeping each test focused on one behavior, you build a safety net that catches bugs the moment they appear.