A Double-Edged Sword in the Garden of Integration Tests
A Double-Edged Sword in the Garden of Integration Tests

@SpringBootTest: A Double-Edged Sword in the Garden of Integration Tests

Yehonatan Yeshurun
CyberArk Engineering
6 min readFeb 5, 2024

--

Testing. It’s one of those tasks that many of us developers have a love-hate relationship with. We often find ourselves grumbling about the time and effort the tasks need, yet we also recognize we can’t deploy anything confidently without them. It’s like being caught between a rock and a hard place. If we’re going to invest our precious time in tests, let’s at least ensure we truly understand them. This way, we can avoid pouring hours into something that doesn’t yield the results we’re aiming for.

The Two Main Types of Code Tests

There are generally two types of code tests: Unit Tests and Integration Tests. Instead of diving into the technical differences which most of us are familiar with, let’s discuss the fundamental differences based on their aims. Then, we’ll delve into where @SpringBootTest fits in. (Spoiler: there’s a surprise ahead!)

Note: Throughout this article, I use the term service to describe what we’re testing. However, it’s important to note that everything discussed here applies not just to individual services but also to modules or entire applications. It’s relevant for any complete code product with end-users, whether those users are internal or external. The principles stay the same, regardless of how we define the product.

Unit Test

Starting with Unit Tests, many developers would agree that their primary aim isn’t to cater to the service’s end-users. Average users don’t concern themselves with code implementation or whether a specific method’s response is exact. They simply want a functional service.

So, why do we use Unit Tests?

  • It ensures that smaller code blocks function as intended, either initially or after meeting a bug.
  • It helps understand someone else’s code and learn its proper usage by examining the outputs for various inputs. For example, developers create Unit Tests to show how to use their code.
  • On a deeper level, it helps uphold certain clean code principles. For example, if testing a single method or class becomes complex and requires many mocks, it’s a clear indication that the logic is too intricate and should be divided into simpler segments.

The common factor among these scenarios is that once the specific class or method is fully functional, unit tests offer very limited direct benefits, especially when comprehensive integration tests are in place. We often keep them out of nostalgia or just to impress our boss with our testing efforts…

Integration Tests: The General Black-Box Approach

Integration tests are where the real action happens. In fact, while the term integration test might refer to a specific style of testing for an individual or a company, it can also include a wide range of test styles. Many of these adopt what’s often called the black box approach. This isn’t a label given by end-users, but it aptly describes their perspective: They’re primarily concerned with the results, not the inner workings of the service. Essentially, they interact with our service as if it were a black box, inputting data and expecting correct outputs without concern for the processes within. The primary goal of integration tests is to emulate this perspective and ensure that users receive the correct results even without any knowledge of our code-building methods. This lack of knowledge isn’t a limitation; it’s the very essence of integration tests. We aim to simulate real-world user interactions as closely as possible, focusing solely on potential user inputs and outputs.

Introducing @SpringBootTest for Testing in Real-World Environments

Now, let’s discuss @SpringBootTest. First, what does it do?

Without delving too deep into technicalities, @SpringBootTest is a part of the Spring Framework that allows us to harness various Spring features during testing. This includes everything from basic variable injections to complex external service connections. We can manipulate the code being tested in many ways and validate inputs using components from the original code. For instance, we can interact with databases, store results, and query them using the original classes. Essentially, @SpringBootTest combines these capabilities under a modern name.

In the Spring universe, such tests are termed slice tests. This means we can test a specific segment of the service without being bogged down by other parts. Instead of laboriously adjusting service configurations for specific tests, we can let Spring handle it and focus on the segment we wish to assess.

The Challenge: SpringBootTests are More Similar to Unit Tests

Sounds fantastic, right? Well, it’s a double-edged sword. @SpringBootTest offers a lot of benefits, but there’s something we need to keep in mind. Even with all its features, tests using @SpringBootTest often lean more toward unit tests than integration tests.

Here’s why:

With unit tests, we can change a lot of things and configurations when we test. @SpringBootTest lets us do the same. This means we can make the code work in a test setting that might be different from real life. This is good for checking parts of the code, but it might not show how everything works together in real situations.

Furthermore, @SpringBootTest lets us mix the code we’re testing with the test code itself. This means that while there’s unintended isolation from real-world conditions, there’s also a lack of necessary separation from the test code. This dual challenge can lead to tests that don’t truly reflect real-world behavior and potential blind spots in our testing strategy. Because of the extensive manipulation capabilities, these tests often don’t evaluate the code from the user’s perspective.

Here are some examples:

  1. If we use the same objects in both the tested code and the tests themselves for database interactions, any changes to these objects might not cause the tests to fail, since both the service and test environments are using the updated objects. However, in a real-world scenario, end-users might be using different objects to interact with the database. Thus, while our tests may pass, the service could fail for the end-user due to these changes.
  2. The same applies to Rest controllers or other external services that send or receive custom objects.
  3. If we test only one segment of the code, assuming we know the real-world input for that segment, future changes might introduce issues that go unnoticed until users meet them.
  4. Suppose there’s a configuration that’s tailored to work with a specific object, either one we’ve created or one provided by Spring. If we use this same object in both our service and test code, any issues might go unnoticed. This is because the tests will always see the “right” configuration, but in real-world scenarios, different or updated configurations might be used, leading to potential problems.
  5. Using identical security classes in both test and service code can sometimes lead to overlooked security vulnerabilities.

The Right Approach: Simulating a Real Environment

So, we’ve discussed the challenges and pitfalls. Now, let’s talk about what we should be doing to get the most out of our integration tests:

  • Create a Complete Simulated Environment. Instead of testing in isolation, aim to replicate the real-world environment of your service. This includes all the resources: Databases, queues and other services. It’s essential to set up temporary, dedicated sections, schemas, clusters, or virtual hosts for each resource. This ensures that tests run in a controlled environment that can be easily cleaned up afterward.
  • Interact Like a User. When testing, interact with the service only in ways an actual user would. This means using interfaces, APIs or other entry points that a real user has access to. The goal is to experience the service just as they would.
  • Avoid Over-reliance on Internal Classes. It’s tempting to use dedicated classes from the service itself for testing. However, remember that there’s no guarantee a customer will use these same classes. Instead, approach the service from an external perspective, just as a user would, without any insider shortcuts.

By adopting this approach, we ensure our tests are not only comprehensive but also genuinely reflective of real-world user experiences.

Remembering the End Goal of SpringBootTest

To truly harness the power of integration tests, it’s crucial to simulate a real environment. This means creating a complete replica of the service’s environment, interacting with the service as a genuine user would and avoiding over-reliance on internal classes. Whether you’re testing a service, module, or entire application, the goal is still the same: to ensure our tests genuinely reflect real-world user experiences.

Thank you for reading! Please share your thoughts in the comments.

--

--