Unit Testing in a Nutshell – JUnit

JUnit

Share This Post

Share on linkedin
Share on twitter
Share on email
This post is part of our unit series, covering definitions and best practices. We cover JUnit, which is the most common unit testing framework in Java.

Unit Testing in a Nutshell – JUnit

JUnit is the most common unit testing framework in Java. Not only is it used for unit testing, it is also highly relevant for integration tests. But we’ll get to that.
Let’s start with a simple JUnit test. As you may know from the first part of this unit testing series, a unit test method should be structured by the Arrange, Act, Assert-Pattern (AAA). Here’s an example:

class MathTest {
  @Test
  void shouldBeFourWhenValueIsTwo(){
    int value = 2;

    int result = Math.square(value);

    assertEquals(4, result);
  }
}

The line 1 is the test suite. The @Test-Annotation in line 2 allows the JUnit-Runtime to find and execute the test case. The naming convention Should<ExpectedBehavior>When<StateUnderTest> is used as a method name. Then we arrange the test method after the AAA-pattern. Experts usually don’t necessarily stick to this pattern, instead I recommend one liners to reduce the amount of code.

JUnit Lifecycle

Let’s have a closer look at the functionality of JUnit, starting with its lifecycle. What would you guess, how many instances of a test suite with two methods with a @Test-Annotation are started? One or two? The correct answer is two. The JUnit-Engine starts by default for each @Test-annotation method and its own instance. This behavior can be changed to one instance per class with the @TestInstance-Annotation.
Why is this important? You can clean up your test code and replace the @BeforeAll method and initialize your ClassUnderTest directly in line 2, because you know now that JUnit starts a new instance for each test method so the ClassUnderTest has not been reset before the test run. 

class ClassTest{
   ClassUnderTest cut;

   @BeforeAll
   void beforeEach(){
       cut = new ClassUnderTest()
   }

   @Test
   void testMethod1(){
     ...
   }

   @Test
   void testMethod2(){
     ...
   }
}

Assumptions, Conditions and Disabling

Assumptions allow you to abort a test by a condition, instead of marking them as failed.  For example, when you have a case where a test should only be executed in the CI pipeline with some special credentials, then the following code snippet is the correct choice:

void shouldCreateUserWhenInCiPipeline(){
   assumeTrue("CI".equals(System.getenv("ENV")));
   ...
}

Instead of aborting a test, it can also be disabled by the @Disabled annotation.
This is pretty intuitive, but you can also disable a test using a condition. Imagine you have a code which is only working for a minimum JRE version, then you can disable it with the following code snippet:

@Test
@EnableOnJre(Java_8)
void shouldRunTestWhenGreaterThantJava8(){
  assertTure(...);
}

JUnit also provides the possibility to enable or disable the test based on an OS (@EnableOnOS, @DisabledOnOs), on SystemProperty (@EnabledIfSystemProperty@DisabledIfSystemProperty), on Environment Variables (@EnabledIfEnvironmentVariable, @DisabledIfEnvironmentVariable) or you can implement a custom condition (@EnabledIf@DisabledIf)

Test Order

In some cases the order of the executed tests are important. This is especially necessary for integration tests or functional tests. Therefore the @TestMethodOrder-Annotation allows you to choose one of the four ordering methods: @DisplayName, @OrderAnnotation, @Random or @MethodName. Be careful though, this annotation should only be used in conjunction with the class lifecycle (@TestInstance(Lifecycle.PER_CLASS)).
Here is a small example:

@TestMethodOrder(OrderAnnotation.class)
class OrderedTestsDemo {

    @Test
    @Order(1)
    void firstExecutedTest() {
    }

    @Test
    @Order(2)
    void secondExecutedTest() {
    }

    @Test
    @Order(3)
    void thirdExecutedTest() {
    }
}

Repeating Tests, Parameterizing and File System Access

Repeating a test multiple times is unusual, but @RepeatTest-Annotation allows you to do just this. Can you think of a practical case where this is necessary?


The @ParameterizedTest-Annotation on the other hand is frequently used to check all boundary values of a method. For example, if you want to check if your constructor only accept values from 0 – 10, you can type:

@ParameterizedTest
@ValueSource(ints = {0, 10})
void shoudCreateValidRange(int number) {
    ValidNumber validNumber = new ValidNumber(number);
    assertEquals(number, validNumber.get());
}

@ParameterizedTest
@ValueSource(ints = {-1, 11})
void shoudThrowNumberOutOfRangeExceptionWhenCreateValidNumberWithInvalidNumber(int number) {
 assertThrows(NumberOutOfRangeExecption.class, () -> new ValidNumber(number));
}

In these two tests, you have tested all two valid and invalid boundary values for the constructor.
@EnumSource, @CsvFileSource or @MethodSource are popular available argument sources. For more information have a look at this tutorial by baeldung.

In my previous blog post I gave the advice to not access the file system during a test. There are certain cases though where this is inevitable. Especially if you want to test interactions with the files system you have to do so. @TempDir-Annotatoin injects a path to a temporary directory into a test method. It takes care of creation and deletion of the temporary directory, so you can focus on implementing the test. 

@Test
void shouldWriteStringsToFile(@TempDir Path tempDir) throws IOException {
  Path stringFile = tempDir.resolve("strings.txt");

  Files.write(stringFile, asList("str 1", "str 2", "str 3"));

  assertTrue("File should exist", Files.exists(stringFile);
  assertLinesMatch(lines, Files.readAllLines(stringFile));
}

Addition

An exciting feature is the dynamic test generation. It can generate tests during runtime. Maybe you are wondering why this is considered exciting. Sometimes you want to test high level requirements, like all REST endpoints should accept XML and JSON data formats. Normally you would copy the test code from each controller test to another but in some cases this is forgotten or overlooked. In these cases, dynamic tests are a great solution to  generate the test code for each endpoint. But this offers content for an entire blog post.

I hope you enjoyed this article and learned some practical insights for your future tests.

More To Explore

Cheat Sheets

Integration Testing

Learn everything you need to get started in integration testing in our cheat sheet.

UI Testing Myths
Blog

Debunking 4 UI Testing Myths

UI Testing remains one of the most feared challenges for business owners and companies. But some myths around UI testing can be debunked.