├── .github └── workflows │ └── gradle.yml ├── .gitignore ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle ├── src ├── integration │ └── java │ │ └── com │ │ └── demo │ │ ├── AppRestAssuredIT.java │ │ └── AppTestRestTemplateIT.java ├── main │ ├── java │ │ └── com │ │ │ └── demo │ │ │ ├── SpringTestApplication.java │ │ │ ├── config │ │ │ └── SwaggerConfig.java │ │ │ ├── controllers │ │ │ └── VehicleController.java │ │ │ ├── dto │ │ │ └── Vehicle.java │ │ │ ├── exceptions │ │ │ ├── ApiErrorMessage.java │ │ │ ├── VehicleExceptionHandler.java │ │ │ └── VehicleNotFoundException.java │ │ │ ├── repository │ │ │ └── VehicleRepository.java │ │ │ └── services │ │ │ ├── VehicleService.java │ │ │ └── VehicleServiceImpl.java │ └── resources │ │ ├── application-integration.properties │ │ ├── application-test.properties │ │ ├── application.properties │ │ └── data-h2.sql └── test │ └── java │ └── com │ └── demo │ └── DemoWebLayerTest.java └── structure.PNG /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | name: Java CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Set up JDK 1.8 13 | uses: actions/setup-java@v1 14 | with: 15 | java-version: 1.8 16 | - name: Build with Gradle 17 | run: ./gradlew build 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/** 6 | !**/src/test/** 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | 17 | ### IntelliJ IDEA ### 18 | .idea 19 | *.iws 20 | *.iml 21 | *.ipr 22 | out/ 23 | 24 | ### NetBeans ### 25 | /nbproject/private/ 26 | /nbbuild/ 27 | /dist/ 28 | /nbdist/ 29 | /.nb-gradle/ 30 | 31 | ### VS Code ### 32 | .vscode/ 33 | 34 | ### Gradle ### 35 | bin 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spring-demo 2 | Spring boot demo for web layer and integration testing 3 |

4 | For a full demo explanation:
5 | 6 | Testing Spring Boot RESTful APIs using MockMvc/Mockito, Test RestTemplate and RestAssured 7 | 8 | 9 | ![project structure img](structure.PNG?raw=true "Project Structure") 10 | 11 | To build Gradle project from the source directory use: 12 | `./gradlew clean build --info` 13 | 14 | To run unit tests: 15 | `./gradlew clean test --info` 16 | 17 | To run integration tests: 18 | `./gradlew clean integration --info` 19 | 20 | To run the application: 21 | `./gradlew bootRun` it will start on the default 8080 port 22 | 23 | Are you up?? - http://localhost:8080/actuator/health
24 | 25 | Wanna look at some tables?? - http://localhost:8080/h2-console/
26 | User=admin Password=admin 27 |
28 | 29 | Got Swag?? - http://localhost:8080/swagger-ui.html -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'org.springframework.boot' version '2.2.0.RELEASE' 3 | id 'io.spring.dependency-management' version '1.0.8.RELEASE' 4 | id 'java' 5 | } 6 | 7 | group = 'com.example' 8 | version = '0.0.1-SNAPSHOT' 9 | sourceCompatibility = '1.8' 10 | 11 | configurations { 12 | developmentOnly 13 | runtimeClasspath { 14 | extendsFrom developmentOnly 15 | } 16 | compileOnly { 17 | extendsFrom annotationProcessor 18 | } 19 | integrationImplementation.extendsFrom testImplementation 20 | integrationRuntimeOnly.extendsFrom testRuntimeOnly 21 | } 22 | 23 | repositories { 24 | mavenCentral() 25 | } 26 | 27 | dependencies { 28 | implementation 'org.springframework.boot:spring-boot-starter-actuator' 29 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 30 | implementation 'org.springframework.boot:spring-boot-starter-web' 31 | 32 | developmentOnly 'org.springframework.boot:spring-boot-devtools' 33 | 34 | runtimeOnly 'com.h2database:h2' 35 | 36 | compile "io.springfox:springfox-swagger2:2.9.2" 37 | compile "io.springfox:springfox-swagger-ui:2.9.2" 38 | 39 | compileOnly 'org.projectlombok:lombok' 40 | annotationProcessor 'org.projectlombok:lombok' 41 | 42 | testImplementation 'io.rest-assured:rest-assured:3.3.0' 43 | testImplementation 'io.rest-assured:json-schema-validator:3.0.6' 44 | 45 | testImplementation('org.springframework.boot:spring-boot-starter-test') { 46 | exclude(group: 'junit', module: 'junit') 47 | } 48 | testImplementation 'org.junit.jupiter:junit-jupiter-api:5.3.1' 49 | testImplementation 'org.mockito:mockito-junit-jupiter:2.28.2' 50 | 51 | testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.3.1' 52 | testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.3.1' 53 | } 54 | 55 | sourceSets { 56 | 57 | test { 58 | java.srcDirs = ['src/test/java'] 59 | resources.srcDirs = ['src/test/resources'] 60 | } 61 | 62 | integration { 63 | java.srcDirs = ['src/integration/java'] 64 | resources.srcDirs = ['src/integration/resources'] 65 | compileClasspath += sourceSets.main.output + sourceSets.test.output 66 | runtimeClasspath += sourceSets.main.output + sourceSets.test.output 67 | } 68 | } 69 | 70 | task integration(type:Test, description: 'Integration testing of API', group:'Verification') { 71 | testClassesDirs = sourceSets.integration.output.classesDirs 72 | classpath = sourceSets.integration.runtimeClasspath 73 | outputs.upToDateWhen {false} 74 | useJUnitPlatform(){ 75 | failFast = true 76 | } 77 | } 78 | 79 | test { 80 | maxHeapSize = '1024m' 81 | useJUnitPlatform() 82 | } 83 | 84 | build.dependsOn integration 85 | integration.mustRunAfter test 86 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jet-C/spring-demo/4fc55d3d035e9df6249df83e4b2bb4f0f69a3c6f/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-5.6.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | # Determine the Java command to use to start the JVM. 86 | if [ -n "$JAVA_HOME" ] ; then 87 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 88 | # IBM's JDK on AIX uses strange locations for the executables 89 | JAVACMD="$JAVA_HOME/jre/sh/java" 90 | else 91 | JAVACMD="$JAVA_HOME/bin/java" 92 | fi 93 | if [ ! -x "$JAVACMD" ] ; then 94 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 95 | 96 | Please set the JAVA_HOME variable in your environment to match the 97 | location of your Java installation." 98 | fi 99 | else 100 | JAVACMD="java" 101 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 102 | 103 | Please set the JAVA_HOME variable in your environment to match the 104 | location of your Java installation." 105 | fi 106 | 107 | # Increase the maximum file descriptors if we can. 108 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 109 | MAX_FD_LIMIT=`ulimit -H -n` 110 | if [ $? -eq 0 ] ; then 111 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 112 | MAX_FD="$MAX_FD_LIMIT" 113 | fi 114 | ulimit -n $MAX_FD 115 | if [ $? -ne 0 ] ; then 116 | warn "Could not set maximum file descriptor limit: $MAX_FD" 117 | fi 118 | else 119 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 120 | fi 121 | fi 122 | 123 | # For Darwin, add options to specify how the application appears in the dock 124 | if $darwin; then 125 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 126 | fi 127 | 128 | # For Cygwin or MSYS, switch paths to Windows format before running java 129 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 130 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 131 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 132 | JAVACMD=`cygpath --unix "$JAVACMD"` 133 | 134 | # We build the pattern for arguments to be converted via cygpath 135 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 136 | SEP="" 137 | for dir in $ROOTDIRSRAW ; do 138 | ROOTDIRS="$ROOTDIRS$SEP$dir" 139 | SEP="|" 140 | done 141 | OURCYGPATTERN="(^($ROOTDIRS))" 142 | # Add a user-defined pattern to the cygpath arguments 143 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 144 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 145 | fi 146 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 147 | i=0 148 | for arg in "$@" ; do 149 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 150 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 151 | 152 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 153 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 154 | else 155 | eval `echo args$i`="\"$arg\"" 156 | fi 157 | i=$((i+1)) 158 | done 159 | case $i in 160 | (0) set -- ;; 161 | (1) set -- "$args0" ;; 162 | (2) set -- "$args0" "$args1" ;; 163 | (3) set -- "$args0" "$args1" "$args2" ;; 164 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 165 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 166 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 167 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 168 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 169 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 170 | esac 171 | fi 172 | 173 | # Escape application args 174 | save () { 175 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 176 | echo " " 177 | } 178 | APP_ARGS=$(save "$@") 179 | 180 | # Collect all arguments for the java command, following the shell quoting and substitution rules 181 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 182 | 183 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 184 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 185 | cd "$(dirname "$0")" 186 | fi 187 | 188 | exec "$JAVACMD" "$@" 189 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 33 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 34 | 35 | @rem Find java.exe 36 | if defined JAVA_HOME goto findJavaFromJavaHome 37 | 38 | set JAVA_EXE=java.exe 39 | %JAVA_EXE% -version >NUL 2>&1 40 | if "%ERRORLEVEL%" == "0" goto init 41 | 42 | echo. 43 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 44 | echo. 45 | echo Please set the JAVA_HOME variable in your environment to match the 46 | echo location of your Java installation. 47 | 48 | goto fail 49 | 50 | :findJavaFromJavaHome 51 | set JAVA_HOME=%JAVA_HOME:"=% 52 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 53 | 54 | if exist "%JAVA_EXE%" goto init 55 | 56 | echo. 57 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 58 | echo. 59 | echo Please set the JAVA_HOME variable in your environment to match the 60 | echo location of your Java installation. 61 | 62 | goto fail 63 | 64 | :init 65 | @rem Get command-line arguments, handling Windows variants 66 | 67 | if not "%OS%" == "Windows_NT" goto win9xME_args 68 | 69 | :win9xME_args 70 | @rem Slurp the command line arguments. 71 | set CMD_LINE_ARGS= 72 | set _SKIP=2 73 | 74 | :win9xME_args_slurp 75 | if "x%~1" == "x" goto execute 76 | 77 | set CMD_LINE_ARGS=%* 78 | 79 | :execute 80 | @rem Setup the command line 81 | 82 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 83 | 84 | @rem Execute Gradle 85 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 86 | 87 | :end 88 | @rem End local scope for the variables with windows NT shell 89 | if "%ERRORLEVEL%"=="0" goto mainEnd 90 | 91 | :fail 92 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 93 | rem the _cmd.exe /c_ return code! 94 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 95 | exit /b 1 96 | 97 | :mainEnd 98 | if "%OS%"=="Windows_NT" endlocal 99 | 100 | :omega 101 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'demo' 2 | -------------------------------------------------------------------------------- /src/integration/java/com/demo/AppRestAssuredIT.java: -------------------------------------------------------------------------------- 1 | package com.demo; 2 | 3 | import org.junit.jupiter.api.BeforeAll; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.TestInstance; 6 | import org.junit.jupiter.api.TestInstance.Lifecycle; 7 | import org.springframework.beans.factory.annotation.Value; 8 | import org.springframework.boot.test.context.SpringBootTest; 9 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; 10 | import org.springframework.http.HttpStatus; 11 | import org.springframework.http.MediaType; 12 | import org.springframework.test.context.ActiveProfiles; 13 | import com.demo.dto.Vehicle; 14 | 15 | import io.restassured.response.ValidatableResponse; 16 | import static io.restassured.RestAssured.*; 17 | import static org.hamcrest.Matchers.*; 18 | 19 | /* 20 | * @SpringBootTest - Run our app in as a test context enabling @Test methods. 21 | * @ActiveProfiles - Select profile configurations. We will use the (application-'integration'.properties) 22 | * @TestInstance - Enable @BeforeAll and share same instance of class for every test 23 | */ 24 | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = SpringTestApplication.class) 25 | @TestInstance(Lifecycle.PER_CLASS) 26 | @ActiveProfiles({ "integration" }) 27 | public class AppRestAssuredIT { 28 | 29 | @Value("${local.server.port}") 30 | private int ports; 31 | 32 | @BeforeAll 33 | public void setUp() { 34 | port = ports; 35 | baseURI = "http://localhost/demo"; // Will result in "http://localhost:xxxx/demo" 36 | } 37 | 38 | @Test 39 | public void get_AllVehicles_returnsAllVehicles_200() { 40 | 41 | String vinArray[] = { "FR45212A24D4SED66", "FR4EDED2150RFT5GE", "XDFR6545DF3A5R896", "GMDE65A5ED66ER002", 42 | "PQERS2A36458E98CD", "194678S400005", "48955460210" }; 43 | 44 | ValidatableResponse response = given().contentType(MediaType.APPLICATION_JSON_VALUE) 45 | .accept(MediaType.APPLICATION_JSON_VALUE).when().get("/vehicles").then(); 46 | 47 | System.out.println("'getAllVehicles_returnsAllVehicles_200()' response:\n" + response.extract().asString()); 48 | 49 | response.assertThat().statusCode(HttpStatus.OK.value()).body("content.size()", greaterThan(7)) 50 | .body(containsString("Corvette")).body("find {it.vin == 'FR45212A24D4SED66'}.year", equalTo(2010)) 51 | .body("find {it.vin == 'FR45212A24D4SED66'}.year", equalTo(2010)) 52 | .body("make", hasItems("Ford", "Chevrolet", "Toyota", "Nissan")).body("make", not(hasItem("Honda"))) 53 | .body("vin", hasItems(vinArray)).body("findAll {it.year < 1990}.size()", is(2)); 54 | } 55 | 56 | @Test 57 | public void get_VehicleById_returnsVehicle_200() { 58 | 59 | ValidatableResponse response = given().contentType(MediaType.APPLICATION_JSON_VALUE) 60 | .accept(MediaType.APPLICATION_JSON_VALUE).when().get("/vehicles/FR45212A24D4SED66").then(); 61 | 62 | System.out.println("'getVehicleById_returnsVehicle_200()' response:\n" + response.extract().asString()); 63 | 64 | response.assertThat().statusCode(HttpStatus.OK.value()).body("make", equalTo("Ford")) 65 | .body("model", equalTo("F-150")).body("year", equalTo(2010)).body("is_older", equalTo(false)); 66 | } 67 | 68 | @Test 69 | public void get_VehicleById_returnsNotFound_404() { 70 | 71 | ValidatableResponse response = given().contentType(MediaType.APPLICATION_JSON_VALUE) 72 | .accept(MediaType.APPLICATION_JSON_VALUE).when().get("/vehicles/NON-EXISTING-ID77").then(); 73 | 74 | System.out.println("'get_VehicleById_returnsNotFound_404()' response:\n" + response.extract().asString()); 75 | 76 | response.assertThat().statusCode(HttpStatus.NOT_FOUND.value()).body("errorMessage", 77 | containsString("404 Vehicle with VIN (NON-EXISTING-ID77) not found")); 78 | } 79 | 80 | @Test 81 | public void post_newVehicle_returnsCreatedVehicle_201() { 82 | // Build new vehicle to post 83 | Vehicle newVehicle = Vehicle.builder().vin("X0RF654S54A65E66E").make("Toyota").model("Supra").year(2020) 84 | .is_older(false).build(); 85 | 86 | ValidatableResponse response = given().contentType(MediaType.APPLICATION_JSON_VALUE) 87 | .accept(MediaType.APPLICATION_JSON_VALUE).body(newVehicle).when().post("/create/vehicle").then(); 88 | 89 | System.out.println("'post_Vehicle_returnsCreatedVehicle_201()' response:\n" + response.extract().asString()); 90 | 91 | response.assertThat().statusCode(HttpStatus.CREATED.value()).body("vin", equalTo("X0RF654S54A65E66E")) 92 | .body("make", equalTo("Toyota")).body("model", equalTo("Supra")).body("year", equalTo(2020)) 93 | .body("is_older", equalTo(false)); 94 | } 95 | 96 | @Test 97 | public void post_newVehicle_Returns_BadRequest_400() { 98 | // Create new vehicle with a bad VIN length for the declared model year 99 | Vehicle newVehicle = Vehicle.builder().vin("BAD-LENGTH-VIN").make("Chevrolet").model("Camaro").year(2018) 100 | .is_older(false).build(); 101 | 102 | ValidatableResponse response = given().contentType(MediaType.APPLICATION_JSON_VALUE) 103 | .accept(MediaType.APPLICATION_JSON_VALUE).body(newVehicle).when().post("/create/vehicle").then(); 104 | 105 | System.out.println("'post_newVehicle_Returns_BadRequest_400()' response:\n" + response.extract().asString()); 106 | 107 | response.assertThat().statusCode(HttpStatus.BAD_REQUEST.value()).body("errorMessage", 108 | containsString("VIN length is invalid for the declared year")); 109 | } 110 | 111 | @Test 112 | public void put_updateVehicle_returnsUpdatedVehicle_202() { 113 | // Update the year on the vehicle 1992 -> 1997 114 | Vehicle updateVehicle = Vehicle.builder().vin("FR4EDED2150RFT5GE").make("Ford").model("Ranger").year(1997) 115 | .is_older(false).build(); 116 | 117 | ValidatableResponse response = given().contentType(MediaType.APPLICATION_JSON_VALUE) 118 | .accept(MediaType.APPLICATION_JSON_VALUE).body(updateVehicle).when() 119 | .put("/update/vehicle/FR4EDED2150RFT5GE").then(); 120 | 121 | System.out 122 | .println("'put_updateVehicle_returnsUpdatedVehicle_202()' response:\n" + response.extract().asString()); 123 | 124 | response.assertThat().statusCode(HttpStatus.ACCEPTED.value()).body("vin", equalTo("FR4EDED2150RFT5GE")) 125 | .body("make", equalTo("Ford")).body("model", equalTo("Ranger")).body("year", equalTo(1997)) 126 | .body("is_older", equalTo(false)); 127 | } 128 | 129 | @Test 130 | public void delete_vehicle_returnsNoContent_204() { 131 | 132 | ValidatableResponse response = given().contentType(MediaType.APPLICATION_JSON_VALUE) 133 | .accept(MediaType.APPLICATION_JSON_VALUE).when().delete("/vehicles/XDFR64AE9F3A5R78S").then(); 134 | 135 | System.out.println("'delete_vehicle_returnsNoContent_204()' response:\n" + response.extract().asString()); 136 | 137 | response.assertThat().statusCode(HttpStatus.NO_CONTENT.value()); 138 | } 139 | 140 | } 141 | -------------------------------------------------------------------------------- /src/integration/java/com/demo/AppTestRestTemplateIT.java: -------------------------------------------------------------------------------- 1 | package com.demo; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertTrue; 4 | import static org.junit.jupiter.api.Assertions.assertFalse; 5 | import static org.junit.jupiter.api.Assertions.assertNull; 6 | import static org.junit.jupiter.api.Assertions.assertEquals; 7 | 8 | import java.util.List; 9 | import java.util.Optional; 10 | import java.util.stream.Collectors; 11 | import java.util.stream.Stream; 12 | 13 | import org.junit.jupiter.api.Test; 14 | import org.springframework.beans.factory.annotation.Autowired; 15 | import org.springframework.boot.test.context.SpringBootTest; 16 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; 17 | import org.springframework.boot.test.web.client.TestRestTemplate; 18 | import org.springframework.core.ParameterizedTypeReference; 19 | import org.springframework.http.HttpEntity; 20 | import org.springframework.http.HttpHeaders; 21 | import org.springframework.http.HttpMethod; 22 | import org.springframework.http.HttpStatus; 23 | import org.springframework.http.MediaType; 24 | import org.springframework.http.ResponseEntity; 25 | import org.springframework.test.context.ActiveProfiles; 26 | import com.demo.dto.Vehicle; 27 | import com.demo.repository.VehicleRepository; 28 | import com.fasterxml.jackson.core.JsonProcessingException; 29 | import com.fasterxml.jackson.databind.JsonNode; 30 | import com.fasterxml.jackson.databind.ObjectMapper; 31 | 32 | /* 33 | * @SpringBootTest - Run our app in as a test context enabling @Test methods. 34 | * @ActiveProfiles - Select profile configurations. We will use the (application-'integration'.properties) 35 | */ 36 | @SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT, classes = SpringTestApplication.class) 37 | @ActiveProfiles("integration") 38 | public class AppTestRestTemplateIT { 39 | 40 | final private static int port = 8080; 41 | final private static String baseUrl = "http://localhost:"; 42 | 43 | /* 44 | * @SpringBootTest registers a TestRestTeplate bean so we can directly @Autowire 45 | * it in 46 | */ 47 | @Autowired 48 | private TestRestTemplate restTemplate; 49 | 50 | @Autowired 51 | private VehicleRepository vehicleRepository; 52 | 53 | @Test 54 | public void get_allVehicles_ReturnsAllVehicles_OK() { 55 | 56 | List expectedVINList = Stream.of("FR45212A24D4SED66", "FR4EDED2150RFT5GE", "XDFR6545DF3A5R896", 57 | "XDFR64AE9F3A5R78S", "PQERS2A36458E98CD", "194678S400005", "48955460210").collect(Collectors.toList()); 58 | 59 | ResponseEntity> responseEntity = this.restTemplate.exchange(baseUrl + port + "/demo/vehicles", 60 | HttpMethod.GET, null, new ParameterizedTypeReference>() { 61 | }); 62 | 63 | List vehiclesResponseList = responseEntity.getBody(); 64 | 65 | assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); 66 | assertTrue(vehiclesResponseList.size() > 7); 67 | assertTrue(vehiclesResponseList.stream().anyMatch((vehicle) -> { 68 | return expectedVINList.contains(vehicle.getVin()); 69 | })); 70 | } 71 | 72 | @Test 73 | public void get_vehicleById_Returns_Vehicle_OK() { 74 | 75 | ResponseEntity responseEntity = this.restTemplate 76 | .getForEntity(baseUrl + port + "/demo/vehicles/48955460210", Vehicle.class); 77 | 78 | Vehicle expectedVehicle = Vehicle.builder().vin("48955460210").make("Ford").model("Mustang").year(1974) 79 | .is_older(true).build(); 80 | 81 | assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); 82 | assertEquals(expectedVehicle, responseEntity.getBody()); 83 | } 84 | 85 | @Test 86 | public void get_vehicleById_Returns_NotFound_404() { 87 | 88 | // We are expecting an string error message in JSON 89 | ResponseEntity result = this.restTemplate.exchange(baseUrl + port + "/demo/vehicles/MISSING-VIN123456", 90 | HttpMethod.GET, null, String.class); 91 | 92 | // Parse JSON message response 93 | ObjectMapper mapper = new ObjectMapper(); 94 | JsonNode jsonTree = null; 95 | try { 96 | jsonTree = mapper.readTree(result.getBody()); 97 | } catch (JsonProcessingException e) { 98 | e.printStackTrace(); 99 | } 100 | JsonNode jsonNode = jsonTree.get("errorMessage"); 101 | 102 | assertEquals(HttpStatus.NOT_FOUND, result.getStatusCode()); 103 | // Assert the proper error message is received 104 | assertTrue(jsonNode.asText().contains("404 Vehicle with VIN (MISSING-VIN123456) not found")); 105 | } 106 | 107 | @Test 108 | public void post_createNewVehicle_Returns_201_Created() { 109 | 110 | // Create a new vehicle 111 | Vehicle newVehicle = Vehicle.builder().vin("X0RF654S54A65E66E").make("Toyota").model("Supra").year(2020) 112 | .is_older(false).build(); 113 | 114 | HttpHeaders headers = new HttpHeaders(); 115 | headers.setContentType(MediaType.APPLICATION_JSON); 116 | HttpEntity request = new HttpEntity(newVehicle, headers); 117 | 118 | ResponseEntity responseEntity = this.restTemplate 119 | .postForEntity(baseUrl + port + "/demo/create/vehicle", request, Vehicle.class); 120 | 121 | // Post request should return the newly created entity back to the client 122 | assertEquals(HttpStatus.CREATED, responseEntity.getStatusCode()); 123 | assertEquals("X0RF654S54A65E66E", responseEntity.getBody().getVin()); 124 | assertEquals("Toyota", responseEntity.getBody().getMake()); 125 | assertEquals("Supra", responseEntity.getBody().getModel()); 126 | assertFalse(responseEntity.getBody().getIs_older()); 127 | 128 | // Double check this new vehicle has been stored in our embedded H2 db 129 | Optional op = vehicleRepository.findById("X0RF654S54A65E66E"); 130 | assertTrue(op.isPresent()); 131 | assertEquals("X0RF654S54A65E66E", op.get().getVin()); 132 | } 133 | 134 | @Test 135 | public void post_createNewVehicle_Returns_400_BadRequest() { 136 | 137 | ResponseEntity result = null; 138 | 139 | // Create new vehicle with a bad VIN length for the declared model year 140 | Vehicle newVehicle = Vehicle.builder().vin("BAD-LENGTH-VIN").make("Chevrolet").model("Camaro").year(2018) 141 | .is_older(false).build(); 142 | 143 | // We'll use an object mapper to show our HttpEntity also accepts JSON string 144 | ObjectMapper mapper = new ObjectMapper(); 145 | JsonNode jsonNode = null; 146 | // Our post consumes JSON format 147 | HttpHeaders headers = new HttpHeaders(); 148 | headers.setContentType(MediaType.APPLICATION_JSON); 149 | 150 | try { 151 | String vehicleJSONString = mapper.writeValueAsString(newVehicle); 152 | 153 | HttpEntity request = new HttpEntity(vehicleJSONString, headers); 154 | result = this.restTemplate.postForEntity(baseUrl + port + "/demo/create/vehicle", request, String.class); 155 | // Our JSON error message has an "errorMessage" attribute 156 | jsonNode = mapper.readTree(result.getBody()).get("errorMessage"); 157 | 158 | } catch (JsonProcessingException e) { 159 | e.printStackTrace(); 160 | } catch (Exception e) { 161 | e.printStackTrace(); 162 | } 163 | 164 | assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); 165 | // Assert the expected error message 166 | assertTrue(jsonNode.asText().contains("VIN length is invalid for the declared year")); 167 | } 168 | 169 | @Test 170 | public void put_updateVehicle_Returns_202_Accepted() { 171 | 172 | // Update vehicle. Need to update to the correct year '1992' -> '1996' 173 | Vehicle vehicleUpdate = Vehicle.builder().vin("FR4EDED2150RFT5GE").make("Ford").model("Ranger").year(1996) 174 | .is_older(false).build(); 175 | 176 | // Our targeted URI consumes JSON format 177 | HttpHeaders headers = new HttpHeaders(); 178 | headers.setContentType(MediaType.APPLICATION_JSON); 179 | HttpEntity requestEntity = new HttpEntity(vehicleUpdate, headers); 180 | 181 | ResponseEntity responseEntity = this.restTemplate.exchange( 182 | baseUrl + port + "/demo/update/vehicle/FR4EDED2150RFT5GE", HttpMethod.PUT, requestEntity, 183 | Vehicle.class); 184 | 185 | // Put request should return the updated vehicle entity back to the client 186 | assertEquals(HttpStatus.ACCEPTED, responseEntity.getStatusCode()); 187 | assertEquals(vehicleUpdate, responseEntity.getBody()); 188 | } 189 | 190 | @Test 191 | public void delete_vehicleById_Returns_NoContent_204() { 192 | 193 | ResponseEntity responseEntity = this.restTemplate 194 | .exchange(baseUrl + port + "/demo/vehicles/GMDE65A5ED66ER002", HttpMethod.DELETE, null, Object.class); 195 | 196 | assertEquals(HttpStatus.NO_CONTENT, responseEntity.getStatusCode()); 197 | assertNull(responseEntity.getBody()); 198 | 199 | // Double check the vehicle has been deleted from our embedded H2 db 200 | Optional optional = vehicleRepository.findById("GMDE65A5ED66ER002"); 201 | assertFalse(optional.isPresent()); 202 | } 203 | 204 | } -------------------------------------------------------------------------------- /src/main/java/com/demo/SpringTestApplication.java: -------------------------------------------------------------------------------- 1 | package com.demo; 2 | 3 | import java.util.Arrays; 4 | 5 | import org.springframework.boot.CommandLineRunner; 6 | import org.springframework.boot.SpringApplication; 7 | import org.springframework.boot.autoconfigure.SpringBootApplication; 8 | import org.springframework.context.ApplicationContext; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Profile; 11 | 12 | @SpringBootApplication 13 | public class SpringTestApplication { 14 | /* 15 | * Are you up?? - http://localhost:8080/actuator/health 16 | * Wanna look at some tables?? - http://localhost:8080/h2-console/ 17 | * Got Swag?? - http://localhost:8080/swagger-ui.html 18 | */ 19 | public static void main(String[] args) { 20 | SpringApplication.run(SpringTestApplication.class, args); 21 | } 22 | 23 | /* 24 | * Pass the beans please... 25 | * ..take a look at all the beans loaded into the app context 26 | */ 27 | @Bean 28 | @Profile("!test & !integration") 29 | public CommandLineRunner run(ApplicationContext appContext) { 30 | return args -> { 31 | String[] beans = appContext.getBeanDefinitionNames(); 32 | Arrays.stream(beans).sorted().forEach(System.out::println); 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/demo/config/SwaggerConfig.java: -------------------------------------------------------------------------------- 1 | package com.demo.config; 2 | 3 | import java.util.Collections; 4 | 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | 8 | import springfox.documentation.builders.PathSelectors; 9 | import springfox.documentation.builders.RequestHandlerSelectors; 10 | import springfox.documentation.service.ApiInfo; 11 | import springfox.documentation.spi.DocumentationType; 12 | import springfox.documentation.spring.web.plugins.Docket; 13 | import springfox.documentation.swagger2.annotations.EnableSwagger2; 14 | 15 | @Configuration 16 | @EnableSwagger2 17 | public class SwaggerConfig { 18 | @Bean 19 | public Docket api() { 20 | return new Docket(DocumentationType.SWAGGER_2) 21 | .select() 22 | .apis(RequestHandlerSelectors.basePackage("com.demo.controllers")) 23 | .paths(PathSelectors.regex("/demo.*")) 24 | .build() 25 | .apiInfo(getApiInfo()); 26 | } 27 | 28 | private ApiInfo getApiInfo() { 29 | return new ApiInfo( 30 | "SpringTestingDemo", 31 | "Spring boot demo for web layer and integration testing", 32 | "1.0", 33 | null, 34 | null, 35 | "GNU General Public License", 36 | null, 37 | Collections.emptyList() 38 | ); 39 | } 40 | } -------------------------------------------------------------------------------- /src/main/java/com/demo/controllers/VehicleController.java: -------------------------------------------------------------------------------- 1 | package com.demo.controllers; 2 | 3 | import java.util.List; 4 | import java.util.Optional; 5 | 6 | import javax.validation.Valid; 7 | 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.http.HttpStatus; 10 | import org.springframework.http.MediaType; 11 | import org.springframework.http.ResponseEntity; 12 | import org.springframework.web.bind.annotation.DeleteMapping; 13 | import org.springframework.web.bind.annotation.GetMapping; 14 | import org.springframework.web.bind.annotation.PathVariable; 15 | import org.springframework.web.bind.annotation.PostMapping; 16 | import org.springframework.web.bind.annotation.PutMapping; 17 | import org.springframework.web.bind.annotation.RequestBody; 18 | import org.springframework.web.bind.annotation.RequestMapping; 19 | import org.springframework.web.bind.annotation.RestController; 20 | import org.springframework.web.client.HttpClientErrorException; 21 | 22 | import com.demo.dto.Vehicle; 23 | import com.demo.exceptions.VehicleNotFoundException; 24 | import com.demo.services.VehicleService; 25 | 26 | import io.swagger.annotations.ApiOperation; 27 | 28 | @RestController 29 | @RequestMapping("/demo") 30 | public class VehicleController { 31 | 32 | @Autowired 33 | VehicleService vehicleService; 34 | 35 | @ApiOperation(value = "Retrieves a list of all vehicle records") 36 | @GetMapping(value = "/vehicles", produces = MediaType.APPLICATION_JSON_VALUE) 37 | public ResponseEntity> getAllVehicles() { 38 | 39 | List vehicles = vehicleService.getAllVehicles(); 40 | if (vehicles.isEmpty()) { 41 | throw new VehicleNotFoundException("No vehicle records were found"); 42 | } 43 | return new ResponseEntity>(vehicles, HttpStatus.OK); 44 | } 45 | 46 | @ApiOperation(value = "Retrives a single vehicle record by its VIN") 47 | @GetMapping(value = "/vehicles/{vin}", produces = MediaType.APPLICATION_JSON_VALUE) 48 | public ResponseEntity getVechileByVin(@PathVariable(value = "vin") String vin) { 49 | 50 | Optional vehicleToUpdate = vehicleService.getVehicleByVin(vin); 51 | if (!vehicleToUpdate.isPresent()) { 52 | throw new VehicleNotFoundException("Vehicle with VIN (" + vin + ") not found!"); 53 | } 54 | return new ResponseEntity(vehicleToUpdate.get(), HttpStatus.OK); 55 | } 56 | 57 | @ApiOperation(value = "Create a new vehicle record. JSON payload will be validated") 58 | @PostMapping(value = "/create/vehicle", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) 59 | public ResponseEntity createVehicle(@Valid @RequestBody Vehicle createVehicle) { 60 | 61 | Optional vehicle = vehicleService.getVehicleByVin(createVehicle.getVin()); 62 | if (vehicle.isPresent()) { 63 | throw new HttpClientErrorException(HttpStatus.CONFLICT, 64 | "Vehicle with VIN" + "(" + createVehicle.getVin() + ") already exists"); 65 | } 66 | return new ResponseEntity(vehicleService.createVehicle(createVehicle), HttpStatus.CREATED); 67 | } 68 | 69 | @ApiOperation(value = "Update an existing vehicle record. Will not create new record if vehicle does not already exist") 70 | @PutMapping(value = "/update/vehicle/{vin}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) 71 | public ResponseEntity updateVehicle(@PathVariable String vin, @Valid @RequestBody Vehicle vehicle) { 72 | 73 | return new ResponseEntity(vehicleService.updateVehicle(vin, vehicle), HttpStatus.ACCEPTED); 74 | } 75 | 76 | @ApiOperation(value = "Delete an existing vehicle record using its VIN") 77 | @DeleteMapping("/vehicles/{vin}") 78 | public ResponseEntity deleteVehicle(@PathVariable(value = "vin") String vin) { 79 | 80 | vehicleService.deleteVehicle(vin); 81 | return new ResponseEntity(HttpStatus.NO_CONTENT); 82 | } 83 | 84 | } -------------------------------------------------------------------------------- /src/main/java/com/demo/dto/Vehicle.java: -------------------------------------------------------------------------------- 1 | package com.demo.dto; 2 | 3 | import javax.persistence.Column; 4 | import javax.persistence.Entity; 5 | import javax.persistence.Id; 6 | import javax.persistence.Table; 7 | import javax.validation.constraints.DecimalMin; 8 | import javax.validation.constraints.NotEmpty; 9 | 10 | import lombok.AllArgsConstructor; 11 | import lombok.Builder; 12 | import lombok.Data; 13 | import lombok.EqualsAndHashCode; 14 | import lombok.NoArgsConstructor; 15 | import lombok.NonNull; 16 | import lombok.ToString; 17 | 18 | /* 19 | * Using Lombok for auto-generation of boiler plate getters, setters, toString, builder 20 | * equals, hashcode, toString, all-arg and no-arg constructors 21 | */ 22 | 23 | @Data 24 | @NoArgsConstructor 25 | @AllArgsConstructor 26 | @Builder 27 | @ToString 28 | @EqualsAndHashCode 29 | @Entity 30 | @Table(name = "vehicles") 31 | public class Vehicle { 32 | 33 | @Id 34 | @Column(name = "VIN", nullable = false, length = 17) 35 | @NonNull 36 | private String vin; 37 | 38 | @Column(name = "make", nullable = false) 39 | @NonNull 40 | @NotEmpty(message = "'make' field was empty") 41 | private String make; 42 | 43 | @Column(name = "model", nullable = false) 44 | @NonNull 45 | @NotEmpty(message = "model' field was empty") 46 | private String model; 47 | 48 | @Column(name = "year", nullable = false) 49 | @NonNull 50 | // Fun fact: VINs were first used until 1954 in the United States 51 | @DecimalMin(value = "1954", message = "VINs before 1954 are not accepted") 52 | private Integer year; 53 | 54 | @Column(name = "is_older", nullable = true) 55 | private Boolean is_older; 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/com/demo/exceptions/ApiErrorMessage.java: -------------------------------------------------------------------------------- 1 | package com.demo.exceptions; 2 | 3 | import java.time.Instant; 4 | import lombok.Getter; 5 | import lombok.Setter; 6 | 7 | @Getter 8 | @Setter 9 | public class ApiErrorMessage { 10 | 11 | private String errorMessage; 12 | private String requestingURI; 13 | private Instant timeStamp; 14 | 15 | public ApiErrorMessage(String messageError, String URI, Instant timeStamp) { 16 | this.errorMessage = messageError; 17 | this.requestingURI = URI; 18 | this.timeStamp = timeStamp; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/demo/exceptions/VehicleExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.demo.exceptions; 2 | 3 | import java.time.Instant; 4 | import org.springframework.http.HttpHeaders; 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.http.converter.HttpMessageNotReadableException; 8 | import org.springframework.web.bind.MethodArgumentNotValidException; 9 | import org.springframework.web.bind.annotation.ExceptionHandler; 10 | import org.springframework.web.bind.annotation.RestControllerAdvice; 11 | import org.springframework.web.client.HttpClientErrorException; 12 | import org.springframework.web.context.request.WebRequest; 13 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; 14 | 15 | @RestControllerAdvice 16 | public class VehicleExceptionHandler extends ResponseEntityExceptionHandler { 17 | 18 | @ExceptionHandler({ VehicleNotFoundException.class }) 19 | public final ResponseEntity handleVehicleException(VehicleNotFoundException ex, WebRequest request) { 20 | Instant timeStamp = Instant.now(); 21 | ApiErrorMessage ApiErrorMessage = new ApiErrorMessage(ex.getMessage(), request.getDescription(false), 22 | timeStamp); 23 | return new ResponseEntity(ApiErrorMessage, ex.getStatusCode()); 24 | } 25 | 26 | @ExceptionHandler({ HttpClientErrorException.class }) 27 | public final ResponseEntity handleVehicleConflictException(HttpClientErrorException ex, 28 | WebRequest request) { 29 | Instant timeStamp = Instant.now(); 30 | ApiErrorMessage ApiErrorMessage = new ApiErrorMessage(ex.getLocalizedMessage(), request.getDescription(false), 31 | timeStamp); 32 | return new ResponseEntity(ApiErrorMessage, ex.getStatusCode()); 33 | } 34 | 35 | @ExceptionHandler({ Exception.class }) 36 | public final ResponseEntity handleVehicleGeneralException(Exception ex, WebRequest request) { 37 | Instant timeStamp = Instant.now(); 38 | ApiErrorMessage ApiErrorMessage = new ApiErrorMessage(ex.getLocalizedMessage(), request.getDescription(true), 39 | timeStamp); 40 | return new ResponseEntity(ApiErrorMessage, HttpStatus.INTERNAL_SERVER_ERROR); 41 | } 42 | 43 | @Override 44 | public ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadableException ex, HttpHeaders headers, 45 | HttpStatus status, WebRequest request) { 46 | Instant timeStamp = Instant.now(); 47 | ApiErrorMessage ApiErrorMessage = new ApiErrorMessage(ex.getLocalizedMessage(), request.getDescription(false), 48 | timeStamp); 49 | return new ResponseEntity(ApiErrorMessage, status); 50 | } 51 | 52 | @Override 53 | protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex, 54 | HttpHeaders headers, HttpStatus status, WebRequest request) { 55 | Instant timeStamp = Instant.now(); 56 | ApiErrorMessage ApiErrorMessage = new ApiErrorMessage(ex.getLocalizedMessage(), request.getDescription(false), 57 | timeStamp); 58 | return new ResponseEntity(ApiErrorMessage, status); 59 | } 60 | } -------------------------------------------------------------------------------- /src/main/java/com/demo/exceptions/VehicleNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.demo.exceptions; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.client.HttpStatusCodeException; 5 | 6 | public class VehicleNotFoundException extends HttpStatusCodeException { 7 | 8 | private static final long serialVersionUID = 73263616501570402L; 9 | 10 | public VehicleNotFoundException() { 11 | super(HttpStatus.NOT_FOUND); 12 | } 13 | 14 | public VehicleNotFoundException(String message) { 15 | super(HttpStatus.NOT_FOUND, message); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/demo/repository/VehicleRepository.java: -------------------------------------------------------------------------------- 1 | package com.demo.repository; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | import org.springframework.stereotype.Repository; 5 | 6 | import com.demo.dto.Vehicle; 7 | 8 | /* 9 | * DAO methods interface. Just by extending the JpaRepository 10 | * we can use the concrete implementations of the most relevant query methods. 11 | * For example 'findById' or 'findAll' 12 | * Spring Data JPA uses naming conventions and reflection to generate the 13 | * concrete implementation of the interface we define. 14 | */ 15 | @Repository 16 | public interface VehicleRepository extends JpaRepository { 17 | 18 | } -------------------------------------------------------------------------------- /src/main/java/com/demo/services/VehicleService.java: -------------------------------------------------------------------------------- 1 | package com.demo.services; 2 | 3 | import java.util.List; 4 | import java.util.Optional; 5 | 6 | import com.demo.dto.Vehicle; 7 | 8 | public interface VehicleService { 9 | 10 | List getAllVehicles(); 11 | 12 | Optional getVehicleByVin(String vin); 13 | 14 | Vehicle createVehicle(Vehicle newVehicle); 15 | 16 | Vehicle updateVehicle(String vin, Vehicle vehicleUpdate); 17 | 18 | void deleteVehicle(String vin); 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/demo/services/VehicleServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.demo.services; 2 | 3 | import java.util.List; 4 | import java.util.Optional; 5 | 6 | import org.springframework.beans.BeanUtils; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.stereotype.Service; 10 | import org.springframework.web.client.HttpClientErrorException; 11 | 12 | import com.demo.dto.Vehicle; 13 | import com.demo.exceptions.VehicleNotFoundException; 14 | import com.demo.repository.VehicleRepository; 15 | 16 | @Service 17 | public class VehicleServiceImpl implements VehicleService { 18 | 19 | @Autowired 20 | VehicleRepository vehicleRepository; 21 | 22 | @Override 23 | public List getAllVehicles() { 24 | return vehicleRepository.findAll(); 25 | } 26 | 27 | @Override 28 | public Optional getVehicleByVin(String vin) { 29 | return vehicleRepository.findById(vin); 30 | } 31 | 32 | @Override 33 | public Vehicle createVehicle(Vehicle newVehicle) { 34 | 35 | validateVehicleHelper(newVehicle); 36 | newVehicle.setIs_older((newVehicle.getYear() < 1981) ? true : false); 37 | 38 | return vehicleRepository.save(newVehicle); 39 | } 40 | 41 | @Override 42 | public Vehicle updateVehicle(String vin, Vehicle vehicleUpdate) { 43 | 44 | if (!vin.equals(vehicleUpdate.getVin())) { 45 | throw new HttpClientErrorException(HttpStatus.CONFLICT, "Vin in URI does not match vehicle vin to update"); 46 | } 47 | 48 | Optional op = vehicleRepository.findById(vin); 49 | 50 | if (!op.isPresent()) { 51 | throw new VehicleNotFoundException("Vehicle with VIN (" + vin + ") not found!"); 52 | } 53 | Vehicle orginalVehicle = op.get(); 54 | 55 | BeanUtils.copyProperties(vehicleUpdate, orginalVehicle); 56 | 57 | return vehicleRepository.save(orginalVehicle); 58 | } 59 | 60 | @Override 61 | public void deleteVehicle(String vin) { 62 | 63 | Optional vehicle = vehicleRepository.findById(vin); 64 | 65 | if (!vehicle.isPresent()) { 66 | throw new VehicleNotFoundException("Vehicle with VIN (" + vin + ") not found!"); 67 | } 68 | 69 | vehicleRepository.delete(vehicle.get()); 70 | } 71 | 72 | private static void validateVehicleHelper(Vehicle vehicle) { 73 | int vinLength = vehicle.getVin().length(); 74 | /* 75 | * Fun Fact: Prior to 1981, VINs varied in length from 11 to 17 characters. Auto 76 | * checking on vehicles older than 1981 can resulted in limited info. 77 | */ 78 | if (vinLength > 17 || vinLength < 11) { 79 | throw new HttpClientErrorException(HttpStatus.BAD_REQUEST, "VIN is an invalid length"); 80 | } 81 | if (vinLength < 17 && vehicle.getYear() >= 1981) { 82 | throw new HttpClientErrorException(HttpStatus.BAD_REQUEST, "VIN length is invalid for the declared year"); 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /src/main/resources/application-integration.properties: -------------------------------------------------------------------------------- 1 | spring.profiles=integration 2 | 3 | # create-drop is set by default for embedded DBs. 4 | # Creates tables plus Hibernate will drop the DB after all operations complete. 5 | spring.jpa.hibernate.ddl-auto=create-drop 6 | # Spring Boot automatically creates the schema of an embedded DataSource 7 | spring.datasource.initialization-mode=always 8 | # Platform allows you to switch to database-specific scripts if necessary 'schema-${platform}.sql' 9 | spring.datasource.platform = h2 10 | spring.datasource.driver-class-name=org.h2.Driver 11 | spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_ON_EXIT=FALSE 12 | spring.jpa.database-platform=org.hibernate.dialect.H2Dialect 13 | # dump the queries to standard out with format 14 | spring.jpa.show-sql=true 15 | spring.jpa.properties.hibernate.format_sql=true -------------------------------------------------------------------------------- /src/main/resources/application-test.properties: -------------------------------------------------------------------------------- 1 | spring.profiles=test -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=SpringTestingDemo 2 | app.message=Spring boot demo for web layer and integration testing 3 | 4 | # create-drop is set by default for embedded DBs. 5 | # Creates tables plus Hibernate will drop the DB after all operations complete. 6 | spring.jpa.hibernate.ddl-auto=create-drop 7 | # Spring Boot automatically creates the schema of an embedded DataSource 8 | spring.datasource.initialization-mode=always 9 | # This allows you to switch to database-specific scripts if necessary 'schema-${platform}.sql' 10 | spring.datasource.platform = h2 11 | spring.datasource.driver-class-name=org.h2.Driver 12 | spring.datasource.url=jdbc:h2:mem:testdb; 13 | spring.datasource.username=admin 14 | spring.datasource.password=admin 15 | spring.jpa.database-platform=org.hibernate.dialect.H2Dialect 16 | # dump the queries to standard out with format 17 | spring.jpa.show-sql=true 18 | spring.jpa.properties.hibernate.format_sql=true 19 | 20 | # Wanna look at some tables? - http://localhost:8080/h2-console/ 21 | spring.h2.console.enabled=true 22 | spring.h2.console.path=/h2-console -------------------------------------------------------------------------------- /src/main/resources/data-h2.sql: -------------------------------------------------------------------------------- 1 | -- Create 8 vehicle records for testing 2 | INSERT INTO vehicles (VIN, make, model, year, is_older) VALUES 3 | ('FR45212A24D4SED66', 'Ford', 'F-150', 2010, false), 4 | ('FR4EDED2150RFT5GE', 'Ford', 'Ranger', 1992, null), 5 | ('XDFR64AE9F3A5R78S', 'Chevrolet', 'Silverado 2500', 2017, false), 6 | ('XDFR6545DF3A5R896', 'Toyota', 'Tacoma', 2008, null), 7 | ('GMDE65A5ED66ER002', 'GMC', 'Sierra', 2012, false), 8 | ('PQERS2A36458E98CD', 'Nissan', 'Titan', 2013, false), 9 | ('194678S400005', 'Chevrolet', 'Corvette', 1977, true), 10 | ('48955460210', 'Ford', 'Mustang', 1974, true); -------------------------------------------------------------------------------- /src/test/java/com/demo/DemoWebLayerTest.java: -------------------------------------------------------------------------------- 1 | package com.demo; 2 | 3 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 4 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 5 | 6 | import java.util.ArrayList; 7 | import java.util.List; 8 | import java.util.Optional; 9 | 10 | import org.junit.jupiter.api.Test; 11 | import org.mockito.Mockito; 12 | 13 | import static org.junit.jupiter.api.Assertions.assertEquals; 14 | import static org.junit.jupiter.api.Assertions.assertTrue; 15 | import static org.mockito.Mockito.times; 16 | import static org.mockito.Mockito.verify; 17 | import static org.hamcrest.Matchers.*; 18 | 19 | import org.springframework.beans.factory.annotation.Autowired; 20 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 21 | import org.springframework.boot.test.mock.mockito.MockBean; 22 | import org.springframework.http.MediaType; 23 | import org.springframework.test.context.ActiveProfiles; 24 | import org.springframework.test.web.servlet.MockMvc; 25 | import org.springframework.test.web.servlet.ResultActions; 26 | import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; 27 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; 28 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers; 29 | import org.springframework.web.bind.MethodArgumentNotValidException; 30 | 31 | import com.demo.controllers.VehicleController; 32 | import com.demo.dto.Vehicle; 33 | import com.demo.exceptions.VehicleNotFoundException; 34 | import com.demo.services.VehicleService; 35 | import com.fasterxml.jackson.databind.ObjectMapper; 36 | 37 | /* 38 | * @WebMvcTest - for testing the controller layer exclusively 39 | * Includes @ExtendWith(SpringExtension.class) for Spring TestContext Framework into JUnit 5's Jupiter programming model. 40 | * @AutoConfigureWebMvc and the @AutoConfigureMockMvc are also included among other functionality. 41 | */ 42 | @WebMvcTest(VehicleController.class) 43 | @ActiveProfiles("test") 44 | public class DemoWebLayerTest { 45 | 46 | /* 47 | * We can @Autowire MockMvc because the WebApplicationContext provides an 48 | * instance/bean for us 49 | */ 50 | @Autowired 51 | MockMvc mockMvc; 52 | 53 | /* 54 | * Jackson mapper for Object -> JSON conversion 55 | */ 56 | @Autowired 57 | ObjectMapper mapper; 58 | 59 | /* 60 | * We use @MockBean because the WebApplicationContext does not provide 61 | * any @Component, @Service or @Repository beans instance/bean of this service 62 | * in its context. It only loads the beans solely required for testing the 63 | * controller. 64 | */ 65 | @MockBean 66 | VehicleService vechicleService; 67 | 68 | @Test 69 | public void get_allVehicles_returnsOkWithListOfVehicles() throws Exception { 70 | 71 | List vehicleList = new ArrayList<>(); 72 | Vehicle vehicle1 = new Vehicle("AD23E5R98EFT3SL00", "Ford", "Fiesta", 2016, false); 73 | Vehicle vehicle2 = new Vehicle("O90DEPADE564W4W83", "Volkswagen", "Jetta", 2016, false); 74 | vehicleList.add(vehicle1); 75 | vehicleList.add(vehicle2); 76 | 77 | // Mocking out the vehicle service 78 | Mockito.when(vechicleService.getAllVehicles()).thenReturn(vehicleList); 79 | 80 | mockMvc.perform(MockMvcRequestBuilders.get("/demo/vehicles").contentType(MediaType.APPLICATION_JSON)) 81 | .andExpect(status().isOk()).andExpect(jsonPath("$", hasSize(2))) 82 | .andExpect(jsonPath("$[0].vin", is("AD23E5R98EFT3SL00"))).andExpect(jsonPath("$[0].make", is("Ford"))) 83 | .andExpect(jsonPath("$[1].vin", is("O90DEPADE564W4W83"))) 84 | .andExpect(jsonPath("$[1].make", is("Volkswagen"))); 85 | } 86 | 87 | @Test 88 | public void post_createsNewVehicle_andReturnsObjWith201() throws Exception { 89 | Vehicle vehicle = new Vehicle("AD23E5R98EFT3SL00", "Ford", "Fiesta", 2016, false); 90 | 91 | Mockito.when(vechicleService.createVehicle(Mockito.any(Vehicle.class))).thenReturn(vehicle); 92 | 93 | // Build post request with vehicle object payload 94 | MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.post("/demo/create/vehicle") 95 | .contentType(MediaType.APPLICATION_JSON_VALUE).accept(MediaType.APPLICATION_JSON) 96 | .characterEncoding("UTF-8").content(this.mapper.writeValueAsBytes(vehicle)); 97 | 98 | mockMvc.perform(builder).andExpect(status().isCreated()).andExpect(jsonPath("$.vin", is("AD23E5R98EFT3SL00"))) 99 | .andExpect(MockMvcResultMatchers.content().string(this.mapper.writeValueAsString(vehicle))); 100 | } 101 | 102 | @Test 103 | public void post_submitsInvalidVehicle_WithEmptyMake_Returns400() throws Exception { 104 | // Create new vehicle with empty 'make' field 105 | Vehicle vehicle = new Vehicle("AD23E5R98EFT3SL00", "", "Firebird", 1982, false); 106 | 107 | String vehicleJsonString = this.mapper.writeValueAsString(vehicle); 108 | 109 | ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.post("/demo/create/vehicle/") 110 | .contentType(MediaType.APPLICATION_JSON).content(vehicleJsonString)).andExpect(status().isBadRequest()); 111 | 112 | // @Valid annotation in controller will cause exception to be thrown 113 | assertEquals(MethodArgumentNotValidException.class, 114 | resultActions.andReturn().getResolvedException().getClass()); 115 | assertTrue(resultActions.andReturn().getResolvedException().getMessage().contains("'make' field was empty")); 116 | } 117 | 118 | @Test 119 | public void put_updatesAndReturnsUpdatedObjWith202() throws Exception { 120 | Vehicle vehicle = new Vehicle("AD23E5R98EFT3SL00", "Ford", "Fiesta", 2016, false); 121 | 122 | Mockito.when(vechicleService.updateVehicle("AD23E5R98EFT3SL00", vehicle)).thenReturn(vehicle); 123 | 124 | MockHttpServletRequestBuilder builder = MockMvcRequestBuilders 125 | .put("/demo/update/vehicle/AD23E5R98EFT3SL00", vehicle).contentType(MediaType.APPLICATION_JSON_VALUE) 126 | .accept(MediaType.APPLICATION_JSON).characterEncoding("UTF-8") 127 | .content(this.mapper.writeValueAsBytes(vehicle)); 128 | 129 | mockMvc.perform(builder).andExpect(status().isAccepted()).andExpect(jsonPath("$.vin", is("AD23E5R98EFT3SL00"))) 130 | .andExpect(MockMvcResultMatchers.content().string(this.mapper.writeValueAsString(vehicle))); 131 | } 132 | 133 | @Test 134 | public void delete_deleteVehicle_Returns204Status() throws Exception { 135 | String vehicleVin = "AD23E5R98EFT3SL00"; 136 | 137 | VehicleService serviceSpy = Mockito.spy(vechicleService); 138 | Mockito.doNothing().when(serviceSpy).deleteVehicle(vehicleVin); 139 | 140 | mockMvc.perform(MockMvcRequestBuilders.delete("/demo/vehicles/AD23E5R98EFT3SL00") 141 | .contentType(MediaType.APPLICATION_JSON)).andExpect(status().isNoContent()); 142 | 143 | verify(vechicleService, times(1)).deleteVehicle(vehicleVin); 144 | } 145 | 146 | @Test 147 | public void get_vehicleByVin_ThrowsVehicleNotFoundException() throws Exception { 148 | 149 | // Return an empty Optional object since we didn't find the vin 150 | Mockito.when(vechicleService.getVehicleByVin("AD23E5R98EFT3SL00")).thenReturn(Optional.empty()); 151 | 152 | ResultActions resultActions = mockMvc.perform( 153 | MockMvcRequestBuilders.get("/demo/vehicles/AD23E5R98EFT3SL00").contentType(MediaType.APPLICATION_JSON)) 154 | .andExpect(status().isNotFound()); 155 | 156 | assertEquals(VehicleNotFoundException.class, resultActions.andReturn().getResolvedException().getClass()); 157 | assertTrue(resultActions.andReturn().getResolvedException().getMessage() 158 | .contains("Vehicle with VIN (" + "AD23E5R98EFT3SL00" + ") not found!")); 159 | } 160 | } -------------------------------------------------------------------------------- /structure.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jet-C/spring-demo/4fc55d3d035e9df6249df83e4b2bb4f0f69a3c6f/structure.PNG --------------------------------------------------------------------------------