1. Overview
Professionals well-versed in The Testing Pyramid recognize the pivotal role Integration Tests play in contemporary backend (micro)services development. In this article I will try to make a deep dive into one of my favourite ways of setting up Integration Tests suite for Java/Kotlin applications. It encompasses a comprehensive exploration of setup methodologies, essential tools, best practices, and the seamless integration with various third-party entities often encountered in the development landscape.
As an illustrative example, the primary emphasis will be on the Spring Boot framework, but I believe many of the concepts presented in the article can be used in other technologies too. Personally, I have garnered experience in setting it up with Ktor and Docker Compose as well.
I have opted for Kotlin as my language of choice and Spock framework (based on Groovy) for testing, as I am a big fan of it. However, it’s important to note that you are free to leverage alternative tools aligned with your preferences. If you want to learn more about Spock read my previous article about it.
2. Preparation
All of the code in this article can be found in this GitHub repository
Project setup
Let’s start with the project setup and dependencies. This is the build.gradle.kts file to setup basic Spring Boot application, plus unit and integration test suites. We shall go through the most important parts of it in detail.
Plugins
Here is the list of the plugins you need with a short description.
plugins { id("org.springframework.boot") version "3.1.1" // Spring Boot id("io.spring.dependency-management") version "1.1.0" // Spring Dependency Management id("org.jlleitschuh.gradle.ktlint") version "11.4.2" // Kotlin linter https://pinterest.github.io/ktlint/1.0.0/ id("com.adarshr.test-logger") version "3.2.0" // Plugin for printing beautiful logs on the console while running tests. https://github.com/radarsh/gradle-test-logger-plugin kotlin("jvm") version "1.8.22" // Kotlin plugin kotlin("plugin.spring") version "1.8.22" // Kotlin Spring support idea // Intelij Idea plugin groovy // Groovy plugin (needed for Spock) }
Integration tests task setup
Adhering to best practices, I do recommend separating the unit test suite from the integration test suite.
That is why I opted for having a separate Gradle task for integration tests. This deliberate separation extends to declaring different dependencies for each suite, fostering a high degree of decoupling.
sourceSets { create("itest") { compileClasspath += sourceSets.main.get().output compileClasspath += sourceSets.test.get().output runtimeClasspath += sourceSets.main.get().output runtimeClasspath += sourceSets.test.get().output } } idea.module { testSourceDirs = testSourceDirs + project.sourceSets["itest"].allJava.srcDirs + project.sourceSets["test"].allJava.srcDirs } configurations["itestRuntimeOnly"].extendsFrom(configurations.runtimeOnly.get())
Firstly, let’s create a new `sourceSet` for integration tests and help IntelliJ IDE to find it. The unit tests will be stored in `src/test/groovy` directory and the integration tests located in `src/itest/groovy` directory.
Secondly, let’s create a gradle task `itest` which will inherit from the `Test` task and become responsible for running integration tests.
// ITests val itest = task<Test>("itest") { description = "Runs integration tests." group = "verification" testClassesDirs = sourceSets["itest"].output.classesDirs classpath = sourceSets["itest"].runtimeClasspath shouldRunAfter("test") }
Now, you should plug-in the task into the standard Gradle `check` task.
tasks.check { dependsOn(tasks.ktlintCheck) dependsOn(tasks.test) dependsOn(itest) }
Last but not least, the next step is to create a helper method for adding dependencies in `itest` scope only.
val itestImplementation: Configuration by configurations.getting { extendsFrom(configurations.implementation.get()) }
At this point, defining all the dependencies required for a given project becomes feasible.
dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.spockframework:spock-core:$spockVersion") testImplementation("org.apache.groovy:groovy-all:$groovyVersion") itestImplementation("org.springframework.boot:spring-boot-starter-test") itestImplementation("org.spockframework:spock-core:$spockVersion") itestImplementation("org.spockframework:spock-spring:$spockVersion") itestImplementation("org.apache.groovy:groovy-all:$groovyVersion") }
This is all we need to run our first integration test.
3. First Integration Test
Simple Spring Boot application
Let’s write a very basic Spring Boot application and our first integration test.
@SpringBootApplication class Application fun main(args: Array<String>) { runApplication<Application>(*args) }
Due to the inclusion of `spring-boot-starter-actuator` to the classpath, the application can expose management endpoints such as `health`. To enable the creation of our initial integration test, specific configurations in the `application.yml` file are necessary. This setup allows the test to initiate the application and confirm the proper functioning of the health endpoint.
server: port: 8080 management: endpoints: web: base-path: /_ exposure: include: health endpoint: health: show-details: always show-components: always server: port: 8081
It’s typically advantageous to create a common base for all the tests, encapsulating any boilerplate code and incorporating common configurations. Here’s how the `IntegrationTestBase` class could be written for Spring Boot.
@SpringBootTest(webEnvironment = RANDOM_PORT) @ActiveProfiles('itest') @ContextConfiguration abstract class IntegrationTestBase extends Specification
We can break it down into details:
- `@SpringBootTest` annotation tells Spring to run this class as an integration test, which means it will start the whole application.
- `webEnvironment = RANDOM_PORT` makes sure the application runs on a random available port on our machine
- `@ActiveProfiles(‘itest’) `activates Spring `itest` profile which we will need later to make custom configuration for our application
As you can see – the test base is simple, but its subsequent stages will be expanded later.
First Integration Test
Let’s write our first test.
class ITestManagement extends IntegrationTestBase { @LocalManagementPort int mgmtPort def "should return 200 status for management path: health"() { when: HttpURLConnection connection = new URL("http://localhost:$mgmtPort/_/health").openConnection() as HttpURLConnection then: connection.getResponseCode() == 200 } }
This concise yet powerful test case perfectly exemplifies the ease with which one can commence in terms of a testing journey.
This single test case showcased Spock’s framework syntax and structure. Additionally, it injects `@LocalManagementPort`, the port where our application exposes all management endpoints. The objective here is to execute a straightforward HTTP call to one such endpoint – the `health` endpoint – enabling everyone to assert its successful response.
When executing all application’s tests (both unit and integration) from IntelliJ, the result is a concise and informative summary.
Furthermore, upon building the project with Gradle, it becomes evident that there are two separate tasks allocated for both unit and integration tests.
Starting a Gradle Daemon (subsequent builds will be faster) > Task :test pl.kurczyna.springit.SimpleSpec Test should add 2 numbers PASSED SUCCESS: Executed 1 tests in 543ms > Task :itest OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended pl.kurczyna.springit.ITestManagement Test should return 200 status for management path: health PASSED SUCCESS: Executed 1 tests in 2.5s BUILD SUCCESSFUL in 14s 16 actionable tasks: 16 executed
4. Testing REST endpoints
If the application exposes REST endpoints, Spring Boot offers convenient tools to test them in the integration test context. In the block below, we have a simple REST controller featuring a single endpoint for calculating a square root of a given number.
@RestController @RequestMapping("/api/math") class MathController { @GetMapping("/sqrt/{number}") fun squareRoot(@PathVariable("number") number: Double): ResponseEntity<Double> { return ResponseEntity.ok(sqrt(number)) } }
To double check current endeavours, write a test that spins up the Spring Boot application and verifies whether the endpoint works properly.
In order to do that, you should enhance the `IntegrationTestBase` first.
@LocalServerPort int appPort @Autowired TestRestTemplate restTemplate
`@LocalServerPort `injects the randomly selected port number, on which the application is running, into the variable required for subsequent use“`
@Autowired
TestRestTemplate restTemplate“` injects the Spring Boot utility `RestTemplate` which can be used for making REST calls to the application.
The test can take the following form:
class ITestMath extends IntegrationTestBase { def "should calculate square root"() { given: double number = 9.0 when: def result = restTemplate.getForEntity("/api/math/sqrt/$number", Double.class) then: result.statusCode == HttpStatus.OK result.body == 3.0 } }
An actual `GET` request to the `/api/math/sqrt/9.0` endpoint is initiated using `TestRestTemplate`. Following this, several assertions can be conducted, including the verification of the response status code, response headers, or the body.
5. Database testing
One of the most common integrations for a number of applications is SQL database. In this section, let’s cover tools and techniques that one can use to be able to test this integration in a simple and effective fashion.
Required dependencies
We have to add some dependencies to our `build.gradle.kts` file:
implementation("org.springframework.boot:spring-boot-starter-jdbc") implementation("org.postgresql:postgresql:$postgresqlVersion") implementation("org.liquibase:liquibase-core:$liquibaseVersion")
Database schema
You should now create a database schema for the application, initially requiring a single table named `users`. Liquibase, a widely utilised database schema change management tool, will be employed to execute the DB migration during application startup.
The sole task required is to specify migration scripts (`.yml` files) in the proper place, so they will be automatically picked up by Spring Boot when `liquibase-core` dependency is on our classpath.
The two files need to be created:
- `src/main/resources/db/changelog/db.changelog-master.yaml`
databaseChangeLog: - include: file: db/changelog/db.changelog-users.yaml
- `src/main/resources/db/changelog/db.changelog-users.yaml`
databaseChangeLog: - changeSet: id: create-table-users author: patrykkurczyna preConditions: - onFail: MARK_RAN not: tableExists: tableName: users changes: - createTable: tableName: users columns: - column: autoIncrement: true constraints: nullable: false primaryKey: true primaryKeyName: user_pkey name: id type: BIGINT - column: constraints: nullable: false name: name type: VARCHAR(255)
Users table has two columns:
- `id` – primary key
- `name` – varchar
The rows can be represented by this object:
data class User( val id: Long, val name: String )
Upon the subsequent application startup, Spring Boot will attempt to execute this migration, thereby creating the `users` table.
Controller and repository
Let’s implement a simple CRUD API for the application. The API will be responsible for managing user entries in the SQL database. For illustrative purposes, PostgresQL will be used, but you can use any RDBMS of your choice.
UserRestController
@RestController @RequestMapping("/api/users") class UserController(private val repository: UserRepository) { @GetMapping fun getAllUsers(): ResponseEntity<List<User>> { val users = repository.getAll() return ResponseEntity.ok(users) } @PostMapping fun addOrUpdate(@RequestBody user: User): ResponseEntity<Unit> { repository.addOrUpdate(user) return ResponseEntity.status(HttpStatus.CREATED).build() } }
The controller exposes two endpoints:
- `GET /api/users ` – returns the list of all users from the DB
- `POST /api/users` – adds the user to the DB when it does not exist, or updates existing entry
UserRepository
interface UserRepository { fun getAll(): List<User> fun addOrUpdate(user: User) } class DefaultUserRepository(private val jdbcTemplate: NamedParameterJdbcTemplate) : UserRepository { private companion object { const val GET_ALL_USERS_QUERY = "SELECT * FROM users;" const val UPSERT_USER_QUERY = """ INSERT INTO users (id, name) values (:id, :name) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name """ } override fun getAll(): List<User> = jdbcTemplate.query(GET_ALL_USERS_QUERY) { rs, _ -> User(rs.getLong("id"), rs.getString("name")) } override fun addOrUpdate(user: User) { jdbcTemplate.update(UPSERT_USER_QUERY, mapOf("id" to user.id, "name" to user.name)) } }
Thanks to the `spring-boot-starter-jdbc` you can use `NamedParamJdbcTemplate` to make database calls using simple queries and map the results to the User object.
Integration Test
Testing scenarios
Multiple testing scenarios can be defined for the two methods that our controller provides:
- List all users from the database
- Prepare database so the table contains some users
- Call the` GET /api/users` endpoint
- Verify that the response body contains all the users
- Add new user
- Call the `POST /api/users` endpoint
- Verify the response status
- Verify if the user is added to the DB
- Update the user
- Prepare database so the table contains user with id `x`
- Call the `POST /api/users` endpoint with a new user `name`
- Verify the response status
- Verify if the user name in the DB is updated
Database test setup
To execute the test scenarios described above, additional setup is required.
First, the task involves spinning up the actual PostgresQL database server to which our application connects. We could set up the server locally or use the database on our development server, however the primary goal of this setup is to ensure independence from the testing infrastructure. This is why Testcontainers will be employed. It’s a library that provides lightweight, throwaway containerised instances of common services, like databases, message buses or basically anything that can run in a Docker container.
- It requires Docker to be installed
- It supports many JVM testing frameworks like JUnit4, JUnit5 and Spock
The setup is very straightforward. You need one more dependency (Testcontainers Postgres Module) in the `itest` scope added in our Gradle script:
itestImplementation("org.testcontainers:postgresql:$testcontainersVersion")
In addition to that, you need to configure your application so it connects to the proper database server. This can be done by specifying the url in the `application-itest.yml` file. This approach allows for the separation of the application’s production configuration – where it should connect to the ‘real’ production database – from the test configuration.
spring: datasource: driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver url: jdbc:tc:postgresql:15.3:///springit
The specification of a database driver (to utilise the `testcontainers` one) and the database URL is required. `springit` serves as the name of the database that is intended to be used.
Typically, when using Testcontainers, the lifecycle of the containers needs to be managed – start and stop them at a specific time, usually when the application starts and stops. However, the PostgresQL Testcontainers Module is a bit special and needs nothing beyond the earlier definitions. When setting up the DB driver and the url that points to Testcontainers Postgres, it is enough for Spring Boot to know that it should start the container before the application startup and close it after it shuts down.
Additional testing tools
Considering the testing scenarios we’ve outlined, it’s evident that a tool is required to assist in preparing the database before the tests and validating the database state after the tests. Various approaches can be employed for this purpose. I opted for creating a small utility class in Groovy that uses `NamedParameterJdbcTemplate` to make database calls.
You can ask why we cannot use the already existing `UsersRepository`. It can be done, but I prefer not to use application components in isolation, when I run integration tests. In fact, `UsersRepository` is indeed one of the components “under test”.
First, let’s inject the `jdbcTemplate` in the `IntegrationTestBase`:
@Autowired private NamedParameterJdbcTemplate jdbcTemplate DbTestClient dbTestClient def setup() { dbTestClient = new DbTestClient(jdbcTemplate) }
And here’s our `DbTestClient`:
class DbTestClient { @Language("PostgreSQL") private static final String INSERT_USER = """ INSERT INTO users (id, name) VALUES (:id, :name) """ @Language("PostgreSQL") private static final String GET_USER_BY_ID = / SELECT * FROM users WHERE id = :id / private static def USER_ROW_MAPPER = new RowMapper() { @Override User mapRow(ResultSet rs, int rowNum) throws SQLException { return new User( rs.getObject("id", Long.class), rs.getString("name") ) } } NamedParameterJdbcTemplate template DbTestClient(NamedParameterJdbcTemplate template) { this.template = template } void insertUser(Map args = [:]) { template.update(INSERT_USER, new MapSqlParameterSource([ id : args.id ?: nextLong(), name : args.name ?: 'John Doe' ]) ) } User getUserById(Long id) { try { return template.queryForObject(GET_USER_BY_ID, ['id' : id], USER_ROW_MAPPER) as User } catch (EmptyResultDataAccessException ignored) { return null } } }
The details of this utility class won’t be explored, but the most important parts are the two methods it exposes:
- `getUserById(Long id)` – fetches the user from the DB by its `id`
- `insertUser(Map args = [:])` – inserts the user to the db, the input is a map of arguments for the columns of the table
Test implementation
class ITestUsers extends IntegrationTestBase { def "should retrieve user list"() { given: 'There is one user in the DB' Long id = nextLong() String name = 'Arthur Morgan' dbTestClient.insertUser(id: id, name: name) when: 'We fetch users from the API' def result = restTemplate.exchange('/api/users', HttpMethod.GET, null, new ParameterizedTypeReference<List<User>>() {}) then: 'List containing one element is returned' result.statusCode == HttpStatus.OK result.body.size() == 1 result.body.first() == new User(id, name) } def "should add new user"() { given: 'There is a new user to be added' Long id = nextLong() User user = new User(id, 'Micah Bell') when: 'We call the API to insert user' def result = restTemplate.postForEntity('/api/users', new HttpEntity(user), Unit.class) then: 'result is success' result.statusCode == HttpStatus.CREATED and: 'There is a new user in the DB' User inDb = dbTestClient.getUserById(id) inDb == user } def "should update user name"() { given: 'There is one user in the DB' Long id = nextLong() String name = 'Arthur Morgan' dbTestClient.insertUser(id: id, name: name) when: 'We call the API to update user' User user = new User(id, 'John Marston') def result = restTemplate.postForEntity('/api/users', new HttpEntity(user), Unit.class) then: 'result is success' result.statusCode == HttpStatus.CREATED and: 'Name of the user is updated' User inDb = dbTestClient.getUserById(id) inDb.name == 'John Marston' } }
This represents the implementation of the defined testing scenarios. Spock allows the addition of comments for specific test sections, improving readability and making the code self-explanatory.
A critical point to keep in mind is that this is the interaction with an authentic database. Its lifecycle spans the whole integration tests suite run, meaning that DB tables, and more importantly its contents, are carried over from one test case to another and from one test class to another. Always be mindful of that particular aspect. For instance, you should not assume that the database is empty before the test execution; it might still contain some data from the previous runs. That’s why it’s considered a good practice to use random identifiers for your test entities. Hence, it is recommended to use `RandomUtils.nextLong()` every time you need to generate user id in the tests. Therefore, the rows will almost certainly not interfere with each other between different test runs.
6. External REST services
Another common integration point for most of the applications are external REST APIs. It would be highly beneficial to simulate and test various scenarios for the external calls made – from the happy path to different error scenarios, such as internal server errors or service being unavailable. To achieve this, it is advisable to mock the responses from external REST API, and there are several ways to accomplish this.
Firstly, there are services like: https://smartmock.io or https://mockable.io that provide very powerful tools for mocking and stubbing http requests, along with its own servers, therefore they can even be used from your applications running on DEV or STAGING environments.
There is, however, a more convenient utility that we can use in the integration tests context and it’s called WireMock. It’s an open-source tool that provides many different distributions, but also libraries that allow creating mock APIs.
Payment Service
In the illustrative scenario, to test a REST service for processing payments via Stripe, consider the following setup:
data class Payment( val amount: Long, val currency: Currency, val paymentMethod: String ) interface PaymentService { fun makePayment(payment: Payment) } @Service class StripePaymentService( private val template: RestTemplate, @Value("\${payment.url}") private val paymentServiceUrl: String ): PaymentService { override fun makePayment(payment: Payment) { template.postForEntity("$paymentServiceUrl/api/pay", HttpEntity(payment), Unit::class.java) } }
Required bean definition:
@Bean fun restTemplate(restTemplateBuilder: RestTemplateBuilder): RestTemplate = restTemplateBuilder .messageConverters(MappingJackson2HttpMessageConverter(jacksonObjectMapper())) .build()
The url (`payment.url`) for the service is defined in the `application.yml`:
payment: url: https://example.com
Integration Test
Testing scenarios
You could define two example testing scenarios for the payment service:
- Successful “make payment” call
- Mock Stripe API to return success status code (e.g. HTTP 202)
- Call `makePayment` method
- Verify that Stripe API has been called exactly once
- Verify that NO exception has been thrown
- Failed “make payment” call
- Mock Stripe API to return error status code (e.g. HTTP 500)
- Call `makePayment` method
- Verify that Stripe API has been called exactly once
- Verify that expected exception has been thrown
Test setup
For the application, only a single dependency is required to use WireMock.
itestImplementation("com.github.tomakehurst:wiremock-jre8-standalone:$wiremockVersion")
Now, it’s important to start the WireMock server before the tests are run. It can be done in the `IntegrationTestBase` class.
import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort private static WireMockServer stripeServer static int stripePort = findAvailableTcpPort() // WireMock server will run on the randomly chosen available port // this method will run before all tests def setupSpec() { stripeServer = new WireMockServer(stripePort) stripeServer.start() // starting the server } // this method will run after each test case def cleanup() { stripeServer.resetAll() // reset all mappings (mocks) } // this method will run after all test cases def cleanupSpec() { stripeServer.stop() // stop WireMock server }
WireMock server is now up and running, but how to “tell” the application that it should use it? The accurate step includes configuring the `payment.url` property to be pointing to the WireMock server. There are multiple approaches to achieve this, but my preferred method is to use the port placeholder in `application-itest.yml`:
payment: url: http://localhost:${wiremock.stripePort}
You may ask, how does Spring Boot know what is the value of the `wiremock.stripePort` when it’s about to run the application? We can populate this property in the `IntegrationTestBase` using `TestPropertySourceUtils`:
static class PropertyInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> { @Override void initialize(ConfigurableApplicationContext applicationContext) { String[] properties = ["wiremock.stripePort=$stripePort"] TestPropertySourceUtils.addInlinedPropertiesToEnvironment( applicationContext, properties ) } }
Also, ensure that `PropertyInitializer` is picked up by Spring during context initialization by adding `ContextConfiguration` annotation.
@ContextConfiguration(initializers = PropertyInitializer) abstract class IntegrationTestBase extends Specification { ...
Now, the application is configured to use the WireMock server.
Defining mocks
To decouple WireMock internals from the integration test logic, create an utility class for mocking all Stripe requests. Let’s call it `StripeMock`.
class StripeMock { WireMockServer server ObjectMapper mapper StripeMock(WireMockServer server) { this.server = server this.mapper = new ObjectMapper() } def payRespondWithSuccess(Payment payment) { server.stubFor(post(urlEqualTo("/api/pay")) .withRequestBody(equalTo(mapper.writeValueAsString(payment))) .willReturn(aResponse() .withHeader("Content-Type", "application/json") .withStatus(ACCEPTED.value()))) } def payRespondWithFailure(Payment payment) { server.stubFor(post(urlEqualTo("/api/pay")) .withRequestBody(equalTo(mapper.writeValueAsString(payment))) .willReturn(aResponse() .withStatus(INTERNAL_SERVER_ERROR.value()))) } def verifyPayCalled(Payment payment, int times = 1) { server.verify(times, postRequestedFor(urlEqualTo("/api/pay")) .withRequestBody(equalTo(mapper.writeValueAsString(payment))) ) true } }
It offers 3 methods:
- `payRespondWithSuccess `- creates a successful stub (HTTP 202 Accepted response) for the `POST /api/pay` call, we also assume the request body to be the `Payment payment` object serialised to json
- `payRespondWithFailure `- creates a failure stub (HTTP 500 Internal Server Error response) for the `POST /api/pay` call, we also assume the request body to be the `Payment payment` object serialised to json
- `verifyPayCalled `- verifies that the call to `POST /api/pay` has been made exactly
x
(variable `times`) times, with the proper body
You can now initialise the mock class in the `IntegrationTestBase`, so it can be used by all child test classes.
StripeMock stripeMock def setup() { dbTestClient = new DbTestClient(template) stripeMock = new StripeMock(stripeServer) // initializing StripeMock with the WireMock server we created earlier }
Test implementation
Having all those utilities in place makes it fairly easy to implement the integration test.
class ITestPayments extends IntegrationTestBase { @Autowired PaymentService paymentService def "should make a successful payment using Stripe"() { given: 'Payment is prepared' Payment payment = new Payment(100, Currency.getInstance('PLN'), "VISA") and: 'Stripe responds with success on new payment call' stripeMock.payRespondWithSuccess(payment) when: 'We make a payment' paymentService.makePayment(payment) then: 'No exception is thrown' noExceptionThrown() and: 'Stripe pay endpoint was called once with specific data' stripeMock.verifyPayCalled(payment, 1) } def "should throw exception when Stripe API call fails"() { given: 'Payment is prepared' Payment payment = new Payment(100, Currency.getInstance('PLN'), "VISA") and: 'Stripe responds with failure' stripeMock.payRespondWithFailure(payment) when: 'We make a payment' paymentService.makePayment(payment) then: 'An exception is thrown' thrown(HttpServerErrorException.InternalServerError) and: 'Stripe pay endpoint was called once with specific data' stripeMock.verifyPayCalled(payment, 1) } }
7. Conclusion
This article implemented many integration tests covering the most common external services that most applications use, including databases and REST APIs.
We also looked at the exact setup that is needed to create an efficient integration tests suite for your application, using Gradle, Spock and Spring Boot.
However, this is certainly not everything, that this mighty setup empowers you to do. There is many more things that can be tested in a similar fashion, such as:
- Kafka
- Google Cloud Storage
- AWS S3
- AWS SQS
- AWS SNS
- AWS DynamoDB
- Email Sending
- Elasticsearch
- … and many more
I will cover some of them in the Part II of this article, it will be coming soon!
I’m not saying goodbye, stay tuned! 🙂
Useful links
- My previous article about Spock – https://www.schibsted.pl/blog/testing-java-kotlin-code-spock
- Repository with all the examples from this article – https://github.com/patrykkurczyna/spring-integration-tests
- Testcontainers – https://testcontainers.com/
- WireMock – https://wiremock.org