# 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`:

```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`:

```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`:

```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.

