Java • Debugging and Testing

JUnit Basics — Use JUnit basics to structure tests (intro)

Learn how to write automated tests that verify your Java code works correctly every time.

Java programming emblem with a coffee cup, code braces, and terminal
Inside this lesson 10 sections 5 questions 5 figures

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

Kent Beck creates SUnit, the first widely adopted unit testing framework, proving that automated tests can dramatically improve code quality.

1997 • JUnit Is Born

Kent Beck and Erich Gamma write JUnit on a flight to a conference, adapting the SUnit concept for Java. It quickly becomes the standard testing framework for Java developers.

2006 • JUnit 4 Released

JUnit 4 introduces Java annotations like @Test, making tests much easier to write and read. This version dominated Java testing for over a decade.

2017 • JUnit 5 (Jupiter)

JUnit 5 introduces a modular architecture with the JUnit Jupiter API, adding powerful features like nested tests, parameterized tests, and better extension support.

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.

1

Test Class

A regular Java class that contains your test methods. By convention, if you are testing a class called Calculator, your test class is named CalculatorTest.
2

Test Method

A method annotated with @Test that runs a specific check. Each test method should test one behavior and have a descriptive name like testAddTwoPositiveNumbers().
3

Assertions

Methods like assertEquals() and assertTrue() that compare the expected result to the actual result. If they don't match, the test fails.
4

Annotations

Special markers like @Test, @BeforeEach, and @AfterEach that tell JUnit how and when to run each method.
5

Test Runner

The engine inside JUnit that discovers your test methods, executes them, and reports which ones passed or failed. IDEs like IntelliJ and VS Code integrate with it automatically.
✦

KEY TAKEAWAY

Think of JUnit like a teacher who grades homework automatically. You (the developer) write the answer key (assertions), turn in your work (the code you're testing), and JUnit checks every answer instantly. If something is wrong, it tells you exactly which answer didn't match—so you can fix it right away instead of guessing.

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.

Anatomy of a JUnit 5 Test Classimport org.junit.jupiter.api.*;import static org.junit.jupiter.api.Assertions.*;class CalculatorTest {@BeforeEachvoid setUp() { calc = new Calculator(); }← Runs before EACH test@Testvoid testAddTwoPositiveNumbers() {assertEquals(5, calc.add(2, 3));}← One test method← Assertion checks result@Testvoid testAddNegativeNumbers() {assertEquals(-5, calc.add(-2, -3));}← Another test method@AfterEachvoid tearDown() { calc = null; }← Cleanup after EACH test
The test class contains import statements at the top, a @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

  1. @BeforeAll — Runs once before all tests in the class (must be static). Use it for expensive setup like opening a database connection.
  2. @BeforeEach — Runs before each individual test method. Use it to create fresh objects so tests don't share state.
  3. @Test — The actual test method. Contains assertions that check expected vs. actual values.
  4. @AfterEach — Runs after each test method. Use it to release resources or reset variables.
  5. @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 MethodWhat It ChecksExample
assertEquals(a, b)a equals bassertEquals(10, calc.add(4, 6))
assertTrue(condition)condition is trueassertTrue(list.isEmpty())
assertFalse(condition)condition is falseassertFalse(str.contains("error"))
assertNull(object)object is nullassertNull(map.get("missing"))
assertNotNull(object)object is not nullassertNotNull(user.getName())
assertThrows(Ex.class, () -> ...)An exception is thrownassertThrows(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.

Arrange → Act → Assert PatternARRANGECreate objects &set up test dataCalculator calc = new Calculator();int a=2, b=3;ACTCall the methodyou want to testint result = calc.add(a, b);ASSERTVerify the resultmatches expectedassertEquals( 5, result);Test Outcome FlowAssertion runsMatch?✓ PASSYes✗ FAILNo
The Arrange-Act-Assert pattern structures every test method. After the assertion runs, the test either passes (expected matches actual) or fails (they differ).

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

Name your test methods to describe the behavior they verify. A name like 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. 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, and divide(int a, int b) returns the integer quotient. If b is 0, it throws an ArithmeticException.
    public class Calculator { public int add(int a, int b) { return a + b; } public int divide(int a, int b) { return a / b; } }
  2. 2

    Step 2 — Create the Test Class with Imports

    Create a new file called CalculatorTest.java. Import the JUnit 5 annotations and the static assertion methods. The static import lets you call assertEquals() directly without a class prefix.
    import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*;
  3. 3

    Step 3 — Arrange with @BeforeEach

    Declare a Calculator instance variable and use @BeforeEach to 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. 4

    Step 4 — Write the First Test (Act & Assert)

    Add a @Test method that calls calc.add(2, 3) and asserts the result equals 5. The first argument to assertEquals is the expected value; the second is the actual value returned by your code.
    @Test void testAddPositiveNumbers() { assertEquals(5, calc.add(2, 3)); }
  5. 5

    Step 5 — Test an Edge Case (Exception Handling)

    Use assertThrows to verify that dividing by zero throws an ArithmeticException. 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. 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 change Calculator, 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 methodStuffing 10 assertions into one testWhen a multi-assertion test fails, you can't immediately see which check failed without digging into the error message.
Use descriptive method namesNaming tests test1(), test2()Good names serve as documentation. When a test fails, the name should tell you what broke.
Use @BeforeEach for shared setupDuplicating setup code in every testReduces 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 independentTests that depend on each other's orderJUnit 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

Think of each test like a fire alarm in a building. You want one alarm per room—not one giant alarm for the whole building. If a single alarm covers everything, you'll know something is wrong, but you won't know where the fire is. Focused, independent tests pinpoint problems just like individual room alarms pinpoint fires.

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.

FeatureJUnit Basics (This Lesson)Advanced JUnit & Beyond
Test scopeSingle methods (unit tests)Integration tests, end-to-end tests spanning multiple classes
Test dataHardcoded values in each testParameterized tests with @ParameterizedTest that run the same test with many data sets
DependenciesReal objectsMock objects (using Mockito) that simulate dependencies like databases
Test organizationFlat list of test methodsNested tests with @Nested to group related tests hierarchically
Code coverageManual inspectionCode 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

Explain in your own words why a @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
A @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

You have a class 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
Following the Arrange-Act-Assert pattern: In the Arrange step, create a 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

A student writes the following test class, but it has two problems. Identify them and explain how to fix each one. 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
Problem 1: The 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

You are building a grading app with a class 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
A good test suite for boundary conditions would include: (1) @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

A classmate argues: "I don't need JUnit. I just add 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
Three reasons JUnit is superior: (1) JUnit tests are automated and repeatable—you run them with one click every time you change your code, while print statements require you to manually read and interpret output each time. (2) JUnit reports pass or fail definitively; with print statements, you might misread a number or overlook an incorrect value in a wall of console output. (3) JUnit tests serve as living documentation—new team members can read your tests to understand how the code is supposed to behave. A realistic failure scenario: imagine you change a method that 15 other methods depend on. With print statements, you'd have to manually run each of those 15 scenarios and check the output by eye. You might forget one, or the output for scenario #12 could look 'close enough' and you'd miss a subtle bug. JUnit would flag the exact failing test in milliseconds, pointing you directly to the broken behavior.

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.

Varsity Tutors • Java • JUnit Basics — Use JUnit basics to structure tests (intro)