Spring-less testing

Spring is a great project, it helps a lot with common, usually mundane, tasks. One of the best features is, of course, dependency injection. It is very easy to use, too. For example:

class Foo {
    @Resource
    private Bar bar;
    ...
}

And that’s about it, once you instantiate your application context properly, you’ll get your foo bean with its dependency injected automatically. When you use Spring Boot, then one simple annotation, @SpringBootApplication, is all that’s required.

I’ll show how a Spring application can evolve and how Spring’s presence can influence testability. I’ll do it the TDD way, as this will show the evolution of the application quite well. It will also show, how I can start running into problems very early on. I’ll also be using JUnit5, AssertJ and Mockito whenever necessary.

I’ll write a simple application that does foo. Ever wondered what it does? As far as I know, foo is equal to 1 if bar is true, else it is equal to 2. The details of bar are not important.

First steps - The Spring way

I started with Spring Initializer and I got myself an application skeleton, so I have something I can actually execute, and thus get a working Spring ApplicationContext. You can check the full source code on GitHub and the commits history will more or less reflect what’s happening in here.

I’ll be spending most of the time in two classes: Foo and FooTest. A first, trivial test will probably look like this:

@Test
void ifBar_return1() {
    int result = foo.apply();
    assertThat(result).isEqualTo(1);
}

The test looks good, but I have to get this foo from somewhere. Since I have no clue where it comes from, I’ll just declare it as a test class field

private Foo foo;

The IDE stopped complaining about the foo part, now it’s complaining about apply. Look at the implementation, aka “the simplest thing that could possibly work”:

int apply() {
    return 1;
}

As expected, when I try to execute the test, I get a NullPointerException. The test has no idea of Spring, ApplicationContext is not started, and there’s nobody to inject Foo bean into the test. Let’s fix this by adding some annotations to the test:

@ContextConfiguration(classes = DemoApplication.class)
@ExtendWith(SpringExtension.class)
class FooTest {

I also annotate the foo field with @Resource to let Spring know I want it injected:

@Resource
private Foo foo;

Almost there, but a “No qualifying bean of type ‘com.example.demo.Foo’ available” comes up this time. So I annotate Foo with @Component:

@Component
class Foo {

Now it’s working. Execution time is also not that bad, 71ms on my machine, so I can move on.

My tests are running within Spring ApplicationContext and can use all of its power. Now I need to verify that, if bar is false, then foo returns the 2. Test:

@Test
void ifNotBar_return2() {
    int result = foo.apply();
    assertThat(result).isEqualTo(2);
}

Last time, I cheated and ignored bar completely. This time, the test looks exactly the same like the first one, but I’m expecting the foo to behave differently, based on bar results. That forces me to include bar and also ensure, that it will be returning what I want. I guess it’s time to invite some mocking framework, like Mockito. Now the tests should look like this:

@ContextConfiguration(classes = DemoApplication.class)
@ExtendWith(SpringExtension.class)
class FooTest {
    @Resource
    private Foo foo;
    @MockBean
    private Bar bar;

    @Test
    void ifBar_return1() {
        when(bar.apply()).thenReturn(true);
        int result = foo.apply();
        assertThat(result).isEqualTo(1);
    }

    @Test
    void ifNotBar_return2() {
        when(bar.apply()).thenReturn(false);
        int result = foo.apply();
        assertThat(result).isEqualTo(2);
    }
}

Also, I need a Bar class now:

@Component
class Bar {
    boolean apply() {
        return false;
    }
}

Tests are executing properly, but the second one is obviously failing. Let’s fix it:

@Component
class Foo {
    @Resource
    private Bar bar;

    int apply() {
        return (bar.apply() ? 1 : 2);
    }
}

Now the tests are green and are taking around 1.45 seconds to execute.

First steps - The Spring-less way

So far, I developed the tests in a fashion that I very often see. Let’s try to achieve the same thing in a different way and then compare both approaches. I’ll be refactoring the code trying to get rid of Spring as much as possible.

Actually, the only thing that Spring currently provides is dependencies. There are two places that use it: bar is injected into foo and foo along with bar are injected into the test class. The first one we can fix by switching to constructor injection. Later on, this will enable us to inject dependencies manually.

@Component
class Foo {
    private Bar bar;

    Foo(Bar bar) {
        this.bar = bar;
    }

    int apply() {
        return (bar.apply() ? 1 : 2);
    }
}

As new versions of Spring can figure out automatically that they should use the constructor injection, we don’t even need an annotation. The tests work now, so we can move on.

To eliminate the injection of foo and bar into the test class is actually really simple. We instantiate them manually:

private Bar bar = mock(Bar.class);
private Foo foo = new Foo(bar);

Tests are still green. They stay that way even if I remove Spring annotations from the test class. So, I don’t need Spring for testing any more. Now the whole test class looks like:

class FooTest {
    private Bar bar = mock(Bar.class);
    private Foo foo = new Foo(bar);

    @Test
    void ifBar_return1() {
        when(bar.apply()).thenReturn(true);
        int result = foo.apply();
        assertThat(result).isEqualTo(1);
    }

    @Test
    void ifNotBar_return2() {
        when(bar.apply()).thenReturn(false);
        int result = foo.apply();
        assertThat(result).isEqualTo(2);
    }
}

If I execute it, it’s all green and done in around 0.66s.

Comparing two approaches

Getting rid of Spring in my tests saved me around 0.8 seconds per run (all times taken from the Gradle output and averaged over a few runs, variation was minimal). It might not seem like much, but please think about what it might mean for any “real” project.

Currently, my ApplicationContext is ridiculously small, only two classes. In real projects we might have hundreds or thousands of them. Then, starting the context might take some time, and if we run our tests often, we’d already be wasting minutes every day, just waiting. We could shorten the time by avoiding the initialization of things we don’t need (like databases). But then the tests get more complicated, because we need to control which parts of our application are “real” and which need to be mocked.

Another issue that is not showing up yet, is cascading dependencies (a common problem with all examples: they’re usually too simple to show a full problem or a solution). Following this path would make our tests depend on things we don’t really need or care about. If some dependencies of our dependencies suddenly change their behaviour, we might find ourselves wondering why our tests turned red, although nothing has changed on our side. Now, our code indirectly depends on much more than we’d like.

Let’s try to see if I can push this little example a little further so it starts showing this second problem.

Adding a second dependency - The Spring-less way

Now my business logic gets slightly more complicated. Before I check if bar is true, I need to check if baz is above 10. If it is, I apply the old logic, if not, I return 0 immediately.

This time I’ll start with a “Spring-less” way. Because the logic has changed, I will need to change at least some of my existing tests. Let’s start with a first one:

@Test
void ifBazAbove10AndBar_return1() {
    when(baz.apply()).thenReturn(12);
    when(bar.apply()).thenReturn(true);
    int result = foo.apply();
    assertThat(result).isEqualTo(1);
}

I need to provide baz, so in FooTest this happens:

private Baz baz = mock(Baz.class);

I’ll fake it again and don’t even inject baz into foo, and the test will still pass.

Time for fixing the second test:

@Test
void ifBazAbove10AndNotBar_return2() {
    when(baz.apply()).thenReturn(12);
    when(bar.apply()).thenReturn(false);
    int result = foo.apply();
    assertThat(result).isEqualTo(2);
}

As before, the only changes I made are the name of the test and mocking baz’s behaviour. Still no changes to production code needed, the tests pass. To finally force myself to use baz in foo, I need to write a new test:

@Test
void ifBazNotAbove10_return0() {
    when(baz.apply()).thenReturn(10);
    int result = foo.apply();
    assertThat(result).isEqualTo(0);
}

This one fails. I cannot run away from adding a new dependency in foo:

private Bar bar;
private Baz baz;

Foo(Bar bar, Baz baz) {
    this.baz = baz;
    this.bar = bar;
}

Now, the fix is quite simple:

int apply() {
    if (baz.apply() > 10)
        return (bar.apply() ? 1 : 2);
    else
        return 0;
}

All three tests are now green and are executing in around 0,7 seconds.

Adding a second dependency - The Spring way

There are honestly no changes to the spring-less way, apart from a few annotations here and there. Both implementation and tests look exactly the same, apart from one small thing:

class FooTest {
    @Resource
    private Foo foo;
    @MockBean
    private Bar bar;
    @MockBean
    private Baz baz;

But that is also no surprise. Tests are still executed in around 1.45 seconds.

Simplifying

Now, I’ll do a small trick on the spring-less code. So far foo, bar and baz were defined on a class level, outside of the test method. I’ll pull all three into my last test method and see if some interesting possibilities will present themselves.

@Test
void ifBazNotAbove10_return0() {
    Bar bar = mock(Bar.class);
    Baz baz = mock(Baz.class);
    Foo foo = new Foo(bar, baz);
    when(baz.apply()).thenReturn(10);
    int result = foo.apply();
    assertThat(result).isEqualTo(0);
}

It seems, I don’t need to care about what bar is doing. Can I remove it completely from the picture? Yes, I can!

@Test
void ifBazNotAbove10_return0() {
    Baz baz = mock(Baz.class);
    Foo foo = new Foo(null, baz);
    when(baz.apply()).thenReturn(10);
    int result = foo.apply();
    assertThat(result).isEqualTo(0);
}

Passing in null is OK in here, as I’ll never reach a piece of code, that actually tries to do something with it.

Conclusion

Would it be possible to do such a trick on tests with Spring? Not really. And that is exactly where the problem starts to surface. Dependencies start to creep into our tests. All of a sudden we find ourselves in a situation where some dependency, far away from a piece of code we’re testing, changes its behaviour and affects our tests. In any project with a reasonably large ApplicationContext, tests written this way now depend on half of the whole codebase. It also means, that in order to run the tests, Spring needs to initialize the whole context, no matter how big it is.

By removing Spring from the picture, we make those problems go away. There’s no more magic around where my dependencies are coming from or how many are there. We have full control over it and can provide only what’s necessary. This also has the additional benefit, that we’re very explicit as to what influences our test.

Having said all that, Spring is not bad and also necessary! Sooner or later, you will need to write a test that starts the whole ApplicationContext, for example, just to see if it can start. Or to see if you’ve connected a few beans correctly and they can interact with each other. Just not every single test needs to be a Spring one. Unit tests should definitely be Spring-free. Yet I see, time and again, that most tests are using Spring. Make yours and your fellow colleagues' life easier and only use Spring when necessary.

Happy coding!

TAGS

Comments

Please accept our cookie agreement to see full comments functionality. Read more