Escribe pruebas con Testcontainers
Para probar los endpoints protegidos de la API, necesitas una instancia de Keycloak en ejecución y una base de datos PostgreSQL, además de un contexto de Spring iniciado. Testcontainers levanta ambos servicios en contenedores Docker y los conecta a Spring mediante el registro dinámico de propiedades.
Configurar los contenedores de prueba
El soporte de Testcontainers de Spring Boot te permite declarar contenedores como beans. Para Keycloak, @ServiceConnection no está disponible, pero puedes usar DynamicPropertyRegistry para configurar la URI del emisor de JWT de forma dinámica.
Crea ContainersConfig.java bajo src/test/java:
package com.testcontainers.products;
import dasniko.testcontainers.keycloak.KeycloakContainer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.testcontainers.postgresql.PostgreSQLContainer;
@TestConfiguration(proxyBeanMethods = false)
public class ContainersConfig {
static String POSTGRES_IMAGE = "postgres:16-alpine";
static String KEYCLOAK_IMAGE = "quay.io/keycloak/keycloak:25.0";
static String realmImportFile = "/keycloaktcdemo-realm.json";
static String realmName = "keycloaktcdemo";
@Bean
@ServiceConnection
PostgreSQLContainer postgres() {
return new PostgreSQLContainer(POSTGRES_IMAGE);
}
@Bean
KeycloakContainer keycloak(DynamicPropertyRegistry registry) {
var keycloak = new KeycloakContainer(KEYCLOAK_IMAGE)
.withRealmImportFile(realmImportFile);
registry.add(
"spring.security.oauth2.resourceserver.jwt.issuer-uri",
() -> keycloak.getAuthServerUrl() + "/realms/" + realmName
);
return keycloak;
}
}Esta configuración:
- Declara un bean
PostgreSQLContainercon@ServiceConnection, el cual inicia un contenedor PostgreSQL y registra automáticamente las propiedades del origen de datos (datasource). - Declara un bean
KeycloakContainerusando la imagenquay.io/keycloak/keycloak:25.0, importa el archivo de configuración del reino (realm) y registra dinámicamente la URI del emisor de JWT a partir de la URL del servidor de autenticación del contenedor de Keycloak.
Escribir la prueba
Crea ProductControllerTests.java:
package com.testcontainers.products.api;
import static io.restassured.RestAssured.given;
import static io.restassured.RestAssured.when;
import static java.util.Collections.singletonList;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.testcontainers.products.ContainersConfig;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Import(ContainersConfig.class)
class ProductControllerTests {
static final String GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials";
static final String CLIENT_ID = "product-service";
static final String CLIENT_SECRET = "jTJJqdzeCSt3DmypfHZa42vX8U9rQKZ9";
@LocalServerPort
private int port;
@Autowired
OAuth2ResourceServerProperties oAuth2ResourceServerProperties;
@BeforeEach
void setup() {
RestAssured.port = port;
}
@Test
void shouldGetProductsWithoutAuthToken() {
when().get("/api/products").then().statusCode(200);
}
@Test
void shouldGetUnauthorizedWhenCreateProductWithoutAuthToken() {
given()
.contentType("application/json")
.body(
"""
{
"title": "New Product",
"description": "Brand New Product"
}
"""
)
.when()
.post("/api/products")
.then()
.statusCode(401);
}
@Test
void shouldCreateProductWithAuthToken() {
String token = getToken();
given()
.header("Authorization", "Bearer " + token)
.contentType("application/json")
.body(
"""
{
"title": "New Product",
"description": "Brand New Product"
}
"""
)
.when()
.post("/api/products")
.then()
.statusCode(201);
}
private String getToken() {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.put("grant_type", singletonList(GRANT_TYPE_CLIENT_CREDENTIALS));
map.put("client_id", singletonList(CLIENT_ID));
map.put("client_secret", singletonList(CLIENT_SECRET));
String authServerUrl =
oAuth2ResourceServerProperties.getJwt().getIssuerUri() +
"/protocol/openid-connect/token";
var request = new HttpEntity<>(map, httpHeaders);
KeyCloakToken token = restTemplate.postForObject(
authServerUrl,
request,
KeyCloakToken.class
);
assert token != null;
return token.accessToken();
}
record KeyCloakToken(@JsonProperty("access_token") String accessToken) {}
}Esto es lo que cubren las pruebas:
shouldGetProductsWithoutAuthToken()invocaGET /api/productssin una cabeceraAuthorization. Dado que este endpoint está configurado para permitir el acceso no autenticado, el código de estado de la respuesta es 200.shouldGetUnauthorizedWhenCreateProductWithoutAuthToken()invoca el endpoint protegidoPOST /api/productssin una cabeceraAuthorizationy verifica que el código de estado de la respuesta sea 401 (no autorizado).shouldCreateProductWithAuthToken()obtiene primero unaccess_tokenusando el flujo Client Credentials. Luego, incluye el token como un token Bearer en la cabeceraAuthorizational invocarPOST /api/productsy verifica que el código de estado de la respuesta sea 201 (creado).
El método auxiliar getToken() solicita un token de acceso del endpoint de token de Keycloak utilizando el ID de cliente y el secreto de cliente configurados en el reino (realm) exportado.
Usar Testcontainers para desarrollo local
El soporte de Testcontainers de Spring Boot también funciona para desarrollo local. Crea TestApplication.java bajo src/test/java:
package com.testcontainers.products;
import org.springframework.boot.SpringApplication;
public class TestApplication {
public static void main(String[] args) {
SpringApplication
.from(Application::main)
.with(ContainersConfig.class)
.run(args);
}
}Ejecuta TestApplication.java desde tu IDE en lugar de la clase Application.java principal. Esto iniciará los contenedores definidos en ContainersConfig y configurará la aplicación para usar las propiedades registradas dinámicamente, por lo que no tendrás que instalar ni configurar PostgreSQL y Keycloak de forma manual.