Introduction to Test Driven Development — JUnit walkthrough (Part 1 — basics)
toptal

Introduction to Test Driven Development — JUnit walkthrough (Part 1 — basics)

If you are a software developer with decent experience creating applications on a medium to large scale, then you most probably experienced design ambiguity in addition to increased time fixing your code to make it bulletproof and void of unexpected errors. We are all guilty of this in our initial stages of our career and try to find ways to lay down a concrete and organized path to build our app. Despite all the hassle we go through, few actually emphasize on writing test cases as they incrementally build. I know because I did the same mistake in my past projects and let me tell you — I wasted a lot of time post release trying to figure out why my application suddenly stops working after days of uptime. The countless hours I spent hunting for bugs in my massive code base made me realize my practices we’re faulty and that’s when I came across the?TDD (Test Driven Development) Framework. If you already implement this approach, you can choose to keep reading cause you might learn something new. But if not, then ask yourself this — Do you want your application to meet a certain requirement or a requirement to match your application? The difference speaks a lot about your software practices.

Test Driven Development is a practice where you identify all the edge cases that could occur at runtime, write tests for them AND THEN write your functionality for that behavior after.

This includes not just testing the correctness of the code, but also tests to see if your application is failing when it is required to. If you’ve done competitive programming in the past, then this is no different. You have a requirement, and the test cases needed to pass to ensure completion. Now you write the functionality. Except, in this article we focus on writing the tests in contrast to the actual code.

Junit

Junit is an open source testing framework for Java and it is widely used in the Software industry. It contains a lot of useful features that we will later discuss in detail throughout this article. It is also supported by a variety of integrated development environments (IDEs) and continuous integration (CI) tools, making it easy to incorporate automated testing into the software development process. Let’s take a look at a sample test case written using Junit.

import org.junit.Test;
import static org.junit.Assert.*;

public class PalindromeTest {
    
    @Test
    public void testIsPalindrome() {
        Palindrome palindrome = new Palindrome();
        assertTrue(palindrome.isPalindrome("racecar"));
    }
}        

Without confusing you too much, let’s focus here on two things. The?@Test?annotation?and the?assertions.

@Test

This is a simple and straightforward way to mark a method in a class as a Test case. How the method evaluates a test and returns defines the behavior and result of the test. In the above example, the method?testIsPalindrome()?is a test case. When the project is being built, certain IDEs automatically run the testcases associated with the project automatically (typically in the?/src/test?folder), runs all the methods annotated with @Test unless specified otherwise and checks if the test evaluation is passed. In this case the evaluation is asserting that the term “racecar” is a palindrome. Since it is a palindrome, the test case passes.


A Test class can have multiple methods (or test cases). In JUnit you have complete control of how your orchestrate your test cases within a class and between classes along with manipulating the state of each test method. We will discuss all of this in detail.

Running methods Before and After

There will be times when you need to run a piece of code before running a test case or after for initialization and cleanup respectively (database pool init etc). You can do this by utilizing the?@BeforeEach?and?@AfterEach?annotation methods to run code before and after each test case in a class OR?@BeforeAll?and?@AfterAll?to run code before and after all the test cases in a class (shared state).

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

public class PalindromeTest {

    private Palindrome palindrome;

    @BeforeAll
    static void init() {
        System.out.println("Initializing test suite");
    }

    @BeforeEach
    void setUp() {
        palindrome = new Palindrome();
    }

    @Test
    void testIsPalindrome() {
        assertTrue(palindrome.isPalindrome("racecar"));
    }

    @AfterEach
    void tearDown() {
        palindrome = null;
    }

    @AfterAll
    static void done() {
        System.out.println("Cleaning up test suite");
    }
}        

@Disabled

Optionally, there will be times when a feature is in progress but you have already defined a test case which is causing your application to fail test builds. In such cases, you can tag a method with @Disabled and opt it out of running temporarily. You can also annotate it with a reason for disabling that makes it intuitive for your team as to why it is disabled.


public class PalindromeTest {

    @Disabled("Test case is not ready yet")
    @Test
    void testIsPalindrome() {
        Palindrome palindrome = new Palindrome();
        assertTrue(palindrome.isPalindrome("racecar"));
    }
}        

Execution Condition

JUnit Jupiter allows developers to either?enable?or?disable?a container or test based on certain conditions?programmatically.?ExecutionCondition?defines the?Extension?API for programmatic,?conditional test execution. The?ExecutionCondition?is evaluated against both the Test Class and the Test Method to determine if it should run or not. There can be multiple conditions, in which case it short circuits on the first return of?Disabled.?You can choose to disable by:

  • OS and Architecture
  • Runtime Environment Conditions
  • Native Image Conditions
  • System Properties
  • Env Variables
  • Custom Conditions (@DisabledIf, @EnabledIf)[example below]

public class PalindromeTest {

    @DisabledIf("customCondition")
    @Test
    void testIsPalindrome() {
        Palindrome palindrome = new Palindrome();
        assertTrue(palindrome.isPalindrome("racecar"));
    }

    // the custom condition to evaluate
    boolean customCondition() {
        return System.getProperty("disablePalindromeTest") != null;
    }
}        

@Tag

Think of @Tag intuitively as a way to tag and filter through test cases, a grouping of sorts. These tags can be used to filter test discovery and execution.


@Tag("fast")
public class PalindromeTest {

    @Tag("positive")
    @Test
    void testIsPalindrome() {
        Palindrome palindrome = new Palindrome();
        assertTrue(palindrome.isPalindrome("racecar"));
    }

    @Tag("negative")
    @Test
    void testNonPalindrome() {
        Palindrome palindrome = new Palindrome();
        assertFalse(palindrome.isPalindrome("apple"));
    }
}        

In JUnit you can run tag specific test cases using the?--include-tag?and?--exclude-tag?options. For example, you can run all test cases with the?positive?tag using the command:

./gradlew test --tests * --include-tag positive        

This will run only the?testIsPalindrome?method in the?PalindromeTest?class. Similarly, you can exclude specific test cases using the?--exclude-tag?option.

In the above example you see we declared each test with a @Test and a @Tag. Seems kinda redundant right?

Solution: Meta Annotations

Let’s create a meta annotation that collectively implies both that it is a @Test and a @Tag (”positive”).

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Tag("positive")
@Test
public @interface PostiveTest {
}

public class PalindromeTest {
  // Same
  @PostiveTest
  void testIsPalindrome() {
      Palindrome palindrome = new Palindrome();
      assertTrue(palindrome.isPalindrome("racecar"));
  }
}


// why? saves time and reduces redundancy        

Assertions in JUnit

Coming to the core feature of writing a test case, assertions are used to ,well, assert something is of some state. There are many types of assertions available within JUnit with some of the most basic ones like


  • assertTrue
  • assertFalse
  • assertAll

public class PalindromeTest {

    @Test
    void testIsPalindrome() {
        Palindrome palindrome = new Palindrome();
        assertAll("palindromes",
            () -> assertTrue(palindrome.isPalindrome("racecar")),
            () -> assertTrue(palindrome.isPalindrome("level")),
            () -> assertTrue(palindrome.isPalindrome("deified"))
        );
        assertAll("non-palindromes",
            () -> assertFalse(palindrome.isPalindrome("hello")),
            () -> assertFalse(palindrome.isPalindrome("world")),
            () -> assertFalse(palindrome.isPalindrome("apple"))
        );
    }
}        

Basically, this should give you an idea but feel free to explore the diverse set of assertions provided by JUnit to get an idea of how they all work?here.

Assumptions in JUnit

Unlike assertions, Assumptions are used when you need to run a certain snippet of your code only if a condition matches. You’re thinking this can be done with @Disabled but no. With @Disabled you completely turn off a method or a class based on the condition. However, using assumptions you can switch off a part of the method. Let me show you how.


public class PalindromeTest {

    @Test
    void testIsPalindrome() {
        Palindrome palindrome = new Palindrome();
        // assumption 1
        assumeTrue(System.getProperty("os.name").contains("Windows"));
        assertTrue(palindrome.isPalindrome("racecar"));
        assertFalse(palindrome.isPalindrome("hello"));
        
        // assumption 2
        assumeFalse(palindrome.isPalindrome("world"));
        assertTrue(palindrome.isPalindrome("a"));
        assertTrue(palindrome.isPalindrome(""));
    }
}        

Here, if the assumption 1 that “OS is windows” is?true, then the subsequent assertions will run, but skipped otherwise?(including the next assumption). The next assumption then follows the same evaluation and either choose to run the assertions following it based on the assumption 2.

Class and Method Ordering in JUnit

Typically, unit tests should NOT be dependent on the order of execution. However, there are times when it is necessary to enforce a specific test method execution order — for example, when writing?integration tests?or?functional tests?where the sequence of the tests is important. Especially during the lifecycle,?Lifecycle.PER_CLASS?(talk later).


The ordering at the class level or the method level is done using the annotations?@TestClassOrder?and?@TestMethodOrder?respectively. The implementations for ordering are predefined enums in JUnit based on the following criteria:

  • DisplayName?(alphanumerically)
  • Method / ClassName?(alphanumerically?based on their fully qualified class names)
  • OrderAnnotation?(numerically)
  • Random?(pseudo-randomly)
  • Alphanumeric?(Method only,?alphanumerically?based on their names and formal parameter lists) [deprecated in 6.0]

For the?basics - part 1, let’s discuss our last topic:

Test Instance Lifecycle

Every test class or method has its own state of execution and it’s crucial to identify whether we need to share the state among the method or isolate it for each case. Our requirements may vary depending on its function and therefore a key topic to end this discussion with.


By default every test method has an isolated state and is mutually exclusive from the other test methods (Lifecycle.PER_METHOD). This creates a new “Test Instance” for every test method. While this works most of the time, there are scenarios where we would need to share the state among all the methods in the class. To change this behavior to adapt to that, we can re-define the lifecycle policy for a class using the?@TestInstance?annotation with a?Lifecycle.PER_CLASS?mode.

@TestInstance(Lifecycle.PER_CLASS)
public class PalindromeTest {

    @Test
    void testIsPalindrome() {
        Palindrome palindrome = new Palindrome();
        assertTrue(palindrome.isPalindrome("racecar"));
        assertFalse(palindrome.isPalindrome("hello"));
        assertTrue(palindrome.isPalindrome("a"));
        assertTrue(palindrome.isPalindrome(""));
    }

    @Test
    void testNonPalindrome() {
        Palindrome palindrome = new Palindrome();
        assertFalse(palindrome.isPalindrome("apple"));
        assertFalse(palindrome.isPalindrome("world"));
    }
}        

So basically what it does is — it tells JUnit to create a single test instance for the entire class. This will reuse the same TestInstance for the entire class. We could however, use @BeforeEach, @AfterEach, @BeforeAll, @AfterAll to manipulate parts of the state while using the “PER_CLASS” mode.

This concludes our basics of getting started with JUnit and in the continuing article I will talk more about some complex topics in JUnit like NestedTests, Repeated and Parameterized tests etc. I really appreciate the time you took to read this article to completion and I hope you have a good day!

— Kautilya Kondragunta

要查看或添加评论,请登录

Kautilya Kondragunta的更多文章

社区洞察