Unit / Java · Junior & Senior

JUnit & TestNG

The standard Java testing frameworks. JUnit 5 is the default for modern Spring Boot projects; TestNG is the choice where complex parallel execution, data providers, and suite-level configuration are required.

Junior Senior
01

The Hook: The Bedrock of Enterprise

In the world of NZ banking and government IT, Java is the king. Millions of dollars move through systems built on Java every day. You wouldn't trust a bank that didn't test its code, right? JUnit and TestNG are the tools that ensure those millions go to the right place.

If you want to work at CloudBooks, Harbour Bank, or the Revenue NZ, knowing how to write and run Java unit tests isn't just a "nice to have" — it's the ticket to the game.

02

The Rule: Isolate or Fail

The golden rule of Java unit testing is Isolation. Your test must not depend on the file system, the network, or the database. If it does, and the network is down, your test fails even if your code is perfect. That's a "False Negative," and it's the enemy of a fast pipeline.

03

The Analogy: Foundations vs. Decoration

Imagine building a skyscraper like the Sky Tower. Unit Tests are checking the strength of the individual steel beams. Integration Tests are checking if the beams fit together. E2E Tests are checking if the elevator reaches the top floor.

If the beams (Unit) are weak, the elevator (E2E) will eventually crash, no matter how many times you test the buttons.

Senior engineer insight

The moment I stopped thinking of JUnit tests as "safety checks" and started treating them as executable documentation is the moment my team's onboarding time halved. A well-named @ParameterizedTest with a clear @DisplayName tells the next developer — six months later, under pressure — exactly what the system is supposed to do and why the edge case exists. At Harbour Bank-style shops running thousands of tests across a Spring Boot monolith, that clarity compounds into real velocity.

The most common mistake: teams treat @BeforeEach as optional and then spend days debugging intermittent failures caused by shared mutable state leaking between tests.

From the field

A Wellington-based fintech migrating from a legacy COBOL payroll system to a Spring Boot microservices platform assumed JUnit 5 tests would "just work" after porting the business logic. What they discovered was that the original code had tight coupling to a shared Oracle database connection — every test class instantiated a real connection pool on startup, which worked fine on developer laptops but caused the CI pipeline on their GitHub Actions runners to collapse under thread-contention after roughly 400 tests. The fix wasn't more infrastructure; it was retrofitting Mockito mocks for the data layer and switching to @TestInstance(Lifecycle.PER_CLASS) for the handful of integration tests that genuinely needed state. Pipeline time dropped from 47 minutes to 9. The lesson: inherited architecture assumptions don't disappear when you port the code — they just hide in your test setup until CI reveals them.

04

The Setup: Maven & Gradle

In Java, we don't just "install" a package; we declare a dependency in our build tool. For Maven (the most common in NZ), you add this to your pom.xml:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>5.10.0</version>
    <scope>test</scope>
</dependency>
05

The First Script: Assertions

Java tests use Annotations (the @ symbol) to tell the runner what to do. Here is your first test:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class CalculatorTest {
    @Test
    void testAddition() {
        int result = 2 + 2;
        assertEquals(4, result, "2 + 2 should equal 4");
    }
}
06

The "Gotcha": Flaky Setup

The most common "Gotcha" in Java testing is Shared State. If Test A changes a static variable that Test B relies on, Test B might fail only when run in a certain order. This makes your tests "flaky."

The Fix: Use @BeforeEach. This annotation runs a "cleanup" or "setup" function before every single test, ensuring every test starts with a fresh, clean environment.

07

The Framework: Power of Annotations

JUnit 5 (Jupiter) gives you a powerful toolkit:

  • @Test: Marks a method as a test.
  • @ParameterizedTest: Runs the same test with different data.
  • @Disabled: Skips a test (don't use this too often!).
  • @DisplayName: Gives the test a human-readable name in reports.
08

The NZ Example: Revenue NZ Number Validator

Let's look at a classic NZ requirement: validating an Revenue NZ number. They must be 8 or 9 digits and follow a specific checksum logic.

@ParameterizedTest
@ValueSource(strings = {"12345678", "123456789"})
void testValidIrdLength(String ird) {
    assertTrue(IrdValidator.isValidLength(ird));
}

@Test
void testInvalidIrdChars() {
    assertFalse(IrdValidator.isValid("123-456-78")); // No dashes allowed!
}
09

The Pro Move: Parallel Execution

In large enterprise projects with 10,000+ tests, running them one-by-one takes hours. TestNG was built specifically to solve this with Parallel Execution.

By simply adding parallel="methods" thread-count="5" to your XML config, you can run 5 tests at once, cutting your wait time by 80%. This is how senior leads optimise the CI pipeline for speed.

10

The Challenge: Your Turn

Write a test class for a BankAccount class. It has a withdraw(amount) method.

Your Task: Write three tests: 1. A successful withdrawal, 2. An attempted withdrawal with insufficient funds (should throw an exception), and 3. A withdrawal of a negative amount (should be rejected).

Why teams fail here

  • Treating test order as guaranteed. JUnit 5 does not guarantee execution order by default. Tests that pass locally in IDE order but fail randomly in CI almost always have hidden dependencies — one test mutates state another test silently relies on.
  • Enabling TestNG parallel execution without thread-safe fixtures. Adding parallel="methods" to the suite XML is one line. Making your test data, driver instances, and static helpers thread-safe is weeks of refactoring. Teams that skip the second step end up with tests that are faster on average and catastrophically unreliable.
  • Over-mocking to the point of testing nothing. When every collaborator is mocked, the test only verifies that you know how to write Mockito stubs. Real defects — wrong SQL, mismatched DTO field names, off-by-one date arithmetic — live precisely in the layer that was mocked away.
  • Ignoring the test report until something breaks. JUnit and TestNG both generate rich XML/HTML reports. Teams that only look at green/red counts miss skipped tests silently accumulating (@Disabled without a ticket reference), assertion messages that say "expected true but was false" with zero context, and test durations that signal a genuine network call hiding inside a "unit" test.

Key takeaway

The test framework is not the hard part — the discipline of keeping tests isolated, named like documentation, and fast enough that nobody disables them under deadline pressure is what separates a maintained test suite from a graveyard of @Disabled annotations.

← Jest & Vitest k6 →