Java Testing Best Practices





In order to have sufficient code coverage, and to create new functionality and refactor old ones without fear of breaking something, tests must be maintainable and easy to read. In this article, I will talk about many techniques for writing unit and integration tests in Java, which I have collected over the years. I will rely on modern technologies: JUnit5, AssertJ, Testcontainers, and also I will not ignore Kotlin. Some of the tips will seem obvious to you, while others may go against what you've read in books about software development and testing.



In a nutshell



  • Write tests concisely and specifically, using helper functions, parameterization, various primitives of the AssertJ library, do not abuse variables, check only what is relevant to the functionality under test and do not stuff all non-standard cases into one test
  • , ,
  • , -,
  • KISS DRY
  • , , , in-memory-
  • JUnit5 AssertJ —
  • : , , Clock - .




Given, When, Then (, , )



The test must contain three blocks, separated by blank lines. Each block should be as short as possible. Use local methods to keep things compact.



Given / Given (input): test preparation, for example, data creation and mocking configuration.

When (action): call the tested method

Then / To (output): check the correctness of the received value



// 
@Test
public void findProduct() {
    insertIntoDatabase(new Product(100, "Smartphone"));

    Product product = dao.findProduct(100);

    assertThat(product.getName()).isEqualTo("Smartphone");
}


Use the prefixes “actual *” and “expected *”



// 
ProductDTO product1 = requestProduct(1);

ProductDTO product2 = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(product1).isEqualTo(product2);



If you are going to use variables in a match test, add the “actual” and “expected” prefixes to these variables. This will improve the readability of your code and clarify the purpose of the variables. It also makes them more difficult to confuse when comparing.



// 
ProductDTO actualProduct = requestProduct(1);

ProductDTO expectedProduct = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(actualProduct).isEqualTo(expectedProduct); //   


Use preset values ​​instead of random ones



Avoid feeding random values ​​to the input of tests. This can lead to blinking tests, which is damn hard to debug. In addition, if you see a random value in an error message, you cannot trace it back to where the error occurred.



// 
Instant ts1 = Instant.now(); // 1557582788
Instant ts2 = ts1.plusSeconds(1); // 1557582789
int randomAmount = new Random().nextInt(500); // 232
UUID uuid = UUID.randomUUID(); // d5d1f61b-0a8b-42be-b05a-bd458bb563ad



Use different predefined values ​​for everything. This way you will get perfectly reproducible test results, as well as quickly find the right place in the code by the error message.



// 
Instant ts1 = Instant.ofEpochSecond(1550000001);
Instant ts2 = Instant.ofEpochSecond(1550000002);
int amount = 50;
UUID uuid = UUID.fromString("00000000-000-0000-0000-000000000001");



You can write this even shorter using helper functions (see below).



Write concise and specific tests



Use helper functions where possible



Isolate repetitive code into local functions and give them meaningful names. This will keep your tests compact and easy to read at a glance.



// 
@Test
public void categoryQueryParameter() throws Exception {
    List<ProductEntity> products = List.of(
            new ProductEntity().setId("1").setName("Envelope").setCategory("Office").setDescription("An Envelope").setStockAmount(1),
            new ProductEntity().setId("2").setName("Pen").setCategory("Office").setDescription("A Pen").setStockAmount(1),
            new ProductEntity().setId("3").setName("Notebook").setCategory("Hardware").setDescription("A Notebook").setStockAmount(2)
    );
    for (ProductEntity product : products) {
        template.execute(createSqlInsertStatement(product));
    }

    String responseJson = client.perform(get("/products?category=Office"))
            .andExpect(status().is(200))
            .andReturn().getResponse().getContentAsString();

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("1", "2");
}


// 
@Test
public void categoryQueryParameter2() throws Exception {
    insertIntoDatabase(
            createProductWithCategory("1", "Office"),
            createProductWithCategory("2", "Office"),
            createProductWithCategory("3", "Hardware")
    );

    String responseJson = requestProductsByCategory("Office");

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("1", "2");
}


  • use helper functions to create data (objects) ( createProductWithCategory()) and complex checks. Pass only those parameters to the helper functions that are relevant in this test; for the rest, use adequate defaults. In Kotlin, there are default parameter values ​​for this, and in Java you can use method call chains and overloading to simulate default parameters.
  • variable length parameter list will make your code even more elegant ( ìnsertIntoDatabase())
  • helper functions can also be used to create simple values. Kotlin does it even better through extension functions.


//  (Java)
Instant ts = toInstant(1); // Instant.ofEpochSecond(1550000001)
UUID id = toUUID(1); // UUID.fromString("00000000-0000-0000-a000-000000000001")


//  (Kotlin)
val ts = 1.toInstant()
val id = 1.toUUID()


Helper functions in Kotlin can be implemented like this:



fun Int.toInstant(): Instant = Instant.ofEpochSecond(this.toLong())

fun Int.toUUID(): UUID = UUID.fromString("00000000-0000-0000-a000-${this.toString().padStart(11, '0')}")


Don't overuse variables



The programmer's conditioned reflex is to move frequently used values ​​into variables.



// 
@Test
public void variables() throws Exception {
    String relevantCategory = "Office";
    String id1 = "4243";
    String id2 = "1123";
    String id3 = "9213";
    String irrelevantCategory = "Hardware";
    insertIntoDatabase(
            createProductWithCategory(id1, relevantCategory),
            createProductWithCategory(id2, relevantCategory),
            createProductWithCategory(id3, irrelevantCategory)
    );

    String responseJson = requestProductsByCategory(relevantCategory);

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly(id1, id2);
}


Alas, this is very code overload. Worse, seeing the value in the error message will be impossible to trace back to where the error occurred.

"KISS is more important than DRY"


// 
@Test
public void variables() throws Exception {
    insertIntoDatabase(
            createProductWithCategory("4243", "Office"),
            createProductWithCategory("1123", "Office"),
            createProductWithCategory("9213", "Hardware")
    );

    String responseJson = requestProductsByCategory("Office");

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("4243", "1123");
}


If you are trying to write tests as compact as possible (which I warmly recommend anyway), then the reused values ​​are clearly visible. The code itself becomes more compact and more readable. Finally, the error message will lead you to the exact line where the error occurred.



Don't extend existing tests to "add one more little thing"



// 
public class ProductControllerTest {
    @Test
    public void happyPath() {
        //   ...
    }
}


It is always tempting to add a special case to an existing test that validates basic functionality. But as a result, the tests get bigger and harder to understand. Particular cases scattered over a large sheet of code are easy to overlook. If the test fails, you may not immediately understand what exactly caused it.



// 
public class ProductControllerTest {
    @Test
    public void multipleProductsAreReturned() {}
    @Test
    public void allProductValuesAreReturned() {}
    @Test
    public void filterByCategory() {}
    @Test
    public void filterByDateCreated() {}
}


Instead, write a new test with a descriptive name that makes it immediately clear what behavior it expects from the code under test. Yes, you have to type more letters on the keyboard (against this, remember, helper functions help well), but you will get a simple and understandable test with a predictable result. This is a great way to document new functionality, by the way.



Check only what you want to test



Think about the functionality you are testing. Avoid doing unnecessary checks just because you can. Moreover, be aware of what has already been tested in previously written tests and do not re-test it. Tests should be compact and their expected behavior should be obvious and devoid of unnecessary detail.



Let's say we want to test an HTTP handle that returns a list of products. Our test suite should contain the following tests:



1. One large mapping test that verifies that all values ​​from the database are correctly returned in the JSON response and are correctly assigned in the correct format. We can easily write this using the functions isEqualTo()(for a single item) or containsOnly()(for multiple items) from the AssertJ package, if you implement the method correctlyequals()...



String responseJson = requestProducts();

ProductDTO expectedDTO1 = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED));
ProductDTO expectedDTO2 = new ProductDTO("2", "envelope", new Category("smartphone"), List.of(States.ACTIVE));
assertThat(toDTOs(responseJson))
        .containsOnly(expectedDTO1, expectedDTO2);


2. Several tests that check the correct behavior of the? Category parameter. Here we only want to check if the filters are working properly, not property values, because we did it before. Therefore, it is enough for us to check the matches of the received product ids:



String responseJson = requestProductsByCategory("Office");

assertThat(toDTOs(responseJson))
        .extracting(ProductDTO::getId)
        .containsOnly("1", "2");


3. A couple more tests that check special cases or special business logic, for example, that certain values ​​in the response are calculated correctly. In this case, we are only interested in a few fields from the entire JSON response. Thus, we are documenting this special logic with our test. It is clear that we do not need anything other than these fields here.



assertThat(actualProduct.getPrice()).isEqualTo(100);


Self-contained tests



Don't hide relevant parameters (in helper functions)



// 
insertIntoDatabase(createProduct());
List<ProductDTO> actualProducts = requestProductsByCategory();
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));


It is convenient to use helper functions for generating data and checking conditions, but they must be called with parameters. Accept parameters for everything that is meaningful within the test and needs to be controlled from the test code. Don't force the reader to jump into the helper function to understand the meaning of the test. A simple rule: the meaning of the test should be clear when looking at the test itself.



// 
insertIntoDatabase(createProduct("1", "Office"));
List<ProductDTO> actualProducts = requestProductsByCategory("Office");
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));


Keep test data inside the tests themselves



Everything should be inside. It is tempting to transfer some of the data into a method @Beforeand reuse it from there. But this will force the reader to jump back and forth through the file to understand what exactly is happening here. Again, helper functions will help you avoid repetition and make your tests easier to understand.



Use composition instead of inheritance



Don't build complex test class hierarchies.



// 
class SimpleBaseTest {}
class AdvancedBaseTest extends SimpleBaseTest {}
class AllInklusiveBaseTest extends AdvancedBaseTest {}
class MyTest extends AllInklusiveBaseTest {}


Such hierarchies complicate understanding and you, most likely, will quickly find yourself writing the next successor of the basic test, inside which a lot of junk is sewn up that the current test does not need at all. This distracts the reader and leads to subtle errors. Inheritance is not flexible: do you think you can use all the methods of a class AllInclusiveBaseTest, but none of its parent ? AdvancedBaseTest?Moreover, the reader will have to constantly jump between different base classes to understand the big picture.

“It's better to duplicate code than choose the wrong abstraction” (Sandi Metz)



I recommend using composition instead. Write small snippets and classes for each fixture-related task (start a test database, create a schema, insert data, start a mock server). Reuse these parts in a method @BeforeAllor by assigning the created objects to the fields of the test class. This way, you will be able to build each new test class from these blanks, as from Lego parts. As a result, each test will have its own understandable set of fixtures and ensure that nothing extraneous happens in it. The test becomes self-sufficient because it contains everything you need.



// 
public class MyTest {
    //   
    private JdbcTemplate template;
    private MockWebServer taxService;

    @BeforeAll
    public void setupDatabaseSchemaAndMockWebServer() throws IOException {
        this.template = new DatabaseFixture().startDatabaseAndCreateSchema();
        this.taxService = new MockWebServer();
        taxService.start();
    }
}


//   
public class DatabaseFixture {
    public JdbcTemplate startDatabaseAndCreateSchema() throws IOException {
        PostgreSQLContainer db = new PostgreSQLContainer("postgres:11.2-alpine");
        db.start();
        DataSource dataSource = DataSourceBuilder.create()
                .driverClassName("org.postgresql.Driver")
                .username(db.getUsername())
                .password(db.getPassword())
                .url(db.getJdbcUrl())
                .build();
        JdbcTemplate template = new JdbcTemplate(dataSource);
        SchemaCreator.createSchema(template);
        return template;
    }
}


Once again:

"KISS is more important than DRY"


Straightforward tests are good. Compare the result with constants



Don't reuse production code



Tests should validate the production code, not reuse it. If you reuse the combat code in a test, you can miss a bug in that code because you are no longer testing it.



// 
boolean isActive = true;
boolean isRejected = true;
insertIntoDatabase(new Product(1, isActive, isRejected));

ProductDTO actualDTO = requestProduct(1);


//   
List<State> expectedStates = ProductionCode.mapBooleansToEnumList(isActive, isRejected);
assertThat(actualDTO.states).isEqualTo(expectedStates);


Instead, think in terms of input and output when writing tests. The test feeds data to the input and compares the output with predefined constants. Most of the time, code reuse is not required.



// Do
assertThat(actualDTO.states).isEqualTo(List.of(States.ACTIVE, States.REJECTED));


Don't copy business logic into tests



Object mapping is a prime example of a case when tests pull logic from the combat code into themselves. Suppose our test contains a method mapEntityToDto(), the result of which is used to check that the resulting DTO contains the same values ​​as the elements that were added to the base at the beginning of the test. In this case, you will most likely copy the combat code into the test, which may contain errors.



// 
ProductEntity inputEntity = new ProductEntity(1, "envelope", "office", false, true, 200, 10.0);
insertIntoDatabase(input);

ProductDTO actualDTO = requestProduct(1);

 // mapEntityToDto()    ,   -
ProductDTO expectedDTO = mapEntityToDto(inputEntity);
assertThat(actualDTO).isEqualTo(expectedDTO);



The correct solution is to actualDTOcompare it to a manually created reference object with the specified values. It's extremely simple, straightforward, and protects against potential mistakes.



// 
ProductDTO expectedDTO = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED))
assertThat(actualDTO).isEqualTo(expectedDTO);


If you do not want to create and check for a match for a whole reference object, you can check the child object or generally only the properties of the object that are relevant to the test.



Don't write too much logic



Let me remind you that testing is mainly about input and output. Submit data and check what is returned to you. There is no need to write complex logic inside the tests. If you introduce loops and conditions into a test, you make it less understandable and more error prone. If your validation logic is complex, use the many AssertJ functions to do the job for you.



Run tests in a combat-like environment



Test the fullest possible bundle of components



It is generally recommended that you test each class in isolation using mocks. This approach, however, has drawbacks: in this way, the interaction of classes with each other is not tested, and any refactoring of general entities will break all tests at once, because each inner class has its own tests. In addition, if you write tests for each class, then there will simply be too many of them.





Isolated unit testing of each class



Instead, I recommend focusing on integration testing. By "integration testing" I mean collecting all the classes together (as in production) and testing the entire bundle, including the infrastructure components (HTTP server, database, business logic). In this case, you are testing the behavior instead of the implementation. Such tests are more accurate, closer to the real world, and resistant to refactoring of internal components. Ideally, one class of tests will suffice.





Integration testing (= put all the classes together and test the bundle)



Don't use in-memory databases for tests





With an in-memory base, you test in a different environment where your code will work.



Using an in-memory base ( H2 , HSQLDB , Fongo ) for tests, you sacrifice their validity and scope. Such databases often behave differently and produce different results. Such a test may pass successfully, but it does not guarantee the correct operation of the application in production. Moreover, you can easily find yourself in a situation where you cannot use or test some behavior or feature characteristic of your base, because they are not implemented in the in-memory database or behave differently.



Solution: use the same database as in real operation. Wonderful Testcontainers library provides a rich API for Java applications that allows you to manage containers directly from your test code.



Java / JVM



Use -noverify -XX:TieredStopAtLevel=1



Always add options JVM -noverify -XX:TieredStopAtLevel=1to your configuration to run tests. This will save you 1-2 seconds on starting the virtual machine before running the tests. This is especially useful in the early days of your tests, when you often run them from the IDE.



Please note that since Java 13 has -noverifybeen deprecated.



Tip: Add these arguments to the “JUnit” configuration template in IntelliJ IDEA so you don't have to do this every time you create a new project.







Use AssertJ



AssertJ is an extremely powerful and mature library with a rich and secure API, as well as a rich set of value validation functions and informative test error messages. Many convenient validation functions relieve the programmer from the need to describe complex logic in the body of tests, allowing them to make tests concise. For instance:



assertThat(actualProduct)
        .isEqualToIgnoringGivenFields(expectedProduct, "id");

assertThat(actualProductList).containsExactly(
        createProductDTO("1", "Smartphone", 250.00),
        createProductDTO("1", "Smartphone", 250.00)
);

assertThat(actualProductList)
        .usingElementComparatorIgnoringFields("id")
        .containsExactly(expectedProduct1, expectedProduct2);

assertThat(actualProductList)
        .extracting(Product::getId)
        .containsExactly("1", "2");

assertThat(actualProductList)
        .anySatisfy(product -> assertThat(product.getDateCreated()).isBetween(instant1, instant2));

assertThat(actualProductList)
        .filteredOn(product -> product.getCategory().equals("Smartphone"))
        .allSatisfy(product -> assertThat(product.isLiked()).isTrue());


Avoid using assertTrue()andassertFalse()



Using simple assertTrue()or assertFalse()leads to cryptic test error messages:



// 
assertTrue(actualProductList.contains(expectedProduct));
assertTrue(actualProductList.size() == 5);
assertTrue(actualProduct instanceof Product);

expected: <true> but was: <false>


Use AssertJ calls instead, which return clear and informative messages out of the box.



// 
assertThat(actualProductList).contains(expectedProduct);
assertThat(actualProductList).hasSize(5);
assertThat(actualProduct).isInstanceOf(Product.class);

Expecting:
 <[Product[id=1, name='Samsung Galaxy']]>
to contain:
 <[Product[id=2, name='iPhone']]>
but could not find:
 <[Product[id=2, name='iPhone']]>


If you need to check the boolean value, make the message more as()descriptive with the AssertJ method .



Use JUnit5



JUnit5 is an excellent library for (unit-) testing. It is under constant development and provides the programmer with many useful features, such as parameterized tests, groupings, conditional tests, life cycle control.



Use parameterized tests



Parameterized tests allow you to run the same test with a set of different input values. This allows you to check multiple cases without writing extra code. In JUnit5 for this is the excellent tools @ValueSource, @EnumSource, @CsvSourceand @MethodSource.



// 
@ParameterizedTest
@ValueSource(strings = ["§ed2d", "sdf_", "123123", "§_sdf__dfww!"])
public void rejectedInvalidTokens(String invalidToken) {
    client.perform(get("/products").param("token", invalidToken))
            .andExpect(status().is(400))
}

@ParameterizedTest
@EnumSource(WorkflowState::class, mode = EnumSource.Mode.INCLUDE, names = ["FAILED", "SUCCEEDED"])
public void dontProcessWorkflowInCaseOfAFinalState(WorkflowState itemsInitialState) {
    // ...
}


I highly recommend making the most of this trick, as it allows you to test more cases with minimal effort.



Finally, I want to draw your attention to @CsvSourceand @MethodSource, which can be used for more complex parameterization, where you also need to control the result: you can pass it in one of the parameters.



@ParameterizedTest
@CsvSource({
    "1, 1, 2",
    "5, 3, 8",
    "10, -20, -10"
})
public void add(int summand1, int summand2, int expectedSum) {
    assertThat(calculator.add(summand1, summand2)).isEqualTo(expectedSum);
}


@MethodSourceespecially effective in conjunction with a separate test object containing all the desired parameters and expected results. Unfortunately, in Java, the description of such data structures (so-called POJOs) is very cumbersome. Therefore, I will give an example using Kotlin data classes.



data class TestData(
    val input: String?,
    val expected: Token?
)

@ParameterizedTest
@MethodSource("validTokenProvider")
fun `parse valid tokens`(data: TestData) {
    assertThat(parse(data.input)).isEqualTo(data.expected)
}

private fun validTokenProvider() = Stream.of(
    TestData(input = "1511443755_2", expected = Token(1511443755, "2")),
    TestData(input = "151175_13521", expected = Token(151175, "13521")),
    TestData(input = "151144375_id", expected = Token(151144375, "id")),
    TestData(input = "15114437599_1", expected = Token(15114437599, "1")),
    TestData(input = null, expected = null)
)


Group tests



Annotation @Nestedfrom JUnit5 is handy for grouping test methods. Logically, it makes sense to group together certain types of tests (such as InputIsXY, ErrorCases) or to collect in your group each test methods ( GetDesignand UpdateDesign).



public class DesignControllerTest {
    @Nested
    class GetDesigns {
        @Test
        void allFieldsAreIncluded() {}
        @Test
        void limitParameter() {}
        @Test
        void filterParameter() {}
    }
    @Nested
    class DeleteDesign {
        @Test
        void designIsRemovedFromDb() {}
        @Test
        void return404OnInvalidIdParameter() {}
        @Test
        void return401IfNotAuthorized() {}
    }
}






Readable test names with @DisplayNameor backquotes in Kotlin



In Java, you can use annotation @DisplayNameto give your tests more readable names.



public class DisplayNameTest {
    @Test
    @DisplayName("Design is removed from database")
    void designIsRemoved() {}
    @Test
    @DisplayName("Return 404 in case of an invalid parameter")
    void return404() {}
    @Test
    @DisplayName("Return 401 if the request is not authorized")
    void return401() {}
}






In Kotlin, you can use function names with spaces inside them if you enclose them in backticks. This way you get readability of results without code redundancy.



@Test
fun `design is removed from db`() {}


Simulate external services



To test HTTP clients, we need to simulate the services they access. I often use MockWebServer from OkHttp for this purpose . Alternatives are WireMock or Mockserver from Testcontainers .



MockWebServer serviceMock = new MockWebServer();
serviceMock.start();
HttpUrl baseUrl = serviceMock.url("/v1/");
ProductClient client = new ProductClient(baseUrl.host(), baseUrl.port());
serviceMock.enqueue(new MockResponse()
        .addHeader("Content-Type", "application/json")
        .setBody("{\"name\": \"Smartphone\"}"));

ProductDTO productDTO = client.retrieveProduct("1");

assertThat(productDTO.getName()).isEqualTo("Smartphone");


Use Awaitility to Test Asynchronous Code



Awaitility is a library for testing asynchronous code. You can specify how many times to retry checking the result before declaring a test unsuccessful.



private static final ConditionFactory WAIT = await()
        .atMost(Duration.ofSeconds(6))
        .pollInterval(Duration.ofSeconds(1))
        .pollDelay(Duration.ofSeconds(1));

@Test
public void waitAndPoll(){
    triggerAsyncEvent();
    WAIT.untilAsserted(() -> {
        assertThat(findInDatabase(1).getState()).isEqualTo(State.SUCCESS);
    });
}


No need to resolve DI dependencies (Spring)



DI framework initialization takes a few seconds before tests can start. This slows down the feedback loop, especially in the early stages of development.



Therefore, I try not to use DI in integration tests, but create the necessary objects manually and "tie" them together. If you're using constructor injection, this is the easiest. Typically, in your tests, you validate business logic, and you don't need DI for that.



Moreover, since version 2.2, Spring Boot supports lazy initialization of beans, which significantly speeds up tests using DI.



Your code must be testable



Don't use static access. Never



Static access is an anti-pattern. First, it obfuscates dependencies and side effects, making the entire code difficult to read and prone to subtle errors. Second, static access gets in the way of testing. You can no longer replace objects, but in tests you need to use mocks or real objects with a different configuration (for example, a DAO object pointing to the test database).



Instead of accessing the code statically, put it in a non-static method, instantiate the class, and pass the resulting object to the constructor.



// 
public class ProductController {
    public List<ProductDTO> getProducts() {
        List<ProductEntity> products = ProductDAO.getProducts();
        return mapToDTOs(products);
    }
}



// 
public class ProductController {
    private ProductDAO dao;
    public ProductController(ProductDAO dao) {
        this.dao = dao;
    }
    public List<ProductDTO> getProducts() {
        List<ProductEntity> products = dao.getProducts();
        return mapToDTOs(products);
    }
}


Fortunately, DI frameworks like Spring provide tools that make static access unnecessary by automatically creating and linking objects without our involvement.



Parameterize



All relevant parts of the class must be configurable from the test side. Such settings can be passed to the class constructor.



Imagine, for example, that your DAO has a fixed limit of 1000 objects per request. To check this limit, you will need to add 1001 objects to the test database before testing. Using the constructor argument, you can make this value customizable: in production, leave 1000, in testing, reduce to 2. Thus, to check the work of the limit, you will only need to add only 3 records to the test database.



Use constructor injection



Field injection is evil and leads to poor code testability. You need to initialize DI before tests or do some weird reflection magic. Therefore, it is preferable to use constructor injection to easily control dependent objects during testing.



In Java, you have to write a little extra code:



// 
public class ProductController {

    private ProductDAO dao;
    private TaxClient client;

    public ProductController(ProductDAO dao, TaxClient client) {
        this.dao = dao;
        this.client = client;
    }
}


In Kotlin, the same thing is written much more concisely:



// 
class ProductController(
    private val dao: ProductDAO,
    private val client: TaxClient
){
}


Do not use Instant.now() ornew Date()



You don't need to get the current time by calls Instant.now()or new Date()in production code if you want to test this behavior.



// 
public class ProductDAO {
    public void updateDateModified(String productId) {
        Instant now = Instant.now(); // !
        Update update = Update()
            .set("dateModified", now);
        Query query = Query()
            .addCriteria(where("_id").eq(productId));
        return mongoTemplate.updateOne(query, update, ProductEntity.class);
    }
}


The problem is that the time taken cannot be controlled by the test. You will not be able to compare the obtained result with a specific value, because it is different all the time. Use a class Clockfrom Java instead .



// 
public class ProductDAO {
    private Clock clock; 

    public ProductDAO(Clock clock) {
        this.clock = clock;
    }

    public void updateProductState(String productId, State state) {
        Instant now = clock.instant();
        // ...
    }
}


In this test, you can create a mock object for Clock, pass it to, ProductDAOand configure the mock object to return the same time. After the calls, updateProductState()we will be able to check that exactly the value we specified has got into the database.



Separate asynchronous execution from actual logic



Testing asynchronous code is tricky. Libraries like Awaitility are of great help, but the process is still convoluted and we might end up with a blinking test. It makes sense to separate business logic (usually synchronous) and asynchronous infrastructure code, if possible.



For example, by placing business logic in ProductController, we can easily test it synchronously. All asynchronous and parallel logic will remain in the ProductScheduler, which can be tested in isolation.



// 
public class ProductScheduler {

    private ProductController controller;

    @Scheduled
    public void start() {
        CompletableFuture<String> usFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.US));
        CompletableFuture<String> germanyFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.GERMANY));
        String usResult = usFuture.get();
        String germanyResult = germanyFuture.get();
    }
}


Kotlin



My article Best practices for unit-testing in Kotlin contains many Kotlin-specific unit testing techniques. (Note translation: write in the comments if you are interested in the Russian translation of this article).



All Articles