Navigation
Recherche
|
How to test your Java applications with JUnit 5
jeudi 5 juin 2025, 11:00 , par InfoWorld
![]() This article gets you started with testing your Java applications using JUnit 5. You’ll learn: How to configure a Maven project to use JUnit 5. How to write tests using the @Test and @ParameterizedTest annotations. How to validate test results using JUnit 5’s built-in assertion functionality. How to work with the lifecycle annotations in JUnit 5. How to use JUnit tags to control which test cases are executed. Before we do all that, let’s take a minute to talk about test-driven development. What is test-driven development? If you are developing Java code, you’re probably intimately familiar with test-driven development, so I’ll keep this section brief. It’s important to understand why we write unit tests, however, as well as the strategies developers employ when designing them. Test-driven development (TDD) is a software development process that interweaves coding, testing, and design. It’s a test-first approach that aims to improve the quality of your applications. Test-driven development is defined by the following lifecycle: Add a test. Run all your tests and observe the new test failing. Implement the code. Run all your tests and observe the new test succeeding. Refactor the code. Here’s a visual overview of the TDD lifecycle. Steven Haines There’s a twofold purpose to writing tests before writing your code. First, it forces you to think about the business problem you are trying to solve. For example, how should successful scenarios behave? What conditions should fail? How should they fail? Second, testing first gives you more confidence in your tests. Whenever I write tests after writing code, I have to break them to ensure they’re actually catching errors. Writing tests first avoids this extra step. Writing tests for the happy path is usually easy: Given good input, the class should return a deterministic response. But writing negative (or failure) test cases, especially for complex components, can be more complicated. As an example, consider a test for calling a database repository class. On the happy path, we insert a record into the database and receive back the created object, including any generated keys. In reality, we must also consider the possibility of a conflict, such as inserting a record with a unique column value that is already held by another record. Additionally, what happens when the repository can’t connect to the database, perhaps because the username or password has changed? Or if there’s a network error in transit? What happens if the request doesn’t complete in your defined timeout limit? To build a robust component, you need to consider all likely and unlikely scenarios, develop tests for them, and write your code to satisfy those tests. Later in the article, we’ll look at strategies for creating failure scenarios, along with some of the features in JUnit 5 that can help you test them. Get the code Download the source code for all examples in this article. Unit testing with JUnit 5 Let’s start with an example of how to configure a project to use JUnit 5 for a unit test. Listing 1 shows a MathTools class whose method converts a numerator and denominator to a double. Listing 1. An example JUnit 5 project (MathTools.java) package com.javaworld.geekcap.math; public class MathTools { public static double convertToDecimal(int numerator, int denominator) { if (denominator == 0) { throw new IllegalArgumentException('Denominator must not be 0'); } return (double)numerator / (double)denominator; } } We have two primary scenarios for testing the MathTools class and its method: A valid test, in which we pass a non-zero integer for the denominator. A failure scenario, in which we pass a zero value for the denominator. Listing 2 shows a JUnit 5 test class to test these two scenarios. Listing 2. A JUnit 5 test class (MathToolsTest.java) package com.javaworld.geekcap.math; import java.lang.IllegalArgumentException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; class MathToolsTest { @Test void testConvertToDecimalSuccess() { double result = MathTools.convertToDecimal(3, 4); Assertions.assertEquals(0.75, result); } @Test void testConvertToDecimalInvalidDenominator() { Assertions.assertThrows(IllegalArgumentException.class, () -> MathTools.convertToDecimal(3, 0)); } } In Listing 2, the testConvertToDecimalInvalidDenominator method executes the MathTools::convertToDecimal method inside an assertThrows call. The first argument is the expected type of the exception to be thrown. The second argument is a function that will throw that exception. The assertThrows method executes the function and validates that the expected type of exception is thrown. The Assertions class and its methods The org.junit.jupiter.api.Test annotation denotes a test method. The testConvertToDecimalSuccess method first executes the MathTools::convertToDecimal method with a numerator of 3 and a denominator of 4, then asserts that the result is equal to 0.75. The org.junit.jupiter.api.Assertions class provides a set of static methods for comparing actual and expected results. Methods in the JUnit Assertions class cover most of the primitive data types: assertArrayEquals: Compares the contents of an actual array to an expected array. assertEquals: Compares an actual value to an expected value. assertNotEquals: Compares two values to validate that they are not equal. assertTrue: Validates that the provided value is true. assertFalse: Validates that the provided value is false. assertLinesMatch: Compares two lists of Strings. assertNull: Validates that the provided value is null. assertNotNull: Validates that the provided value is not null. assertSame: Validates that two values reference the same object. assertNotSame: Validates that two values do not reference the same object. assertThrows: Validates that the execution of a method throws an expected exception. (You can see this in the testConvertToDecimalInvalidDenominator example above.) assertTimeout: Validates that a supplied function completes within a specified timeout. assertTimeoutPreemptively: Validates that a supplied function completes within a specified timeout, but once the timeout is reached, it kills the function’s execution. If any of these assertion methods fails, the unit test is marked as failed. That failure notice will be written to the screen when you run the test, then saved in a report file. Using delta with assertEquals When using float and double values in an assertEquals, you can also specify a delta that represents a threshold of difference between the two values being compared. For example, 22/7 is often used as an approximation of PI, or 3.14, but if we divide 22 by 7, we do not get 3.14. Instead, we get 3.14285. Listing 3 shows how to use a delta value to validate that 22/7 returns a value between 3.141 and 3.143. Listing 3. Testing assertEquals with a delta @Test void testConvertToDecimalWithDeltaSuccess () { double result = MathTools.convertToDecimal(22, 7); Assertions.assertEquals(3.142, result, 0.001); } In this example, we expect 3.142 +/- 0.001, which matches all values between 3.141 and 3.143. Both 3.140 and 3.144 would fail the test, but 3.142857 passes. Analyzing your test results In addition to validating a value or behavior, the assert methods can also accept a textual description of the error, which can help you diagnose failures. Consider the two variations in the following output: Assertions.assertEquals(0.75, result, 'The MathTools::convertToDecimal value did not return the correct value of 0.75 for 3/4'); Assertions.assertEquals(0.75, result, () -> 'The MathTools::convertToDecimal value did not return the correct value of 0.75 for 3/4'); The output shows the expected value of 0.75 as well as the actual value. It also displays the specified message, which can help you understand the context of the error. The difference between the two variations is that the first one always creates the message, even if it is not displayed, whereas the second one only constructs the message if the assertion fails. In this case, the construction of the message is trivial, so it doesn’t really matter. Still, there is no need to construct an error message for a test that passes, so it’s usually a best practice to use the second style. Finally, if you’re using a Java IDE like IntelliJ to run your tests, each test method will be displayed by its method name. This is fine if your method names are readable, but you can also add a @DisplayName annotation to your test methods to better identify the tests: @Test @DisplayName('Test successful decimal conversion') void testConvertToDecimalSuccess() { double result = MathTools.convertToDecimal(3, 4); Assertions.assertEquals(0.751, result); } Running your unit tests with Maven To run JUnit 5 tests from a Maven project, you need to include the maven-surefire-plugin in the Maven pom.xml file and add a new dependency. Listing 4 shows the pom.xml file for this project. Listing 4. Maven pom.xml for an example JUnit 5 project 4.0.0 org.example JUnitExample 1.0-SNAPSHOT 24 24 UTF-8 org.apache.maven.plugins maven-surefire-plugin 3.5.3 org.junit.jupiter junit-jupiter 5.12.2 test JUnit 5 packages its components in the org.junit.jupiter group and uses the junit-jupiter aggregator artifact to import dependencies. Adding junit-jupiter imports the following dependencies: junit-jupiter-api: Defines the API for writing tests and extensions. junit-jupiter-engine: The test engine implementation that runs the unit tests. junit-jupiter-params: Supports parameterized tests. Next, we add the Maven build plugin, maven-surefire-plugin, to run the tests. Finally, we target our build to Java 24, using the maven.compiler.source and maven.compiler.target properties. Run the test class Now we’re ready to run our test class. You can use the following command to run the test class from your IDE or Maven: mvn clean test If you’re successful, you should see something like the following: [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- [INFO] Running com.javaworld.geekcap.math.MathToolsTest [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.04 s - in com.javaworld.geekcap.math.MathToolsTest [INFO] [INFO] Results: [INFO] [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 3.832 s [INFO] Finished at: 2025-05-21T08:21:15-05:00 [INFO] ------------------------------------------------------------------------ Parameterized tests in JUnit 5 You’ve seen how to write and run a basic JUnit 5 unit test, so now let’s go a bit further. The test class in this section is also based on the MathTools class, but we’ll use parameterized tests to more thoroughly test our code. To start, I’ve added another method, isEven, to the MathTools class: public static boolean isEven(int number) { return number % 2 == 0; } We could test this code the same way we did in the previous section, by passing different numbers to the isEven method and validating the response: @Test void testIsEvenSuccessful() { Assertions.assertTrue(MathTools.isEven(2)); Assertions.assertFalse(MathTools.isEven(1)); } The methodology works, but if we want to test a large number of values, it will soon become cumbersome to enter the values manually. In this case, we can use a parameterized test to specify the values that we want to test: @ParameterizedTest @ValueSource(ints = {0, 2, 4, 6, 8, 10, 100, 1000}) void testIsEven(int number) { Assertions.assertTrue(MathTools.isEven(number)); } For this test, we use the @ParameterizedTest annotation instead of the @Test annotation. We also have to provide a source for the parameters. A note about the ValueSource annotation The ValueSource in the above example accepts an integer array by specifying an ints argument, but the ValueSource annotation also supports booleans, bytes, chars, classes, doubles, floats, longs, shorts, and strings. For example, you might supply a list of String literals: @ValueSource(strings = {'foo', 'bar', 'baz'}). Using sources in parameterized testing There are different types of sources, but the simplest is the @ValueSource, which lets us specify a list of Integers or Strings. The parameter is passed as an argument to the test method and then can be used in the test. In this case, we’re passing in eight even integers and validating that the MathTools::isEven method properly identifies them as even. This is better, but we still have to enter all the values we want to test. What would happen if we wanted to test all the even numbers between 0 and 1,000? Rather than manually entering all 500 values, we could replace our @ValueSource with @MethodSource, which generates the list of numbers for us. Here’s an example: @ParameterizedTest @MethodSource('generateEvenNumbers') void testIsEvenRange(int number) { Assertions.assertTrue(MathTools.isEven(number)); } static IntStream generateEvenNumbers() { return IntStream.iterate(0, i -> i + 2).limit(500); } When using a @MethodSource, we define a static method that returns a stream or collection. Each value will be sent to our test method as a method argument. In this example, we create an IntStream, which is a stream of integers. The IntStream starts at 0, increments by twos, and limits the total number of items in the stream to 500. This means the isEven method will be called 500 times, with all even numbers between 0 and 998. Parameterized tests include support for the following types of sources: ValueSource: Specifies a hardcoded list of integers or Strings. MethodSource: Invokes a static method that generates a stream or collection of items. EnumSource: Specifies an enum, whose values will be passed to the test method. It allows you to iterate over all enum values or include or exclude specific enum values. CsvSource: Specifies a comma-separated list of values. CsvFileSource: Specifies a path to a comma-separated value file with test data. ArgumentsSource: Allows you to specify a class that implements the ArgumentsProvider interface, which generates a stream of arguments to be passed to your test method. NullSource: Passes null to your test method if you are working with Strings, collections, or arrays. You can include this annotation with other annotations, such as ValueSource, to test a collection of values and null. EmptySource: Includes an empty value if you are working with Strings, collections, or arrays. NullAndEmptySource: Includes both null and an empty value if you are working with Strings, collections, or arrays. FieldSource: Allows you to refer to one or more fields of the test class or external classes. Multiple sources: JUnit allows you to use multiple “repeatable” sources by specifying multiple source annotations on your parameterized test method. Repeatable sources include: ValueSource, EnumSource, MethodSource, FieldSource, CsvSource, CsvFileSource, and ArgumentsSource. JUnit 5’s test lifecycle For many tests, there are things that you might want to do before and after each of your test runs and before and after all of your tests run. For example, if you were testing database queries, you might want to set up a connection to a database and import a schema before all the tests run, insert test data before each individual test runs, clean up the database after each test runs, and then delete the schema and close the database connection after all the tests run. JUnit 5 provides the following annotations that you can add to methods in your test class for these purposes: @BeforeAll: A static method in your test class that is called before all its tests run. @AfterAll: A static method in your test class that is called after all its tests run. @BeforeEach: A method that is called before each individual test runs. @AfterEach: A method that is called after each individual test runs. Listing 5 shows a very simple example that logs the invocations of the various lifecycle methods. Listing 5. Logging the invocations of JUnit 5 lifecycle methods (LifecycleDemoTest.java) package com.javaworld.geekcap.lifecycle; import org.junit.jupiter.api.*; public class LifecycleDemoTest { @BeforeAll static void beforeAll() { System.out.println('Connect to the database'); } @BeforeEach void beforeEach() { System.out.println('Load the schema'); } @AfterEach void afterEach() { System.out.println('Drop the schema'); } @AfterAll static void afterAll() { System.out.println('Disconnect from the database'); } @Test void testOne() { System.out.println('Test One'); } @Test void testTwo() { System.out.println('Test Two'); } } The output from running this test prints the following: Connect to the database Load the schema Test One Drop the schema Load the schema Test Two Drop the schema Disconnect from the database As you can see from this output, the beforeAll method is called first and may do something like connect to a database or create a large data structure into memory. Next, the beforeEach method prepares the data for each test; for example, by populating a test database with an expected set of data. The first test then runs, followed by the afterEach method. This process (beforeEach—> test—>afterEach) continues until all the tests have completed. Finally, the afterAll method cleans up the test environment, possibly by disconnecting from a database. Tags and filtering in JUnit 5 Before wrapping up this initial introduction to testing with JUnit 5, I’ll show you how to use tags to selectively run different kinds of test cases. Tags are used to identify and filter specific tests that you want to run in various scenarios. For example, you might tag one test class or method as an integration test and another as development code. The names and uses of the tags are all up to you. We’ll create three new test classes and tag two of them as development and one as integration, presumably to differentiate between tests you want to run when building for different environments. Listings 6, 7, and 8 show these three simple tests. Listing 6. JUnit 5 tags, test 1 (TestOne.java) package com.javaworld.geekcap.tags; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @Tag('Development') class TestOne { @Test void testOne() { System.out.println('Test 1'); } } Listing 7. JUnit 5 tags, test 2 (TestTwo.java) package com.javaworld.geekcap.tags; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @Tag('Development') class TestTwo { @Test void testTwo() { System.out.println('Test 2'); } } Listing 8. JUnit 5 tags, test 3 (TestThree.java) package com.javaworld.geekcap.tags; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @Tag('Integration') class TestThree { @Test void testThree() { System.out.println('Test 3'); } } Tags are implemented through annotations, and you can annotate either an entire test class or individual methods in a test class; furthermore, a class or a method can have multiple tags. In this example, TestOne and TestTwo are annotated with the “Development” tag, and TestThree is annotated with the “Integration” tag. We can filter test runs in different ways based on tags. The simplest of these is to specify a test in your Maven command line; for example, the following only executes tests tagged as “Development”: mvn clean test -Dgroups='Development' The groups property allows you to specify a comma-separated list of tag names for the tests that you want JUnit 5 to run. Executing this yields the following output: [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- [INFO] Running com.javaworld.geekcap.tags.TestOne Test 1 [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.029 s - in com.javaworld.geekcap.tags.TestOne [INFO] Running com.javaworld.geekcap.tags.TestTwo Test 2 [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.001 s - in com.javaworld.geekcap.tags.TestTwo Likewise, we could execute just the integration tests as follows: mvn clean test -Dgroups='Integration' Or, we could execute both development and integration tests: mvn clean test -Dgroups='Development, Integration' In addition to the groups property, JUnit 5 allows you to use an excludedGroups property to execute all tests that do not have the specified tag. For example, in a development environment, we do not want to execute the integration tests, so we could execute the following: mvn clean test -DexcludedGroups='Integration' This is helpful because a large application can have literally thousands of tests. If you wanted to create this environmental differentiation and add some new production tests, you would not want to have to go back and add a “Development” tag to the other 10,000 tests. Finally, you can add these same groups and excludedGroups fields to the surefire plugin in your Maven POM file. You can also control these fields using Maven profiles. I encourage you to review the JUnit 5 user guide to learn more about tags. Conclusion This article introduced some of the highlights of working with JUnit 5. I showed you how to configure a Maven project to use JUnit 5 and how to write tests using the @Test and @ParameterizedTest annotations. I then introduced the JUnit 5 lifecycle annotations, followed by a look at the use and benefits of filter tags.
https://www.infoworld.com/article/3993538/how-to-test-your-java-applications-with-junit-5.html
Voir aussi |
56 sources (32 en français)
Date Actuelle
ven. 6 juin - 20:08 CEST
|