Hands-on Java 25 Series: #4 – Integration Testing – Simplicity with Spring Boot 4

The Context

While unit tests focus on isolation, Integration Tests (IT) are crucial for ensuring that different software modules work together in harmony. In this P.o.C. project, we utilize a dedicated test-it folder to separate these broader tests from our unit tests, representing a best practice in project organization.


The Problem: how to implement?

Since these tests verify full flows, catching system-level issues that unit testing might miss, such as inconsistent code logic between integrated modules, we need to have a clear perspective of inputs and expected output of the integrated features to be validated.
Again, the most effective way to structure any test is by following the AAA (Arrange, Act, Assert) pattern. First, you Arrange by setting up the necessary objects and prerequisites. Then, you Act by performing the actual work or calling the function being tested. Finally, you Assert by verifying that the result matches your expected outcome. This consistency makes tests easier to read and maintain.

Important: keep in mind these concepts to review at the Result section:

  • SOLID principles.
  • Clear desing.
  • Decoupled architecture.


The Solution

Important Disclaimer: This is a Proof of Concept (PoC) built for a specific course need. If you find this code useful and decide to use it, you do so entirely at your own risk. The authors decline any responsibility for its use.

Before we dive in, the full source code is available on GitHub: 👉 https://github.com/gabo-gil-playground/java-poc-oauth-jqoo-openai


To keep our Integration Tests fast and independent of the host environment, we use SQLite as an in-memory database. For a Proof of Concept (P.o.C.) like this, using SQLite is a minimalist and efficient choice that avoids the complexity of defining and managing Testcontainers. This allows us to run the entire integration suite—including schema creation and data insertion—entirely in memory, ensuring that the tests are repeatable and performant.

A critical aspect of integration testing in modern apps is mocking the security context and JWTs. Since our application acts as an OAuth2 Resource Server, we need to verify that our endpoints are properly protected without needing a live identity provider like Auth0. Using @WithMockUser or custom security builders, we can simulate authenticated users with specific roles and claims to test our RBAC (Role-Based Access Control) logic.
Our build.gradle configuration ensures that the test-it source set is recognized and has access to the main application classes and test dependencies. By separating the configurations, we can run unit tests and integration tests independently or as part of a complete build:



// integration test source folders
sourceSets {
    testIt {
        java.srcDirs = ['src/test-it/java']
        resources.srcDirs = ['src/test-it/resources']

        compileClasspath += sourceSets.main.output + sourceSets.test.output
        runtimeClasspath += output + compileClasspath
    }
}

// integration test load dependencies
configurations {
    testItImplementation.extendsFrom testImplementation
    testItRuntimeOnly.extendsFrom testRuntimeOnly
}

// integration test task
tasks.register('integrationTest', Test) {
    description = 'Integration tests task (test-it folder).'
    group = 'integration test'

    testClassesDirs = sourceSets.testIt.output.classesDirs
    classpath = sourceSets.testIt.runtimeClasspath

    useJUnitPlatform()
}

...

    // test common dependencies (unit + integration)
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // integration test exclusive dependencies
    testItImplementation 'org.springframework.security:spring-security-test'
    testItImplementation 'org.xerial:sqlite-jdbc:3.51.2.0'



Pro-tip #1: The integration test environment configuration

In src/test-it/resources, we provide a specific application.yml and schema.sql for the integration environment. The YAML file configures the datasource to use the in-memory SQLite instance while schema.sql ensures the database structure is ready before the tests execute -important: tables are “refreshed” between test cases-:


spring:
  datasource:
    url: 'jdbc:sqlite::memory:'
    driver-class-name: org.sqlite.JDBC
    username: sa
    password: ''
  jpa:
    database-platform: org.hibernate.dialect.PostgreSQLDialect
  sql:
    init:
      mode: always
      schema-locations: classpath:schema.sql
  jooq:
    sql-dialect: postgres

  ai:
    openai:
      api-key: 'mock-key-for-it-tests'
      chat:
        enabled: true

logging:
  level:
    org.jooq: DEBUG
    org.springframework.jdbc.datasource.init: DEBUG

CREATE TABLE IF NOT EXISTS blog_request (
	blog_request_id INTEGER PRIMARY KEY,
	create_user text NOT NULL,
	summarize text NULL,
	create_ts timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP),
	update_ts timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);

CREATE TABLE IF NOT EXISTS blog_request_content (
	blog_request_content_id INTEGER PRIMARY KEY,
	blog_request_id INTEGER NOT NULL,
	blog_url text NOT NULL,
	blog_text text NOT NULL,
	create_ts timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP),
	update_ts timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP),
	CONSTRAINT blog_request_content_blog_request_id_fkey FOREIGN KEY (blog_request_id) REFERENCES blog_request(blog_request_id)
);


Pro-tip #2: Test the Application context

The BlogSummarizeIT.java class demonstrates a full integration flow. By using @SpringBootTest, we load the application context and execute a real HTTP request against our controller, verifying that the interaction between the service, MyBatis, and the (in-memory) database works correctly using the AAA pattern:


@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BlogSummarizeIT {
    @Test
    @WithMockUser(roles = "USER")
    void testFullSummarizeFlow() {
        // Arrange: Setup data in SQLite
        // Act: Perform POST to /api/v1/blog/summarize
        // Assert: Verify DB update and response
    }
}

To run the code, you just need to run gradle integrationTest via your terminal or favorite IDE (I personally recommend Intellij). For more details, please, check the README.md file.


Summary of Steps

  1. Define your test dependencies: real, in-memory, mock.
  2. In the Arrange phase, setup the integration context, mock behaviors and expected result values.
  3. Act by calling the function under test.
  4. Assert that the result matches your expectations.


The Results

Just like with unit tests, the importance of SOLID principles and clear design cannot be overstated. A decoupled architecture, such as Hexagonal Architecture, allows us to swap the real database for SQLite and the real Auth0 provider for a mock security context with ease. This simplicity in design is what ultimately allows us to implement powerful integration tests that remain easy to manage and execute.

Want to dive deeper into any of these concepts? Drop a comment below and let me know which topic you’d like us to explore in our next Hands-on series!




Do you need help modernizing your legacy applications, optimizing your architecture or improving your development workflows? Let’s connect on LinkedIn or check out my services on Upwork.


Comments

Leave a Reply

Discover more from Gabo Gil

Subscribe now to keep reading and get access to the full archive.

Continue reading