diff --git a/apps/api/build.gradle.kts b/apps/api/build.gradle.kts index be5855f..c09c771 100644 --- a/apps/api/build.gradle.kts +++ b/apps/api/build.gradle.kts @@ -12,7 +12,10 @@ dependencies { // Spring Boot dependencies implementation(local.springboot.starter) implementation(local.springboot.starter.validation) - implementation(local.springboot.starter.web) + implementation(local.springboot.starter.webmvc) + + // Spring Boot Liquibase dependency for database migrations + implementation(local.springboot.starter.liquibase) // H2 database dependency for in-memory database runtimeOnly(local.h2database) @@ -20,9 +23,6 @@ dependencies { // FasterXML Jackson support for Java 8 date/time implementation(local.jackson.datatype.jsr310) - // Liquibase core dependency for database migrations - runtimeOnly(local.liquibase.core) - // PostgreSQL database driver runtimeOnly(local.postgres) @@ -30,8 +30,10 @@ dependencies { implementation(local.springdoc.openapi.starter.webmvc) // Spring Boot and Testcontainers test dependencies + testImplementation(local.springboot.resttestclient) testImplementation(local.springboot.starter.test) testImplementation(local.springboot.testcontainers) + testImplementation(local.testcontainers.junit.jupiter) testImplementation(local.testcontainers.postgresql) // JUnit platform launcher dependency for running JUnit tests diff --git a/apps/api/src/test/java/com/github/thorlauridsen/BaseControllerTest.java b/apps/api/src/test/java/com/github/thorlauridsen/BaseControllerTest.java new file mode 100644 index 0000000..8acb144 --- /dev/null +++ b/apps/api/src/test/java/com/github/thorlauridsen/BaseControllerTest.java @@ -0,0 +1,48 @@ +package com.github.thorlauridsen; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.client.RestTestClient; + +/** + * This is the BaseMockMvc class that allows you to send and test HTTP requests. + */ +@AutoConfigureRestTestClient +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class BaseControllerTest { + + @Autowired + private RestTestClient restTestClient; + + /** + * Test an HTTP GET request. + * + * @param getUrl the URL to send an HTTP GET request to. + * @return {@link RestTestClient.ResponseSpec} response. + */ + public RestTestClient.ResponseSpec get(String getUrl) { + return restTestClient.get() + .uri(getUrl) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .exchange(); + } + + /** + * Test an HTTP POST request. + * + * @param postUrl the URL to send an HTTP POST request to. + * @param jsonBody the JSON body to send with the request. + * @return {@link RestTestClient.ResponseSpec} response. + */ + public RestTestClient.ResponseSpec post(String postUrl, String jsonBody) { + return restTestClient.post() + .uri(postUrl) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .body(jsonBody) + .exchange(); + } +} diff --git a/apps/api/src/test/java/com/github/thorlauridsen/BaseMockMvc.java b/apps/api/src/test/java/com/github/thorlauridsen/BaseMockMvc.java deleted file mode 100644 index 8ea310e..0000000 --- a/apps/api/src/test/java/com/github/thorlauridsen/BaseMockMvc.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.github.thorlauridsen; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; - -/** - * This is the BaseMockMvc class that allows you to send and test HTTP requests. - */ -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@AutoConfigureMockMvc -public class BaseMockMvc { - - private final MockMvc mockMvc; - - @Autowired - public BaseMockMvc(MockMvc mockMvc) { - this.mockMvc = mockMvc; - } - - /** - * Mock an HTTP GET request. - * - * @param getUrl The URL to send an HTTP GET request to. - * @return {@link MockHttpServletResponse} response. - */ - public MockHttpServletResponse mockGet(String getUrl) throws Exception { - return mockMvc.perform( - MockMvcRequestBuilders - .get(getUrl) - .contentType(MediaType.APPLICATION_JSON) - ).andReturn().getResponse(); - } - - /** - * Mock an HTTP POST request. - * - * @param jsonBody JSON body as a string. - * @param postUrl The URL to send an HTTP POST request to. - * @return {@link MockHttpServletResponse} response. - */ - public MockHttpServletResponse mockPost(String jsonBody, String postUrl) throws Exception { - return mockMvc.perform( - MockMvcRequestBuilders - .post(postUrl) - .contentType(MediaType.APPLICATION_JSON) - .content(jsonBody) - ).andReturn().getResponse(); - } -} diff --git a/apps/api/src/test/java/com/github/thorlauridsen/CustomerControllerTest.java b/apps/api/src/test/java/com/github/thorlauridsen/CustomerControllerTest.java index a51d22a..f3d81bf 100644 --- a/apps/api/src/test/java/com/github/thorlauridsen/CustomerControllerTest.java +++ b/apps/api/src/test/java/com/github/thorlauridsen/CustomerControllerTest.java @@ -1,6 +1,5 @@ package com.github.thorlauridsen; -import com.fasterxml.jackson.databind.ObjectMapper; import com.github.thorlauridsen.dto.CustomerDto; import com.github.thorlauridsen.dto.CustomerInputDto; import com.github.thorlauridsen.dto.ErrorDto; @@ -10,10 +9,13 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Import; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.http.HttpStatus; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.json.JsonMapper; import static com.github.thorlauridsen.controller.BaseEndpoint.CUSTOMER_BASE_ENDPOINT; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -26,22 +28,22 @@ * A local Docker instance is required to run the tests as Testcontainers is used. */ @ActiveProfiles("postgres") -@Import(TestContainerConfig.class) -class CustomerControllerTest extends BaseMockMvc { +@Testcontainers +class CustomerControllerTest extends BaseControllerTest { - @Autowired - private ObjectMapper objectMapper; + @Container + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:18"); @Autowired - public CustomerControllerTest(MockMvc mockMvc) { - super(mockMvc); - } + private JsonMapper jsonMapper; @Test - void getCustomer_randomId_returnsNotFound() throws Exception { + void getCustomer_randomId_returnsNotFound() { val id = UUID.randomUUID(); - val response = mockGet(CUSTOMER_BASE_ENDPOINT + "/" + id); - assertEquals(HttpStatus.NOT_FOUND.value(), response.getStatus()); + val response = get(CUSTOMER_BASE_ENDPOINT + "/" + id); + + response.expectStatus().isEqualTo(HttpStatus.NOT_FOUND); } @ParameterizedTest @@ -49,51 +51,48 @@ void getCustomer_randomId_returnsNotFound() throws Exception { "alice@gmail.com", "bob@gmail.com" }) - void postCustomer_getCustomer_success(String mail) throws Exception { + void postCustomer_getCustomer_success(String mail) { val customer = new CustomerInputDto(mail); - val json = objectMapper.writeValueAsString(customer); - val response = mockPost(json, CUSTOMER_BASE_ENDPOINT); - assertEquals(HttpStatus.CREATED.value(), response.getStatus()); + val json = jsonMapper.writeValueAsString(customer); + val response = post(CUSTOMER_BASE_ENDPOINT, json); + response.expectStatus().isEqualTo(HttpStatus.CREATED); - val responseJson = response.getContentAsString(); - val createdCustomer = objectMapper.readValue(responseJson, CustomerDto.class); + val createdCustomer = response.expectBody(CustomerDto.class).returnResult().getResponseBody(); + assertNotNull(createdCustomer); assertCustomer(createdCustomer, mail); - val response2 = mockGet(CUSTOMER_BASE_ENDPOINT + "/" + createdCustomer.id()); - assertEquals(HttpStatus.OK.value(), response2.getStatus()); + val response2 = get(CUSTOMER_BASE_ENDPOINT + "/" + createdCustomer.id()); + response2.expectStatus().isEqualTo(HttpStatus.OK); - val responseJson2 = response2.getContentAsString(); - val fetchedCustomer = objectMapper.readValue(responseJson2, CustomerDto.class); + val fetchedCustomer = response2.expectBody(CustomerDto.class).returnResult().getResponseBody(); assertCustomer(fetchedCustomer, mail); } @Test - void postCustomer_blankEmail_returnsBadRequest() throws Exception { + void postCustomer_blankEmail_returnsBadRequest() { val customer = new CustomerInputDto(""); - val json = objectMapper.writeValueAsString(customer); - val response = mockPost(json, CUSTOMER_BASE_ENDPOINT); + val json = jsonMapper.writeValueAsString(customer); + val response = post(CUSTOMER_BASE_ENDPOINT, json); - assertEquals(HttpStatus.BAD_REQUEST.value(), response.getStatus()); - - val responseJson = response.getContentAsString(); - val error = objectMapper.readValue(responseJson, ErrorDto.class); + response.expectStatus().isEqualTo(HttpStatus.BAD_REQUEST); + val error = response.expectBody(ErrorDto.class).returnResult().getResponseBody(); + assertNotNull(error); assertEquals("Validation failed", error.description()); assertTrue(error.fieldErrors().containsKey("mail")); assertEquals("Email is required", error.fieldErrors().get("mail")); } @Test - void postCustomer_invalidEmailFormat_returnsBadRequest() throws Exception { + void postCustomer_invalidEmailFormat_returnsBadRequest() { val customer = new CustomerInputDto("invalid-email"); - val json = objectMapper.writeValueAsString(customer); - val response = mockPost(json, CUSTOMER_BASE_ENDPOINT); - - assertEquals(HttpStatus.BAD_REQUEST.value(), response.getStatus()); + val json = jsonMapper.writeValueAsString(customer); + val response = post(CUSTOMER_BASE_ENDPOINT, json); - val responseJson = response.getContentAsString(); - val error = objectMapper.readValue(responseJson, ErrorDto.class); + response.expectStatus().isEqualTo(HttpStatus.BAD_REQUEST); + val error = response.expectBody(ErrorDto.class).returnResult().getResponseBody(); + assertNotNull(error); assertEquals("Validation failed", error.description()); assertTrue(error.fieldErrors().containsKey("mail")); assertEquals("Invalid email format", error.fieldErrors().get("mail")); diff --git a/apps/api/src/test/java/com/github/thorlauridsen/CustomerServiceTest.java b/apps/api/src/test/java/com/github/thorlauridsen/CustomerServiceTest.java index 9baf47c..14c774f 100644 --- a/apps/api/src/test/java/com/github/thorlauridsen/CustomerServiceTest.java +++ b/apps/api/src/test/java/com/github/thorlauridsen/CustomerServiceTest.java @@ -12,6 +12,11 @@ import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -22,9 +27,15 @@ * This class uses the @SpringBootTest annotation to spin up a Spring Boot instance. * This ensures that Spring can automatically inject {@link CustomerService} with a {@link CustomerRepo} */ +@ActiveProfiles("postgres") @SpringBootTest +@Testcontainers class CustomerServiceTest { + @Container + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:18"); + @Autowired private CustomerService customerService; diff --git a/apps/api/src/test/java/com/github/thorlauridsen/TestContainerConfig.java b/apps/api/src/test/java/com/github/thorlauridsen/TestContainerConfig.java deleted file mode 100644 index 22951d4..0000000 --- a/apps/api/src/test/java/com/github/thorlauridsen/TestContainerConfig.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.github.thorlauridsen; - -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.context.annotation.Bean; -import org.testcontainers.containers.PostgreSQLContainer; - -@TestConfiguration(proxyBeanMethods = false) -public class TestContainerConfig { - - /** - * A PostgreSQLContainer bean to be used in tests. - * This uses Testcontainers to spin up a temporary PostgreSQL instance in a Docker container. - * The ServiceConnection annotation allows Spring Boot to automatically - * configure the datasource properties based on the container settings. - */ - @Bean - @ServiceConnection - public PostgreSQLContainer postgresContainer() { - return new PostgreSQLContainer<>("postgres:17") - .withDatabaseName("sample-db") - .withUsername("postgres") - .withPassword("postgres"); - } -} diff --git a/gradle/local.versions.toml b/gradle/local.versions.toml index 77d3def..719b80b 100644 --- a/gradle/local.versions.toml +++ b/gradle/local.versions.toml @@ -1,13 +1,12 @@ [versions] h2database = "2.4.240" jackson = "2.20.1" -junitPlatformLauncher = "1.12.2" -liquibase = "5.0.1" +junitPlatformLauncher = "6.0.1" lombok = "9.1.0" postgres = "42.7.8" -springboot = "3.5.8" +springboot = "4.0.0" springDependencyPlugin = "1.1.7" -springdoc = "2.8.14" +springdoc = "3.0.0" testcontainers = "1.21.3" [libraries] @@ -20,21 +19,21 @@ jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-dat # JUnit platform launcher for running JUnit tests junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatformLauncher" } -# Liquibase for managing database changelogs -liquibase-core = { module = "org.liquibase:liquibase-core", version.ref = "liquibase" } - # PostgreSQL for a live database postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" } # Spring Boot libraries +springboot-resttestclient = { module = 'org.springframework.boot:spring-boot-resttestclient', version.ref = "springboot" } springboot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springboot" } springboot-starter-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa", version.ref = "springboot" } +springboot-starter-liquibase = { module = "org.springframework.boot:spring-boot-starter-liquibase", version.ref = "springboot" } springboot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "springboot" } springboot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "springboot" } -springboot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "springboot" } +springboot-starter-webmvc = { module = "org.springframework.boot:spring-boot-starter-webmvc", version.ref = "springboot" } springboot-testcontainers = { module = "org.springframework.boot:spring-boot-testcontainers", version.ref = "springboot" } # Testcontainers for running PostgreSQL in tests +testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers" } testcontainers-postgresql = { module = "org.testcontainers:postgresql", version.ref = "testcontainers" } # Springdoc provides swagger docs with support for Spring Web MVC