View all services
Talk to QA Advisor
Browse the Knowledge Hub211 resources
Tools Library · We run this

JUnit review: what it is, tutorial, pros and cons

JUnit is the standard testing framework for Java and the JVM. Version 6 keeps the design introduced in JUnit 5: the Platform, which discovers and launches test engines, and Jupiter, the programming model with its extension API. It now needs Java 17, gives every module one version number and deprecates the Vintage engine for JUnit 4 tests. It is free under the Eclipse Public License 2.0.

QAble runs this tool on client engagements. The assessment below is from that experience.

last reviewed
13 October 2026
version covered
JUnit 6.1
licence
Open source

Reviewed by Viral Patel

Co-Founder, QAbleLast reviewed 13 October 2026

At a glance

JUnit at a glance

Official site
Vendor
JUnit Team
Category
Unit testing
Licence
Open source (EPL 2.0)
Platforms
JVM
Languages
Java, Kotlin, Groovy
Integrates with
Maven, Gradle, Mockito, Selenium, REST Assured
First released
2000
Version reviewed
JUnit 6.1

Verdict

Our verdict on JUnit

Strengths

  • Maven, Gradle, IntelliJ, Eclipse, VS Code and CI systems run and report JUnit tests with no extra plugins
  • One extension model covers Spring, Mockito and Testcontainers, so integrations compose instead of fighting over the runner
  • Parameterised tests cover data-driven cases from CSV, methods, enums or files, with each row reported separately
  • Tags with boolean expressions let one build select smoke, slow or integration tests without separate suites
  • The Platform runs other engines too, so Jupiter, Cucumber and TestNG tests can share one launcher
  • Free under EPL 2.0, with no paid edition and nothing held back

Weaknesses

  • JUnit 6 requires Java 17, so older codebases must stay on the 5.x maintenance line
  • Parallel execution is opt-in through properties and assumes test independence that most suites lack
  • Deliberately no dependencies between tests, which UI suites built around ordered flows sometimes miss
  • Tutorials mix JUnit 4 and Jupiter imports, and a JUnit 4 test without Vintage is silently ignored

Best for

Any Java or Kotlin codebase, at every level from unit tests to the runner underneath Selenium, REST Assured and Testcontainers suites. Maven, Gradle, every major IDE and every CI system understand it without plugins, and the extension model means Spring, Mockito and Testcontainers plug in through one mechanism instead of competing runners. For a new JVM project there is no real decision to make: start with Jupiter and add libraries around it.

Not for

Large UI automation estates that depend on ordered, dependent tests and on one suite file that selects groups, environments and thread counts together. TestNG still models that more directly. JUnit is also the wrong tool for non-JVM code, and a team stuck on Java 8 or 11 cannot use version 6 at all and has to stay on the 5.x line.

Setup cost

Low. Import the junit-bom, add the junit-jupiter aggregate, and make sure Surefire is recent: JUnit 6 drops support for Surefire and Failsafe below 3.0.0. A first test runs in minutes. The real setup decisions come later and are worth making early: a tagging scheme for slow and integration tests, a convention for parameterised data, and whether parallel execution is on from the start, because switching it on late exposes every hidden shared state at once.

Maintenance burden

Low for the framework itself. Minor releases arrive several times a year and rarely break anything, and the extension model keeps third-party integrations from conflicting, which was the chronic problem with JUnit 4 runners and rules. Major versions are the exception: moving to 6 means Java 17, Surefire 3, removed APIs such as MethodOrderer.Alphanumeric, and a plan to retire the JUnit 4 tests that Vintage still runs.

Honest limitations

JUnit verifies only what you assert, so a test that runs code and checks nothing still passes and still counts toward coverage. Parallel execution is off by default, configured through properties rather than annotations alone, and only safe once tests are truly independent. There are no dependencies between tests by design. Reporting beyond the XML that Surefire and Gradle write needs a separate tool such as Allure.

How it works

What is JUnit and how does it work?

A platform that launches engines

JUnit is not one library but a launcher plus engines. The JUnit Platform discovers tests through any TestEngine on the classpath and executes them, and build tools and IDEs talk only to the Platform. Jupiter is the engine for JUnit 5 and 6 style tests; Vintage is the engine that runs JUnit 3 and 4 tests; third parties ship engines for Cucumber, TestNG and others. That split is why one Maven or Gradle configuration can run several kinds of test, and also why declaring the Jupiter API without its engine compiles cleanly and then finds nothing to run. In JUnit 6 all of these modules share one version number, so the Platform is no longer 1.x beside a Jupiter 5.x.

Extensions instead of runners and rules

JUnit 4 allowed one runner per class, so combining Spring with a parameterised runner meant workarounds. Jupiter replaced runners and rules with extensions: small classes that implement callback interfaces such as BeforeEachCallback, ParameterResolver or TestExecutionExceptionHandler, registered with @ExtendWith or a @RegisterExtension field. Any number can apply to one class, and they run in a defined order. Most of the integrations you will use, including SpringExtension and MockitoExtension, are just extensions, and the same mechanism is open to your own code for shared setup, custom parameters or conditional execution.

Lifecycle, instances and injection

By default Jupiter creates a new instance of the test class for every test method, so instance fields cannot leak state between tests. That is also why @BeforeAll and @AfterAll methods must be static unless the class is annotated @TestInstance(Lifecycle.PER_CLASS). Test methods and constructors can take parameters, which JUnit fills through parameter resolvers: TestInfo and TestReporter are built in, @TempDir supplies a fresh directory, and extensions add their own. @Nested inner classes group related tests and inherit the outer setup, which keeps large test classes readable.

Configuration through parameters

Much of JUnit’s behaviour, including parallel execution, default timeouts, display name generators and test ordering, is controlled by configuration parameters rather than annotations. They can be set in a junit-platform.properties file on the test classpath, passed as system properties, or set by the build tool. This keeps tests free of environment-specific settings, but it also means a behaviour can change between a developer’s IDE and CI if the two read different sources. Since 6.0, invalid enum values in these parameters fail the run instead of being ignored, which catches typos that used to switch features off quietly.

Strengths

What it does well

Data-driven tests without a separate framework

@ParameterizedTest runs one method for each row of data from @ValueSource, @CsvSource, @CsvFileSource, @EnumSource or a @MethodSource factory, with implicit conversion of strings into numbers, dates and enums. @ParameterizedClass, added in 5.13 and still marked experimental, applies the same idea to a whole class. Each invocation is reported separately, so a failing row is identified by its arguments rather than by a loop index.

Tagging and filtering

@Tag marks tests as slow, smoke or integration, and both Maven and Gradle select by tag expression, such as smoke and not flaky. One codebase can then run a quick suite on every commit and a full suite nightly, without duplicating test classes or maintaining a separate suite file for each.

Conditional and assumption-based execution

Annotations such as @EnabledOnOs, @EnabledForJreRange and @EnabledIfEnvironmentVariable skip tests where they cannot run, and assumptions abort a test mid-way when a precondition is missing. Skipped tests are reported as skipped rather than passed, which keeps environment gaps visible instead of hidden behind a green build.

Assertions that report everything at once

assertAll groups several assertions and reports every failure together, which suits checking the fields of one response. assertThrows returns the exception for further checks, and assertTimeout bounds slow code. Many teams add AssertJ for fluent assertions, which works alongside Jupiter without any integration step.

Parallel execution built in

Jupiter can run classes and methods concurrently on a fork-join pool, sized by a dynamic factor of available processors or a fixed count. @ResourceLock serialises tests that share a resource, @Isolated runs a class on its own, and @Execution overrides the mode per class. Version 6.1 adds an experimental worker thread pool executor as an alternative.

Built-in extensions for common chores

Version 6.1 brought @DefaultLocale, @DefaultTimeZone and system property extensions into JUnit itself, adapted from the JUnit Pioneer project. Combined with @TempDir and its configurable cleanup, they remove a class of helper code that suites used to copy from project to project.

Pricing

JUnit pricing and licensing

JUnit is free and open source under the Eclipse Public License 2.0, with no paid edition, no hosted service and no feature held back. The project is maintained by the JUnit team and asks users to sponsor it through GitHub Sponsors, which funds time rather than features. The costs of a JUnit estate are the ones around it: CI minutes, any reporting tool you add, and the engineering time to keep tests independent and to handle major upgrades.

  • Licence: EPL 2.0 for the framework, stated in the junit-framework repository. EPL is a weak copyleft licence; using JUnit to test proprietary code places no obligation on that code.
  • No tiers: parameterised tests, parallel execution, tagging, the extension model and the console launcher are all in the open-source artifacts on Maven Central.
  • Commercial support: none is offered by the project. Support comes from the user guide, GitHub Discussions and the wider Java community.
  • Infrastructure: CI time is the main running cost, and it scales with suite duration, which parallel execution and tag-based suite splitting reduce.
  • Hidden cost: majors. Moving from 5 to 6 needs Java 17, Surefire 3 or later, and code changes wherever removed APIs or JUnit 4 tests remain.

Checked against the vendor’s own pages on 13 October 2026. Pricing changes; confirm on the official site before buying.

Getting started

JUnit tutorial: getting started

Setup, step by step

  1. Check the JDK: JUnit 6 needs Java 17 or later at runtime.
  2. Import org.junit:junit-bom into dependencyManagement so every JUnit artifact shares one version.
  3. Add org.junit.jupiter:junit-jupiter with test scope; it brings the API, params and the engine.
  4. Set maven-surefire-plugin to a 3.x version explicitly rather than relying on the Maven default.
  5. Write a test class under src/test/java whose name ends in Test.
  6. Run mvn test, or ./gradlew test with useJUnitPlatform() in the test task.

Install

<!-- pom.xml -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.junit</groupId>
      <artifactId>junit-bom</artifactId>
      <version>6.1.3</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>
<!-- and in build/plugins: maven-surefire-plugin 3.6.0 -->

A first example

// src/test/java/com/example/cart/CartTest.java
package com.example.cart;

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class CartTest {

    private Cart cart;

    @BeforeEach // a new CartTest instance is created for every test anyway
    void setUp() {
        cart = new Cart();
    }

    @Test
    @DisplayName("adding two items updates count and total")
    void addsItems() {
        cart.add("SKU-1", 2, 1_500); // prices in pence
        // assertAll reports every failed check, not just the first
        assertAll(
            () -> assertEquals(2, cart.itemCount()),
            () -> assertEquals(3_000, cart.totalPence())
        );
    }

    @Test
    void rejectsZeroQuantity() {
        var ex = assertThrows(IllegalArgumentException.class,
            () -> cart.add("SKU-1", 0, 1_500));
        assertEquals("quantity must be positive", ex.getMessage());
    }
}

What trips people up first

Imports from the wrong generation. Search results still mix JUnit 4 and Jupiter, and an IDE will happily auto-import org.junit.Test. With only the Jupiter engine on the classpath, a class annotated that way is not discovered, so the build reports fewer tests than you wrote rather than failing. Use org.junit.jupiter.api.Test, @BeforeEach instead of @Before, @BeforeAll instead of @BeforeClass and @ExtendWith instead of @RunWith. The second hurdle is the build: a missing engine or an old Surefire also ends in zero tests run, so check the test count in the build output on the first run, not only the colour.

In practice

Using JUnit in practice

Laying out tests and selecting them with tags

Mirror the production package structure under src/test/java so tests can reach package-private code, and name classes after what they test, such as CartTest. Surefire’s default includes pick up classes named Test*, *Test, *Tests and *TestCase, so stick to those names. Keep slow tests separate by tag rather than by directory: @Tag("integration") on the class, then run quick tests on every commit and the full set nightly. In Maven, set groups or excludedGroups on Surefire; in Gradle, use includeTags inside useJUnitPlatform. Many teams run integration tests through the Failsafe plugin under an *IT naming convention instead, which also works and keeps the two phases distinct in the build output. Whichever you pick, make it the only mechanism, because mixing naming and tagging conventions is how tests end up in no suite at all.

Data-driven tests done properly

Parameterised tests replace loops inside one test, which stop at the first failure and report it by index. Give each invocation a name pattern so reports read clearly, keep inline CSV for short tables, and move larger or structured data to a @MethodSource factory that returns Arguments. Strings convert implicitly to numbers, enums and dates, and an ArgumentsAccessor or a custom aggregator handles wide rows. JUnit 6 parses CSV with FastCSV, which is stricter: characters after a closing quote are now an error, and the lineSeparator attribute is gone from @CsvFileSource.

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.params.provider.Arguments.arguments;

import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;

class DiscountTest {

    // {0}, {1} and {2} are the arguments, so each row is named in reports
    @ParameterizedTest(name = "{0} pence with code {1} costs {2}")
    @CsvSource({
        "1000, NONE,   1000",
        "1000, SAVE10,  900",
        "999,  SAVE10,  900"  // rounds up to whole pence
    })
    void appliesCode(int pence, DiscountCode code, int expected) {
        assertEquals(expected, Discounts.apply(pence, code));
    }

    static Stream<Arguments> loyaltyTiers() {
        return Stream.of(
            arguments(new Customer("ada", Tier.GOLD), 800),
            arguments(new Customer("bob", Tier.NONE), 1000)
        );
    }

    @ParameterizedTest
    @MethodSource("loyaltyTiers")
    void appliesTier(Customer customer, int expected) {
        assertEquals(expected, Discounts.forCustomer(1000, customer));
    }
}

Running JUnit in GitHub Actions

A JUnit pipeline is a Java build: check out, set up a JDK with dependency caching, run the build, and keep the reports when something fails. Use the Maven wrapper so CI and developers run the same Maven version, and run verify rather than test so Failsafe integration tests are included. Surefire writes plain-text and XML reports to target/surefire-reports, which most CI test reporters read. For Gradle, swap the run step for gradle/actions/setup-gradle and ./gradlew test, and upload build/reports/tests instead. If the suite takes long enough to matter, split it by tag across jobs before reaching for anything more elaborate.

# .github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '21'
          cache: maven
      # -B for batch mode logs; verify also runs Failsafe *IT tests
      - run: ./mvnw -B verify
      - uses: actions/upload-artifact@v7
        if: failure()
        with:
          name: test-reports
          path: |
            target/surefire-reports
            target/failsafe-reports

Turning on parallel execution safely

Parallelism is configured in junit-platform.properties on the test classpath. Enabling it alone changes nothing visible, because the default mode stays same_thread; you also set the default mode to concurrent for methods, classes or both. Start with classes concurrent and methods in the same thread, which suits most suites, then mark the exceptions. @ResourceLock declares a shared resource, such as a database schema or system properties, and JUnit never runs two tests holding the same lock at once. @Isolated runs a class with nothing else alongside. Expect the first parallel run to expose hidden coupling, such as static caches and shared test data; fix it rather than serialising everything.

// src/test/resources/junit-platform.properties
//   junit.jupiter.execution.parallel.enabled = true
//   junit.jupiter.execution.parallel.mode.default = same_thread
//   junit.jupiter.execution.parallel.mode.classes.default = concurrent
//   junit.jupiter.execution.parallel.config.strategy = dynamic

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock;

class OrderRepositoryTest {

    // Never runs at the same time as another test holding "orders-db"
    @Test
    @ResourceLock("orders-db")
    void savesAndReloadsOrder() {
        // ...
    }
}

Upgrading from JUnit 5 to 6

Most Jupiter tests compile unchanged, so the work is in the build and the edges. Move the JDK to 17 or later, set Surefire and Failsafe to 3.x, and switch to the 6.x BOM. Then search for removed APIs: MethodOrderer.Alphanumeric (use MethodName), the junit-platform-runner module and its @RunWith(JUnitPlatform.class), and ConsoleLauncher calls without a subcommand, which now need execute. Kotlin users get suspend test functions but need a current Kotlin 2.x. The Vintage engine still runs JUnit 4 tests, but it is deprecated and reports an INFO-level discovery issue, so treat 6 as the deadline to migrate them. Teams that cannot move to Java 17 can stay on the 5.14 line, which is still published.

Pitfalls

Common pitfalls

A green build that ran zero tests

Why it happens: Declaring junit-jupiter-api without the engine, or running an old Surefire, compiles everything and discovers nothing. Nothing fails, so the build stays green.

The fix: Depend on the junit-jupiter aggregate, which includes the engine, pin Surefire to a 3.x version, and set failIfNoTests in Surefire so an empty run fails the build.

JUnit 4 annotations in a Jupiter project

Why it happens: An auto-imported org.junit.Test, @Before or @RunWith belongs to JUnit 4. Without the Vintage engine those tests are not discovered; with it they run under a deprecated engine.

The fix: Ban the org.junit package outside org.junit.jupiter with a build rule such as ArchUnit or a Checkstyle import control, and migrate remaining JUnit 4 classes before Vintage is removed.

Copying removed APIs from older articles

Why it happens: JUnit 6 removed MethodOrderer.Alphanumeric, the JUnitPlatform runner, several ReflectionSupport methods and support for Surefire below 3.0.0. Articles written for 5.x still show them.

The fix: Use MethodOrderer.MethodName, run suites with @Suite from junit-platform-suite, and check the 6.0 release notes for the replacement of any symbol the compiler reports missing.

Non-static @BeforeAll methods

Why it happens: Jupiter creates a new instance per test method, so a class-level setup method has no instance to run on unless it is static.

The fix: Make @BeforeAll and @AfterAll static, or annotate the class @TestInstance(Lifecycle.PER_CLASS) and accept that instance fields are then shared across its tests.

Relying on test method order

Why it happens: JUnit’s default method order is deterministic but intentionally not obvious, so a test that depends on an earlier one passes until a method is renamed or added.

The fix: Make each test set up its own state. Where order is the point, as in a deliberate workflow test, say so with @TestMethodOrder(OrderAnnotation.class) and @Order.

Switching on parallelism for the whole suite at once

Why it happens: Static caches, shared test data and system property changes are invisible when tests run one at a time, and appear as random failures under concurrency.

The fix: Enable concurrent classes first, add @ResourceLock where tests share a resource, and run the suite several times locally before turning it on in CI.

Compared

How JUnit compares

ToolLanguageData-driven testsParallel executionDependencies between testsLicence
JUnitJava, Kotlin, other JVM languages@ParameterizedTest and @ParameterizedClassBuilt in, opt-in via configurationNone by design; ordering onlyEPL 2.0
TestNGJava and JVM languages@DataProvider methodsBuilt in, suite XML or annotationdependsOnMethods and dependsOnGroupsApache 2.0
pytestPython@pytest.mark.parametrizeVia the pytest-xdist pluginNone built inMIT
NUnitC#, F#, VB.NET[TestCase] and [TestCaseSource]Opt-in with [Parallelizable]None; [Order] controls orderingMIT

FAQ

Frequently asked questions

What is JUnit?

JUnit is the standard testing framework for Java and the JVM. Version 6 keeps the design introduced in JUnit 5: the Platform, which discovers and launches test engines, and Jupiter, the programming model with its extension API. It now needs Java 17, gives every module one version number and deprecates the Vintage engine for JUnit 4 tests. It is free under the Eclipse Public License 2.0.

Is JUnit free?

Yes. JUnit is open source under the Eclipse Public License 2.0, with no paid edition and no feature tiers. Parameterised tests, parallel execution, tagging and the extension model are all in the free artifacts on Maven Central. The project is maintained by the JUnit team and funded through sponsorship. Your costs are CI time and the engineering effort to keep tests independent and to handle major upgrades.

What changed in JUnit 6, and should I upgrade from JUnit 5?

JUnit 6.0 shipped on 30 September 2025 and 6.1 on 19 May 2026. It requires Java 17, gives all modules one version number, adds JSpecify nullability annotations and Kotlin suspend test functions, switches CSV parsing to FastCSV, deprecates the Vintage engine and removes long-deprecated APIs. If you are on Java 17 or later, upgrade: most Jupiter tests compile unchanged. If you are stuck on an older JDK, stay on the 5.14 line.

JUnit vs TestNG: which should I use?

For unit and integration testing in Java, JUnit, because it has the broader tool support and extension ecosystem. TestNG still has an edge for large UI automation suites that rely on test dependencies, groups configured in one suite file and data providers that run in parallel. Both run on the JUnit Platform today, so a project can use both while migrating.

Can JUnit 6 still run my JUnit 4 tests?

Yes, through the Vintage engine, which is still published in 6.x. It is deprecated, though: it reports an INFO-level discovery issue when it finds JUnit 4 tests, which you can silence with junit.vintage.discovery.issue.reporting.enabled set to false. Treat it as a migration aid. Rewrite JUnit 4 classes to Jupiter, replacing runners and rules with extensions, before it is removed in a future major.

How do I run JUnit tests in parallel?

Add junit.jupiter.execution.parallel.enabled set to true in junit-platform.properties, then set the default mode for methods, classes or both to concurrent. The pool is sized dynamically from the number of processors unless you choose the fixed strategy. Use @ResourceLock for tests that share a resource and @Isolated for classes that must run alone, and expect to fix shared state the first time you switch it on.

Does JUnit work with Kotlin?

Yes. Jupiter tests can be written in Kotlin, JUnit ships Kotlin-friendly assertion functions, and since 6.0 a test method can be a suspend function, so coroutine code can be tested without wrapping every test in runBlocking. JUnit 6 sets a minimum Kotlin 2.x version, so check the release notes before upgrading an older Kotlin project.

Is JUnit still worth learning?

Yes. It is the default test framework for Java and Kotlin, the runner under most Java API and UI automation suites, and the platform other JVM test engines plug into. The concepts, including fixtures, parameterised tests, tags and extensions, also transfer directly to pytest, NUnit and Jest, so time spent learning it pays off beyond Java.

Alternatives

JUnit alternatives worth weighing

Resources

Where to go next

Keep going

Prepare, or get it done

Ready

Picking the wrong tool costs more than picking late.

QAble advises on toolchain decisions and then implements them, with engineers who have run these tools on real delivery. Start with a free QA audit.

Talk to QA Advisor