├── .devcontainer └── devcontainer.json ├── .gitignore ├── .mvn └── wrapper │ ├── MavenWrapperDownloader.java │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── LICENSE ├── README.md ├── SUMMARY.md ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── mvnw ├── mvnw.cmd ├── pom.xml ├── src ├── main │ ├── java │ │ └── com │ │ │ └── example │ │ │ └── demo │ │ │ ├── DemoApplication.java │ │ │ ├── api │ │ │ └── RatingsController.java │ │ │ ├── model │ │ │ └── Rating.java │ │ │ ├── repository │ │ │ ├── RatingsRepository.java │ │ │ └── TalksRepository.java │ │ │ ├── streams │ │ │ └── RatingsListener.java │ │ │ └── view │ │ │ └── RatingsViewController.java │ └── resources │ │ ├── application.yml │ │ └── templates │ │ └── index.html └── test │ ├── java │ └── com │ │ └── example │ │ └── demo │ │ └── .keep │ └── resources │ └── logback-test.xml ├── step-1-getting-started.md ├── step-2-exploring-the-app.md ├── step-3-adding-some-tests.md ├── step-4-your-first-testcontainers-integration.md ├── step-5-dude-r-u-200-ok.md ├── step-6-adding-redis.md ├── step-7-test-the-api.md ├── step-7.7-data-init-strategies.md ├── step-8-local-development-environment.md ├── step-extra-chaos-engineering.md ├── step-extra-custom-modules.md ├── step-extra-edge-cases.md ├── step-extra-migrating-from-docker-compose.md └── step-extra-tooling-in-containers.md /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/java-8 3 | { 4 | "name": "Java 8", 5 | "image": "mcr.microsoft.com/devcontainers/java:1-8-buster", 6 | "features": { 7 | "ghcr.io/devcontainers/features/java:1": { 8 | "version": "none", 9 | "installMaven": "true", 10 | "installGradle": "true" 11 | }, 12 | "ghcr.io/devcontainers/features/docker-in-docker:2": {}, 13 | "ghcr.io/devcontainers/features/sshd:1": {} 14 | }, 15 | 16 | // Configure tool-specific properties. 17 | "customizations": { 18 | // Configure properties specific to VS Code. 19 | "vscode": { 20 | // Set *default* container specific settings.json values on container create. 21 | "settings": { 22 | "java.import.gradle.java.home": "/usr/local/sdkman/candidates/java/current", 23 | "java.configuration.runtimes": [ 24 | { 25 | "default": true, 26 | "name": "JavaSE-1.8", 27 | "path": "/usr/local/sdkman/candidates/java/current" 28 | } 29 | ] 30 | }, 31 | "extensions": [ 32 | "vscjava.vscode-java-pack", 33 | "vscjava.vscode-gradle" 34 | ] 35 | } 36 | } 37 | 38 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 39 | // "forwardPorts": [], 40 | 41 | // Use 'postCreateCommand' to run commands after the container is created. 42 | // "postCreateCommand": "java -version", 43 | 44 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 45 | // "remoteUser": "root" 46 | } 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | .gradle/ 4 | build/ 5 | out/ 6 | target/ 7 | 8 | .vscode/ 9 | /.project 10 | /.classpath 11 | /.settings/ 12 | /bin/ 13 | 14 | .idea 15 | *.iml 16 | *.ipr 17 | *.iws 18 | 19 | 20 | /_book/ 21 | 22 | 23 | /src/test 24 | !/src/test/java/com/example/demo/.keep 25 | !/src/test/resources/logback-test.xml 26 | /src/acceptanceTest 27 | -------------------------------------------------------------------------------- /.mvn/wrapper/MavenWrapperDownloader.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2007-present the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import java.net.*; 17 | import java.io.*; 18 | import java.nio.channels.*; 19 | import java.util.Properties; 20 | 21 | public class MavenWrapperDownloader { 22 | 23 | private static final String WRAPPER_VERSION = "0.5.6"; 24 | /** 25 | * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. 26 | */ 27 | private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" 28 | + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; 29 | 30 | /** 31 | * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to 32 | * use instead of the default one. 33 | */ 34 | private static final String MAVEN_WRAPPER_PROPERTIES_PATH = 35 | ".mvn/wrapper/maven-wrapper.properties"; 36 | 37 | /** 38 | * Path where the maven-wrapper.jar will be saved to. 39 | */ 40 | private static final String MAVEN_WRAPPER_JAR_PATH = 41 | ".mvn/wrapper/maven-wrapper.jar"; 42 | 43 | /** 44 | * Name of the property which should be used to override the default download url for the wrapper. 45 | */ 46 | private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; 47 | 48 | public static void main(String args[]) { 49 | System.out.println("- Downloader started"); 50 | File baseDirectory = new File(args[0]); 51 | System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); 52 | 53 | // If the maven-wrapper.properties exists, read it and check if it contains a custom 54 | // wrapperUrl parameter. 55 | File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); 56 | String url = DEFAULT_DOWNLOAD_URL; 57 | if(mavenWrapperPropertyFile.exists()) { 58 | FileInputStream mavenWrapperPropertyFileInputStream = null; 59 | try { 60 | mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); 61 | Properties mavenWrapperProperties = new Properties(); 62 | mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); 63 | url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); 64 | } catch (IOException e) { 65 | System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); 66 | } finally { 67 | try { 68 | if(mavenWrapperPropertyFileInputStream != null) { 69 | mavenWrapperPropertyFileInputStream.close(); 70 | } 71 | } catch (IOException e) { 72 | // Ignore ... 73 | } 74 | } 75 | } 76 | System.out.println("- Downloading from: " + url); 77 | 78 | File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); 79 | if(!outputFile.getParentFile().exists()) { 80 | if(!outputFile.getParentFile().mkdirs()) { 81 | System.out.println( 82 | "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); 83 | } 84 | } 85 | System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); 86 | try { 87 | downloadFileFromURL(url, outputFile); 88 | System.out.println("Done"); 89 | System.exit(0); 90 | } catch (Throwable e) { 91 | System.out.println("- Error downloading"); 92 | e.printStackTrace(); 93 | System.exit(1); 94 | } 95 | } 96 | 97 | private static void downloadFileFromURL(String urlString, File destination) throws Exception { 98 | if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { 99 | String username = System.getenv("MVNW_USERNAME"); 100 | char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); 101 | Authenticator.setDefault(new Authenticator() { 102 | @Override 103 | protected PasswordAuthentication getPasswordAuthentication() { 104 | return new PasswordAuthentication(username, password); 105 | } 106 | }); 107 | } 108 | URL website = new URL(urlString); 109 | ReadableByteChannel rbc; 110 | rbc = Channels.newChannel(website.openStream()); 111 | FileOutputStream fos = new FileOutputStream(destination); 112 | fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); 113 | fos.close(); 114 | rbc.close(); 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testcontainers/workshop/655f47facf8ee061a121e644ab8c32d2a00b4cb7/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.4/apache-maven-3.8.4-bin.zip 18 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International 2 | Public License 3 | 4 | By exercising the Licensed Rights (defined below), You accept and agree 5 | to be bound by the terms and conditions of this Creative Commons 6 | Attribution-NonCommercial-ShareAlike 4.0 International Public License 7 | ("Public License"). To the extent this Public License may be 8 | interpreted as a contract, You are granted the Licensed Rights in 9 | consideration of Your acceptance of these terms and conditions, and the 10 | Licensor grants You such rights in consideration of benefits the 11 | Licensor receives from making the Licensed Material available under 12 | these terms and conditions. 13 | 14 | 15 | Section 1 -- Definitions. 16 | 17 | a. Adapted Material means material subject to Copyright and Similar 18 | Rights that is derived from or based upon the Licensed Material 19 | and in which the Licensed Material is translated, altered, 20 | arranged, transformed, or otherwise modified in a manner requiring 21 | permission under the Copyright and Similar Rights held by the 22 | Licensor. For purposes of this Public License, where the Licensed 23 | Material is a musical work, performance, or sound recording, 24 | Adapted Material is always produced where the Licensed Material is 25 | synched in timed relation with a moving image. 26 | 27 | b. Adapter's License means the license You apply to Your Copyright 28 | and Similar Rights in Your contributions to Adapted Material in 29 | accordance with the terms and conditions of this Public License. 30 | 31 | c. BY-NC-SA Compatible License means a license listed at 32 | creativecommons.org/compatiblelicenses, approved by Creative 33 | Commons as essentially the equivalent of this Public License. 34 | 35 | d. Copyright and Similar Rights means copyright and/or similar rights 36 | closely related to copyright including, without limitation, 37 | performance, broadcast, sound recording, and Sui Generis Database 38 | Rights, without regard to how the rights are labeled or 39 | categorized. For purposes of this Public License, the rights 40 | specified in Section 2(b)(1)-(2) are not Copyright and Similar 41 | Rights. 42 | 43 | e. Effective Technological Measures means those measures that, in the 44 | absence of proper authority, may not be circumvented under laws 45 | fulfilling obligations under Article 11 of the WIPO Copyright 46 | Treaty adopted on December 20, 1996, and/or similar international 47 | agreements. 48 | 49 | f. Exceptions and Limitations means fair use, fair dealing, and/or 50 | any other exception or limitation to Copyright and Similar Rights 51 | that applies to Your use of the Licensed Material. 52 | 53 | g. License Elements means the license attributes listed in the name 54 | of a Creative Commons Public License. The License Elements of this 55 | Public License are Attribution, NonCommercial, and ShareAlike. 56 | 57 | h. Licensed Material means the artistic or literary work, database, 58 | or other material to which the Licensor applied this Public 59 | License. 60 | 61 | i. Licensed Rights means the rights granted to You subject to the 62 | terms and conditions of this Public License, which are limited to 63 | all Copyright and Similar Rights that apply to Your use of the 64 | Licensed Material and that the Licensor has authority to license. 65 | 66 | j. Licensor means the individual(s) or entity(ies) granting rights 67 | under this Public License. 68 | 69 | k. NonCommercial means not primarily intended for or directed towards 70 | commercial advantage or monetary compensation. For purposes of 71 | this Public License, the exchange of the Licensed Material for 72 | other material subject to Copyright and Similar Rights by digital 73 | file-sharing or similar means is NonCommercial provided there is 74 | no payment of monetary compensation in connection with the 75 | exchange. 76 | 77 | l. Share means to provide material to the public by any means or 78 | process that requires permission under the Licensed Rights, such 79 | as reproduction, public display, public performance, distribution, 80 | dissemination, communication, or importation, and to make material 81 | available to the public including in ways that members of the 82 | public may access the material from a place and at a time 83 | individually chosen by them. 84 | 85 | m. Sui Generis Database Rights means rights other than copyright 86 | resulting from Directive 96/9/EC of the European Parliament and of 87 | the Council of 11 March 1996 on the legal protection of databases, 88 | as amended and/or succeeded, as well as other essentially 89 | equivalent rights anywhere in the world. 90 | 91 | n. You means the individual or entity exercising the Licensed Rights 92 | under this Public License. Your has a corresponding meaning. 93 | 94 | 95 | Section 2 -- Scope. 96 | 97 | a. License grant. 98 | 99 | 1. Subject to the terms and conditions of this Public License, 100 | the Licensor hereby grants You a worldwide, royalty-free, 101 | non-sublicensable, non-exclusive, irrevocable license to 102 | exercise the Licensed Rights in the Licensed Material to: 103 | 104 | a. reproduce and Share the Licensed Material, in whole or 105 | in part, for NonCommercial purposes only; and 106 | 107 | b. produce, reproduce, and Share Adapted Material for 108 | NonCommercial purposes only. 109 | 110 | 2. Exceptions and Limitations. For the avoidance of doubt, where 111 | Exceptions and Limitations apply to Your use, this Public 112 | License does not apply, and You do not need to comply with 113 | its terms and conditions. 114 | 115 | 3. Term. The term of this Public License is specified in Section 116 | 6(a). 117 | 118 | 4. Media and formats; technical modifications allowed. The 119 | Licensor authorizes You to exercise the Licensed Rights in 120 | all media and formats whether now known or hereafter created, 121 | and to make technical modifications necessary to do so. The 122 | Licensor waives and/or agrees not to assert any right or 123 | authority to forbid You from making technical modifications 124 | necessary to exercise the Licensed Rights, including 125 | technical modifications necessary to circumvent Effective 126 | Technological Measures. For purposes of this Public License, 127 | simply making modifications authorized by this Section 2(a) 128 | (4) never produces Adapted Material. 129 | 130 | 5. Downstream recipients. 131 | 132 | a. Offer from the Licensor -- Licensed Material. Every 133 | recipient of the Licensed Material automatically 134 | receives an offer from the Licensor to exercise the 135 | Licensed Rights under the terms and conditions of this 136 | Public License. 137 | 138 | b. Additional offer from the Licensor -- Adapted Material. 139 | Every recipient of Adapted Material from You 140 | automatically receives an offer from the Licensor to 141 | exercise the Licensed Rights in the Adapted Material 142 | under the conditions of the Adapter's License You apply. 143 | 144 | c. No downstream restrictions. You may not offer or impose 145 | any additional or different terms or conditions on, or 146 | apply any Effective Technological Measures to, the 147 | Licensed Material if doing so restricts exercise of the 148 | Licensed Rights by any recipient of the Licensed 149 | Material. 150 | 151 | 6. No endorsement. Nothing in this Public License constitutes or 152 | may be construed as permission to assert or imply that You 153 | are, or that Your use of the Licensed Material is, connected 154 | with, or sponsored, endorsed, or granted official status by, 155 | the Licensor or others designated to receive attribution as 156 | provided in Section 3(a)(1)(A)(i). 157 | 158 | b. Other rights. 159 | 160 | 1. Moral rights, such as the right of integrity, are not 161 | licensed under this Public License, nor are publicity, 162 | privacy, and/or other similar personality rights; however, to 163 | the extent possible, the Licensor waives and/or agrees not to 164 | assert any such rights held by the Licensor to the limited 165 | extent necessary to allow You to exercise the Licensed 166 | Rights, but not otherwise. 167 | 168 | 2. Patent and trademark rights are not licensed under this 169 | Public License. 170 | 171 | 3. To the extent possible, the Licensor waives any right to 172 | collect royalties from You for the exercise of the Licensed 173 | Rights, whether directly or through a collecting society 174 | under any voluntary or waivable statutory or compulsory 175 | licensing scheme. In all other cases the Licensor expressly 176 | reserves any right to collect such royalties, including when 177 | the Licensed Material is used other than for NonCommercial 178 | purposes. 179 | 180 | 181 | Section 3 -- License Conditions. 182 | 183 | Your exercise of the Licensed Rights is expressly made subject to the 184 | following conditions. 185 | 186 | a. Attribution. 187 | 188 | 1. If You Share the Licensed Material (including in modified 189 | form), You must: 190 | 191 | a. retain the following if it is supplied by the Licensor 192 | with the Licensed Material: 193 | 194 | i. identification of the creator(s) of the Licensed 195 | Material and any others designated to receive 196 | attribution, in any reasonable manner requested by 197 | the Licensor (including by pseudonym if 198 | designated); 199 | 200 | ii. a copyright notice; 201 | 202 | iii. a notice that refers to this Public License; 203 | 204 | iv. a notice that refers to the disclaimer of 205 | warranties; 206 | 207 | v. a URI or hyperlink to the Licensed Material to the 208 | extent reasonably practicable; 209 | 210 | b. indicate if You modified the Licensed Material and 211 | retain an indication of any previous modifications; and 212 | 213 | c. indicate the Licensed Material is licensed under this 214 | Public License, and include the text of, or the URI or 215 | hyperlink to, this Public License. 216 | 217 | 2. You may satisfy the conditions in Section 3(a)(1) in any 218 | reasonable manner based on the medium, means, and context in 219 | which You Share the Licensed Material. For example, it may be 220 | reasonable to satisfy the conditions by providing a URI or 221 | hyperlink to a resource that includes the required 222 | information. 223 | 3. If requested by the Licensor, You must remove any of the 224 | information required by Section 3(a)(1)(A) to the extent 225 | reasonably practicable. 226 | 227 | b. ShareAlike. 228 | 229 | In addition to the conditions in Section 3(a), if You Share 230 | Adapted Material You produce, the following conditions also apply. 231 | 232 | 1. The Adapter's License You apply must be a Creative Commons 233 | license with the same License Elements, this version or 234 | later, or a BY-NC-SA Compatible License. 235 | 236 | 2. You must include the text of, or the URI or hyperlink to, the 237 | Adapter's License You apply. You may satisfy this condition 238 | in any reasonable manner based on the medium, means, and 239 | context in which You Share Adapted Material. 240 | 241 | 3. You may not offer or impose any additional or different terms 242 | or conditions on, or apply any Effective Technological 243 | Measures to, Adapted Material that restrict exercise of the 244 | rights granted under the Adapter's License You apply. 245 | 246 | 247 | Section 4 -- Sui Generis Database Rights. 248 | 249 | Where the Licensed Rights include Sui Generis Database Rights that 250 | apply to Your use of the Licensed Material: 251 | 252 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right 253 | to extract, reuse, reproduce, and Share all or a substantial 254 | portion of the contents of the database for NonCommercial purposes 255 | only; 256 | 257 | b. if You include all or a substantial portion of the database 258 | contents in a database in which You have Sui Generis Database 259 | Rights, then the database in which You have Sui Generis Database 260 | Rights (but not its individual contents) is Adapted Material, 261 | including for purposes of Section 3(b); and 262 | 263 | c. You must comply with the conditions in Section 3(a) if You Share 264 | all or a substantial portion of the contents of the database. 265 | 266 | For the avoidance of doubt, this Section 4 supplements and does not 267 | replace Your obligations under this Public License where the Licensed 268 | Rights include other Copyright and Similar Rights. 269 | 270 | 271 | Section 5 -- Disclaimer of Warranties and Limitation of Liability. 272 | 273 | a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE 274 | EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS 275 | AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF 276 | ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, 277 | IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, 278 | WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR 279 | PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, 280 | ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT 281 | KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT 282 | ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. 283 | 284 | b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE 285 | TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, 286 | NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, 287 | INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, 288 | COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR 289 | USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN 290 | ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR 291 | DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR 292 | IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. 293 | 294 | c. The disclaimer of warranties and limitation of liability provided 295 | above shall be interpreted in a manner that, to the extent 296 | possible, most closely approximates an absolute disclaimer and 297 | waiver of all liability. 298 | 299 | 300 | Section 6 -- Term and Termination. 301 | 302 | a. This Public License applies for the term of the Copyright and 303 | Similar Rights licensed here. However, if You fail to comply with 304 | this Public License, then Your rights under this Public License 305 | terminate automatically. 306 | 307 | b. Where Your right to use the Licensed Material has terminated under 308 | Section 6(a), it reinstates: 309 | 310 | 1. automatically as of the date the violation is cured, provided 311 | it is cured within 30 days of Your discovery of the 312 | violation; or 313 | 314 | 2. upon express reinstatement by the Licensor. 315 | 316 | For the avoidance of doubt, this Section 6(b) does not affect any 317 | right the Licensor may have to seek remedies for Your violations 318 | of this Public License. 319 | 320 | c. For the avoidance of doubt, the Licensor may also offer the 321 | Licensed Material under separate terms or conditions or stop 322 | distributing the Licensed Material at any time; however, doing so 323 | will not terminate this Public License. 324 | 325 | d. Sections 1, 5, 6, 7, and 8 survive termination of this Public 326 | License. 327 | 328 | 329 | Section 7 -- Other Terms and Conditions. 330 | 331 | a. The Licensor shall not be bound by any additional or different 332 | terms or conditions communicated by You unless expressly agreed. 333 | 334 | b. Any arrangements, understandings, or agreements regarding the 335 | Licensed Material not stated herein are separate from and 336 | independent of the terms and conditions of this Public License. 337 | 338 | 339 | Section 8 -- Interpretation. 340 | 341 | a. For the avoidance of doubt, this Public License does not, and 342 | shall not be interpreted to, reduce, limit, restrict, or impose 343 | conditions on any use of the Licensed Material that could lawfully 344 | be made without permission under this Public License. 345 | 346 | b. To the extent possible, if any provision of this Public License is 347 | deemed unenforceable, it shall be automatically reformed to the 348 | minimum extent necessary to make it enforceable. If the provision 349 | cannot be reformed, it shall be severed from this Public License 350 | without affecting the enforceability of the remaining terms and 351 | conditions. 352 | 353 | c. No term or condition of this Public License will be waived and no 354 | failure to comply consented to unless expressly agreed to by the 355 | Licensor. 356 | 357 | d. Nothing in this Public License constitutes or may be interpreted 358 | as a limitation upon, or waiver of, any privileges and immunities 359 | that apply to the Licensor or You, including from the legal 360 | processes of any jurisdiction or authority. 361 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=master&repo=140400673&machine=standardLinux32gb&location=WestEurope) 2 | 3 | # Introduction 4 | 5 | This workshop will explain how to use Testcontainers \([https://www.testcontainers.com](https://www.testcontainers.com)\) in your Java applications. 6 | 7 | We'll build a sample Spring Boot application, add tests to it, and explore Testcontainers for Java API you need to know to be effective with your tests. 8 | 9 | ## Table of contents 10 | 11 | * [Introduction](README.md) 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: Adding some tests](step-3-adding-some-tests.md) 15 | * [Step 4: Your first Testcontainers integration](step-4-your-first-testcontainers-integration.md) 16 | * [Step 5: Hello, r u 200 OK?](step-5-dude-r-u-200-ok.md) 17 | * [Step 6: Adding Redis](step-6-adding-redis.md) 18 | * [Step 7: Test the API](step-7-test-the-api.md) 19 | * [Step 8: Local Development Environment with Testcontainers](step-8-local-development-environment.md) 20 | 21 | Next steps which you can do in any order based on the interest: 22 | * [Edge cases](step-extra-edge-cases.md) 23 | * [Data initialization strategies](step-7.7-data-init-strategies.md) 24 | * [Migrating from Docker Compose](step-extra-migrating-from-docker-compose.md) 25 | * [Chaos Engineering](step-extra-chaos-engineering.md) 26 | * [Custom Modules](step-extra-custom-modules.md) 27 | 28 | 29 | -------------------------------------------------------------------------------- /SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Table of contents 2 | 3 | * [Introduction](README.md) 4 | * [Step 1: Getting Started](step-1-getting-started.md) 5 | * [Step 2: Exploring the app](step-2-exploring-the-app.md) 6 | * [Step 3: Adding some tests](step-3-adding-some-tests.md) 7 | * [Step 4: Your first Testcontainers integration](step-4-your-first-testcontainers-integration.md) 8 | * [Step 5: Hello, r u 200 OK?](step-5-dude-r-u-200-ok.md) 9 | * [Step 6: Adding Redis](step-6-adding-redis.md) 10 | * [Step 7: Test the API](step-7-test-the-api.md) 11 | * 12 | Extra steps for curious ones: 13 | * [Edge cases](step-extra-edge-cases.md) 14 | * [Data initialization strategies](step-7.7-data-init-strategies.md) 15 | * [Migrating from Docker Compose](step-extra-migrating-from-docker-compose.md) 16 | * [Chaos Engineering](step-extra-chaos-engineering.md) 17 | * [Custom Modules](step-extra-custom-modules.md) 18 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testcontainers/workshop/655f47facf8ee061a121e644ab8c32d2a00b4cb7/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /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 | # http://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 | # Maven Start Up Batch script 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # M2_HOME - location of maven2's installed home dir 31 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 32 | # e.g. to debug Maven itself, use 33 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 34 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 35 | # ---------------------------------------------------------------------------- 36 | 37 | if [ -z "$MAVEN_SKIP_RC" ] ; then 38 | 39 | if [ -f /usr/local/etc/mavenrc ] ; then 40 | . /usr/local/etc/mavenrc 41 | fi 42 | 43 | if [ -f /etc/mavenrc ] ; then 44 | . /etc/mavenrc 45 | fi 46 | 47 | if [ -f "$HOME/.mavenrc" ] ; then 48 | . "$HOME/.mavenrc" 49 | fi 50 | 51 | fi 52 | 53 | # OS specific support. $var _must_ be set to either true or false. 54 | cygwin=false; 55 | darwin=false; 56 | mingw=false 57 | case "`uname`" in 58 | CYGWIN*) cygwin=true ;; 59 | MINGW*) mingw=true;; 60 | Darwin*) darwin=true 61 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 62 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 63 | if [ -z "$JAVA_HOME" ]; then 64 | if [ -x "/usr/libexec/java_home" ]; then 65 | export JAVA_HOME="`/usr/libexec/java_home`" 66 | else 67 | export JAVA_HOME="/Library/Java/Home" 68 | fi 69 | fi 70 | ;; 71 | esac 72 | 73 | if [ -z "$JAVA_HOME" ] ; then 74 | if [ -r /etc/gentoo-release ] ; then 75 | JAVA_HOME=`java-config --jre-home` 76 | fi 77 | fi 78 | 79 | if [ -z "$M2_HOME" ] ; then 80 | ## resolve links - $0 may be a link to maven's home 81 | PRG="$0" 82 | 83 | # need this for relative symlinks 84 | while [ -h "$PRG" ] ; do 85 | ls=`ls -ld "$PRG"` 86 | link=`expr "$ls" : '.*-> \(.*\)$'` 87 | if expr "$link" : '/.*' > /dev/null; then 88 | PRG="$link" 89 | else 90 | PRG="`dirname "$PRG"`/$link" 91 | fi 92 | done 93 | 94 | saveddir=`pwd` 95 | 96 | M2_HOME=`dirname "$PRG"`/.. 97 | 98 | # make it fully qualified 99 | M2_HOME=`cd "$M2_HOME" && pwd` 100 | 101 | cd "$saveddir" 102 | # echo Using m2 at $M2_HOME 103 | fi 104 | 105 | # For Cygwin, ensure paths are in UNIX format before anything is touched 106 | if $cygwin ; then 107 | [ -n "$M2_HOME" ] && 108 | M2_HOME=`cygpath --unix "$M2_HOME"` 109 | [ -n "$JAVA_HOME" ] && 110 | JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 111 | [ -n "$CLASSPATH" ] && 112 | CLASSPATH=`cygpath --path --unix "$CLASSPATH"` 113 | fi 114 | 115 | # For Mingw, ensure paths are in UNIX format before anything is touched 116 | if $mingw ; then 117 | [ -n "$M2_HOME" ] && 118 | M2_HOME="`(cd "$M2_HOME"; pwd)`" 119 | [ -n "$JAVA_HOME" ] && 120 | JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" 121 | fi 122 | 123 | if [ -z "$JAVA_HOME" ]; then 124 | javaExecutable="`which javac`" 125 | if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then 126 | # readlink(1) is not available as standard on Solaris 10. 127 | readLink=`which readlink` 128 | if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then 129 | if $darwin ; then 130 | javaHome="`dirname \"$javaExecutable\"`" 131 | javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" 132 | else 133 | javaExecutable="`readlink -f \"$javaExecutable\"`" 134 | fi 135 | javaHome="`dirname \"$javaExecutable\"`" 136 | javaHome=`expr "$javaHome" : '\(.*\)/bin'` 137 | JAVA_HOME="$javaHome" 138 | export JAVA_HOME 139 | fi 140 | fi 141 | fi 142 | 143 | if [ -z "$JAVACMD" ] ; then 144 | if [ -n "$JAVA_HOME" ] ; then 145 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 146 | # IBM's JDK on AIX uses strange locations for the executables 147 | JAVACMD="$JAVA_HOME/jre/sh/java" 148 | else 149 | JAVACMD="$JAVA_HOME/bin/java" 150 | fi 151 | else 152 | JAVACMD="`\\unset -f command; \\command -v java`" 153 | fi 154 | fi 155 | 156 | if [ ! -x "$JAVACMD" ] ; then 157 | echo "Error: JAVA_HOME is not defined correctly." >&2 158 | echo " We cannot execute $JAVACMD" >&2 159 | exit 1 160 | fi 161 | 162 | if [ -z "$JAVA_HOME" ] ; then 163 | echo "Warning: JAVA_HOME environment variable is not set." 164 | fi 165 | 166 | CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher 167 | 168 | # traverses directory structure from process work directory to filesystem root 169 | # first directory with .mvn subdirectory is considered project base directory 170 | find_maven_basedir() { 171 | 172 | if [ -z "$1" ] 173 | then 174 | echo "Path not specified to find_maven_basedir" 175 | return 1 176 | fi 177 | 178 | basedir="$1" 179 | wdir="$1" 180 | while [ "$wdir" != '/' ] ; do 181 | if [ -d "$wdir"/.mvn ] ; then 182 | basedir=$wdir 183 | break 184 | fi 185 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 186 | if [ -d "${wdir}" ]; then 187 | wdir=`cd "$wdir/.."; pwd` 188 | fi 189 | # end of workaround 190 | done 191 | echo "${basedir}" 192 | } 193 | 194 | # concatenates all lines of a file 195 | concat_lines() { 196 | if [ -f "$1" ]; then 197 | echo "$(tr -s '\n' ' ' < "$1")" 198 | fi 199 | } 200 | 201 | BASE_DIR=`find_maven_basedir "$(pwd)"` 202 | if [ -z "$BASE_DIR" ]; then 203 | exit 1; 204 | fi 205 | 206 | ########################################################################################## 207 | # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 208 | # This allows using the maven wrapper in projects that prohibit checking in binary data. 209 | ########################################################################################## 210 | if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then 211 | if [ "$MVNW_VERBOSE" = true ]; then 212 | echo "Found .mvn/wrapper/maven-wrapper.jar" 213 | fi 214 | else 215 | if [ "$MVNW_VERBOSE" = true ]; then 216 | echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." 217 | fi 218 | if [ -n "$MVNW_REPOURL" ]; then 219 | jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 220 | else 221 | jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 222 | fi 223 | while IFS="=" read key value; do 224 | case "$key" in (wrapperUrl) jarUrl="$value"; break ;; 225 | esac 226 | done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" 227 | if [ "$MVNW_VERBOSE" = true ]; then 228 | echo "Downloading from: $jarUrl" 229 | fi 230 | wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" 231 | if $cygwin; then 232 | wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` 233 | fi 234 | 235 | if command -v wget > /dev/null; then 236 | if [ "$MVNW_VERBOSE" = true ]; then 237 | echo "Found wget ... using wget" 238 | fi 239 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 240 | wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 241 | else 242 | wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 243 | fi 244 | elif command -v curl > /dev/null; then 245 | if [ "$MVNW_VERBOSE" = true ]; then 246 | echo "Found curl ... using curl" 247 | fi 248 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 249 | curl -o "$wrapperJarPath" "$jarUrl" -f 250 | else 251 | curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f 252 | fi 253 | 254 | else 255 | if [ "$MVNW_VERBOSE" = true ]; then 256 | echo "Falling back to using Java to download" 257 | fi 258 | javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" 259 | # For Cygwin, switch paths to Windows format before running javac 260 | if $cygwin; then 261 | javaClass=`cygpath --path --windows "$javaClass"` 262 | fi 263 | if [ -e "$javaClass" ]; then 264 | if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then 265 | if [ "$MVNW_VERBOSE" = true ]; then 266 | echo " - Compiling MavenWrapperDownloader.java ..." 267 | fi 268 | # Compiling the Java class 269 | ("$JAVA_HOME/bin/javac" "$javaClass") 270 | fi 271 | if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then 272 | # Running the downloader 273 | if [ "$MVNW_VERBOSE" = true ]; then 274 | echo " - Running MavenWrapperDownloader.java ..." 275 | fi 276 | ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") 277 | fi 278 | fi 279 | fi 280 | fi 281 | ########################################################################################## 282 | # End of extension 283 | ########################################################################################## 284 | 285 | export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} 286 | if [ "$MVNW_VERBOSE" = true ]; then 287 | echo $MAVEN_PROJECTBASEDIR 288 | fi 289 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 290 | 291 | # For Cygwin, switch paths to Windows format before running java 292 | if $cygwin; then 293 | [ -n "$M2_HOME" ] && 294 | M2_HOME=`cygpath --path --windows "$M2_HOME"` 295 | [ -n "$JAVA_HOME" ] && 296 | JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` 297 | [ -n "$CLASSPATH" ] && 298 | CLASSPATH=`cygpath --path --windows "$CLASSPATH"` 299 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 300 | MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` 301 | fi 302 | 303 | # Provide a "standardized" way to retrieve the CLI args that will 304 | # work with both Windows and non-Windows executions. 305 | MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" 306 | export MAVEN_CMD_LINE_ARGS 307 | 308 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 309 | 310 | exec "$JAVACMD" \ 311 | $MAVEN_OPTS \ 312 | $MAVEN_DEBUG_OPTS \ 313 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 314 | "-Dmaven.home=${M2_HOME}" \ 315 | "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 316 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 317 | -------------------------------------------------------------------------------- /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 http://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 Maven Start Up Batch script 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 M2_HOME - location of maven2's installed home dir 28 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 29 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 30 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | @REM e.g. to debug Maven itself, use 32 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | @REM ---------------------------------------------------------------------------- 35 | 36 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 37 | @echo off 38 | @REM set title of command window 39 | title %0 40 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 41 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 42 | 43 | @REM set %HOME% to equivalent of $HOME 44 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 45 | 46 | @REM Execute a user defined script before this one 47 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 48 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 49 | if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* 50 | if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* 51 | :skipRcPre 52 | 53 | @setlocal 54 | 55 | set ERROR_CODE=0 56 | 57 | @REM To isolate internal variables from possible post scripts, we use another setlocal 58 | @setlocal 59 | 60 | @REM ==== START VALIDATION ==== 61 | if not "%JAVA_HOME%" == "" goto OkJHome 62 | 63 | echo. 64 | echo Error: JAVA_HOME not found in your environment. >&2 65 | echo Please set the JAVA_HOME variable in your environment to match the >&2 66 | echo location of your Java installation. >&2 67 | echo. 68 | goto error 69 | 70 | :OkJHome 71 | if exist "%JAVA_HOME%\bin\java.exe" goto init 72 | 73 | echo. 74 | echo Error: JAVA_HOME is set to an invalid directory. >&2 75 | echo JAVA_HOME = "%JAVA_HOME%" >&2 76 | echo Please set the JAVA_HOME variable in your environment to match the >&2 77 | echo location of your Java installation. >&2 78 | echo. 79 | goto error 80 | 81 | @REM ==== END VALIDATION ==== 82 | 83 | :init 84 | 85 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 86 | @REM Fallback to current working directory if not found. 87 | 88 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 89 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 90 | 91 | set EXEC_DIR=%CD% 92 | set WDIR=%EXEC_DIR% 93 | :findBaseDir 94 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 95 | cd .. 96 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 97 | set WDIR=%CD% 98 | goto findBaseDir 99 | 100 | :baseDirFound 101 | set MAVEN_PROJECTBASEDIR=%WDIR% 102 | cd "%EXEC_DIR%" 103 | goto endDetectBaseDir 104 | 105 | :baseDirNotFound 106 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 107 | cd "%EXEC_DIR%" 108 | 109 | :endDetectBaseDir 110 | 111 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 112 | 113 | @setlocal EnableExtensions EnableDelayedExpansion 114 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 115 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 116 | 117 | :endReadAdditionalConfig 118 | 119 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 120 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 121 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 122 | 123 | set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 124 | 125 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 126 | IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B 127 | ) 128 | 129 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 130 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 131 | if exist %WRAPPER_JAR% ( 132 | if "%MVNW_VERBOSE%" == "true" ( 133 | echo Found %WRAPPER_JAR% 134 | ) 135 | ) else ( 136 | if not "%MVNW_REPOURL%" == "" ( 137 | SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 138 | ) 139 | if "%MVNW_VERBOSE%" == "true" ( 140 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 141 | echo Downloading from: %DOWNLOAD_URL% 142 | ) 143 | 144 | powershell -Command "&{"^ 145 | "$webclient = new-object System.Net.WebClient;"^ 146 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 147 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 148 | "}"^ 149 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ 150 | "}" 151 | if "%MVNW_VERBOSE%" == "true" ( 152 | echo Finished downloading %WRAPPER_JAR% 153 | ) 154 | ) 155 | @REM End of extension 156 | 157 | @REM Provide a "standardized" way to retrieve the CLI args that will 158 | @REM work with both Windows and non-Windows executions. 159 | set MAVEN_CMD_LINE_ARGS=%* 160 | 161 | %MAVEN_JAVA_EXE% ^ 162 | %JVM_CONFIG_MAVEN_PROPS% ^ 163 | %MAVEN_OPTS% ^ 164 | %MAVEN_DEBUG_OPTS% ^ 165 | -classpath %WRAPPER_JAR% ^ 166 | "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ 167 | %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 168 | if ERRORLEVEL 1 goto error 169 | goto end 170 | 171 | :error 172 | set ERROR_CODE=1 173 | 174 | :end 175 | @endlocal & set ERROR_CODE=%ERROR_CODE% 176 | 177 | if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost 178 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 179 | if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" 180 | if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" 181 | :skipRcPost 182 | 183 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 184 | if "%MAVEN_BATCH_PAUSE%"=="on" pause 185 | 186 | if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% 187 | 188 | cmd /C exit /B %ERROR_CODE% 189 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 3.1.4 9 | 10 | 11 | com.example 12 | demo 13 | 0.0.1-SNAPSHOT 14 | demo 15 | Demo project for Spring Boot 16 | 17 | 18 | 17 19 | 20 | 1.19.1 21 | 22 | 23 | 24 | 25 | org.springframework.boot 26 | spring-boot-starter-actuator 27 | 28 | 29 | org.springframework.boot 30 | spring-boot-starter-jdbc 31 | 32 | 33 | org.springframework.boot 34 | spring-boot-starter-data-redis 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-web 39 | 40 | 41 | org.springframework.kafka 42 | spring-kafka 43 | 44 | 45 | 46 | org.postgresql 47 | postgresql 48 | runtime 49 | 50 | 51 | com.h2database 52 | h2 53 | 54 | 55 | org.springframework.boot 56 | spring-boot-starter-test 57 | test 58 | 59 | 60 | org.springframework.boot 61 | spring-boot-testcontainers 62 | 63 | 64 | 65 | org.testcontainers 66 | kafka 67 | test 68 | 69 | 70 | org.testcontainers 71 | postgresql 72 | test 73 | 74 | 75 | org.testcontainers 76 | junit-jupiter 77 | test 78 | 79 | 80 | 81 | io.rest-assured 82 | rest-assured 83 | test 84 | 85 | 86 | 87 | org.awaitility 88 | awaitility 89 | test 90 | 91 | 92 | 93 | 94 | 95 | 96 | org.springframework.boot 97 | spring-boot-maven-plugin 98 | 99 | 100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /src/main/java/com/example/demo/DemoApplication.java: -------------------------------------------------------------------------------- 1 | package com.example.demo; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class DemoApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(DemoApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/example/demo/api/RatingsController.java: -------------------------------------------------------------------------------- 1 | package com.example.demo.api; 2 | 3 | import com.example.demo.model.Rating; 4 | import com.example.demo.repository.RatingsRepository; 5 | import com.example.demo.repository.TalksRepository; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.kafka.core.KafkaTemplate; 8 | import org.springframework.web.bind.annotation.*; 9 | 10 | import java.util.Map; 11 | 12 | @RestController 13 | @RequestMapping("/ratings") 14 | public class RatingsController { 15 | 16 | private final KafkaTemplate kafkaTemplate; 17 | 18 | private final RatingsRepository ratingsRepository; 19 | 20 | private final TalksRepository talksRepository; 21 | 22 | public RatingsController(KafkaTemplate kafkaTemplate, RatingsRepository ratingsRepository, TalksRepository talksRepository) { 23 | this.kafkaTemplate = kafkaTemplate; 24 | this.ratingsRepository = ratingsRepository; 25 | this.talksRepository = talksRepository; 26 | } 27 | 28 | @PostMapping 29 | public ResponseEntity recordRating(@RequestBody Rating rating) throws Exception { 30 | if (!talksRepository.exists(rating.getTalkId())) { 31 | return ResponseEntity.notFound().build(); 32 | } 33 | 34 | kafkaTemplate.send("ratings", rating).get(); 35 | return ResponseEntity.accepted().build(); 36 | } 37 | 38 | @GetMapping 39 | public Map getRatings(@RequestParam String talkId) { 40 | return ratingsRepository.findAll(talkId); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/example/demo/model/Rating.java: -------------------------------------------------------------------------------- 1 | package com.example.demo.model; 2 | 3 | public class Rating { 4 | 5 | String talkId; 6 | 7 | int value; 8 | 9 | public Rating() { 10 | } 11 | 12 | public Rating(String talkId, int value) { 13 | this.talkId = talkId; 14 | this.value = value; 15 | } 16 | 17 | public String getTalkId() { 18 | return talkId; 19 | } 20 | 21 | public int getValue() { 22 | return value; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/example/demo/repository/RatingsRepository.java: -------------------------------------------------------------------------------- 1 | package com.example.demo.repository; 2 | 3 | import org.springframework.data.redis.core.StringRedisTemplate; 4 | import org.springframework.stereotype.Repository; 5 | 6 | import java.util.Map; 7 | import java.util.stream.Collectors; 8 | 9 | @Repository 10 | public class RatingsRepository { 11 | 12 | final StringRedisTemplate redisTemplate; 13 | 14 | public RatingsRepository(StringRedisTemplate redisTemplate) { 15 | this.redisTemplate = redisTemplate; 16 | } 17 | 18 | public Map findAll(String talkId) { 19 | return redisTemplate.opsForHash() 20 | .entries(toKey(talkId)) 21 | .entrySet() 22 | .stream() 23 | .collect(Collectors.toMap( 24 | it -> Integer.valueOf((String) it.getKey()), 25 | it -> Integer.valueOf((String) it.getValue()) 26 | )); 27 | } 28 | 29 | public void add(String talkId, int value) { 30 | redisTemplate.opsForHash() 31 | .increment(toKey(talkId), value + "", 1); 32 | } 33 | 34 | protected String toKey(String talkId) { 35 | return "ratings/" + talkId; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/com/example/demo/repository/TalksRepository.java: -------------------------------------------------------------------------------- 1 | package com.example.demo.repository; 2 | 3 | import org.springframework.jdbc.core.JdbcTemplate; 4 | import org.springframework.stereotype.Repository; 5 | 6 | import java.util.List; 7 | 8 | @Repository 9 | public class TalksRepository { 10 | 11 | private final JdbcTemplate jdbcTemplate; 12 | 13 | public TalksRepository(JdbcTemplate jdbcTemplate) { 14 | this.jdbcTemplate = jdbcTemplate; 15 | } 16 | 17 | public Boolean exists(String talkId) { 18 | List results = jdbcTemplate.query( 19 | "SELECT 1 FROM talks WHERE id = ?", 20 | (row, i) -> true, 21 | talkId 22 | ); 23 | return !results.isEmpty(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/example/demo/streams/RatingsListener.java: -------------------------------------------------------------------------------- 1 | package com.example.demo.streams; 2 | 3 | import com.example.demo.model.Rating; 4 | import com.example.demo.repository.RatingsRepository; 5 | import org.springframework.kafka.annotation.KafkaListener; 6 | import org.springframework.messaging.handler.annotation.Payload; 7 | import org.springframework.stereotype.Component; 8 | 9 | @Component 10 | public class RatingsListener { 11 | 12 | private final RatingsRepository ratingsRepository; 13 | 14 | public RatingsListener(RatingsRepository ratingsRepository) { 15 | this.ratingsRepository = ratingsRepository; 16 | } 17 | 18 | @KafkaListener(groupId = "ratings", topics = "ratings") 19 | public void handle(@Payload Rating rating) { 20 | System.out.println("Received rating: " + rating); 21 | 22 | ratingsRepository.add(rating.getTalkId(), rating.getValue()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/example/demo/view/RatingsViewController.java: -------------------------------------------------------------------------------- 1 | package com.example.demo.view; 2 | 3 | import com.example.demo.repository.RatingsRepository; 4 | import com.example.demo.repository.TalksRepository; 5 | import org.springframework.stereotype.Controller; 6 | import org.springframework.web.bind.annotation.GetMapping; 7 | import org.springframework.web.bind.annotation.RequestMapping; 8 | 9 | @Controller 10 | @RequestMapping("/view/ratings") 11 | public class RatingsViewController { 12 | 13 | private final TalksRepository talksRepository; 14 | 15 | private final RatingsRepository ratingsRepository; 16 | 17 | public RatingsViewController(TalksRepository talksRepository, RatingsRepository ratingsRepository) { 18 | this.talksRepository = talksRepository; 19 | this.ratingsRepository = ratingsRepository; 20 | } 21 | 22 | @GetMapping("/") 23 | String index() { 24 | return "index"; 25 | } 26 | 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | management.endpoint.health.show-details: always 2 | 3 | spring: 4 | kafka: 5 | listener: 6 | missingTopicsFatal: false 7 | consumer: 8 | properties.spring.json.trusted.packages: com.example.demo.model 9 | auto-offset-reset: earliest 10 | value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer 11 | producer: 12 | value-serializer: org.springframework.kafka.support.serializer.JsonSerializer 13 | 14 | output: 15 | ansi: 16 | enabled: always 17 | sql: 18 | init: 19 | mode: always -------------------------------------------------------------------------------- /src/main/resources/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 |

Foobar

9 | 10 | -------------------------------------------------------------------------------- /src/test/java/com/example/demo/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testcontainers/workshop/655f47facf8ee061a121e644ab8c32d2a00b4cb7/src/test/java/com/example/demo/.keep -------------------------------------------------------------------------------- /src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /step-1-getting-started.md: -------------------------------------------------------------------------------- 1 | # Step 1: Getting Started 2 | 3 | ## Check Java 4 | You'll need Java 17 or newer for this workshop. 5 | Testcontainers libraries are compatible with Java 8+, but this workshop uses a Spring Boot 3.x application which requires Java 17 or newer. 6 | 7 | ## Install Testcontainers Desktop 8 | Testcontainers Desktop is a free tool that can help you during the local development and debugging of your tests. 9 | You can download the app from [https://testcontainers.com/desktop/](https://testcontainers.com/desktop/). 10 | 11 | ## Check Docker 12 | 13 | To use Testcontainers, you need to have a supported Docker environment. 14 | If you have the Testcontainers Desktop application installed, it will show you the available container runtimes on your machine. 15 | 16 | * You can choose any of the installed container runtimes like **Docker Desktop**, **OrbStack**, **Rancher Desktop**, etc. 17 | * It can be [Testcontainers Cloud](https://testcontainers.com/cloud) recommended to avoid straining the conference network by pulling heavy Docker images. 18 | * If you don't have any container runtimes installed on your machine, you can also use the **Embedded Runtime** provided by Testcontainers Desktop. 19 | To use the Embedded Runtime, you need to add the property `embedded.runtime.enabled=true` to your **$HOME/.testcontainers.properties** file. 20 | 21 | You can check the Docker availability by running: 22 | ```text 23 | $ docker version 24 | 25 | Client: 26 | Cloud integration: v1.0.22 27 | Version: 20.10.11 28 | API version: 1.41 29 | Go version: go1.16.10 30 | Git commit: dea9396 31 | Built: Thu Nov 18 00:42:51 2021 32 | OS/Arch: windows/amd64 33 | Context: default 34 | Server: Docker Engine - Community 35 | Engine: 36 | Version: 20.10.11 37 | API version: 1.41 (minimum version 1.12) 38 | Go version: go1.16.9 39 | Git commit: 847da18 40 | Built: Thu Nov 18 00:35:39 2021 41 | OS/Arch: linux/amd64 42 | Experimental: false 43 | ... 44 | ``` 45 | 46 | ## Download the project 47 | 48 | Clone the following project from GitHub to your computer: 49 | [https://github.com/testcontainers/workshop](https://github.com/testcontainers/workshop) 50 | 51 | ## Build the project to download the dependencies 52 | 53 | With Maven: 54 | ```text 55 | ./mvnw verify 56 | ``` 57 | 58 | ## \(optionally\) Pull the required images before doing the workshop 59 | 60 | This might be helpful if the internet connection at the workshop venue is somewhat slow. 61 | 62 | ```text 63 | docker pull postgres:16-alpine 64 | docker pull redis:7-alpine 65 | docker pull openjdk:8-jre-alpine 66 | docker pull confluentinc/cp-kafka:7.5.0 67 | ``` 68 | 69 | ### 70 | [Next](step-2-exploring-the-app.md) 71 | 72 | 73 | -------------------------------------------------------------------------------- /step-2-exploring-the-app.md: -------------------------------------------------------------------------------- 1 | # Step 2: Exploring the app 2 | 3 | The app is a simple microservice based on Spring Boot for rating conference talks. It provides an API to track the ratings of the talks in real time. 4 | 5 | ## Storage 6 | 7 | ### SQL database with the talks 8 | 9 | When a rating is submitted, we must verify that the talk for the given ID is present in our database. 10 | 11 | Our database of choice is PostgreSQL, accessed with Spring JDBC. 12 | 13 | Check `com.example.demo.repository.TalksRepository`. 14 | 15 | ### Redis 16 | 17 | We store the ratings in Redis database with Spring Data Redis. 18 | 19 | Check `com.example.demo.repository.RatingsRepository`. 20 | 21 | ### Kafka 22 | 23 | We use ES/CQRS to materialize the events into the state. Kafka acts as a broker and we use Spring Kafka. 24 | 25 | Check `com.example.demo.streams.RatingsListener`. 26 | 27 | ## API 28 | 29 | The API is a Spring Web REST controller \(`com.example.demo.api.RatingsController`\) and exposes two endpoints: 30 | 31 | * `POST /ratings { "talkId": ?, "value": 1-5 }` to add a rating for a talk 32 | * `GET /ratings?talkId=?` to get the histogram of ratings of the given talk 33 | 34 | ### 35 | [Next](step-3-adding-some-tests.md) -------------------------------------------------------------------------------- /step-3-adding-some-tests.md: -------------------------------------------------------------------------------- 1 | # Step 3: Adding some tests 2 | 3 | The app doesn't have any tests yet. 4 | But before we write our first test, let's create an abstract test class for the things which are common between the tests. 5 | 6 | ## Abstract class 7 | 8 | Add `com.example.demo.support.AbstractIntegrationTest` class to `src/test/java` sourceset. 9 | It should be an abstract class with standard Spring Boot's testing framework annotations on it: 10 | 11 | ```java 12 | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) 13 | ``` 14 | 15 | ## Our very first test 16 | 17 | Now we need to test that the context starts. 18 | Add `com.example.demo.DemoApplicationTest`, extend it from your base class `AbstractIntegrationTest` and add a dummy test: 19 | 20 | ```java 21 | @Test 22 | void contextLoads() { 23 | } 24 | ``` 25 | 26 | Run it and verify that the application starts and the test passes. 27 | Spring will detect H2 on the classpath and use it as an embedded DB. 28 | 29 | This is already a useful smoke test since it ensures, that Spring Boot is able to initialize the application context successfully. 30 | 31 | ## Populate the database 32 | 33 | The context starts. 34 | However, we need to populate the database with some data before we can write the tests. 35 | 36 | Let's add a `src/test/resources/schema.sql` file with the following content: 37 | 38 | ```sql 39 | CREATE TABLE IF NOT EXISTS talks( 40 | id VARCHAR(64) NOT NULL, 41 | title VARCHAR(255) NOT NULL, 42 | PRIMARY KEY (id) 43 | ); 44 | 45 | INSERT 46 | INTO talks (id, title) 47 | VALUES ('testcontainers-integration-testing', 'Modern Integration Testing with Testcontainers') 48 | ON CONFLICT do nothing; 49 | 50 | INSERT 51 | INTO talks (id, title) 52 | VALUES ('flight-of-the-flux', 'A look at Reactor execution model') 53 | ON CONFLICT do nothing; 54 | ``` 55 | 56 | Now run the test again. Oh no, it fails! 57 | 58 | ```text 59 | ... 60 | Caused by: org.h2.jdbc.JdbcSQLException: Syntax error in SQL statement "INSERT INTO TALKS (ID, TITLE) VALUES ('testcontainers-integration-testing', 'Modern Integration Testing with Testcontainers') ON[*] CONFLICT DO NOTHING"; 61 | ... 62 | ``` 63 | 64 | It seems that H2 does not support the PostgreSQL SQL syntax, at least not by default. 65 | 66 | ### 67 | [Next](step-4-your-first-testcontainers-integration.md) -------------------------------------------------------------------------------- /step-4-your-first-testcontainers-integration.md: -------------------------------------------------------------------------------- 1 | # Step 4: Your first Testcontainers integration 2 | 3 | From the Testcontainers website, we learn that there is a simple way of running different supported JDBC databases with Docker: 4 | [https://www.testcontainers.org/usage/database\_containers.html](https://www.testcontainers.org/usage/database_containers.html) 5 | 6 | An especially interesting part are JDBC-URL based containers: 7 | [https://www.testcontainers.org/usage/database\_containers.html\#jdbc-url](https://www.testcontainers.org/usage/database_containers.html#jdbc-url) 8 | 9 | It means that starting to use Testcontainers in our project \(once we add a dependency\) is as simple as changing a few properties in Spring Boot: 10 | 11 | ```java 12 | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = { 13 | "spring.datasource.url=jdbc:tc:postgresql:16-alpine://testcontainers/workshop" 14 | }) 15 | ``` 16 | 17 | If we split the magical JDBC url, we see: 18 | 19 | * `jdbc:tc:` - this part says that we should use Testcontainers as JDBC provider 20 | * `postgresql:16-alpine://` - we use a PostgreSQL database, and we select the correct PostgreSQL image from the Docker Hub as the image 21 | * `testcontainers/workshop` - the host name \(can be anything\) is `testcontainers` and the database name is `workshop`. Your choice! 22 | 23 | After adding the properties and run the test again. Fixed? Good! 24 | 25 | Check the logs. 26 | 27 | ```text 28 | 13:30:59.352 INFO --- [ Test worker] o.t.d.DockerClientProviderStrategy : Found Docker environment with local Npipe socket (npipe:////./pipe/docker_engine) 29 | 13:30:59.369 INFO --- [ Test worker] org.testcontainers.DockerClientFactory : Docker host IP address is localhost 30 | 13:30:59.431 INFO --- [ Test worker] org.testcontainers.DockerClientFactory : Connected to docker: 31 | Server Version: 20.10.11 32 | API Version: 1.41 33 | Operating System: Docker Desktop 34 | Total Memory: 3929 MB 35 | 13:31:03.294 INFO --- [ Test worker] org.testcontainers.DockerClientFactory : Ryuk started - will monitor and terminate Testcontainers containers on JVM exit 36 | 13:31:03.295 INFO --- [ Test worker] org.testcontainers.DockerClientFactory : Checking the system... 37 | 13:31:03.296 INFO --- [ Test worker] org.testcontainers.DockerClientFactory : ✔ Docker server version should be at least 1.6.0 38 | 13:31:03.588 INFO --- [ Test worker] org.testcontainers.DockerClientFactory : ✔ Docker environment should have more than 2GB free disk space 39 | ``` 40 | 41 | As you can see, Testcontainers quickly discovered your environment and connected to Docker. 42 | It did some pre-flight checks as well to ensure that you have a valid environment. 43 | 44 | ## Hint 1: 45 | 46 | Add the following line to your `~/.testcontainers.properties` file to disable these checks and speed up the tests: 47 | 48 | ```text 49 | checks.disable=true 50 | ``` 51 | 52 | ## Hint 2: 53 | 54 | Changing the PostgreSQL version is as simple as replacing `16-alpine` with, for example, `15-alpine`. 55 | Try it, but don't forget that it will download the new image from the internet, if it's not already present on your computer. 56 | 57 | ### 58 | [Next](step-5-dude-r-u-200-ok.md) -------------------------------------------------------------------------------- /step-5-dude-r-u-200-ok.md: -------------------------------------------------------------------------------- 1 | # Step 5: Hello, r u 200 OK? 2 | 3 | One of the great features of Spring Boot is the Actuator and its health endpoint. 4 | It gives you an overview how healthy your app is. 5 | 6 | The context starts, but what's about the health of the app? 7 | 8 | ## Configure Rest Assured 9 | 10 | To check the health endpoint of our app, we will use the [RestAssured](http://rest-assured.io/) library. 11 | 12 | However, before using it, we first need to configure it. 13 | Add the following to your abstract test class since we will share it between all tests: 14 | 15 | ```java 16 | protected RequestSpecification requestSpecification; 17 | 18 | @LocalServerPort 19 | protected int localServerPort; 20 | 21 | @BeforeEach 22 | void setUpAbstractIntegrationTest() { 23 | RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); 24 | requestSpecification = new RequestSpecBuilder() 25 | .setPort(localServerPort) 26 | .addHeader( 27 | HttpHeaders.CONTENT_TYPE, 28 | MediaType.APPLICATION_JSON_VALUE 29 | ) 30 | .build(); 31 | } 32 | ``` 33 | 34 | Here we ask Spring Boot to inject the random port it received at the start of the app, so that we can pre-configure RestAssured's requestSpecification. 35 | 36 | ## Call the endpoint 37 | 38 | Now let's check if the app is actually healthy by doing the following in our `DemoApplicationTest`: 39 | 40 | ```java 41 | @Test 42 | void healthy() { 43 | given(requestSpecification) 44 | .when() 45 | .get("/actuator/health") 46 | .then() 47 | .statusCode(200) 48 | .log().ifValidationFails(LogDetail.ALL); 49 | } 50 | ``` 51 | 52 | If we run the test, it will fail: 53 | 54 | ```text 55 | ... 56 | HTTP/1.1 503 Service Unavailable 57 | transfer-encoding: chunked 58 | Content-Type: application/vnd.spring-boot.actuator.v2+json;charset=UTF-8 59 | 60 | { 61 | "status": "DOWN", 62 | "details": { 63 | "diskSpace": { ... }, 64 | "redis": { 65 | "status": "DOWN", 66 | "details": { 67 | "error": "org.springframework.data.redis.RedisConnectionFailureException: Unable to connect to Redis; nested exception is io.lettuce.core.RedisConnectionException: Unable to connect to localhost:6379" 68 | } 69 | }, 70 | "db": { 71 | "status": "UP", 72 | "details": { 73 | "database": "PostgreSQL", 74 | "hello": 1 75 | } 76 | } 77 | } 78 | } 79 | ... 80 | Expected status code <200> but was <503>. 81 | ``` 82 | 83 | It seems that it couldn't find Redis and there is no autoconfigurable option for it. 84 | 85 | ### 86 | [Next](step-6-adding-redis.md) -------------------------------------------------------------------------------- /step-6-adding-redis.md: -------------------------------------------------------------------------------- 1 | # Step 6: Adding Redis 2 | 3 | The simplest way to provide a Redis instance for your tests is to use `GenericContainer` with a Redis Docker image: [https://www.testcontainers.org/usage/generic\_containers.html](https://www.testcontainers.org/usage/generic_containers.html) 4 | The integration between the tests code and Testcontainers is straightforward. 5 | 6 | ## Rules? No thanks! 7 | 8 | Testcontainers comes with first class support for JUnit, but in our app we want to have a single Redis instance shared between **all** tests. 9 | Luckily, there are the `.start()`/`.stop()` methods of `GenericContainer` to start or stop it manually. 10 | 11 | Just add the following code to your `AbstractIntegrationTest` with the following code: 12 | 13 | ```java 14 | static final GenericContainer redis = new GenericContainer("redis:7-alpine") 15 | .withExposedPorts(6379); 16 | 17 | @DynamicPropertySource 18 | public static void configureRedis(DynamicPropertyRegistry registry) { 19 | redis.start(); 20 | registry.add("spring.data.redis.host", redis::getHost); 21 | registry.add("spring.data.redis.port", redis::getFirstMappedPort); 22 | } 23 | ``` 24 | 25 | Simple and beautiful, huh? 26 | 27 | Run the tests, now they should all pass. 28 | 29 | ### 30 | [Next](step-7-test-the-api.md) -------------------------------------------------------------------------------- /step-7-test-the-api.md: -------------------------------------------------------------------------------- 1 | # Step 7: Test the API 2 | 3 | Now let's create a test for our API which will verify the business logic. 4 | 5 | ```java 6 | package com.example.demo.api; 7 | 8 | import com.example.demo.model.Rating; 9 | import com.example.demo.support.AbstractIntegrationTest; 10 | import org.junit.jupiter.api.Test; 11 | 12 | import static io.restassured.RestAssured.given; 13 | import static org.awaitility.Awaitility.await; 14 | import static org.hamcrest.Matchers.is; 15 | 16 | public class RatingsControllerTest extends AbstractIntegrationTest { 17 | 18 | @Test 19 | public void testRatings() { 20 | String talkId = "testcontainers-integration-testing"; 21 | 22 | given(requestSpecification) 23 | .body(new Rating(talkId, 5)) 24 | .when() 25 | .post("/ratings") 26 | .then() 27 | .statusCode(202); 28 | 29 | await().untilAsserted(() -> { 30 | given(requestSpecification) 31 | .queryParam("talkId", talkId) 32 | .when() 33 | .get("/ratings") 34 | .then() 35 | .body("5", is(1)); 36 | }); 37 | 38 | for (int i = 1; i <= 5; i++) { 39 | given(requestSpecification) 40 | .body(new Rating(talkId, i)) 41 | .when() 42 | .post("/ratings"); 43 | } 44 | 45 | await().untilAsserted(() -> { 46 | given(requestSpecification) 47 | .queryParam("talkId", talkId) 48 | .when() 49 | .get("/ratings") 50 | .then() 51 | .body("1", is(1)) 52 | .body("2", is(1)) 53 | .body("3", is(1)) 54 | .body("4", is(1)) 55 | .body("5", is(2)); 56 | }); 57 | } 58 | 59 | @Test 60 | public void testUnknownTalk() { 61 | String talkId = "cdi-the-great-parts"; 62 | 63 | given(requestSpecification) 64 | .body(new Rating(talkId, 5)) 65 | .when() 66 | .post("/ratings") 67 | .then() 68 | .statusCode(404); 69 | } 70 | } 71 | ``` 72 | 73 | Run it, and it will fail. 74 | 75 | Why? 76 | 77 | There is no Kafka! 78 | 79 | Running Kafka in Docker is easy with Testcontainers. 80 | There is a Testcontainers module providing integration with Kafka and the `KafkaContainer` abstraction for your code. 81 | 82 | Just add it the same way as you added Redis and set the `spring.kafka.bootstrap-servers` system property. 83 | 84 | ## Hint 1: 85 | 86 | Some containers expose helper methods. Check if there is one on `KafkaContainer` which might help you. 87 | 88 | ## Hint 2: 89 | 90 | You can start several containers in parallel by doing: 91 | 92 | ```java 93 | Stream.of(redis, kafka).parallel().forEach(GenericContainer::start); 94 | ``` 95 | 96 | ### 97 | [Next](step-7.7-data-init-strategies.md) 98 | -------------------------------------------------------------------------------- /step-7.7-data-init-strategies.md: -------------------------------------------------------------------------------- 1 | # Step 7.7: Data initialization strategies 2 | 3 | Initializing data using Spring is neat, but sometimes you might need alternative solutions. 4 | 5 | In this step we're going to turn off the Spring's database initialization, and explore how we can initialize the database using container specific configuration, followed by switching to using the [Flyway](https://flywaydb.org/) migrations. 6 | 7 | ## Assert the data is really there 8 | 9 | To make the task run or fail faster, add a testcase to `DemoApplicationTest` which checks that the data from `schema.sql` is loaded into the database properly. 10 | For that you can `@Autowire` the `TalksRepository` into the test class and use it to verify that a talk with a given ID can be found in the database. 11 | 12 | ```java 13 | Assertions.assertTrue(talksRepository.exists("testcontainers-integration-testing")); 14 | ``` 15 | 16 | ## Running PostgreSQL explicitly 17 | 18 | First of all, we'll remove the Testcontainers "modified JDBC URL" approach and create an explicit PostgreSQL container object to simplify further configuration. 19 | 20 | In the `AbstractIntegratonTest` please remove `properties` from `@SpringBootTest(...)` altogether. 21 | 22 | Then you can instantiate a PostgreSQL container using: 23 | 24 | ```java 25 | static final PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine"); 26 | ``` 27 | 28 | Additionally, we need to start `postgres` container similar to the other service dependencies and configure our application to use that containerized database which can be done by setting the following properties in the `@DynamicPropertySource` annotated method: 29 | 30 | ```text 31 | spring.datasource.url 32 | spring.datasource.username 33 | spring.datasource.password 34 | ``` 35 | 36 | Use the values provided by the `postgres` object to fill the required configuration. 37 | 38 | Running the test added in the beginning of this Step should pass now. 39 | 40 | ## Initialize the DB without Spring 41 | 42 | It might happen that loading `schema.sql` is not enough to fully initialize the database. 43 | We are going to simulate that by circumventing the Spring's convention. Please rename `schema.sql` to `talks-schema.sql`. 44 | The test should fail now since the schema isn't initialized in the database container and without it the app cannot function properly. 45 | 46 | Make the database initialization work again (and the test pass) by initializing the DB directly in the container. 47 | 48 | ### Hint 49 | Most database containers have functionality to initialize the Database from the script files provided in the container. 50 | The PostgreSQL container happens to run all SQL files from `/docker-entrypoint-initdb.d/` directory, as described in the _Initialization scripts_ chapter of the [Postgres container](https://hub.docker.com/_/postgres/) docs. 51 | 52 | Configure the `postgres` object using the `withCopyToContainer` method and `MountableFile.forClasspathResource(String path)` to configure the database schema. 53 | After you initialize the DB correctly, the test should work again (despite _not_ having the `schema.sql` file). 54 | 55 | ## Migrating the DB with Flyway 56 | 57 | Next, we're going to remove the data initialization queries from the `talks-schema.sql` file and use [Flyway](https://flywaydb.org/) for populating the DB with actual data. 58 | Liquibase or other database migration tools would work similarly. 59 | 60 | Please add the Flyway dependency in `build.gradle`: 61 | 62 | ```text 63 | implementation 'org.flywaydb:flyway-core' 64 | ``` 65 | 66 | or `pom.xml`: 67 | ```text 68 | 69 | org.flywaydb 70 | flyway-core 71 | 72 | ``` 73 | 74 | Next, move all the `INSERT ...` statements from the `talks-schema.sql` to `src/main/resources/db/migration/V1_1__talks.sql` file. 75 | 76 | Note that the migrations file is not on the **test** classpath, as Flyway is likely to be used for production schema management as well. 77 | 78 | For Flyway not to complain that it can't store its data in the DB, we need to configure it to create its missing database management tables and data. 79 | 80 | This can be done in `application.yml` with: 81 | 82 | ```yaml 83 | flyway: 84 | baseline-on-migrate: true 85 | ``` 86 | 87 | Note that `spring.flyway.locations=classpath:db/migration` is the default location for the migration files used by Flyway so we don't need to configure that explicitly. 88 | For more details on Spring Boot and Flyway integration please refer to [Spring manual](https://docs.spring.io/spring-boot/docs/2.6.7/reference/htmlsingle/#howto.data-initialization.migration-tool.flyway). 89 | 90 | The test verifying that the data is correctly initialized in the Database should pass after we configure Flyway to run the migrations correctly. 91 | 92 | ### 93 | [Next](step-8-local-development-environment.md) 94 | -------------------------------------------------------------------------------- /step-8-local-development-environment.md: -------------------------------------------------------------------------------- 1 | # Step 8: Local Development environment with Testcontainers 2 | 3 | Testcontainers is essential for creating ephemeral environments for testing your applications. 4 | However, nothing in the API is specific to those conditions, and you can use it to programmatically manage any environment. 5 | 6 | One of the more common scenarios is creating an environment for your application when you run it locally. 7 | Some application frameworks integrate with Testcontainers to provide this functionality out of the box: 8 | 9 | * Quarkus has [Dev Services](https://quarkus.io/guides/dev-services). 10 | * Micronaut has [Test Resources](https://micronaut-projects.github.io/micronaut-test-resources/latest/guide/). 11 | * Spring Boot [can be configured too](https://docs.spring.io/spring-boot/docs/3.1.0/reference/html/features.html#features.testing.testcontainers.at-development-time). 12 | 13 | In this chapter we'll configure our Spring Boot application to use Testcontainers at Development Time. 14 | 15 | ## Extracting environment to a Configuration 16 | 17 | Verify your applications has the `org.springframework.boot:spring-boot-testcontainers` dependency. 18 | 19 | This application requires configuring Postgres, Kafka, and Redis instances to run locally. 20 | Our tests already have all the code to instantiate these services, configure them, and the application to use them. 21 | However, for easier time running the application locally, we should refactor this code to be a part of the application initialization lifecycle. 22 | 23 | We'll use a [TestConfiguration](https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/test/context/TestConfiguration.html) to encapsulate the environment creation. 24 | 25 | Create a class `ContainerConfig` in the Test classpath that will implement the `TestConfiguration`: 26 | 27 | ```java 28 | @TestConfiguration(proxyBeanMethods = false) 29 | public class ContainerConfig { 30 | @Bean 31 | @ServiceConnection(name = "redis") 32 | public GenericContainer redis() { 33 | return new GenericContainer<>("redis:7-alpine") 34 | .withExposedPorts(6379); 35 | } 36 | } 37 | ``` 38 | 39 | The example above contains a `@Bean` definition for a Redis container. 40 | Create similar `@Bean` definitions for `PostgresContainer` and `KafkaContainer`; you can copy the container configuration from the test classes. 41 | 42 | Spring Boot 3.1 has integration with Testcontainers so containers exposed as `Bean` will be started by the framework automatically. 43 | The `ServiceConnection` helps Spring Boot to configure itself to use the services (similar to how the `@DynamicPropertySource` method before). 44 | 45 | Now we'll use this configuration class to create the environment for our tests and running application locally. 46 | 47 | ## Running tests with the Context Initializer approach 48 | 49 | Remove the container configration from the `AbstractIntegrationTest` class, and remove the `@DynamicPropertySource` method. 50 | 51 | Instruct the test to use the configuration where containers are initialized as beans, by adding the `classes` property to the `SpringBootTest`: 52 | 53 | ```java 54 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, 55 | properties = { 56 | }, classes = {ContainerConfig.class}) 57 | ``` 58 | 59 | Now you can check whether the tests still pass normally. 60 | 61 | ## Running your application with the Context Initializer approach 62 | 63 | The `ContainerConfig` class is on the **test** classpath and unavailable to the application classpath. 64 | This is by design because we don't want to make Testcontainers and other testing libraries available in the application classpath. 65 | 66 | So to use the `ContainerConfig`, we need to create a separate entry point for running the application. 67 | Create a `TestDemoApplication` class, that uses the actual `DemoApplication` and also uses the `ContainerConfig` we implemented above: 68 | 69 | ```java 70 | public class TestApplication { 71 | public static void main(String[] args) { 72 | SpringApplication 73 | .from(DemoApplication::main) 74 | .with(ContainerConfig.class) 75 | .run(args); 76 | } 77 | } 78 | ``` 79 | 80 | Running this class will run the `DemoApplication`, and you should see from the logs that the application is started successfully. 81 | Stopping the application will remove the containers just like Testcontainers cleans up the environment after running the tests. 82 | 83 | ## Hint 1: 84 | 85 | You can detach the lifecycle of the containers from the lifecycle of the Spring context by using Dev tools. 86 | Add the `spring-boot-devtools` dependency. 87 | Annotate beans and bean methods with `@RestartScope`. 88 | 89 | When reloading the project changes with devtools you can see that the containers are not restarted. 90 | 91 | ## Connecting to the Database 92 | Once the application is running, you might want to connect to the database to inspect the data in the database. 93 | By default, Testcontainers starts the containers and map it to a random available port on the host. 94 | So, you need to find out the mapped port to connect to the database. 95 | 96 | Instead, we can use Testcontainers Desktop fixed ports support to connect to the database. 97 | 98 | Open Testcontainers Desktop, and select the `Services` -> `Open config location`. 99 | It will open a directory with the example configuration files for commonly used services. 100 | 101 | Copy the `postgres.toml.example` to `postgres.toml`, and update it's content to the following: 102 | 103 | ```toml 104 | ports = [ 105 | {local-port = 5432, container-port = 5432}, 106 | ] 107 | 108 | selector.image-names = ["postgres"] 109 | ``` 110 | 111 | This configuration will map Postgres container port 5432 to the host port 5432. 112 | Now, when you run the application, you can connect to the database using the following connection details: 113 | 114 | ``` 115 | host: localhost 116 | port: 5432 117 | username: test 118 | password: test 119 | database: test 120 | ``` 121 | 122 | ## Reusable Containers 123 | During the development of the application, you might want to stop and start the application multiple times. 124 | Instead of creating the containers from scratch every time, you can use the `reuse` feature of Testcontainers. 125 | 126 | * Enable the `reuse` feature in Testcontainers Desktop by enabling **Preferences** -> **Enable reusable containers**. 127 | * Update the Containers configuration to use the `reuse` feature with `.withReuse(true)`: 128 | 129 | ```java 130 | @Bean 131 | @ServiceConnection 132 | public PostgreSQLContainer postgres() { 133 | return new PostgreSQLContainer<>("postgres:16-alpine").withReuse(true); 134 | } 135 | ``` 136 | 137 | Now, when you restart the application, you can see that the container is reused from the previous run. 138 | BY enabling the `reuse` feature, Testcontainers won't remove those reusable containers automatically 139 | when the application is stopped or test execution is done. 140 | 141 | If you no longer need the container, you can remove it from the Testcontainers Desktop -> **Terminate Containers**. 142 | 143 | ## Freezing containers to prevent their shutdown to debug 144 | When you run Testcontainers tests, the containers are automatically stopped and removed after the test execution is done. 145 | This is a great feature to keep the environment clean and prevent resource leaks. 146 | But, sometimes you might want to debug the test and inspect the data in the database or the messages in the Kafka topic. 147 | 148 | You can use the Testcontainers Desktop **Freeze containers shutdown** feature 149 | that will prevent the container shutdown allowing you to debug the issue. 150 | -------------------------------------------------------------------------------- /step-extra-chaos-engineering.md: -------------------------------------------------------------------------------- 1 | # Step 11: Using Testcontainers for Chaos Engineering 2 | 3 | So far we have tested our system under very expected conditions. 4 | But in reality, we know that things can go wrong. 5 | The network can be slow, the database can be unavailable, and so on. 6 | 7 | In this step, we will use Testcontainers to simulate such conditions and see how our system behaves. 8 | 9 | For this, we will use the [Toxiproxy](https://www.testcontainers.org/modules/toxiproxy/) module. 10 | 11 | Check out the documentation and write a test that checks the following test scenario: 12 | 1. Initially, PostgreSQL is available, and we can record a rating. 13 | 2. We then simulate a network outage between our application and PostgreSQL using Toxiproxy, and we expect the endpoint to return an error. 14 | 3. We then simulate a network recovery, and we expect the endpoint to return a success. 15 | 16 | ## Hint 17 | 18 | You need to add the Toxiproxy module to your project's dependencies. 19 | -------------------------------------------------------------------------------- /step-extra-custom-modules.md: -------------------------------------------------------------------------------- 1 | # Step 12: Custom Modules 2 | 3 | Testcontainers provides a range of extension points to write your own modules 4 | and you are not limited to the modules that are provided out of the box. 5 | Such a container module can be used to encapsulate the logic of starting a container for a specific service, 6 | and configuring it accordingly. 7 | 8 | Writing a custom container module is normally done by creating a new class that extends `GenericContainer`. 9 | 10 | And although using the `GenericContainer` for Redis is perfectly fine, we want to get a feeling for writing a custom module, 11 | by writing a `RedisContainer` that extends `GenericContainer` and has the corresponding configuration applied. 12 | 13 | What about a helper method, returning a Redis URI? 14 | ``` 15 | redis :// [[username :] password@] host [:port][/database] 16 | [?[timeout=timeout[d|h|m|s|ms|us|ns]] 17 | ``` 18 | 19 | We also want to make sure, a user can't use the container accidentally with a wrong Docker image. 20 | For this, you can make use of the `dockerImage.assertCompatibleWith(compatibleImageName)` method. 21 | 22 | ## Bonus 23 | 24 | A container module can also be an abstraction for multiple containers. 25 | Write a `ToxicPostgreSQLContainer` that starts a PostgreSQL container and a Toxiproxy container, 26 | providing more convenient methods for cutting the connection or introducing other failures. -------------------------------------------------------------------------------- /step-extra-edge-cases.md: -------------------------------------------------------------------------------- 1 | # Step 8.8: Using Testcontainers without frameworks' support 2 | 3 | Redis has its own limits. 4 | Are there any limits of Hash's increment? 5 | Let's figure out! 6 | 7 | ## `RatingsRepositoryTest` 8 | 9 | We're going to create an isolated test for the Redis-based repository and verify our edge cases. 10 | 11 | ```java 12 | package com.example.demo.repository; 13 | 14 | import org.junit.jupiter.api.Test; 15 | 16 | import static org.assertj.core.api.Assertions.assertThat; 17 | 18 | public class RatingsRepositoryTest { 19 | 20 | final String talkId = "testcontainers"; 21 | 22 | RatingsRepository repository; 23 | 24 | @Test 25 | public void testEmptyIfNoKey() { 26 | assertThat(repository.findAll(talkId)).isEmpty(); 27 | } 28 | 29 | @Test 30 | public void testLimits() { 31 | repository.redisTemplate.opsForHash() 32 | .put(repository.toKey(talkId), "5", Long.MAX_VALUE + ""); 33 | 34 | repository.add(talkId, 5); 35 | } 36 | } 37 | ``` 38 | 39 | But since we're not using Spring Context here, we need to create an instance of our repository ourselves: 40 | 41 | ```java 42 | @BeforeEach 43 | public void setUp() { 44 | LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory( 45 | ?, 46 | ? 47 | ); 48 | connectionFactory.afterPropertiesSet(); 49 | repository = new RatingsRepository(new StringRedisTemplate(connectionFactory)); 50 | } 51 | ``` 52 | 53 | The only missing part is `LettuceConnectionFactory`'s arguments, Redis' host and port. 54 | 55 | We will use Testcontainers' JUnit Jupiter extension for starting Redis: 56 | 57 | ```java 58 | @Container 59 | public GenericContainer redis = new GenericContainer("redis:3-alpine") 60 | .withExposedPorts(6379); 61 | ``` 62 | 63 | And add the `@Testcontainers` annotation to the class: 64 | ```java 65 | @Testcontainers 66 | public class RatingsRepositoryTest { 67 | ``` 68 | And the code for initializing the connection factory: 69 | ```java 70 | LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory( 71 | redis.getHost(), 72 | redis.getFirstMappedPort() 73 | ); 74 | ``` 75 | 76 | The test should fail with a somewhat cryptic error: 77 | ```text 78 | Error in execution; nested exception is io.lettuce.core.RedisCommandExecutionException: ERR increment or decrement would overflow 79 | ``` 80 | And there's nothing on your side to fix, the test is pushing the boundaries of Redis. 81 | But on the bright side we learned how to use Testcontainers outside of the Spring Framework. 82 | We also saw how we can utilize to learn about the limitations and behavior of extra components. 83 | 84 | Delete the test before anyone notices. 85 | Just kidding, let's turn this into a useful test by asserting we throw a custom exception `MaxRatingsAddedException`, 86 | which indicates that our repository recorded the maximum amount of ratings. 87 | In the future we can still make a different decision of how our business logic should deal with this (and if this is even an edge-case worth solving), 88 | but with this test, we consciously documented our knowledge of the limitations of the systems we integrate against. 89 | ```java 90 | @Test 91 | public void testLimits() { 92 | repository.redisTemplate.opsForHash() 93 | .put(repository.toKey(talkId), "5", Long.MAX_VALUE + ""); 94 | 95 | Assertions.assertThrows(MaxRatingsAddedException.class, () -> repository.add(talkId, 5)); 96 | } 97 | ``` 98 | 99 | The final exercise is now to adapt the implementation of `RatingsRepository.add()` accordingly, to make the test pass. 100 | 101 | 102 | -------------------------------------------------------------------------------- /step-extra-migrating-from-docker-compose.md: -------------------------------------------------------------------------------- 1 | # Step 10: Migrating from Docker Compose 2 | 3 | We don't always encounter green field projects. 4 | Maybe you are already invested some time in using Docker Compose to spin up your test environment and are wondering how to get started from here? 5 | 6 | Let's look into how Testcontainers can support you on this journey. 7 | 8 | ## `Dockerfile` and `docker-compose.yml` 9 | 10 | Let's assume we did start out with running our application as a Docker container as well, 11 | using the following, pretty standard, Dockerfile: 12 | 13 | ```Dockerfile 14 | FROM openjdk:8-jre-alpine 15 | RUN addgroup -S spring && adduser -S spring -G spring 16 | USER spring:spring 17 | ARG JAR_FILE=build/libs/*.jar 18 | COPY ${JAR_FILE} app.jar 19 | ENTRYPOINT ["java","-jar","/app.jar"] 20 | ``` 21 | 22 | We also need to make sure the Spring-Boot jar has been built: 23 | 24 | ```bash 25 | ./gradlew bootJar 26 | ``` 27 | 28 | Finally, we have a Docker Compose file, that automatically builds the app image and spins it up, together with all dependencies: 29 | 30 | ```yaml 31 | version: "2.4" 32 | services: 33 | app: 34 | build: . 35 | environment: 36 | SPRING_REDIS_HOST: "redis" 37 | SPRING_REDIS_PORT: "6379" 38 | SPRING_KAFKA_BOOTSTRAP_SERVERS: "PLAINTEXT://kafka:9093" 39 | SPRING_DATASOURCE_URL: "jdbc:postgresql://db:5432/workshop" 40 | SPRING_DATASOURCE_USERNAME: "postgres" 41 | SPRING_DATASOURCE_PASSWORD: "example" 42 | ports: 43 | - "8080:8080" 44 | db: 45 | image: "postgres:16-alpine" 46 | environment: 47 | POSTGRES_PASSWORD: example 48 | POSTGRES_DB: workshop 49 | volumes: 50 | - "./src/main/resources/db/migration/V1_1__talks.sql:/docker-entrypoint-initdb.d/schema.sql" 51 | redis: 52 | image: "redis:7-alpine" 53 | kafka: 54 | image: "confluentinc/cp-kafka:7.5.0" 55 | environment: 56 | KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181' 57 | KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9093 58 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "1" 59 | KAFKA_OFFSETS_TOPIC_NUM_PARTITIONS: "1" 60 | zookeeper: 61 | image: confluentinc/cp-zookeeper:7.5.0 62 | environment: 63 | ZOOKEEPER_CLIENT_PORT: 2181 64 | ZOOKEEPER_TICK_TIME: 2000 65 | 66 | ``` 67 | 68 | We have a traditional JUnit Jupiter test `DockerComposeApplicationTest`, which assumes the application is running at `localhost:8080`: 69 | 70 | ```java 71 | package com.example.demo; 72 | 73 | import com.example.demo.model.Rating; 74 | import io.restassured.RestAssured; 75 | import io.restassured.builder.RequestSpecBuilder; 76 | import io.restassured.filter.log.LogDetail; 77 | import io.restassured.specification.RequestSpecification; 78 | import org.junit.jupiter.api.BeforeEach; 79 | import org.junit.jupiter.api.Test; 80 | import org.springframework.http.HttpHeaders; 81 | import org.springframework.http.MediaType; 82 | 83 | import static io.restassured.RestAssured.given; 84 | import static org.awaitility.Awaitility.await; 85 | import static org.hamcrest.Matchers.is; 86 | 87 | public class DockerComposeApplicationTest { 88 | 89 | protected RequestSpecification requestSpecification; 90 | 91 | @BeforeEach 92 | public void setUpAbstractIntegrationTest() { 93 | RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); 94 | requestSpecification = new RequestSpecBuilder() 95 | .setPort(8080) 96 | .addHeader( 97 | HttpHeaders.CONTENT_TYPE, 98 | MediaType.APPLICATION_JSON_VALUE 99 | ) 100 | .build(); 101 | } 102 | 103 | @Test 104 | public void healthy() { 105 | given(requestSpecification) 106 | .when() 107 | .get("/actuator/health") 108 | .then() 109 | .statusCode(200) 110 | .log().ifValidationFails(LogDetail.ALL); 111 | } 112 | 113 | @Test 114 | public void testRatings() { 115 | String talkId = "testcontainers-integration-testing"; 116 | 117 | given(requestSpecification) 118 | .body(new Rating(talkId, 5)) 119 | .when() 120 | .post("/ratings") 121 | .then() 122 | .statusCode(202); 123 | 124 | await().untilAsserted(() -> { 125 | given(requestSpecification) 126 | .queryParam("talkId", talkId) 127 | .when() 128 | .get("/ratings") 129 | .then() 130 | .body("5", is(1)); 131 | }); 132 | 133 | for (int i = 1; i <= 5; i++) { 134 | given(requestSpecification) 135 | .body(new Rating(talkId, i)) 136 | .when() 137 | .post("/ratings"); 138 | } 139 | 140 | await().untilAsserted(() -> { 141 | given(requestSpecification) 142 | .queryParam("talkId", talkId) 143 | .when() 144 | .get("/ratings") 145 | .then() 146 | .body("1", is(1)) 147 | .body("2", is(1)) 148 | .body("3", is(1)) 149 | .body("4", is(1)) 150 | .body("5", is(2)); 151 | }); 152 | } 153 | 154 | } 155 | ``` 156 | 157 | To run this rest, make sure the Docker Compose setup is running: 158 | ```bash 159 | docker compose up 160 | ``` 161 | 162 | You can run the tests directly from the IDE. 163 | 164 | Afterwards, you can stop the Docker Compose services again: 165 | ```bash 166 | docker compose down -v 167 | ``` 168 | 169 | ## Migrating to `DockerComposeContainer` 170 | 171 | In order to tightly integrate the lifecycle of our test environment with the lifecycle of our tests, 172 | we can already integrate Testcontainers and still make use of our existing `docker-compose.yml`: 173 | 174 | ```java 175 | @Container 176 | static DockerComposeContainer composeContainer = new DockerComposeContainer(new File("docker-compose.yml")) 177 | .withLocalCompose(true) 178 | .withExposedService("app_1", 8080) 179 | .waitingFor("app_1", Wait.forHttp("/actuator/health")); 180 | ``` 181 | 182 | You also need to add the `@Testcontainers` annotation to the test class, if you want the [Testcontainers-JUnit-Jupiter extension](https://www.testcontainers.org/test_framework_integration/junit_5/) 183 | to manage the container lifecycle (similar to how we did in step 8). 184 | 185 | Finally, make sure to configure RestAssured to access the dynamic port exposed by Testcontainers: 186 | 187 | ```java 188 | requestSpecification = new RequestSpecBuilder() 189 | .setBaseUri(String.format("http://%s:%d", composeContainer.getServiceHost("app_1", 8080), composeContainer.getServicePort("app_1", 8080))) 190 | .addHeader( 191 | HttpHeaders.CONTENT_TYPE, 192 | MediaType.APPLICATION_JSON_VALUE 193 | ) 194 | .build(); 195 | ``` 196 | 197 | Run the test from the IDE, it works! 198 | Note how you don't need to run `docker compose` before the test, or manually clean up the environment after. 199 | 200 | ## Migrating to individual Testcontainers objects 201 | 202 | Instead of defining the necessary services in the `docker-compose.yml` file, we will now declare them as Java objects. 203 | Furthermore, we make use of the Docker networking feature, so that we can hardcode connection URLs and leverage the 204 | Docker DNS features. 205 | 206 | ```java 207 | static Network network = Network.newNetwork(); 208 | 209 | @Container 210 | static final GenericContainer redis = new GenericContainer("redis:7-alpine") 211 | .withExposedPorts(6379) 212 | .withNetwork(network) 213 | .withNetworkAliases("redis"); 214 | 215 | @Container 216 | static final KafkaContainer kafka = new KafkaContainer ( 217 | DockerImageName.parse("confluentinc/cp-kafka:7.5.0")) 218 | .withNetwork(network) 219 | .withNetworkAliases("kafka"); 220 | 221 | 222 | @Container 223 | static final PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") 224 | .withCopyFileToContainer(MountableFile.forClasspathResource("/talks-schema.sql"), "/docker-entrypoint-initdb.d/") 225 | .withNetwork(network) 226 | .withNetworkAliases("db"); 227 | ``` 228 | 229 | Testcontainers also allows to build images as part of the test execution and run the corresponding container. 230 | We will use this for our Spring-Boot application: 231 | 232 | ```java 233 | @Container 234 | static final GenericContainer appContainer = new GenericContainer<>( 235 | new ImageFromDockerfile() 236 | .withFileFromPath("Dockerfile", Paths.get("Dockerfile")) 237 | .withFileFromPath("build/libs/workshop.jar", Paths.get("build/libs/workshop.jar")) 238 | ) 239 | .withExposedPorts(8080) 240 | .withEnv("SPRING_REDIS_HOST", "redis") 241 | .withEnv("SPRING_REDIS_PORT", "6379") 242 | .withEnv("SPRING_KAFKA_BOOTSTRAP_SERVERS", "BROKER://kafka:9092") 243 | .withEnv("SPRING_DATASOURCE_URL", "jdbc:postgresql://db:5432/test") 244 | .withEnv("SPRING_DATASOURCE_USERNAME", "test") 245 | .withEnv("SPRING_DATASOURCE_PASSWORD", "test") 246 | .withNetwork(network) 247 | .waitingFor(Wait.forHttp("/actuator/health")) 248 | .dependsOn(redis, kafka, postgres); 249 | ``` 250 | 251 | Notice that we can also use the `dependsOn()` method, to control the startup order of our containers. 252 | As compared to the `dependsOn` config in Docker Compose, this will fully utilize Testcontainers' `WaitStrategy` support, 253 | to ensure the applications in the container are in a ready-to-use state. 254 | 255 | Don't forget to configure RestAssured accordingly to use the `appContainer` details: 256 | 257 | ```java 258 | requestSpecification = new RequestSpecBuilder() 259 | .setBaseUri(String.format("http://%s:%d", appContainer.getHost(), appContainer.getFirstMappedPort())) 260 | .addHeader( 261 | HttpHeaders.CONTENT_TYPE, 262 | MediaType.APPLICATION_JSON_VALUE 263 | ) 264 | .build(); 265 | ``` 266 | 267 | Now let's run the test again. 268 | 269 | ## Moving back to `@SpringBootTest` 270 | 271 | From this point, is just a small step to move our setup back to a `@SpringBootTest`. 272 | But why would we want to do this? 273 | Using `@SpringBootTest` bring a couple quality-of-life improvements for us as developers, such as faster feedback cycles 274 | (we don't have to rebuild the whole application and the image) or much easier debugging of the Java process. 275 | 276 | So let's make our test a `@SpringBootTest` again, by annotating it: 277 | 278 | ```java 279 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 280 | ``` 281 | 282 | We will also use the random local port: 283 | 284 | ```java 285 | @LocalServerPort 286 | protected int localServerPort; 287 | ``` 288 | 289 | And now we can use the `@DynamicPropertySource` method to comfortably configure the Spring-Boot application to use the containerized service dependencies. 290 | 291 | ```java 292 | @DynamicPropertySource 293 | public static void configureRedis(DynamicPropertyRegistry registry){ 294 | Stream.of(redis,kafka,postgres).parallel().forEach(GenericContainer::start); 295 | registry.add("spring.redis.host",redis::getHost); 296 | registry.add("spring.redis.port",redis::getFirstMappedPort); 297 | registry.add("spring.kafka.bootstrap-servers",kafka::getBootstrapServers); 298 | registry.add("spring.datasource.url",postgres::getJdbcUrl); 299 | registry.add("spring.datasource.username",postgres::getUsername); 300 | registry.add("spring.datasource.password",postgres::getPassword); 301 | } 302 | ``` 303 | 304 | Note that our test now looks very similar to the tests that we created when following the best practices of using Testcontainers from scratch. 305 | -------------------------------------------------------------------------------- /step-extra-tooling-in-containers.md: -------------------------------------------------------------------------------- 1 | # Step 13: Tooling in containers 2 | 3 | Testcontainers gives you an API to manage applications in containers. 4 | It can be databases, message brokers, or anything else your application needs to run, but you can also run additional tools that you as a developer want to have access to during development and testing. 5 | 6 | For example, you might want to run a tool to connect to the database, or a Kafka cluster monitoring console, and so on. 7 | Running these tools together with the containers and wiring them together programmatically is an easy way to ensure everyone on your team can use the same setup reliably. 8 | 9 | In this chapter, we'll write a sample test spinning up a Kubernetes cluster, and wire it together with the [k9s console](https://k9scli.io/). 10 | 11 | # Setup 12 | 13 | We'll need a K8s module for Testcontainers implemnetation, and a library to interact with the K8s cluster from our Java code. 14 | 15 | Include the following dependencies into your project: 16 | 17 | ``` 18 | 19 | com.dajudge.kindcontainer 20 | kindcontainer 21 | 1.4.1 22 | test 23 | 24 | 25 | io.fabric8 26 | kubernetes-client 27 | 6.4.1 28 | 29 | ``` 30 | 31 | Since we're not actually making our Spring Boot application work within the K8s cluster, we can completely detach the source files. 32 | Create a new `K8sTest.java` class file in the test sources with the boilerplate setup we'll need. 33 | 34 | The `createDeployment` method you see below will take a configured `KubernetesClient` and deploy 2 replicas of Nginx webserver. 35 | 36 | ```java 37 | public class K8sTest { 38 | static final String NAME = "testcontainers"; 39 | static Network network = Network.newNetwork(); 40 | 41 | private static void createDeployment(KubernetesClient client, Map selectors) { 42 | Deployment d = new DeploymentBuilder() 43 | .withNewMetadata() 44 | .withName(NAME) 45 | .withLabels(selectors) 46 | .endMetadata() 47 | .withSpec(new DeploymentSpecBuilder() 48 | .withReplicas(2) 49 | .withTemplate(new PodTemplateSpecBuilder() 50 | .withNewMetadata() 51 | .withLabels(selectors) 52 | .endMetadata() 53 | .withNewSpec() 54 | .addNewContainer() 55 | .withName("nginx") 56 | .withImage("nginx:1.23.1") 57 | .addNewPort().withContainerPort(80).endPort() 58 | .endContainer() 59 | .endSpec() 60 | .build()) 61 | .withNewSelector() 62 | .withMatchLabels(selectors) 63 | .endSelector() 64 | .build()) 65 | .build(); 66 | 67 | client.apps().deployments().inNamespace(NAME).create(d); 68 | } 69 | } 70 | ``` 71 | 72 | # Spinning up a K8s cluster 73 | Now let's spin up a K8s cluster, create a normal JUnit 5 `@Test` method, and start a `K3sContainer` in it: 74 | 75 | ```java 76 | @Test 77 | public void myTest() throws IOException { 78 | K3sContainer k8s = new K3sContainer<>(); 79 | 80 | k8s.withNetwork(network); 81 | k8s.withNetworkAliases("k3s"); 82 | 83 | k8s.start(); 84 | 85 | // obtain a kubeconfig file which allows us to connect to k3s 86 | String kubeConfigYaml = k8s.getKubeconfig(); 87 | 88 | // use the config and deploy nginx containers. 89 | Config config = Config.fromKubeconfig(kubeConfigYaml); 90 | KubernetesClient client = new DefaultKubernetesClient(config); 91 | Namespace ns = new NamespaceBuilder().withNewMetadata().withName(NAME) 92 | .endMetadata().build(); 93 | client.namespaces().create(ns); 94 | var selectors = Map.of("app", NAME); 95 | createDeployment(client, selectors); 96 | } 97 | ``` 98 | 99 | Now in the `kubeConfigYaml` variable we have the yaml configuration that allows us to connect to this cluster. 100 | And the rest of the code deploys the containers into our clusters. 101 | 102 | The test doesn't actually verify anything right now, so to observe the k8s cluster working you can set a breakpoint on the last line of the test, or stop the execution otherwise: 103 | 104 | ``` 105 | // Don't do this in your tests! 106 | System.in.read(); 107 | ``` 108 | 109 | The `getKubeconfig()` method produces the config to connect from the host machine. 110 | We'll need to edit it to allow other tools running in the same Docker environment to connect using the correct host/ports. 111 | 112 | ``` 113 | String inDockerConfig = kubeConfigYaml.replaceAll("127\\.0\\.0\\.1", "k3s"); 114 | inDockerConfig = inDockerConfig.replaceAll(Integer.toString(k8s.getFirstMappedPort(), "6443"), 115 | ``` 116 | 117 | # Adding the k9s console 118 | 119 | Let's add a `GenericContainer` with the K9s running. Here's the example config you can use: 120 | 121 | ```java 122 | final String command = "#!/bin/sh\n" 123 | + "set -ex \n" 124 | + "wget https://github.com/tsl0922/ttyd/releases/download/1.7.3/ttyd.i686 \n" 125 | + "chmod u+x ttyd.i686 \n" 126 | + "./ttyd.i686 -W k9s \n"; 127 | 128 | GenericContainer k9s = new GenericContainer("derailed/k9s:latest") { 129 | @Override 130 | protected void containerIsStarting(InspectContainerResponse containerInfo) { 131 | super.containerIsStarting(containerInfo); 132 | this.copyFileToContainer(Transferable.of(command, 0777), "/testcontainers_start.sh"); 133 | } 134 | } 135 | .withNetwork(network) 136 | .withExposedPorts(7681) 137 | .withCopyToContainer(Transferable.of(inDockerConfig), "/root/.kube/config") 138 | .withStartupTimeout(Duration.of(20, ChronoUnit.SECONDS)) 139 | .waitingFor(Wait.forLogMessage(".*Listening on port:.*\\n", 1)) 140 | 141 | .withCommand(new String[]{"-c", "while [ ! -f /testcontainers_start.sh ]; do sleep 0.1; done; /testcontainers_start.sh"}); 142 | 143 | k9s.withCreateContainerCmdModifier((cmd) -> { 144 | cmd.withEntrypoint(new String[]{"sh"}); 145 | }); 146 | k9s.start(); 147 | System.out.println("http://localhost:" + k9s.getFirstMappedPort() + "/"); 148 | ``` 149 | 150 | Note, we use the `GenericContainer("derailed/k9s:latest")` for the container, put it on the same `Network`, and configure it to have shell as the enrtypoint. 151 | Additionally we use [ttyd](https://github.com/tsl0922/ttyd) to expose the K9s console as a web application. 152 | 153 | If you run the test now, the output will contain a link to the k9s in the browser. Click on it and explore your Kubernetes cluster. 154 | --------------------------------------------------------------------------------