Compartir comentarios
Las respuestas se generan en base a la documentación.

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 PostgreSQLContainer con @ServiceConnection, el cual inicia un contenedor PostgreSQL y registra automáticamente las propiedades del origen de datos (datasource).
  • Declara un bean KeycloakContainer usando la imagen quay.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() invoca GET /api/products sin una cabecera Authorization. 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 protegido POST /api/products sin una cabecera Authorization y verifica que el código de estado de la respuesta sea 401 (no autorizado).
  • shouldCreateProductWithAuthToken() obtiene primero un access_token usando el flujo Client Credentials. Luego, incluye el token como un token Bearer en la cabecera Authorization al invocar POST /api/products y 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.