From Fragile to Fearless

Wouter Bauweraerts

Writing Tests That Last!

Introduction

I believe that every modern software developer by now realizes that writing tests is an indispensable part of our job. We also know that it’s best to start writing our tests before we write the actual implementation. There are already many articles that elaborate on the test-driven development process. We will not investigate how our development process should work. Instead, we will investigate how we can write tests that help us create good software, without adding overhead.

Problem-Solving Assistant

Tests serve multiple purposes while we are working in our codebase. For starters, a test can help us gain more understanding of the problem at hand. By writing the test, we think about the small steps we can take to implement the solution step by step. Writing tests can also force us to think about the internal structure of our code in advance. This helps us structure our code so that we will still understand it in several months.

Functional Validation

One of the main reasons we write tests is to validate that the code we are writing is correct. By thinking about the expected outcome before we write the code, we know when we have reached our goal. Level up! Write the next test and continue implementing the feature you’re working on. Good job!

Finally, our tests can also serve as some kind of regression suite. Especially when working in an agile environment, we won’t be implementing the complete feature at once. We’ll start with the easiest happy path, without adding all the complexities that may be required in the future. We will add additional requirements defined by what the evolving business needs are in the end, during the next iterations.

Let’s say we are implementing the checkout process for a web shop. First, we calculate our total price, ignoring discounts and shipping costs. In the next sprints, we can add volume discounts and shipping fees based on destination, total price, and various shipment options. But we still need to be able to ensure that the most basic flow still works as it did before! We will also have to be able to check the correctness of a feature after changing our code structure.

Different Types of Tests

To ensure the quality of our entire system, we often need different types of tests; not every test is equal. They differ in both how we write them and what they are validating. Some tests only cover a small piece of complex business logic. On the other hand, we write tests that mimic an end user by clicking a button that results in adding a new record in our database, returning the persisted data to the browser to show it to our end user. Let’s briefly dive into the most common test types that we use daily. Let’s order them by starting with the most expensive type!

End-to-End Tests

And the winner is… the end-to-end (E2E) test. The most common use of E2E tests is to mimic what our end user does by automatically navigating to the user interface and clicking on elements that are visible. We can write code to simulate the process of filling out a form. Once submitted, we can check that the expected data is displayed on screen.

We also have the possibility to directly query the database or check whether a file has been created or downloaded. We could also try to check for outgoing messages, if they are crucial in our system. I strongly advise focusing on high-level outcomes instead of asserting internal implementation details. E2E tests are hard enough to write as is! If you need to check for a specific outgoing message, it’s often easier to validate this with another type of test.

Integration Tests

Next in line are our integration tests, as the name suggests, we are testing some kind of integration. This does not mean that we are testing the integrations between different microservices! No, we are testing that the components of our platform integrate well with the required infrastructure and with the framework we are using. Writing integration tests has become easier over the past few years with the rise of tools like testcontainers, strongly reducing the overhead of managing the required infrastructure.

What I like to test in an integration test is that my setup of framework features, like security and validation, is correct. I also validate that I can communicate with the required data sources and other external systems, like a message broker, a remote file system, or even external APIs. Even though I want my test to be as realistic as possible, I never integrate with the real instance or an instance being used by a cloud environment. No, I always use an instance specifically created for my tests to avoid failures caused by external factors.

Integration tests don’t require a cloud environment to run. We run our integration tests during the build pipeline of our application. The test framework will handle the application context and required infrastructure we need in our tests.

Integration tests are less expensive than E2E tests, because we only need one component of our system instead of a complete environment. This also makes them faster, but they are still slow, which makes them less useful during the actual development process. If I’m trying to find/fix a bug, I don’t want to waste my time before knowing that my change fixed the issue!

Unit Tests

That’s why we have unit tests. For me, unit tests are the most important type to maintain the functional quality of your codebase. Because unit tests run extremely fast, they’re the most useful type of test to depend on while writing or changing code. Unit tests are also very easy to write, especially when comparing them to the other tests I’ve discussed earlier.

Unit tests don’t require an application context to be able to run. They don’t need to integrate with any of the infrastructure; they focus on testing that your code does what it should be doing. Because we don’t have an application context, we need to instantiate the unit under test ourselves, but this isn’t a drawback, as unit tests focus on a small piece of our application. We only need to instantiate those parts we need for our tests.

Unit tests are also able to run in isolation; they should never share state, giving them the ability to run in any order. If you are sharing state in your unit tests, you’re simply doing it wrong! Modern testing tools like Junit help us by allowing us to simply recreate the unit of code we’re testing before each test, without having to do this manually in each test.

If our code depends on external systems or infrastructure, we should replace this dependency with a test double. These doubles allow us to control the external behavior, giving us the possibility to test various edge cases without adding complexity.

What Is the Unit in a Unit Test?

Now that we are aware of the different types of tests and their main purpose, I want to elaborate more on the unit tests. It’s not because they are easy to write that we shouldn’t think about how we should be writing them. And to know how to write a solid unit test, I think we need to reflect on the meaning of a unit.

A unit can mean anything. If we think beyond code, there are many meanings for the word unit. We could be talking about parts of an air-conditioning system, but also about units of measurement. If we are scrolling through a web shop, the indicated price is the price for one unit of that specific product. For unit testing, the unit can also have multiple interpretations. There’s only one thing that every interpretation has in common. A unit defines the scope of our unit tests. Which parts of our code are being used during the test, and what parts should be replaced by test doubles?

Martin Fowler wrote a great article about this, where he elaborates on two major subtypes of the unit test. I will only highlight those parts we really need to be able to understand what’s up next in this article.

Solitary Unit Test

Schematic description of a solitary test

The first one is the easiest to write, but does that also mean it’s the best choice? In this case, we are limiting our unit test to validate only the code of a method within a single class. If the method delegates parts of the behavior to another class, this will be replaced by a test double, allowing us to control what’s happening. Tests like this are called solitary tests.

Sociable Unit Test

We also have a type of unit test called a sociable unit test, which uses a larger unit. We don’t limit our test to the code within a single class; we focus on an isolated behavior. In a sociable unit test, we accept some dependencies, while we still mock others.

There are some different opinions on what you should be mocking, but I’ll share how I feel you should be doing this. I believe the best way to write a sociable test is to use the real code for everything that’s within the bounds of the codebase we’re working on. We only mock those parts that we don’t control or the dependencies that require an active application context. Some examples of these are remote APIs, communication with application infrastructure, or interactions with a filesystem. So, we will not be using a test container for our database or messaging system, because they would require an application context to read the configuration and to connect with the test instance.

There is one more thing to keep in mind when writing sociable tests. Most developers know that assumptions are the root of all evil. We should never just think something is exactly how we believe it is. With sociable tests, you are allowed to assume one thing, and one thing only! You may assume that all dependencies your test subject has are functionally correct. The simple reason to allow this assumption is that we should have had the discipline to test that new dependency by itself, with its own set of (sociable) tests.

Sociable Test or Integration Test

When I was discussing this with some of my peers, I regularly got the question of whether there is still a difference between a sociable unit test and an integration test besides the presence of the application context. In my opinion, the main purpose of any unit test is to validate the behavior of our code. Do certain inputs result in the expected outcome when we invoke a given method? On the other hand, the purpose of an integration test is to validate that all components of our application work well together. Did we make any mistakes in how we use the features of the framework, or are we injecting the right implementations? Does our code work well with the supporting infrastructure, and do our custom queries behave as expected? This is what I want to validate in an integration test.

A Typical Example

To illustrate this, imagine we are writing a REST API with Spring-Boot. We want to test a POST endpoint that has bean validation and a security constraint. If all goes well, the request should result in a new record being created after gathering some additional information and transforming the data. This record should be created in the expected database table, and the response should contain the expected JSON representation of the data we just added. In such a case, I would not only write an integration test for the happy path, but I would also verify that only users with the expected authorities can call the system. I would also test that an invalid request returns a suitable response, without adding the data to the database. This is easier to test with an integration test, because we are using framework features to add this behavior to our application.

So just to be clear, sociable unit tests are not a replacement for integration tests! Both tests serve their own purpose!

What Is a Robust Unit Test?

We talked about tests, describing how we would write them. This typically happens when we are adding new features or when we extend existing functionality with additional capabilities. If we are writing new tests, we know we are not finished until all tests succeed.

In the introduction, I mentioned that tests should not only serve as a check for the functionality to be correct during implementation. No, our tests should also guarantee that existing functionality will not break when changing the code in the future. This is what we tend to call refactoring the code.

Refactoring

Martin Fowler once defined refactoring as “a change made to the internal structure of software to make it easier to understand and cheaper to modify without changing its observable behavior”. This means that we are changing our codebase by moving code around or even by rewriting entire parts to make it more future-proof.

The most important part of refactoring does not lie in changing the code. No, it lies in the part where we do not change the observable behavior. Unchanged behavior means that when we call a method with the same parameters before and after the refactoring, the outcome of this invocation should be identical. But how can we ensure this? Simple, just run your tests!

If you only improved the code within a single method, without changing the delegations to other classes, all your tests should be fine. If not, you made a mistake! But it gets interesting if we start introducing new delegation in our code by extracting pieces of a method to a separate class. If you click run, most of the time your tests will fail! How they’ll fail and what you must do to fix them will depend on the type of test you’re using.

Solitary Unit Test Behavior

Let’s first see how a solitary unit test works. I must begin by saying that we have two possibilities here. If we use the power of JUnit extensions combined with Mockito, we could simply set up our test by using annotations like @Mock combined with @InjectMocks. The InjectMocks annotation defines which class will be instantiated. While instantiating, Mockito will look for mocks or spies you defined to inject them. If the test subject has dependencies that you did not define, by default, null will be used.

This is exactly what will happen if you refactor your code without updating the tests. This will result in tests failing with the almighty NullPointerException. Add the required mocks to get rid of this awful failure! If you choose to instantiate the test subject yourself in a setup method, your test will not even start. It will not compile, but that’s ok. Just inject the required mock to get back on track.

Now the setup of your unit is just fine again, but will your tests pass again? If you try to run your tests, you’ll see that your tests are still failing. This is because we didn’t define how the new mock(s) should behave yet. To make your tests work again, you’ll have to define what the expected outcome is for a given method invocation on one of your test doubles.

Coupling to the Implementation

The fact that we need to stub some method invocations points out a major drawback of solitary tests. Solitary tests tend to be very tightly coupled to the actual technical implementation of your class. We can see this because we must define the stubs. We can cause test failures by simply moving code from our test subject to a delegate, hiding this code from the solitary test.

Sometimes we can even test for the wrong things. Let’s say we want to test an exception flow, where initially our delegate throws a RuntimeException. So, in our test, we define that our test double must throw an exception if the method is invoked with certain parameters. After a while, we decide that the RuntimeException isn’t good enough. We replace it with a more declarative exception that we added to our codebase. But we forget to update our tests, so our test will still test for the RuntimeException. We believe our work is done because we don’t have any test failures, but once the application is deployed on production, Murphy’s law will haunt us! Our code will crash because we didn’t catch the new exception. Angry management and hotfixes incoming!

When you write sociable tests, your test will also fail to compile if you manage your test subject manually. You will have to update the constructor invocation, adding all new dependencies. If you did your job right, all you have to do now is run your tests, and everything should be ok. If there are some failures, you did something wrong; you changed the observable behavior of your code!

@ExtendWith(MockitoExtension.class)
class GetCheckupPriceSociableTest {
    // constants declaration omitted

    @Mock
    OwnerRepository ownerRepository;

    Clock clock;
    VeterinaryCheckUpConfigProperties configProperties;

    VeterinaryService service;
    
    @BeforeEach
    void setUp() {
        clock = Clock.fixed(FIXED_TEST_INSTANT, ZoneId.systemDefault());

        configProperties = TestConfigProperties.testDefaults();
        var calculator = new CheckupPriceCalculator(
           configProperties,
           new DiscountCalculator(configProperties),
           new FineCalculatorStrategy(
              clock,
              new FixedFineCalculator(configProperties),
              new PercentageCalculator(configProperties)
           )
        );

        service = new VeterinaryService(
                ownerRepository,
                null, null, null,
                clock,
                calculator 
        );
    }

    // tests omitted
}

Going even further

Once you transition from solitary to sociable unit tests, your tests are already a lot more reliable. I can live with it that I have to update my constructor invocations, but I believe that we can make it even easier. The first time I got this idea, I was looking at how dependency injection frameworks handle this. That’s where I got the idea for a little experiment. I started thinking about a very lightweight dependency injection tool that creates only the instances that I need within the scope of my test. My inspiration came from how we do this using frameworks and tools like Spring, Mockito, and even how we test when using the Angular TestBed.

UnitSocializer

I started writing my own custom JUnit extension that works with some simple annotations, instantiating the classes required to be able to test a given behavior. This is when UnitSocializer was born. UnitSocializer is evolving, but the first version, which supports JUnit combined with Mockito, is already available. Currently, I’m working on a framework-agnostic implementation, so UnitSocializer – unlike Spring – is not yet opinionated. There is no default configuration to decide if a given type should be mocked. I will add this later, when I release a specific version for Spring and other popular frameworks.

The first results are above expectations! Tests run extremely fast! And we can refactor without causing test failures in most cases. Sometimes we’ll have to add some additional configuration, especially when we’ve added new abstractions during our refactoring. This way, we can tell UnitSocializer which implementation should be used for a specific test.

Using UnitSocalizer unlocks the full potential of sociable unit tests, while it feels very familiar because we can set up our test unit by adding some annotations. This triggers the internal magic, and your tests just run! UnitSocializer is open source1, so you can freely experiment, suggest improvements, or new features.

@SociableTest
class GetCheckupPriceUnitSocializerTest {
    // constants declaration omitted

    @TestSubject(typeResolvers = {
            @Resolve(
               forClass = OwnerMapper.class, 
               use = OwnerMapperImpl.class
            ),
            @Resolve(
               forClass = PetMapper.class, 
               use = PetMapperImpl.class
            ),
    })
    private VeterinaryService service;

    @InjectTestInstance
    OwnerRepository ownerRepository;

    @Predefined
    Clock clock = Clock.fixed(
            FIXED_TIME_INSTANT,
            ZoneId.systemDefault()
    );

    @Predefined
    VeterinaryConfigProperties config = TestVeterinaryConfig.testDefaults();

    // tests omitted
}

Wrap It Up!

Let’s conclude by going over the key takeaways once more. The entire article elaborated on our tests, focusing on unit tests. The main goal of our tests is to validate that the code we’ve written works exactly the way we expect it to. The behavior should not only be checked when writing the code, but we should also check that we did not break any existing functionality.

To ensure this, our tests need to be able to deal with changes. Writing our tests in such a way that they focus on the desired behavior instead of testing the technical implementation can help us achieve this. We need to upgrade our solitary unit tests to the more robust sociable unit tests.

We can easily achieve this by widening the scope of our test unit. Do not focus on a single class in your test; focus on behavior. Accept internal dependencies that fulfil a part of the behavior under test. This will reduce the number of mocks in our tests.

We can write sociable tests without additional dependencies by manually creating our test unit, setting everything up ourselves. In small projects, this is simple, but it can become a hassle when adding more complexity to our project. This is why I started the UnitSocializer project, which can help you simplify the setup of your sociable tests!2

Once you master the art of sociable testing, you’ll encounter robust unit tests that still validate the behavior of your code, even when moving things around. This allows us to refactor our code with confidence! You’ll boost your productivity and the confidence in your codebase! Join me in the sociable test revolution!

  1. UnitSocializer can be found on GitHub. ↩︎
  2. See the demo project for more test examples. ↩︎

This article is part of the JAVAPRO magazine issue:

Engineering Intelligence – Building Systems in the AI Age

Explore what engineering means in the AI age — beyond code generation and automation.
Gain insights into performance-critical Java systems, evolving architectures, and the responsibilities that come with building intelligent software.

Discover the edition →

Total
0
Shares
Previous Post

Come for Java. Get a Whole Lot More.

Next Post

When Developers Meet Designers: Lessons from Vibe Coding and the Art of Creative Expression

Related Posts