├── .sdkmanrc ├── LICENSE-docs ├── assets └── tcd-workshop-app.png ├── src ├── test │ └── resources │ │ ├── P101.jpg │ │ └── logback-test.xml └── main │ ├── java │ └── com │ │ └── testcontainers │ │ └── catalog │ │ ├── clients │ │ └── inventory │ │ │ ├── ProductInventory.java │ │ │ ├── InventoryServiceClient.java │ │ │ └── RestClientConfig.java │ │ ├── domain │ │ ├── models │ │ │ ├── ProductImageUploadedEvent.java │ │ │ ├── Product.java │ │ │ └── CreateProductRequest.java │ │ ├── FileStorageService.java │ │ ├── ProductNotFoundException.java │ │ ├── ProductService.java │ │ └── internal │ │ │ ├── ProductRepository.java │ │ │ ├── ProductEventPublisher.java │ │ │ ├── S3FileStorageService.java │ │ │ ├── ProductEntity.java │ │ │ └── DefaultProductService.java │ │ ├── Application.java │ │ ├── ApplicationProperties.java │ │ ├── config │ │ └── GlobalExceptionHandler.java │ │ ├── events │ │ └── ProductEventListener.java │ │ └── api │ │ └── ProductController.java │ └── resources │ ├── db │ └── migration │ │ └── V1__catalog_tables.sql │ ├── application.properties │ └── catalog-openapi.yaml ├── .mvn ├── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties └── jvm.config ├── .github └── workflows │ └── maven.yml ├── .gitignore ├── LICENSE ├── README.md ├── step-2-exploring-the-app.md ├── step-4-connect-to-services.md ├── step-1-getting-started.md ├── mvnw.cmd ├── pom.xml ├── step-5-write-tests.md ├── mvnw └── step-3-local-development-environment.md /.sdkmanrc: -------------------------------------------------------------------------------- 1 | java=17.0.8-tem 2 | maven=3.9.5 3 | -------------------------------------------------------------------------------- /LICENSE-docs: -------------------------------------------------------------------------------- 1 | Except where otherwise noted, this work is licensed under https://creativecommons.org/licenses/by-nc-nd/4.0 -------------------------------------------------------------------------------- /assets/tcd-workshop-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testcontainers/java-local-development-workshop/HEAD/assets/tcd-workshop-app.png -------------------------------------------------------------------------------- /src/test/resources/P101.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testcontainers/java-local-development-workshop/HEAD/src/test/resources/P101.jpg -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testcontainers/java-local-development-workshop/HEAD/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/clients/inventory/ProductInventory.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.clients.inventory; 2 | 3 | public record ProductInventory(String code, int quantity) {} 4 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/domain/models/ProductImageUploadedEvent.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.domain.models; 2 | 3 | public record ProductImageUploadedEvent(String code, String image) {} 4 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.5/apache-maven-3.9.5-bin.zip 2 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar 3 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/domain/models/Product.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.domain.models; 2 | 3 | import java.math.BigDecimal; 4 | 5 | public record Product( 6 | Long id, String code, String name, String description, String imageUrl, BigDecimal price, boolean available) {} 7 | -------------------------------------------------------------------------------- /.mvn/jvm.config: -------------------------------------------------------------------------------- 1 | --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED 2 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/domain/FileStorageService.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.domain; 2 | 3 | import java.io.InputStream; 4 | 5 | public interface FileStorageService { 6 | void createBucket(String bucketName); 7 | 8 | void upload(String filename, InputStream inputStream); 9 | 10 | String getPreSignedURL(String filename); 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/clients/inventory/InventoryServiceClient.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.clients.inventory; 2 | 3 | import org.springframework.web.bind.annotation.PathVariable; 4 | import org.springframework.web.service.annotation.GetExchange; 5 | 6 | public interface InventoryServiceClient { 7 | 8 | @GetExchange("/api/inventory/{code}") 9 | ProductInventory getInventory(@PathVariable String code); 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/domain/ProductNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.domain; 2 | 3 | public class ProductNotFoundException extends RuntimeException { 4 | public ProductNotFoundException(String message) { 5 | super(message); 6 | } 7 | 8 | public static ProductNotFoundException withCode(String code) { 9 | return new ProductNotFoundException("Product with code " + code + " not found"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/domain/models/CreateProductRequest.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.domain.models; 2 | 3 | import jakarta.validation.constraints.NotEmpty; 4 | import jakarta.validation.constraints.NotNull; 5 | import jakarta.validation.constraints.Positive; 6 | import java.math.BigDecimal; 7 | 8 | public record CreateProductRequest( 9 | @NotEmpty String code, @NotEmpty String name, String description, @NotNull @Positive BigDecimal price) {} 10 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | 8 | jobs: 9 | test: 10 | name: Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup Java 16 | uses: actions/setup-java@v3 17 | with: 18 | java-version: '17' 19 | distribution: 'temurin' 20 | cache: 'maven' 21 | 22 | - name: Test 23 | run: ./mvnw test 24 | -------------------------------------------------------------------------------- /src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/Application.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 6 | 7 | @SpringBootApplication 8 | @ConfigurationPropertiesScan 9 | public class Application { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(Application.class, args); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/ApplicationProperties.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog; 2 | 3 | import jakarta.validation.constraints.NotEmpty; 4 | import org.springframework.boot.context.properties.ConfigurationProperties; 5 | import org.springframework.validation.annotation.Validated; 6 | 7 | @ConfigurationProperties(prefix = "application") 8 | @Validated 9 | public record ApplicationProperties( 10 | @NotEmpty String productImagesBucketName, 11 | @NotEmpty String productImageUpdatesTopic, 12 | @NotEmpty String inventoryServiceUrl) {} 13 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V1__catalog_tables.sql: -------------------------------------------------------------------------------- 1 | create table products 2 | ( 3 | id bigserial primary key, 4 | code varchar not null unique, 5 | name varchar not null, 6 | description varchar, 7 | image varchar, 8 | price numeric not null 9 | ); 10 | 11 | insert into products(code, name, description, image, price) values 12 | ('P101','Product P101','Product P101 description', null, 34.0), 13 | ('P102','Product P102','Product P102 description', null, 25.0), 14 | ('P103','Product P103','Product P103 description', null, 15.0) 15 | ; -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/domain/ProductService.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.domain; 2 | 3 | import com.testcontainers.catalog.domain.models.CreateProductRequest; 4 | import com.testcontainers.catalog.domain.models.Product; 5 | import java.io.InputStream; 6 | import java.util.Optional; 7 | 8 | public interface ProductService { 9 | 10 | void createProduct(CreateProductRequest request); 11 | 12 | Optional getProductByCode(String code); 13 | 14 | void uploadProductImage(String code, String imageName, InputStream inputStream); 15 | 16 | void updateProductImage(String code, String image); 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | !**/src/main/**/target/ 4 | !**/src/test/**/target/ 5 | 6 | ### STS ### 7 | .apt_generated 8 | .classpath 9 | .factorypath 10 | .project 11 | .settings 12 | .springBeans 13 | .sts4-cache 14 | 15 | ### IntelliJ IDEA ### 16 | .idea 17 | *.iws 18 | *.iml 19 | *.ipr 20 | 21 | ### NetBeans ### 22 | /nbproject/private/ 23 | /nbbuild/ 24 | /dist/ 25 | /nbdist/ 26 | /.nb-gradle/ 27 | build/ 28 | !**/src/main/**/build/ 29 | !**/src/test/**/build/ 30 | 31 | ### VS Code ### 32 | .vscode/ 33 | 34 | /src/test 35 | !/src/test/java/com/testcontainers/catalog/.keep 36 | !/src/test/resources/logback-test.xml 37 | !/src/test/resources/P101.jpg 38 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/domain/internal/ProductRepository.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.domain.internal; 2 | 3 | import java.util.Optional; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.data.jpa.repository.Modifying; 6 | import org.springframework.data.jpa.repository.Query; 7 | import org.springframework.data.repository.query.Param; 8 | 9 | interface ProductRepository extends JpaRepository { 10 | Optional findByCode(String code); 11 | 12 | @Modifying 13 | @Query("update ProductEntity p set p.image = :image where p.code = :code") 14 | void updateProductImage(@Param("code") String code, @Param("image") String image); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/domain/internal/ProductEventPublisher.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.domain.internal; 2 | 3 | import com.testcontainers.catalog.ApplicationProperties; 4 | import com.testcontainers.catalog.domain.models.ProductImageUploadedEvent; 5 | import org.springframework.kafka.core.KafkaTemplate; 6 | import org.springframework.stereotype.Component; 7 | 8 | @Component 9 | class ProductEventPublisher { 10 | private final KafkaTemplate kafkaTemplate; 11 | private final ApplicationProperties properties; 12 | 13 | public ProductEventPublisher(KafkaTemplate kafkaTemplate, ApplicationProperties properties) { 14 | this.kafkaTemplate = kafkaTemplate; 15 | this.properties = properties; 16 | } 17 | 18 | public void publish(ProductImageUploadedEvent event) { 19 | kafkaTemplate.send(properties.productImageUpdatesTopic(), event.code(), event); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/clients/inventory/RestClientConfig.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.clients.inventory; 2 | 3 | import com.testcontainers.catalog.ApplicationProperties; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.web.client.RestClient; 7 | import org.springframework.web.client.support.RestClientAdapter; 8 | import org.springframework.web.service.invoker.HttpServiceProxyFactory; 9 | 10 | @Configuration 11 | class RestClientConfig { 12 | 13 | @Bean 14 | InventoryServiceClient inventoryServiceProxy(ApplicationProperties properties) { 15 | RestClient restClient = RestClient.create(properties.inventoryServiceUrl()); 16 | HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient)) 17 | .build(); 18 | return factory.createClient(InventoryServiceClient.class); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=java-local-development-workshop 2 | server.port=8080 3 | spring.servlet.multipart.max-file-size=10MB 4 | spring.servlet.multipart.max-request-size=10MB 5 | 6 | spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer 7 | spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer 8 | 9 | spring.kafka.consumer.group-id=catalog-service 10 | spring.kafka.consumer.auto-offset-reset=latest 11 | spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer 12 | spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer 13 | spring.kafka.consumer.properties.spring.json.trusted.packages=com.testcontainers.catalog.domain.models 14 | 15 | application.product-images-bucket-name=product-images 16 | application.product-image-updates-topic=product-image-updates 17 | application.inventory-service-url=http://localhost:8081 18 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/config/GlobalExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.config; 2 | 3 | import com.testcontainers.catalog.domain.ProductNotFoundException; 4 | import java.time.Instant; 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.http.ProblemDetail; 7 | import org.springframework.web.bind.annotation.ExceptionHandler; 8 | import org.springframework.web.bind.annotation.RestControllerAdvice; 9 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; 10 | 11 | @RestControllerAdvice 12 | class GlobalExceptionHandler extends ResponseEntityExceptionHandler { 13 | 14 | @ExceptionHandler(ProductNotFoundException.class) 15 | ProblemDetail handleProductNotFoundException(ProductNotFoundException e) { 16 | ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); 17 | problemDetail.setTitle("Product Not Found"); 18 | problemDetail.setProperty("timestamp", Instant.now()); 19 | return problemDetail; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 testcontainers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/events/ProductEventListener.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.events; 2 | 3 | import com.testcontainers.catalog.domain.ProductService; 4 | import com.testcontainers.catalog.domain.models.ProductImageUploadedEvent; 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | import org.springframework.kafka.annotation.KafkaListener; 8 | import org.springframework.stereotype.Component; 9 | import org.springframework.transaction.annotation.Transactional; 10 | 11 | @Component 12 | @Transactional 13 | class ProductEventListener { 14 | private static final Logger log = LoggerFactory.getLogger(ProductEventListener.class); 15 | 16 | private final ProductService productService; 17 | 18 | ProductEventListener(ProductService productService) { 19 | this.productService = productService; 20 | } 21 | 22 | @KafkaListener(topics = "${application.product-image-updates-topic}", groupId = "catalog-service") 23 | public void handle(ProductImageUploadedEvent event) { 24 | log.info("Received a ProductImageUploaded with code:{}", event.code()); 25 | productService.updateProductImage(event.code(), event.image()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Java Local Development Workshop 2 | 3 | This workshop will explain how to use Testcontainers \([https://www.testcontainers.com](https://www.testcontainers.com)\) in your Java application development process. 4 | 5 | We will work with a Spring Boot application and explore how to: 6 | * Use Testcontainers for provisioning application dependent services like PostgreSQL, Kafka, LocalStack for local development 7 | * Use [Testcontainers Desktop](https://testcontainers.com/desktop/) for local development and debugging 8 | * Write tests using Testcontainers 9 | 10 | ## Table of contents 11 | 12 | * [Step 1: Getting Started](step-1-getting-started.md) 13 | * [Step 2: Exploring the app](step-2-exploring-the-app.md) 14 | * [Step 3: Local Development Environment with Testcontainers](step-3-local-development-environment.md) 15 | * [Step 4: Connect to Services](step-4-connect-to-services.md) 16 | * [Step 5: Write Tests](step-5-write-tests.md) 17 | 18 | 19 | ## License Summary 20 | The code in this repository is made available under the MIT license. See the [LICENSE](LICENSE) file for details. 21 | 22 | The documentation in this repository is available under the CC BY-NC-ND 4.0 license. See the [LICENSE-docs](LICENSE-docs) file for details. 23 | If you'd like to run this workshop at your company, please contact us at [hello@atomicjar.com](mailto:hello@atomicjar.com) 24 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/domain/internal/S3FileStorageService.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.domain.internal; 2 | 3 | import com.testcontainers.catalog.ApplicationProperties; 4 | import com.testcontainers.catalog.domain.FileStorageService; 5 | import io.awspring.cloud.s3.S3Template; 6 | import java.io.InputStream; 7 | import java.time.Duration; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | import org.springframework.stereotype.Service; 11 | 12 | @Service 13 | class S3FileStorageService implements FileStorageService { 14 | private static final Logger log = LoggerFactory.getLogger(S3FileStorageService.class); 15 | private final S3Template s3Template; 16 | private final ApplicationProperties properties; 17 | 18 | public S3FileStorageService(S3Template s3Template, ApplicationProperties properties) { 19 | this.s3Template = s3Template; 20 | this.properties = properties; 21 | } 22 | 23 | public void createBucket(String bucketName) { 24 | s3Template.createBucket(bucketName); 25 | } 26 | 27 | public void upload(String filename, InputStream inputStream) { 28 | log.debug("Uploading file with name {} to S3", filename); 29 | try { 30 | s3Template.upload(properties.productImagesBucketName(), filename, inputStream, null); 31 | log.debug("Uploaded file with name {} to S3", filename); 32 | } catch (Exception e) { 33 | log.error("IException: ", e); 34 | throw new RuntimeException(e); 35 | } 36 | } 37 | 38 | public String getPreSignedURL(String filename) { 39 | return s3Template 40 | .createSignedGetURL(properties.productImagesBucketName(), filename, Duration.ofMinutes(60)) 41 | .toString(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /step-2-exploring-the-app.md: -------------------------------------------------------------------------------- 1 | # Step 2: Exploring the app 2 | 3 | The application we are going to work on is a microservice based on Spring Boot for managing a catalog of products. 4 | It provides APIs to save and retrieve the product information. 5 | 6 | ![Sample App Architecture](assets/tcd-workshop-app.png) 7 | 8 | ## SQL database with the products 9 | 10 | When a product is created, we will store the product information in our database. 11 | 12 | Our database of choice is PostgreSQL, accessed with Spring Data JPA. 13 | 14 | Check `com.testcontainers.catalog.domain.internal.ProductRepository`. 15 | 16 | ## LocalStack 17 | 18 | We would like to store the product images in AWS S3 Object storage. 19 | We will use [LocalStack](https://localstack.cloud/) to emulate the AWS cloud environment locally during local development and testing with [Spring Cloud AWS](https://awspring.io/). 20 | 21 | Check `com.testcontainers.catalog.domain.internal.S3FileStorageService`. 22 | 23 | ## Kafka 24 | 25 | When a product image is uploaded to AWS S3, an event will be published to Kafka. 26 | The kafka event listener will then consume the event and update the product information with the image URL. 27 | 28 | Check `com.testcontainers.catalog.domain.internal.ProductEventPublisher` 29 | and `com.testcontainers.catalog.events.ProductEventListener`. 30 | 31 | ## External Service Integrations 32 | Our application talks to `inventory-service` to fetch the product availability information. 33 | We will use [Microcks](https://microcks.io/) to mock the `inventory-service` during local development and testing. 34 | 35 | ## API Endpoints 36 | 37 | The API is a Spring Web REST controller \(`com.testcontainers.catalog.api.ProductController`\) and exposes the following endpoints: 38 | 39 | * `POST /api/products { "code": ?, "name": ?, "description": ?, "price": ? }` to create a new product 40 | * `GET /api/products/{code}` to get the product information by code 41 | * `POST /api/products/{code}/image?file=IMAGE` to upload the product image 42 | 43 | ### 44 | [Next](step-3-local-development-environment.md) -------------------------------------------------------------------------------- /step-4-connect-to-services.md: -------------------------------------------------------------------------------- 1 | # Step 4: Connect to services 2 | 3 | In the previous step, we get our application running locally and invoked our API endpoints. 4 | 5 | What if you want to check the data in the database or the messages in Kafka? 6 | 7 | Testcontainers by default start the containers and map the exposed ports on a random available port on the host machine. 8 | Each time you restart the application, the mapped ports will be different. 9 | This is good for testing, but for local development and debugging, it would be convenient to be able to connect on fixed ports. 10 | 11 | This is where **Testcontainers Desktop** helps you. 12 | 13 | ## Testcontainers Desktop 14 | Testcontainers Desktop application provides several features that helps you with local development and debugging. 15 | To learn more about Testcontainers Desktop, check out the [Simple local development with Testcontainers Desktop](https://testcontainers.com/guides/simple-local-development-with-testcontainers-desktop/) guide. 16 | 17 | The Testcontainers Desktop app makes it easy to use fixed ports for your containers, 18 | so that you can always connect to those services using the same fixed port. 19 | 20 | ## Connect to PostgreSQL database 21 | Click on **Testcontainers Desktop → select Services → Open config location...**. 22 | 23 | In the opened directory there would be a `postgres.toml.example` file. 24 | Make a copy of it and rename it to `postgres.toml` file and update it with the following content: 25 | 26 | ```toml 27 | ports = [ 28 | {local-port = 5432, container-port = 5432}, 29 | ] 30 | selector.image-names = ["postgres"] 31 | ``` 32 | 33 | We are mapping the PostgreSQL container's port 5432 onto the host's port 5432. 34 | Now you should be able to connect to the PostgreSQL database using any SQL client 35 | with the following connection properties: 36 | 37 | ```shell 38 | psql -h localhost -p 5432 -U test -d test 39 | ``` 40 | 41 | If you authenticate with the default password `test` you should be able to and run the following SQL query to check the products: 42 | 43 | ```sql 44 | select * from test.public.products; 45 | ``` 46 | 47 | Similarly, you can connect to any of your containers using the same approach by using the port-mapping feature of Testcontainers Desktop. 48 | 49 | ### 50 | [Next](step-5-write-tests.md) 51 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/domain/internal/ProductEntity.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.domain.internal; 2 | 3 | import jakarta.persistence.Column; 4 | import jakarta.persistence.Entity; 5 | import jakarta.persistence.GeneratedValue; 6 | import jakarta.persistence.GenerationType; 7 | import jakarta.persistence.Id; 8 | import jakarta.persistence.Table; 9 | import jakarta.validation.constraints.DecimalMin; 10 | import jakarta.validation.constraints.NotEmpty; 11 | import jakarta.validation.constraints.NotNull; 12 | import java.math.BigDecimal; 13 | 14 | @Entity 15 | @Table(name = "products") 16 | class ProductEntity { 17 | @Id 18 | @GeneratedValue(strategy = GenerationType.IDENTITY) 19 | private Long id; 20 | 21 | @Column(nullable = false, unique = true) 22 | @NotEmpty(message = "Product code must not be null/empty") 23 | private String code; 24 | 25 | @NotEmpty(message = "Product name must not be null/empty") 26 | @Column(nullable = false) 27 | private String name; 28 | 29 | private String description; 30 | 31 | private String image; 32 | 33 | @NotNull(message = "Product price must not be null") @DecimalMin("0.1") 34 | @Column(nullable = false) 35 | private BigDecimal price; 36 | 37 | public ProductEntity() {} 38 | 39 | public ProductEntity(Long id, String code, String name, String description, String image, BigDecimal price) { 40 | this.id = id; 41 | this.code = code; 42 | this.name = name; 43 | this.description = description; 44 | this.image = image; 45 | this.price = price; 46 | } 47 | 48 | public Long getId() { 49 | return id; 50 | } 51 | 52 | public void setId(Long id) { 53 | this.id = id; 54 | } 55 | 56 | public String getCode() { 57 | return code; 58 | } 59 | 60 | public void setCode(String code) { 61 | this.code = code; 62 | } 63 | 64 | public String getName() { 65 | return name; 66 | } 67 | 68 | public void setName(String name) { 69 | this.name = name; 70 | } 71 | 72 | public String getDescription() { 73 | return description; 74 | } 75 | 76 | public void setDescription(String description) { 77 | this.description = description; 78 | } 79 | 80 | public String getImage() { 81 | return image; 82 | } 83 | 84 | public void setImage(String image) { 85 | this.image = image; 86 | } 87 | 88 | public BigDecimal getPrice() { 89 | return price; 90 | } 91 | 92 | public void setPrice(BigDecimal price) { 93 | this.price = price; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/api/ProductController.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.api; 2 | 3 | import com.testcontainers.catalog.domain.ProductNotFoundException; 4 | import com.testcontainers.catalog.domain.ProductService; 5 | import com.testcontainers.catalog.domain.models.CreateProductRequest; 6 | import com.testcontainers.catalog.domain.models.Product; 7 | import java.io.IOException; 8 | import java.net.URI; 9 | import java.util.Map; 10 | import org.springframework.http.ResponseEntity; 11 | import org.springframework.validation.annotation.Validated; 12 | import org.springframework.web.bind.annotation.GetMapping; 13 | import org.springframework.web.bind.annotation.PathVariable; 14 | import org.springframework.web.bind.annotation.PostMapping; 15 | import org.springframework.web.bind.annotation.RequestBody; 16 | import org.springframework.web.bind.annotation.RequestMapping; 17 | import org.springframework.web.bind.annotation.RequestParam; 18 | import org.springframework.web.bind.annotation.RestController; 19 | import org.springframework.web.multipart.MultipartFile; 20 | import org.springframework.web.servlet.support.ServletUriComponentsBuilder; 21 | 22 | @RestController 23 | @RequestMapping("/api/products") 24 | class ProductController { 25 | private final ProductService productService; 26 | 27 | ProductController(ProductService productService) { 28 | this.productService = productService; 29 | } 30 | 31 | @PostMapping 32 | ResponseEntity createProduct(@Validated @RequestBody CreateProductRequest request) { 33 | productService.createProduct(request); 34 | URI uri = ServletUriComponentsBuilder.fromCurrentContextPath() 35 | .path("/api/products/{code}") 36 | .buildAndExpand(request.code()) 37 | .toUri(); 38 | return ResponseEntity.created(uri).build(); 39 | } 40 | 41 | @GetMapping("/{code}") 42 | ResponseEntity getProductByCode(@PathVariable String code) { 43 | var product = productService.getProductByCode(code).orElseThrow(() -> ProductNotFoundException.withCode(code)); 44 | return ResponseEntity.ok(product); 45 | } 46 | 47 | @PostMapping("/{code}/image") 48 | ResponseEntity> uploadProductImage( 49 | @PathVariable String code, @RequestParam("file") MultipartFile file) throws IOException { 50 | var filename = file.getOriginalFilename(); 51 | var extn = filename.substring(filename.lastIndexOf(".")); 52 | var imageName = code + extn; 53 | productService.uploadProductImage(code, imageName, file.getInputStream()); 54 | Map response = Map.of("status", "success", "filename", imageName); 55 | return ResponseEntity.ok(response); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/resources/catalog-openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.2 2 | info: 3 | title: Catalog Service 4 | version: 1.0 5 | description: API definition of Catalog Service 6 | license: 7 | name: MIT License 8 | url: https://opensource.org/licenses/MIT 9 | paths: 10 | /api/products/{code}: 11 | get: 12 | parameters: 13 | - name: code 14 | description: product code 15 | schema: 16 | type: string 17 | in: path 18 | required: true 19 | examples: 20 | P101: 21 | value: P101 22 | P102: 23 | value: P102 24 | P103: 25 | value: P103 26 | responses: 27 | "200": 28 | content: 29 | application/json: 30 | schema: 31 | $ref: '#/components/schemas/Product' 32 | examples: 33 | P101: 34 | value: 35 | id: 1 36 | code: P101 37 | name: Product P101 38 | description: Product P101 description 39 | imageUrl: null 40 | price: 34.0 41 | available: true 42 | P102: 43 | value: 44 | id: 1 45 | code: P101 46 | name: Product P102 47 | description: Product P102 description 48 | imageUrl: null 49 | price: 25.0 50 | available: true 51 | P103: 52 | value: 53 | id: 3 54 | code: P103 55 | name: Product P103 56 | description: Product P103 description 57 | imageUrl: null 58 | price: 15.0 59 | available: false 60 | components: 61 | schemas: 62 | Product: 63 | title: Root Type for catalog Product 64 | type: object 65 | properties: 66 | id: 67 | description: Unique identifier of this product 68 | type: number 69 | code: 70 | description: Code of this product 71 | type: string 72 | name: 73 | description: Name of this product 74 | type: string 75 | description: 76 | description: Description of this product 77 | type: string 78 | imageUrl: 79 | description: Url of image of this product 80 | type: string 81 | nullable: true 82 | price: 83 | description: Price of this product 84 | type: number 85 | available: 86 | description: Availability of this product 87 | type: boolean 88 | required: 89 | - id 90 | - code 91 | - name 92 | - description 93 | - price 94 | - imageUrl 95 | - available 96 | additionalProperties: false -------------------------------------------------------------------------------- /step-1-getting-started.md: -------------------------------------------------------------------------------- 1 | # Step 1: Getting Started 2 | Before getting started, let's make sure you have everything you need for this workshop. 3 | 4 | ## Prerequisites 5 | 6 | ### Install Java 17 or newer 7 | You'll need Java 17 or newer for this workshop. 8 | Testcontainers libraries are compatible with Java 8+, but this workshop uses a Spring Boot 3.x application which requires Java 17 or newer. 9 | 10 | We would recommend using [SDKMAN](https://sdkman.io/) to install Java on your machine if you are using MacOS, Linux or Windows WSL. 11 | 12 | ### Install Docker 13 | You need to have a Docker environment to use Testcontainers. 14 | 15 | * You can use Docker Desktop on your machine. 16 | * You can use [Testcontainers Cloud](https://testcontainers.com/cloud). If you are going to use Testcontainers Cloud, then you need to install [Testcontainers Desktop](https://testcontainers.com/desktop/) app. 17 | * If you are using MacOS, you can use Testcontainers Desktop Embedded Runtime. 18 | 19 | * If you are using a local Docker, check by running: 20 | 21 | ```shell 22 | $ docker version 23 | 24 | Client: 25 | Version: 24.0.6-rd 26 | API version: 1.43 27 | Go version: go1.20.7 28 | Git commit: da4c87c 29 | Built: Wed Sep 6 16:40:13 2023 30 | OS/Arch: darwin/arm64 31 | Context: desktop-linux 32 | Server: Docker Desktop 4.24.2 (124339) 33 | Engine: 34 | Version: 24.0.6 35 | API version: 1.43 (minimum version 1.12) 36 | Go version: go1.20.7 37 | Git commit: 1a79695 38 | Built: Mon Sep 4 12:31:36 2023 39 | OS/Arch: linux/arm64 40 | Experimental: false 41 | ... 42 | ``` 43 | 44 | ### Install Testcontainers Desktop 45 | [Testcontainers Desktop](https://testcontainers.com/desktop/) is a companion app for the open-source Testcontainers libraries 46 | that makes local development and testing with real dependencies simple. 47 | 48 | Download the latest version of Testcontainers Desktop app from [https://testcontainers.com/desktop/](https://testcontainers.com/desktop/) 49 | and install it on your machine. 50 | 51 | Once you start the Testcontainers Desktop application, it will automatically detect the container runtimes 52 | installed on your system (Docker Desktop, OrbStack, etc) 53 | and allows you to choose which container runtime you want to use by Testcontainers. 54 | 55 | ## Download the project 56 | 57 | Clone the [java-local-development-workshop](https://github.com/testcontainers/java-local-development-workshop) repository from GitHub to your computer: 58 | 59 | ```shell 60 | git clone https://github.com/testcontainers/java-local-development-workshop.git 61 | ``` 62 | 63 | ## Compile the project to download the dependencies 64 | 65 | With Maven: 66 | ```shell 67 | ./mvnw compile 68 | ``` 69 | 70 | ## \(optionally\) Pull the required images before doing the workshop 71 | If you are going to use a local Docker environment, you can pull the required images before the workshop to save time. 72 | This might be helpful if the internet connection at the workshop venue is somewhat slow. 73 | 74 | ```shell 75 | docker pull postgres:16-alpine 76 | docker pull localstack/localstack:2.3 77 | docker pull quay.io/microcks/microcks-uber:1.8.1 78 | docker pull confluentinc/cp-kafka:7.5.0 79 | docker pull confluentinc/cp-schema-registry:7.5.0 80 | docker pull confluentinc/cp-enterprise-control-center:7.5.0 81 | ``` 82 | 83 | ### 84 | [Next](step-2-exploring-the-app.md) 85 | -------------------------------------------------------------------------------- /src/main/java/com/testcontainers/catalog/domain/internal/DefaultProductService.java: -------------------------------------------------------------------------------- 1 | package com.testcontainers.catalog.domain.internal; 2 | 3 | import com.testcontainers.catalog.clients.inventory.InventoryServiceClient; 4 | import com.testcontainers.catalog.domain.FileStorageService; 5 | import com.testcontainers.catalog.domain.ProductService; 6 | import com.testcontainers.catalog.domain.models.CreateProductRequest; 7 | import com.testcontainers.catalog.domain.models.Product; 8 | import com.testcontainers.catalog.domain.models.ProductImageUploadedEvent; 9 | import java.io.InputStream; 10 | import java.util.Optional; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import org.springframework.stereotype.Service; 14 | import org.springframework.transaction.annotation.Transactional; 15 | import org.springframework.util.StringUtils; 16 | 17 | @Service 18 | @Transactional 19 | class DefaultProductService implements ProductService { 20 | private static final Logger log = LoggerFactory.getLogger(DefaultProductService.class); 21 | 22 | private final ProductRepository productRepository; 23 | private final InventoryServiceClient inventoryServiceClient; 24 | private final FileStorageService fileStorageService; 25 | private final ProductEventPublisher productEventPublisher; 26 | 27 | public DefaultProductService( 28 | ProductRepository productRepository, 29 | InventoryServiceClient inventoryServiceClient, 30 | FileStorageService fileStorageService, 31 | ProductEventPublisher productEventPublisher) { 32 | this.productRepository = productRepository; 33 | this.inventoryServiceClient = inventoryServiceClient; 34 | this.fileStorageService = fileStorageService; 35 | this.productEventPublisher = productEventPublisher; 36 | } 37 | 38 | public void createProduct(CreateProductRequest request) { 39 | ProductEntity entity = new ProductEntity(); 40 | entity.setCode(request.code()); 41 | entity.setName(request.name()); 42 | entity.setDescription(request.description()); 43 | entity.setPrice(request.price()); 44 | 45 | productRepository.save(entity); 46 | } 47 | 48 | public Optional getProductByCode(String code) { 49 | Optional productEntity = productRepository.findByCode(code); 50 | if (productEntity.isEmpty()) { 51 | return Optional.empty(); 52 | } 53 | return productEntity.map(this::toProduct); 54 | } 55 | 56 | public void uploadProductImage(String code, String imageName, InputStream inputStream) { 57 | fileStorageService.upload(imageName, inputStream); 58 | productEventPublisher.publish(new ProductImageUploadedEvent(code, imageName)); 59 | log.info("Published event to update product image for code: {}", code); 60 | } 61 | 62 | public void updateProductImage(String code, String image) { 63 | productRepository.updateProductImage(code, image); 64 | } 65 | 66 | private boolean isProductAvailable(String code) { 67 | try { 68 | return inventoryServiceClient.getInventory(code).quantity() > 0; 69 | } catch (Exception e) { 70 | log.error("Error while calling inventory service", e); 71 | // business decision is to show as available if inventory service is down 72 | return true; 73 | } 74 | } 75 | 76 | private Product toProduct(ProductEntity entity) { 77 | return new Product( 78 | entity.getId(), 79 | entity.getCode(), 80 | entity.getName(), 81 | entity.getDescription(), 82 | StringUtils.hasText(entity.getImage()) ? fileStorageService.getPreSignedURL(entity.getImage()) : null, 83 | entity.getPrice(), 84 | isProductAvailable(entity.getCode())); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM https://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Apache Maven Wrapper startup batch script, version 3.2.0 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 28 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 29 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 30 | @REM e.g. to debug Maven itself, use 31 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 32 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 33 | @REM ---------------------------------------------------------------------------- 34 | 35 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 36 | @echo off 37 | @REM set title of command window 38 | title %0 39 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 40 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 41 | 42 | @REM set %HOME% to equivalent of $HOME 43 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 44 | 45 | @REM Execute a user defined script before this one 46 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 47 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 48 | if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* 49 | if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* 50 | :skipRcPre 51 | 52 | @setlocal 53 | 54 | set ERROR_CODE=0 55 | 56 | @REM To isolate internal variables from possible post scripts, we use another setlocal 57 | @setlocal 58 | 59 | @REM ==== START VALIDATION ==== 60 | if not "%JAVA_HOME%" == "" goto OkJHome 61 | 62 | echo. 63 | echo Error: JAVA_HOME not found in your environment. >&2 64 | echo Please set the JAVA_HOME variable in your environment to match the >&2 65 | echo location of your Java installation. >&2 66 | echo. 67 | goto error 68 | 69 | :OkJHome 70 | if exist "%JAVA_HOME%\bin\java.exe" goto init 71 | 72 | echo. 73 | echo Error: JAVA_HOME is set to an invalid directory. >&2 74 | echo JAVA_HOME = "%JAVA_HOME%" >&2 75 | echo Please set the JAVA_HOME variable in your environment to match the >&2 76 | echo location of your Java installation. >&2 77 | echo. 78 | goto error 79 | 80 | @REM ==== END VALIDATION ==== 81 | 82 | :init 83 | 84 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 85 | @REM Fallback to current working directory if not found. 86 | 87 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 88 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 89 | 90 | set EXEC_DIR=%CD% 91 | set WDIR=%EXEC_DIR% 92 | :findBaseDir 93 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 94 | cd .. 95 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 96 | set WDIR=%CD% 97 | goto findBaseDir 98 | 99 | :baseDirFound 100 | set MAVEN_PROJECTBASEDIR=%WDIR% 101 | cd "%EXEC_DIR%" 102 | goto endDetectBaseDir 103 | 104 | :baseDirNotFound 105 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 106 | cd "%EXEC_DIR%" 107 | 108 | :endDetectBaseDir 109 | 110 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 111 | 112 | @setlocal EnableExtensions EnableDelayedExpansion 113 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 114 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 115 | 116 | :endReadAdditionalConfig 117 | 118 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 119 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 120 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 121 | 122 | set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 123 | 124 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 125 | IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B 126 | ) 127 | 128 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 129 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 130 | if exist %WRAPPER_JAR% ( 131 | if "%MVNW_VERBOSE%" == "true" ( 132 | echo Found %WRAPPER_JAR% 133 | ) 134 | ) else ( 135 | if not "%MVNW_REPOURL%" == "" ( 136 | SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 137 | ) 138 | if "%MVNW_VERBOSE%" == "true" ( 139 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 140 | echo Downloading from: %WRAPPER_URL% 141 | ) 142 | 143 | powershell -Command "&{"^ 144 | "$webclient = new-object System.Net.WebClient;"^ 145 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 146 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 147 | "}"^ 148 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ 149 | "}" 150 | if "%MVNW_VERBOSE%" == "true" ( 151 | echo Finished downloading %WRAPPER_JAR% 152 | ) 153 | ) 154 | @REM End of extension 155 | 156 | @REM If specified, validate the SHA-256 sum of the Maven wrapper jar file 157 | SET WRAPPER_SHA_256_SUM="" 158 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 159 | IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B 160 | ) 161 | IF NOT %WRAPPER_SHA_256_SUM%=="" ( 162 | powershell -Command "&{"^ 163 | "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ 164 | "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ 165 | " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ 166 | " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ 167 | " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ 168 | " exit 1;"^ 169 | "}"^ 170 | "}" 171 | if ERRORLEVEL 1 goto error 172 | ) 173 | 174 | @REM Provide a "standardized" way to retrieve the CLI args that will 175 | @REM work with both Windows and non-Windows executions. 176 | set MAVEN_CMD_LINE_ARGS=%* 177 | 178 | %MAVEN_JAVA_EXE% ^ 179 | %JVM_CONFIG_MAVEN_PROPS% ^ 180 | %MAVEN_OPTS% ^ 181 | %MAVEN_DEBUG_OPTS% ^ 182 | -classpath %WRAPPER_JAR% ^ 183 | "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ 184 | %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 185 | if ERRORLEVEL 1 goto error 186 | goto end 187 | 188 | :error 189 | set ERROR_CODE=1 190 | 191 | :end 192 | @endlocal & set ERROR_CODE=%ERROR_CODE% 193 | 194 | if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost 195 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 196 | if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" 197 | if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" 198 | :skipRcPost 199 | 200 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 201 | if "%MAVEN_BATCH_PAUSE%"=="on" pause 202 | 203 | if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% 204 | 205 | cmd /C exit /B %ERROR_CODE% 206 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 4.0.0 7 | 8 | org.springframework.boot 9 | spring-boot-starter-parent 10 | 3.2.1 11 | 12 | 13 | com.testcontainers 14 | java-local-development-workshop 15 | 0.0.1-SNAPSHOT 16 | java-local-development-workshop 17 | 18 | 19 | 17 20 | 3.1.0 21 | 1.3.2 22 | 0.2.4 23 | 2.41.1 24 | 25 | 26 | 27 | 28 | org.springframework.boot 29 | spring-boot-starter-web 30 | 31 | 32 | org.springframework.boot 33 | spring-boot-starter-validation 34 | 35 | 36 | io.awspring.cloud 37 | spring-cloud-aws-starter-s3 38 | 39 | 40 | io.awspring.cloud 41 | spring-cloud-aws-starter-sqs 42 | 43 | 44 | org.springframework.boot 45 | spring-boot-starter-actuator 46 | 47 | 48 | org.springframework.boot 49 | spring-boot-starter-data-jpa 50 | 51 | 52 | org.postgresql 53 | postgresql 54 | runtime 55 | 56 | 57 | org.flywaydb 58 | flyway-core 59 | 60 | 61 | org.springframework.kafka 62 | spring-kafka 63 | 64 | 65 | commons-io 66 | commons-io 67 | ${commons-io.version} 68 | 69 | 70 | 71 | org.springframework.boot 72 | spring-boot-devtools 73 | runtime 74 | true 75 | 76 | 77 | org.springframework.boot 78 | spring-boot-configuration-processor 79 | true 80 | 81 | 82 | 83 | org.springframework.boot 84 | spring-boot-starter-test 85 | test 86 | 87 | 88 | org.springframework.boot 89 | spring-boot-testcontainers 90 | test 91 | 92 | 93 | org.springframework.kafka 94 | spring-kafka-test 95 | test 96 | 97 | 98 | org.testcontainers 99 | junit-jupiter 100 | test 101 | 102 | 103 | org.testcontainers 104 | postgresql 105 | test 106 | 107 | 108 | org.testcontainers 109 | kafka 110 | test 111 | 112 | 113 | org.testcontainers 114 | localstack 115 | test 116 | 117 | 118 | io.rest-assured 119 | rest-assured 120 | test 121 | 122 | 123 | org.awaitility 124 | awaitility 125 | test 126 | 127 | 128 | io.github.microcks 129 | microcks-testcontainers 130 | ${microcks-testcontainers-module.version} 131 | test 132 | 133 | 134 | 135 | 136 | 137 | 138 | io.awspring.cloud 139 | spring-cloud-aws-dependencies 140 | ${awspring.version} 141 | pom 142 | import 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | org.springframework.boot 151 | spring-boot-maven-plugin 152 | 153 | 154 | com.diffplug.spotless 155 | spotless-maven-plugin 156 | ${spotless-maven-plugin.version} 157 | 158 | 159 | 160 | 161 | 162 | 2.38.0 163 | 164 | 165 | 166 | 167 | 168 | 169 | compile 170 | 171 | check 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | spring-milestones 182 | Spring Milestones 183 | https://repo.spring.io/milestone 184 | 185 | false 186 | 187 | 188 | 189 | spring-snapshots 190 | Spring Snapshots 191 | https://repo.spring.io/snapshot 192 | 193 | false 194 | 195 | 196 | 197 | 198 | 199 | spring-milestones 200 | Spring Milestones 201 | https://repo.spring.io/milestone 202 | 203 | false 204 | 205 | 206 | 207 | spring-snapshots 208 | Spring Snapshots 209 | https://repo.spring.io/snapshot 210 | 211 | false 212 | 213 | 214 | 215 | 216 | -------------------------------------------------------------------------------- /step-5-write-tests.md: -------------------------------------------------------------------------------- 1 | # Step 5: Let's write tests 2 | So far, we focused on being able to run the application locally without having to install or run any dependent services manually. 3 | But there is nothing more painful than working on a codebase without a comprehensive test suite. 4 | 5 | Let's fix that!! 6 | 7 | ## Common Test SetUp 8 | For all the integration tests in our application, we need to start PostgreSQL, Kafka, LocalStack and Microcks containers. 9 | So, let's create a `BaseIntegrationTest` class under `src/test/java` with the common setup as follows: 10 | 11 | ```java 12 | package com.testcontainers.catalog; 13 | 14 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; 15 | 16 | import com.testcontainers.catalog.ContainersConfig; 17 | import io.restassured.RestAssured; 18 | import org.junit.jupiter.api.BeforeEach; 19 | import org.springframework.boot.test.context.SpringBootTest; 20 | import org.springframework.boot.test.web.server.LocalServerPort; 21 | import org.springframework.context.annotation.Import; 22 | import org.testcontainers.Testcontainers; 23 | 24 | @SpringBootTest( 25 | webEnvironment = RANDOM_PORT, 26 | properties = { 27 | "spring.kafka.consumer.auto-offset-reset=earliest" 28 | }) 29 | @Import(ContainersConfig.class) 30 | public abstract class BaseIntegrationTest { 31 | 32 | @LocalServerPort 33 | private int port; 34 | 35 | @BeforeEach 36 | void setUpBase() { 37 | RestAssured.port = port; 38 | Testcontainers.exposeHostPorts(port); 39 | } 40 | } 41 | ``` 42 | 43 | * We have reused the `ContainersConfig` class that we created in the previous steps to define all the required containers. 44 | * We have configured the `spring.kafka.consumer.auto-offset-reset` property to `earliest` to make sure that we read all the messages from the beginning of the topic. 45 | * We have configured the `RestAssured.port` to the dynamic port of the application that is started by Spring Boot. 46 | 47 | ## First Test - Verify Application Context Starts Successfully 48 | Let's create the test class `ApplicationTests` under `src/test/java` with the following test: 49 | 50 | ```java 51 | package com.testcontainers.catalog; 52 | 53 | import org.junit.jupiter.api.Test; 54 | 55 | class ApplicationTests extends BaseIntegrationTest { 56 | 57 | @Test 58 | void contextLoads() {} 59 | } 60 | ``` 61 | 62 | If you run this test, it should pass and that means we have successfully configured the application to start with all the required containers. 63 | 64 | ## Lets add tests for ProductController API endpoints 65 | Before writing the API tests, let's create `src/test/resources/test-data.sql` to insert some test data into the database as follows: 66 | 67 | ```sql 68 | DELETE FROM products; 69 | 70 | insert into products(code, name, description, image, price) values 71 | ('P101','Product P101','Product P101 description', null, 34.0), 72 | ('P102','Product P102','Product P102 description', null, 25.0), 73 | ('P103','Product P103','Product P103 description', null, 15.0) 74 | ; 75 | ``` 76 | 77 | Create `ProductControllerTest` and add a test to successfully create a new product as follows: 78 | 79 | ```java 80 | package com.testcontainers.catalog.api; 81 | 82 | import com.testcontainers.catalog.BaseIntegrationTest; 83 | import io.restassured.http.ContentType; 84 | import org.junit.jupiter.api.Test; 85 | import org.springframework.test.context.jdbc.Sql; 86 | 87 | import java.util.UUID; 88 | 89 | import static io.restassured.RestAssured.given; 90 | import static org.hamcrest.CoreMatchers.endsWith; 91 | 92 | @Sql("/test-data.sql") 93 | class ProductControllerTest extends BaseIntegrationTest { 94 | 95 | @Test 96 | void createProductSuccessfully() { 97 | String code = UUID.randomUUID().toString(); 98 | given().contentType(ContentType.JSON) 99 | .body( 100 | """ 101 | { 102 | "code": "%s", 103 | "name": "Product %s", 104 | "description": "Product %s description", 105 | "price": 10.0 106 | } 107 | """ 108 | .formatted(code, code, code)) 109 | .when() 110 | .post("/api/products") 111 | .then() 112 | .statusCode(201) 113 | .header("Location", endsWith("/api/products/%s".formatted(code))); 114 | } 115 | } 116 | ``` 117 | 118 | Next, let's add a test for product image upload API endpoint. 119 | 120 | Copy any sample image with name `P101.jpg` into `src/main/resources`. 121 | 122 | ```java 123 | package com.testcontainers.catalog.api; 124 | 125 | import com.testcontainers.catalog.BaseIntegrationTest; 126 | import com.testcontainers.catalog.domain.ProductService; 127 | import com.testcontainers.catalog.domain.models.Product; 128 | import io.restassured.http.ContentType; 129 | import org.junit.jupiter.api.Test; 130 | import org.springframework.beans.factory.annotation.Autowired; 131 | import org.springframework.core.io.ClassPathResource; 132 | import org.springframework.test.context.jdbc.Sql; 133 | 134 | import java.io.File; 135 | import java.io.IOException; 136 | import java.time.Duration; 137 | import java.util.Optional; 138 | import java.util.UUID; 139 | 140 | import static io.restassured.RestAssured.given; 141 | import static java.util.concurrent.TimeUnit.SECONDS; 142 | import static org.assertj.core.api.Assertions.assertThat; 143 | import static org.awaitility.Awaitility.await; 144 | import static org.hamcrest.CoreMatchers.endsWith; 145 | 146 | @Sql("/test-data.sql") 147 | class ProductControllerTest extends BaseIntegrationTest { 148 | @Autowired 149 | ProductService productService; 150 | 151 | @Test 152 | void shouldUploadProductImageSuccessfully() throws IOException { 153 | String code = "P101"; 154 | File file = new ClassPathResource("P101.jpg").getFile(); 155 | 156 | Optional product = productService.getProductByCode(code); 157 | assertThat(product).isPresent(); 158 | assertThat(product.get().imageUrl()).isNull(); 159 | 160 | given().multiPart("file", file, "multipart/form-data") 161 | .contentType(ContentType.MULTIPART) 162 | .when() 163 | .post("/api/products/{code}/image", code) 164 | .then() 165 | .statusCode(200) 166 | .body("status", endsWith("success")) 167 | .body("filename", endsWith("P101.jpg")); 168 | 169 | await().pollInterval(Duration.ofSeconds(3)).atMost(10, SECONDS).untilAsserted(() -> { 170 | Optional optionalProduct = productService.getProductByCode(code); 171 | assertThat(optionalProduct).isPresent(); 172 | assertThat(optionalProduct.get().imageUrl()).isNotEmpty(); 173 | }); 174 | } 175 | } 176 | ``` 177 | 178 | This test checks the following: 179 | * Before uploading the image, the product image URL is null for the product with code P101. 180 | * Invoke the Product Image Upload API endpoint with the sample image file. 181 | * Assert that the response status is 200 and the response body contains the image file name. 182 | * Assert that the product image URL is updated in the database after the image upload. 183 | 184 | Next, let's add a test for getting the product information by code. 185 | 186 | ```java 187 | @Sql("/test-data.sql") 188 | class ProductControllerTest extends BaseIntegrationTest { 189 | @Autowired 190 | ProductService productService; 191 | 192 | @Test 193 | void getProductByCodeSuccessfully() { 194 | String code = "P101"; 195 | 196 | Product product = given().contentType(ContentType.JSON) 197 | .when() 198 | .get("/api/products/{code}", code) 199 | .then() 200 | .statusCode(200) 201 | .extract() 202 | .as(Product.class); 203 | 204 | assertThat(product.code()).isEqualTo(code); 205 | assertThat(product.name()).isEqualTo("Product %s".formatted(code)); 206 | assertThat(product.description()).isEqualTo("Product %s description".formatted(code)); 207 | assertThat(product.price().compareTo(new BigDecimal("34.0"))).isEqualTo(0); 208 | assertThat(product.available()).isTrue(); 209 | } 210 | } 211 | ``` 212 | 213 | Checking product information like this is easy but become really cumbersome when the number of properties is growing 214 | or when the `Product` class is shared among many different operations of your API. You have to check the properties 215 | presence but also their type and this can result in sprawling code! 216 | 217 | If you're using an "API design-first approach", the conformance of your data structure can be automatically checked by 218 | Microcks for you! Check the `src/main/resources/catalog-openapi.yaml` file that describes our Catalog API. 219 | 220 | Now let's create a test that uses Microcks to automatically check that our `ProductController` is conformance to this definition: 221 | 222 | ```java 223 | import io.github.microcks.testcontainers.MicrocksContainer; 224 | import io.github.microcks.testcontainers.model.TestRequest; 225 | import io.github.microcks.testcontainers.model.TestResult; 226 | import io.github.microcks.testcontainers.model.TestRunnerType; 227 | import io.restassured.RestAssured; 228 | import org.junit.jupiter.api.Test; 229 | import org.springframework.beans.factory.annotation.Autowired; 230 | import org.springframework.core.io.ClassPathResource; 231 | import org.springframework.test.context.jdbc.Sql; 232 | 233 | @Sql("/test-data.sql") 234 | class ProductControllerTest extends BaseIntegrationTest { 235 | @Autowired 236 | MicrocksContainer microcks; 237 | 238 | @Test 239 | void checkOpenAPIConformance() throws Exception { 240 | microcks.importAsMainArtifact(new ClassPathResource("catalog-openapi.yaml").getFile()); 241 | 242 | TestRequest testRequest = new TestRequest.Builder() 243 | .serviceId("Catalog Service:1.0") 244 | .runnerType(TestRunnerType.OPEN_API_SCHEMA.name()) 245 | .testEndpoint("http://host.testcontainers.internal:" + RestAssured.port) 246 | .build(); 247 | 248 | TestResult testResult = microcks.testEndpoint(testRequest); 249 | 250 | assertThat(testResult.isSuccess()).isTrue(); 251 | } 252 | } 253 | ``` 254 | 255 | Let's understand what's going on behind the scenes: 256 | * We complete the Microcks container with our additional `catalog-openapi.yaml` artifact file (this could have also 257 | been done within the `ContainersConfig` class at bean initialisation). 258 | * We prepare a `TestRequest` object that allows to specify the scope of the conformance test. Here we want to check the 259 | conformance of `Catalog Service` with version `1.0` that are the identifier found in `catalog-openapi.yaml`. 260 | * We ask Microcks to validate the `OpenAPI Schema` conformance by specifying a `runnerType`. 261 | * We ask Microcks to validate the localhost endpoint on the dynamic port provided by the Spring Test 262 | (we use the `host.testcontainers.internal` alias for that). 263 | 264 | Finally, we're retrieving a `TestResult` from Microcks containers, and we can assert stuffs on this result, checking it's a success. 265 | 266 | During the test, Microcks has reused all the examples found in the `catalog-openapi.yaml` file to issue requests to 267 | our running application. It also checked that all the received responses conform to the OpenAPI definition elements: 268 | return codes, headers, content-type and JSON schema structure. 269 | 270 | If you want to get more details on the test done by Microcks, you can add those lines just before the `assertThat()`: 271 | 272 | ```java 273 | // You may inspect complete response object with following: 274 | ObjectMapper mapper = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); 275 | System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(testResult)); 276 | ``` 277 | 278 | ## Assignment 279 | * Write tests for create product API fails if the payload is invalid. 280 | * Write tests for create product API fails if the product code already exists. 281 | * Write tests for get product by code API fails if the product code does not exist. 282 | * Write tests for get product by code API that returns `"available": false` when Microcks server return quantity=0. 283 | * Write tests for get product by code API that returns `"available": true` from Microcks server throws Exception. 284 | -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # https://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.2.0 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | # e.g. to debug Maven itself, use 32 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | # ---------------------------------------------------------------------------- 35 | 36 | if [ -z "$MAVEN_SKIP_RC" ] ; then 37 | 38 | if [ -f /usr/local/etc/mavenrc ] ; then 39 | . /usr/local/etc/mavenrc 40 | fi 41 | 42 | if [ -f /etc/mavenrc ] ; then 43 | . /etc/mavenrc 44 | fi 45 | 46 | if [ -f "$HOME/.mavenrc" ] ; then 47 | . "$HOME/.mavenrc" 48 | fi 49 | 50 | fi 51 | 52 | # OS specific support. $var _must_ be set to either true or false. 53 | cygwin=false; 54 | darwin=false; 55 | mingw=false 56 | case "$(uname)" in 57 | CYGWIN*) cygwin=true ;; 58 | MINGW*) mingw=true;; 59 | Darwin*) darwin=true 60 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 61 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 62 | if [ -z "$JAVA_HOME" ]; then 63 | if [ -x "/usr/libexec/java_home" ]; then 64 | JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME 65 | else 66 | JAVA_HOME="/Library/Java/Home"; export JAVA_HOME 67 | fi 68 | fi 69 | ;; 70 | esac 71 | 72 | if [ -z "$JAVA_HOME" ] ; then 73 | if [ -r /etc/gentoo-release ] ; then 74 | JAVA_HOME=$(java-config --jre-home) 75 | fi 76 | fi 77 | 78 | # For Cygwin, ensure paths are in UNIX format before anything is touched 79 | if $cygwin ; then 80 | [ -n "$JAVA_HOME" ] && 81 | JAVA_HOME=$(cygpath --unix "$JAVA_HOME") 82 | [ -n "$CLASSPATH" ] && 83 | CLASSPATH=$(cygpath --path --unix "$CLASSPATH") 84 | fi 85 | 86 | # For Mingw, ensure paths are in UNIX format before anything is touched 87 | if $mingw ; then 88 | [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && 89 | JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" 90 | fi 91 | 92 | if [ -z "$JAVA_HOME" ]; then 93 | javaExecutable="$(which javac)" 94 | if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then 95 | # readlink(1) is not available as standard on Solaris 10. 96 | readLink=$(which readlink) 97 | if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then 98 | if $darwin ; then 99 | javaHome="$(dirname "\"$javaExecutable\"")" 100 | javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" 101 | else 102 | javaExecutable="$(readlink -f "\"$javaExecutable\"")" 103 | fi 104 | javaHome="$(dirname "\"$javaExecutable\"")" 105 | javaHome=$(expr "$javaHome" : '\(.*\)/bin') 106 | JAVA_HOME="$javaHome" 107 | export JAVA_HOME 108 | fi 109 | fi 110 | fi 111 | 112 | if [ -z "$JAVACMD" ] ; then 113 | if [ -n "$JAVA_HOME" ] ; then 114 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 115 | # IBM's JDK on AIX uses strange locations for the executables 116 | JAVACMD="$JAVA_HOME/jre/sh/java" 117 | else 118 | JAVACMD="$JAVA_HOME/bin/java" 119 | fi 120 | else 121 | JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" 122 | fi 123 | fi 124 | 125 | if [ ! -x "$JAVACMD" ] ; then 126 | echo "Error: JAVA_HOME is not defined correctly." >&2 127 | echo " We cannot execute $JAVACMD" >&2 128 | exit 1 129 | fi 130 | 131 | if [ -z "$JAVA_HOME" ] ; then 132 | echo "Warning: JAVA_HOME environment variable is not set." 133 | fi 134 | 135 | # traverses directory structure from process work directory to filesystem root 136 | # first directory with .mvn subdirectory is considered project base directory 137 | find_maven_basedir() { 138 | if [ -z "$1" ] 139 | then 140 | echo "Path not specified to find_maven_basedir" 141 | return 1 142 | fi 143 | 144 | basedir="$1" 145 | wdir="$1" 146 | while [ "$wdir" != '/' ] ; do 147 | if [ -d "$wdir"/.mvn ] ; then 148 | basedir=$wdir 149 | break 150 | fi 151 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 152 | if [ -d "${wdir}" ]; then 153 | wdir=$(cd "$wdir/.." || exit 1; pwd) 154 | fi 155 | # end of workaround 156 | done 157 | printf '%s' "$(cd "$basedir" || exit 1; pwd)" 158 | } 159 | 160 | # concatenates all lines of a file 161 | concat_lines() { 162 | if [ -f "$1" ]; then 163 | # Remove \r in case we run on Windows within Git Bash 164 | # and check out the repository with auto CRLF management 165 | # enabled. Otherwise, we may read lines that are delimited with 166 | # \r\n and produce $'-Xarg\r' rather than -Xarg due to word 167 | # splitting rules. 168 | tr -s '\r\n' ' ' < "$1" 169 | fi 170 | } 171 | 172 | log() { 173 | if [ "$MVNW_VERBOSE" = true ]; then 174 | printf '%s\n' "$1" 175 | fi 176 | } 177 | 178 | BASE_DIR=$(find_maven_basedir "$(dirname "$0")") 179 | if [ -z "$BASE_DIR" ]; then 180 | exit 1; 181 | fi 182 | 183 | MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR 184 | log "$MAVEN_PROJECTBASEDIR" 185 | 186 | ########################################################################################## 187 | # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 188 | # This allows using the maven wrapper in projects that prohibit checking in binary data. 189 | ########################################################################################## 190 | wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" 191 | if [ -r "$wrapperJarPath" ]; then 192 | log "Found $wrapperJarPath" 193 | else 194 | log "Couldn't find $wrapperJarPath, downloading it ..." 195 | 196 | if [ -n "$MVNW_REPOURL" ]; then 197 | wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 198 | else 199 | wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 200 | fi 201 | while IFS="=" read -r key value; do 202 | # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) 203 | safeValue=$(echo "$value" | tr -d '\r') 204 | case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; 205 | esac 206 | done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" 207 | log "Downloading from: $wrapperUrl" 208 | 209 | if $cygwin; then 210 | wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") 211 | fi 212 | 213 | if command -v wget > /dev/null; then 214 | log "Found wget ... using wget" 215 | [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" 216 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 217 | wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 218 | else 219 | wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 220 | fi 221 | elif command -v curl > /dev/null; then 222 | log "Found curl ... using curl" 223 | [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" 224 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 225 | curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" 226 | else 227 | curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" 228 | fi 229 | else 230 | log "Falling back to using Java to download" 231 | javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" 232 | javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" 233 | # For Cygwin, switch paths to Windows format before running javac 234 | if $cygwin; then 235 | javaSource=$(cygpath --path --windows "$javaSource") 236 | javaClass=$(cygpath --path --windows "$javaClass") 237 | fi 238 | if [ -e "$javaSource" ]; then 239 | if [ ! -e "$javaClass" ]; then 240 | log " - Compiling MavenWrapperDownloader.java ..." 241 | ("$JAVA_HOME/bin/javac" "$javaSource") 242 | fi 243 | if [ -e "$javaClass" ]; then 244 | log " - Running MavenWrapperDownloader.java ..." 245 | ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" 246 | fi 247 | fi 248 | fi 249 | fi 250 | ########################################################################################## 251 | # End of extension 252 | ########################################################################################## 253 | 254 | # If specified, validate the SHA-256 sum of the Maven wrapper jar file 255 | wrapperSha256Sum="" 256 | while IFS="=" read -r key value; do 257 | case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; 258 | esac 259 | done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" 260 | if [ -n "$wrapperSha256Sum" ]; then 261 | wrapperSha256Result=false 262 | if command -v sha256sum > /dev/null; then 263 | if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then 264 | wrapperSha256Result=true 265 | fi 266 | elif command -v shasum > /dev/null; then 267 | if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then 268 | wrapperSha256Result=true 269 | fi 270 | else 271 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." 272 | echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." 273 | exit 1 274 | fi 275 | if [ $wrapperSha256Result = false ]; then 276 | echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 277 | echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 278 | echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 279 | exit 1 280 | fi 281 | fi 282 | 283 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 284 | 285 | # For Cygwin, switch paths to Windows format before running java 286 | if $cygwin; then 287 | [ -n "$JAVA_HOME" ] && 288 | JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") 289 | [ -n "$CLASSPATH" ] && 290 | CLASSPATH=$(cygpath --path --windows "$CLASSPATH") 291 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 292 | MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") 293 | fi 294 | 295 | # Provide a "standardized" way to retrieve the CLI args that will 296 | # work with both Windows and non-Windows executions. 297 | MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" 298 | export MAVEN_CMD_LINE_ARGS 299 | 300 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 301 | 302 | # shellcheck disable=SC2086 # safe args 303 | exec "$JAVACMD" \ 304 | $MAVEN_OPTS \ 305 | $MAVEN_DEBUG_OPTS \ 306 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 307 | "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 308 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 309 | -------------------------------------------------------------------------------- /step-3-local-development-environment.md: -------------------------------------------------------------------------------- 1 | # Step 3: Local development environment with Testcontainers 2 | Our application uses PostgreSQL, Kafka, and LocalStack. 3 | 4 | Currently, if you run the `Application.java` from your IDE, you will see the following error: 5 | 6 | ```shell 7 | *************************** 8 | APPLICATION FAILED TO START 9 | *************************** 10 | 11 | Description: 12 | 13 | Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured. 14 | 15 | Reason: Failed to determine a suitable driver class 16 | 17 | Action: 18 | 19 | Consider the following: 20 | If you want an embedded database (H2, HSQL or Derby), please put it on the classpath. 21 | If you have database settings to be loaded from a particular profile you may need to activate it (no profiles are currently active). 22 | 23 | Process finished with exit code 0 24 | ``` 25 | 26 | To run the application locally, we need to have these services up and running. 27 | 28 | Instead of installing these services on our local machine, or using Docker to run these services manually, 29 | we will use [Spring Boot support for Testcontainers at Development Time](https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#features.testing.testcontainers.at-development-time) to provision these services automatically. 30 | 31 | > **NOTE** 32 | > 33 | > Before Spring Boot 3.1.0, Testcontainers libraries are mainly used for testing. 34 | Spring Boot 3.1.0 introduced out-of-the-box support for Testcontainers which not only simplified testing, 35 | but we can use Testcontainers for local development as well. 36 | > 37 | > To learn more, please read [Spring Boot Application Testing and Development with Testcontainers](https://www.atomicjar.com/2023/05/spring-boot-3-1-0-testcontainers-for-testing-and-local-development/) 38 | 39 | First, make sure you have the following Testcontainers dependencies in your `pom.xml`: 40 | 41 | ```xml 42 | 43 | 44 | org.springframework.boot 45 | spring-boot-testcontainers 46 | test 47 | 48 | 49 | org.testcontainers 50 | postgresql 51 | test 52 | 53 | 54 | org.testcontainers 55 | kafka 56 | test 57 | 58 | 59 | org.testcontainers 60 | localstack 61 | test 62 | 63 | 64 | ``` 65 | 66 | We will also use **RestAssured** for API testing and **Awaitility** for testing asynchronous processes. 67 | 68 | So, add the following dependencies as well: 69 | 70 | ```xml 71 | 72 | io.rest-assured 73 | rest-assured 74 | test 75 | 76 | 77 | org.awaitility 78 | awaitility 79 | test 80 | 81 | ``` 82 | 83 | ## Create ContainersConfig class under src/test/java 84 | Let's create `com.testcontainers.catalog.ContainersConfig` class under `src/test/java` to configure the required containers. 85 | 86 | ```java 87 | package com.testcontainers.catalog; 88 | 89 | import static org.testcontainers.utility.DockerImageName.parse; 90 | 91 | import com.testcontainers.catalog.domain.FileStorageService; 92 | import org.springframework.boot.ApplicationRunner; 93 | import org.springframework.boot.test.context.TestConfiguration; 94 | import org.springframework.boot.testcontainers.service.connection.ServiceConnection; 95 | import org.springframework.context.annotation.Bean; 96 | import org.springframework.context.annotation.DependsOn; 97 | import org.springframework.test.context.DynamicPropertyRegistry; 98 | import org.testcontainers.containers.KafkaContainer; 99 | import org.testcontainers.containers.PostgreSQLContainer; 100 | import org.testcontainers.containers.localstack.LocalStackContainer; 101 | 102 | @TestConfiguration(proxyBeanMethods = false) 103 | public class ContainersConfig { 104 | 105 | @Bean 106 | @ServiceConnection 107 | PostgreSQLContainer postgresContainer() { 108 | return new PostgreSQLContainer<>(parse("postgres:16-alpine")); 109 | } 110 | 111 | @Bean 112 | @ServiceConnection 113 | KafkaContainer kafkaContainer() { 114 | return new KafkaContainer(parse("confluentinc/cp-kafka:7.5.0")); 115 | } 116 | 117 | @Bean("localstackContainer") 118 | LocalStackContainer localstackContainer(DynamicPropertyRegistry registry) { 119 | LocalStackContainer localStack = new LocalStackContainer(parse("localstack/localstack:2.3")); 120 | registry.add("spring.cloud.aws.credentials.access-key", localStack::getAccessKey); 121 | registry.add("spring.cloud.aws.credentials.secret-key", localStack::getSecretKey); 122 | registry.add("spring.cloud.aws.region.static", localStack::getRegion); 123 | registry.add("spring.cloud.aws.endpoint", localStack::getEndpoint); 124 | return localStack; 125 | } 126 | 127 | @Bean 128 | @DependsOn("localstackContainer") 129 | ApplicationRunner awsInitializer(ApplicationProperties properties, FileStorageService fileStorageService) { 130 | return args -> fileStorageService.createBucket(properties.productImagesBucketName()); 131 | } 132 | } 133 | ``` 134 | 135 | Let's understand what this configuration class does: 136 | * `@TestConfiguration` annotation indicates that this configuration class defines the beans that can be used for Spring Boot tests. 137 | * Spring Boot provides `ServiceConnection` support for `JdbcConnectionDetails` and `KafkaConnectionDetails` out-of-the-box. 138 | So, we configured `PostgreSQLContainer` and `KafkaContainer` as beans with `@ServiceConnection` annotation. 139 | This configuration will automatically start these containers and register the **DataSource** and **Kafka** connection properties automatically. 140 | * Spring Cloud AWS doesn't provide ServiceConnection support out-of-the-box [yet](https://github.com/awspring/spring-cloud-aws/issues/793). 141 | But there is support for [Contributing Dynamic Properties at Development Time](https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#features.testing.testcontainers.at-development-time.dynamic-properties). 142 | So, we configured `LocalStackContainer` as a bean and registered the Spring Cloud AWS configuration properties using `DynamicPropertyRegistry`. 143 | * We also configured an `ApplicationRunner` bean to create the AWS resources like S3 bucket upon application startup. 144 | 145 | ## Create TestApplication class under src/test/java 146 | Next, let's create a `com.testcontainers.catalog.TestApplication` class under `src/test/java` to start the application with the Testcontainers configuration. 147 | 148 | ```java 149 | package com.testcontainers.catalog; 150 | 151 | import org.springframework.boot.SpringApplication; 152 | 153 | public class TestApplication { 154 | 155 | public static void main(String[] args) { 156 | SpringApplication 157 | //note that we are starting our actual Application from within our TestApplication 158 | .from(Application::main) 159 | .with(ContainersConfig.class) 160 | .run(args); 161 | } 162 | } 163 | ``` 164 | 165 | Run the `TestApplication` from our IDE and verify that the application starts successfully. 166 | 167 | Now, you can invoke the APIs using CURL or Postman or any of your favourite HTTP Client tools. 168 | 169 | ### Create a product 170 | ```shell 171 | curl -v -X "POST" 'http://localhost:8080/api/products' \ 172 | --header 'Content-Type: application/json' \ 173 | --data '{ 174 | "code": "P201", 175 | "name": "Product P201", 176 | "description": "Product P201 description", 177 | "price": 24.0 178 | }' 179 | ``` 180 | 181 | You should get a response similar to the following: 182 | 183 | ```shell 184 | < HTTP/1.1 201 185 | < Location: http://localhost:8080/api/products/P201 186 | < Content-Length: 0 187 | ``` 188 | 189 | ### Upload Product Image 190 | ```shell 191 | curl -X "POST" 'http://localhost:8080/api/products/P101/image' \ 192 | --form 'file=@"src/test/resources/P101.jpg"' 193 | ``` 194 | 195 | You should see a response similar to the following: 196 | 197 | ```shell 198 | {"filename":"P101.jpg","status":"success"} 199 | ``` 200 | 201 | ### Get a product by code 202 | 203 | ```shell 204 | curl -X "GET" 'http://localhost:8080/api/products/P101' 205 | ``` 206 | 207 | You should be able to see the response similar to the following: 208 | 209 | ```json 210 | { 211 | "id":1, 212 | "code":"P101", 213 | "name":"Product P101", 214 | "description":"Product P101 description", 215 | "imageUrl":"http://127.0.0.1:60739/product-images/P101.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&...", 216 | "price":34.0, 217 | "available":true 218 | } 219 | ``` 220 | 221 | If you check the application logs, you should see the following error in logs: 222 | 223 | ```shell 224 | com.testcontainers.catalog.domain.internal.DefaultProductService - Error while calling inventory service 225 | org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localhost:8081/api/inventory/P101": null 226 | at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.createResourceAccessException(DefaultRestClient.java:489) 227 | at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.exchangeInternal(DefaultRestClient.java:414) 228 | at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.retrieve(DefaultRestClient.java:380) 229 | ... 230 | ... 231 | at jdk.proxy4/jdk.proxy4.$Proxy179.getInventory(Unknown Source) 232 | at com.testcontainers.catalog.domain.internal.DefaultProductService.isProductAvailable(DefaultProductService.java:68) 233 | at com.testcontainers.catalog.domain.internal.DefaultProductService.toProduct(DefaultProductService.java:84) 234 | at java.base/java.util.Optional.map(Optional.java:260) 235 | ``` 236 | 237 | When we invoke the `GET /api/products/{code}` API endpoint, 238 | the application tried to call the inventory service to get the inventory details. 239 | As the inventory service is not running, we get the above error. 240 | 241 | Let's use Microcks to mock the inventory service APIs for our local development and testing. 242 | 243 | ## Configure Microcks 244 | Add the following dependency to your `pom.xml`: 245 | 246 | ```xml 247 | 248 | io.github.microcks 249 | microcks-testcontainers 250 | 0.2.4 251 | test 252 | 253 | ``` 254 | 255 | Imagine you got an OpenAPI definition for the inventory service from the service provider. 256 | Create `src/test/resources/inventory-openapi.yaml` with this content; this will define the Mocks behaviour: 257 | 258 | ```yaml 259 | openapi: 3.0.2 260 | info: 261 | title: Inventory Service 262 | version: 1.0 263 | description: API definition of Inventory Service 264 | license: 265 | name: MIT License 266 | url: https://opensource.org/licenses/MIT 267 | paths: 268 | /api/inventory/{code}: 269 | get: 270 | parameters: 271 | - name: code 272 | description: product code 273 | schema: 274 | type: string 275 | in: path 276 | required: true 277 | examples: 278 | P101: 279 | value: P101 280 | P102: 281 | value: P102 282 | P103: 283 | value: P103 284 | responses: 285 | "200": 286 | content: 287 | application/json: 288 | schema: 289 | $ref: '#/components/schemas/Product' 290 | examples: 291 | P101: 292 | value: 293 | code: P101 294 | quantity: 25 295 | P103: 296 | value: 297 | code: P103 298 | quantity: 0 299 | "500": 300 | content: 301 | application/json: 302 | schema: 303 | type: string 304 | examples: 305 | P102: 306 | value: "" 307 | components: 308 | schemas: 309 | Product: 310 | title: Root Type for Product 311 | type: object 312 | properties: 313 | code: 314 | description: Code of this product 315 | type: string 316 | quantity: 317 | description: Remaining quantity for this product 318 | type: number 319 | required: 320 | - code 321 | - quantity 322 | additionalProperties: false 323 | ``` 324 | 325 | Next, update the `ContainersConfig` class to configure the `MicrocksContainer` as follows: 326 | 327 | ```java 328 | package com.testcontainers.catalog; 329 | 330 | import io.github.microcks.testcontainers.MicrocksContainer; 331 | 332 | @TestConfiguration(proxyBeanMethods = false) 333 | public class ContainersConfig { 334 | 335 | // [...] 336 | 337 | @Bean 338 | MicrocksContainer microcksContainer(DynamicPropertyRegistry registry) { 339 | MicrocksContainer microcks = new MicrocksContainer("quay.io/microcks/microcks-uber:1.8.1") 340 | .withMainArtifacts("inventory-openapi.yaml") 341 | .withAccessToHost(true); 342 | 343 | registry.add( 344 | "application.inventory-service-url", () -> microcks.getRestMockEndpoint("Inventory Service", "1.0")); 345 | 346 | return microcks; 347 | } 348 | } 349 | ``` 350 | 351 | Once the Microcks server is started, we are registering the Microcks provided mock endpoint as `application.inventory-service-url`. 352 | So, when we make a call to `inventory-service` from our application, it will call the Microcks endpoint instead. 353 | 354 | Now restart the `TestApplication` and invoke the `GET /api/products/P101` API again. 355 | 356 | ```shell 357 | curl -X "GET" 'http://localhost:8080/api/products/P101' 358 | ``` 359 | 360 | You should see the response similar to the following: 361 | 362 | ```json 363 | { 364 | "id":1, 365 | "code":"P101", 366 | "name":"Product P101", 367 | "description":"Product P101 description", 368 | "imageUrl":null, 369 | "price":34.0, 370 | "available":true 371 | } 372 | ``` 373 | 374 | And there should be no error in the console logs. 375 | 376 | Try `curl -X "GET" 'http://localhost:8080/api/products/P103'`. 377 | You should get the following response with `"available":false` because we mocked inventory-service such that the quantity for P103 to be 0. 378 | 379 | ```json 380 | { 381 | "id":3, 382 | "code":"P103", 383 | "name":"Product P103", 384 | "description":"Product P103 description", 385 | "imageUrl":null, 386 | "price":15.0, 387 | "available":false 388 | } 389 | ``` 390 | 391 | Now we have a working local development environment with PostgreSQL, Kafka, LocalStack, and Microcks. 392 | 393 | ### 394 | [Next](step-4-connect-to-services.md) 395 | --------------------------------------------------------------------------------