Logo Dark
Testing Java Apps Like a Pro: JUnit 5, Mockito & Testcontainers
/
Developer Insights

If you’re still treating JUnit 5 as “JUnit 4 with new annotations”, you’re missing the real upgrade.

JUnit 5 didn’t just change syntax - it changed how Java testing scales in real-world, enterprise codebases.This guide explains what actually matters when moving from JUnit 4 to JUnit 5, and how to combine Mockito, integration tests, and Testcontainers into a testing strategy that mirrors production.

What Really Changed From JUnit 4 to JUnit 5 (Developer POV)

You’ll often hear:

“JUnit 5 is just JUnit 4 with new annotations.”

That’s only half-true.

JUnit 4: One Monolithic Engine

  • One runner
  • One execution model
  • If something breaks → everything breaks

JUnit 5: A Modular Architecture

JUnit 5 is split into three independent layers:

  • JUnit Platform → Executes tests
  • JUnit Jupiter → Modern JUnit 5 API
  • JUnit Vintage → Backward compatibility for JUnit 4

Why this matters in real projects

  • IDEs behave consistently
  • Tooling integrates better
  • Multiple test engines can coexist
  • You aren’t locked into a single testing style

Result: Cleaner architecture, better extensibility, fewer brittle test setups.

These limitations become obvious in real-world systems. In production-grade services similar to those shown in Java Applications Examples, brittle test execution models quickly become a bottleneck as teams and codebases grow.

Extensions: The Biggest Upgrade Over Runners

JUnit 4 Approach

@RunWith(SpringRunner.class)

Problem: You could only use one runner per test class.

JUnit 5 Solution

@ExtendWith(SpringExtension.class)

Now you can stack multiple extensions.

Real-World Use Cases

  • Mocking support
  • Database lifecycle management
  • Logging hooks
  • Retry logic
  • Custom parameter resolvers
  • Cross-cutting test concerns

Instead of hacking runners together, you compose behavior.This is the single biggest improvement for enterprise-scale testing.

Parameterized Tests: Finally Worth Using

JUnit 4 technically supported parameterized tests - but most teams avoided them.

JUnit 5 fixed that.

@ParameterizedTest

@ValueSource(strings = {"A1", "B2", "C3"})

void codeShouldBeValid(String code) {

    assertEquals(2, code.length());

}

Where They Shine

  • Validation rules
  • Edge cases
  • Date and format handling
  • Boundary testing

Outcome: No copy-paste tests. Cleaner suites. Better coverage with less noise.

Nested Tests: Tests That Read Like Documentation

Nested tests let you structure behavior, not just methods.

class OrderServiceTest {

    @Nested
    class WhenPlacingOrder {

        @Test
        void shouldSucceedWhenPaymentPasses() {}

        @Test
        void shouldFailWhenPaymentDeclines() {}
    }
}

Why This Matters

  • Logical grouping
  • Faster debugging
  • Business-flow readability

In larger domains, structure is the difference between maintainable tests and chaos.

Mockito: Powerful, But Easy to Abuse

Most teams fall into one of two traps:

  • Mock everything
  • Mock nothing

When Mocking Is Correct

  • External systems
  • APIs
  • Databases
  • Third-party services
when(paymentClient.charge(any())).thenReturn(true);

When Mocking Is a Smell

when(order.getTotal()).thenReturn(100);

If you need to mock your own domain logic, your design is likely wrong.

Rule of thumb:Mock boundaries, not behavior.

Integration Tests: Where Real Bugs Live

Unit tests prove logic.Integration tests prove reality.

They validate:

  • Wiring
  • Serialization
  • Validation
  • Security
  • Error handling
  • Database behavior

Realistic Spring Boot Integration Test

@SpringBootTest

@AutoConfigureMockMvc

class UserApiTest {

    @Autowired

    MockMvc mockMvc;

    @Test

    void shouldReturnUserById() throws Exception {

        mockMvc.perform(get("/users/1"))

               .andExpect(status().isOk())

               .andExpect(jsonPath("$.id").value(1));

    }

}

This simulates a real HTTP request - no Postman, no guesswork.

This is especially critical in large-scale systems common in Enterprise Application Development, where multiple services, databases, and teams interact across environments.

Testcontainers: Stop Lying to Yourself With H2

H2 ≠ Production DB.

Common issues H2 hides:

  • Broken migrations
  • SQL incompatibilities
  • Timezone bugs
  • UUID behavior
  • Collation differences

Testcontainers Fix This

static PostgreSQLContainer<?> postgres =

    new PostgreSQLContainer<>("postgres:16");

You now test against:

  • Real Postgres / MySQL
  • Zero local setup
  • Identical behavior to production

Teams routinely save weeks of debugging after switching.

Code Coverage: Let’s Be Honest

Target range: 70–80%

But remember:

  • 100% coverage ≠ quality
  • High coverage can hide meaningless tests

What Actually Deserves Coverage

  • Business logic
  • Edge cases
  • Validations
  • Database interactions
  • Error scenarios

Skip:

  • Getters/setters
  • Boilerplate
  • Framework glue

If a test feels pointless - it probably is.

Common Testing Mistakes in Real Projects

❌ Bad Test Names

test1()

✅ Behavior-Based Names

shouldCreateUserWhenDataIsValid()

❌ Mocking Everything

If your tests still pass when the DB is broken, they don’t protect you.

❌ Only Integration Tests

You need both:

  • Fast unit tests
  • Focused integration tests

❌ Treating Tests as Second-Class Code

Messy tests → fragile systems.

Final Thoughts

Teams that treat testing as an engineering practice consistently ship faster and safer. This is why experienced teams like those behind our Java development services design testing strategies alongside architecture, not after deployment. 

Good testing isn’t about tools. It’s about confidence.

Confidence to:

  • Refactor
  • Ship faster
  • Sleep at night

JUnit 5 makes this easier through:

  • Modular architecture
  • Extension-based design
  • Better structure
  • Cleaner parameterized tests

When you combine:

  • Mockito
  • Integration tests
  • Testcontainers

You get a testing setup that actually matches production.

And that’s the goal. Always.

FAQs

What is the main difference between JUnit 4 and JUnit 5?

JUnit 5 uses a modular architecture (Platform, Jupiter, Vintage) instead of a single monolithic engine. This enables better tooling support, extensibility, and the ability to mix multiple test engines in one project.

Is JUnit 5 backward compatible with JUnit 4?

Yes. JUnit Vintage allows you to run existing JUnit 4 tests alongside JUnit 5 tests without rewriting everything at once.

When should I use Mockito in unit tests?

Use Mockito to mock external dependencies like APIs, databases, or services. Avoid mocking internal domain logic or value objects, as it often indicates poor design.

Are integration tests slower than unit tests?

Yes, but they are essential. Unit tests validate logic, while integration tests validate wiring, serialization, database behavior, and real system interactions.

Why is Testcontainers better than H2?

Testcontainers runs real databases in Docker, eliminating differences between test and production environments. This prevents migration issues, SQL incompatibilities, and environment-specific bugs.

What is a good code coverage percentage?

A healthy target is 70–80%. Focus on meaningful coverage of business logic and edge cases rather than chasing 100%.

Harsh Shiyani
JAVA DEVELOPER
Driven by curiosity and problem-solving, I build reliable and scalable backend systems using Java and Spring Boot. With hands-on experience in monolithic and microservice architectures, I focus on writing clean code, designing resilient APIs, and delivering production-ready solutions.
Group

Engineering clarity where others add complexity. We help businesses build, modernize, and scale with the right technology. Whatever your challenge, stage, or vision, we make IT possible.

India (HQ)

201, iSquare Corporate Park, Ahmedabad-380060, Gujarat, India

+91 77 97 977 977
Canada

24 Merlot Court, Timberlea, NS B3T 0C2, Canada

+1 902 789-0496

Looking For Jobs

Apply Now
Logo Dark
ISO 9001:2015 | ISO 42001:2023 Certified