Saltar al contenido principal

Flujo TDD Avanzado con Spring Boot, Kotlin, JUnit y Mockito

Contexto​

El Test-Driven Development (TDD) consiste en escribir pruebas automatizadas antes que el código de producción, siguiendo el ciclo Red-Green-Refactor. En microservicios con Spring Boot y Kotlin, TDD combinado con JUnit 5 y Mockito permite garantizar calidad, mantenibilidad y confiabilidad del código.

Objetivo​

Proporcionar una guía completa y práctica para aplicar TDD en proyectos Spring Boot con Kotlin:

  • Fundamentos teóricos del ciclo Red-Green-Refactor.
  • Configuración del entorno de desarrollo.
  • Ejercicios prácticos aplicados a cada capa.
  • Patrones, técnicas avanzadas (BDD, Testcontainers) y mejores prácticas.

Alcance​

Incluye​

  • Fundamentos de TDD y su ciclo.
  • Configuración con Gradle Kotlin DSL.
  • Testing de entidades JPA, repositorios (@DataJpaTest), servicios (Mockito) y controladores (MockMvc).
  • Testing de seguridad JWT y cache con Redis.
  • Técnicas avanzadas: BDD, código asíncrono, Testcontainers.

No incluye​

  • Configuración de pipelines CI/CD.
  • Despliegue productivo.
  • Testing de frontend.
  • Benchmarking de rendimiento.

Detalle​

1. Fundamentos de TDD​

Ciclo Red-Green-Refactor​

FaseAcciónResultado
🔴 RedEscribir una prueba que valide la nueva funcionalidadLa prueba falla (aún no hay implementación)
🟢 GreenImplementar el código mínimo para pasar la pruebaLa prueba pasa
🔵 RefactorMejorar legibilidad, rendimiento y diseñoLas pruebas siguen pasando

Este ciclo fomenta desarrollo incremental, retroalimentación constante y detección temprana de errores.

2. Configuración Inicial​

Dependencias Gradle (build.gradle.kts)​

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
id("org.springframework.boot") version "3.2.0"
id("io.spring.dependency-management") version "1.1.4"
kotlin("jvm") version "1.9.20"
kotlin("plugin.spring") version "1.9.20"
kotlin("plugin.jpa") version "1.9.20"
}

group = "com.yourname"
version = "0.0.1-SNAPSHOT"

java {
sourceCompatibility = JavaVersion.VERSION_17
}

repositories {
mavenCentral()
}

dependencies {
// Core
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("org.springframework.boot:spring-boot-starter-security")

// Database
implementation("org.postgresql:postgresql")
implementation("org.flywaydb:flyway-core")

// Cache
implementation("org.springframework.boot:spring-boot-starter-data-redis")

// JWT
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")

// Swagger
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0")

// Kotlin
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")

// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.0.0")
testImplementation("io.mockk:mockk:1.13.8")
testImplementation("com.h2database:h2")
testImplementation("org.testcontainers:junit-jupiter:1.19.3")
testImplementation("org.testcontainers:postgresql:1.19.3")
}

tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
jvmTarget = "17"
}
}

tasks.withType<Test> {
useJUnitPlatform()
}

3. TDD con Kotlin​

Ventajas de Kotlin para TDD​

  • Sintaxis concisa: reduce boilerplate; data class genera equals(), hashCode() y toString().
  • Seguridad ante nulos: previene NullPointerException a nivel de tipos.
  • Interoperabilidad con Java: compatible con JUnit y Mockito.
  • Funciones de extensión: utilidades de testing sin modificar clases.
  • Corutinas: simplifican pruebas concurrentes.

Ejemplo Básico​

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class CalculatorTest {

@Test
fun `la suma de 2 y 3 es 5`() {
val calculator = Calculator()
val resultado = calculator.add(2, 3)
assertEquals(5, resultado)
}
}

class Calculator {
fun add(a: Int, b: Int): Int = a + b
}

4. TDD con Spring Boot​

Utilidades de Testing​

AnotaciónPropósito
@SpringBootTestCarga el contexto completo para pruebas de integración
@WebMvcTestCarga solo la capa web (controladores)
@DataJpaTestConfigura JPA con base en memoria
MockMvcPrueba controladores sin levantar servidor
@MockBeanSustituye un bean del contexto por un mock

Ejemplo de Prueba de Controlador​

@WebMvcTest(UserController::class)
class UserControllerTest {

@Autowired
private lateinit var mockMvc: MockMvc

@MockBean
private lateinit var userService: UserService

@Test
fun `debe retornar lista de usuarios`() {
whenever(userService.getAllUsers())
.thenReturn(listOf(User(1L, "Juan Pérez", "[email protected]")))

mockMvc.perform(get("/users"))
.andExpect(status().isOk)
.andExpect(jsonPath("$[0].name").value("Juan Pérez"))
}
}

5. JUnit 5 y Mockito​

JUnit 5 — Anotaciones y Aserciones​

Anotación / APIUso
@TestDeclara un método de prueba
@BeforeEach / @AfterEachSetup / teardown por prueba
@BeforeAll / @AfterAllSetup / teardown por clase
@ParameterizedTestMúltiples conjuntos de datos
assertEquals, assertTrue, assertThrowsVerificación de comportamiento

Mockito — Funciones Clave​

APIUso
@MockCrea un mock
@InjectMocksInyecta mocks en la clase bajo prueba
when(...).thenReturn(...)Define respuesta
verify(...)Verifica invocaciones
any(), eq()Matchers de argumentos

Ejemplo en Capa de Servicio​

@ExtendWith(MockitoExtension::class)
class UserServiceTest {

@Mock
private lateinit var userRepository: UserRepository

@InjectMocks
private lateinit var userService: UserService

@Test
fun `debe retornar usuario por ID`() {
val usuarioEsperado = User(1L, "Juan Pérez", "[email protected]")
whenever(userRepository.findById(1L)).thenReturn(Optional.of(usuarioEsperado))

val resultado = userService.findUserById(1L)

assertEquals("Juan Pérez", resultado.name)
verify(userRepository).findById(1L)
}
}

6. Ejercicios Prácticos​

Ejercicio 1 — Entidad User​

@Test
fun `User entity should have all required fields`() {
val user = User(
id = 1L,
username = "testuser",
email = "[email protected]",
password = "hashedpassword"
)

assertThat(user.id).isEqualTo(1L)
assertThat(user.username).isEqualTo("testuser")
assertThat(user.email).isEqualTo("[email protected]")
assertThat(user.password).isEqualTo("hashedpassword")
}

Ejercicio 2 — Repositorio con @DataJpaTest​

@DataJpaTest
class UserRepositoryTest {

@Autowired
private lateinit var userRepository: UserRepository

@Test
fun `should find user by username`() {
// Given
val user = User(username = "testuser", email = "[email protected]", password = "x")
userRepository.save(user)

// When
val found = userRepository.findByUsername("testuser")

// Then
assertThat(found).isNotNull
assertThat(found?.username).isEqualTo("testuser")
}
}

Ejercicio 3 — Servicio con Mockito​

@ExtendWith(MockitoExtension::class)
class UserServiceImplTest {

@Mock
private lateinit var userRepository: UserRepository

@InjectMocks
private lateinit var userService: UserServiceImpl

@Test
fun `should return user by username when exists`() {
val username = "testuser"
val user = User(username = username, email = "[email protected]", password = "x")
whenever(userRepository.findByUsername(username)).thenReturn(user)

val result = userService.getUserByUsername(username)

assertThat(result).isNotNull
assertThat(result?.username).isEqualTo(username)
verify(userRepository).findByUsername(username)
}
}

Ejercicio 4 — Controlador con MockMvc​

@WebMvcTest(UserController::class)
class UserControllerTest {

@Autowired
private lateinit var mockMvc: MockMvc

@MockBean
private lateinit var userService: UserService

@Test
fun `should return user when GET by username`() {
val username = "testuser"
val user = User(username = username, email = "[email protected]", password = "x")
whenever(userService.getUserByUsername(username)).thenReturn(user)

mockMvc.perform(get("/api/v1/users/$username"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.username").value(username))
.andExpect(jsonPath("$.email").value("[email protected]"))
}
}

Ejercicio 5 — JWT Provider​

@ExtendWith(MockitoExtension::class)
class JwtTokenProviderTest {

private lateinit var jwtTokenProvider: JwtTokenProvider

@BeforeEach
fun setup() {
jwtTokenProvider = JwtTokenProvider(
secretKey = "testsecretkeytestsecretkeytestsecretkey",
validityInMs = 3_600_000
)
}

@Test
fun `should create valid JWT token for user`() {
val user = User(username = "testuser", roles = listOf(Role.USER))

val token = jwtTokenProvider.createToken(user.username, user.roles)

assertThat(token).isNotBlank()
assertThat(jwtTokenProvider.validateToken(token)).isTrue()
assertThat(jwtTokenProvider.getUsername(token)).isEqualTo("testuser")
}

@Test
fun `should reject expired token`() {
val expiredProvider = JwtTokenProvider(
secretKey = "testsecretkeytestsecretkeytestsecretkey",
validityInMs = -1
)
val token = expiredProvider.createToken("testuser", listOf(Role.USER))

assertThat(expiredProvider.validateToken(token)).isFalse()
}
}

Ejercicio 6 — Cache Redis​

@SpringBootTest
class PostCacheTest {

@Autowired private lateinit var postService: PostService
@Autowired private lateinit var postRepository: PostRepository
@Autowired private lateinit var cacheManager: CacheManager

@Test
fun `should cache post after first retrieval`() {
// Given
val post = postRepository.save(Post(title = "Test Post", content = "Content"))
cacheManager.getCache("posts")?.clear()

// When
val firstCall = measureTimeMillis { postService.getPostById(post.id!!) }
val secondCall = measureTimeMillis { postService.getPostById(post.id!!) }

// Then
assertThat(secondCall).isLessThan(firstCall)
assertThat(cacheManager.getCache("posts")?.get(post.id!!)).isNotNull
}
}

7. Guía Paso a Paso​

Estructura del Proyecto​

src/
├── main/
│ └── kotlin/com/yourname/apicore/
│ ├── ApiCoreApplication.kt
│ ├── common/
│ │ ├── config/
│ │ ├── exception/
│ │ └── util/
│ ├── modules/
│ │ ├── auth/
│ │ │ ├── controller/
│ │ │ ├── dto/
│ │ │ ├── entity/
│ │ │ ├── repository/
│ │ │ └── service/
│ │ ├── cms/
│ │ └── pos/
│ └── security/
│ ├── config/
│ ├── jwt/
│ └── service/
└── test/
└── kotlin/com/yourname/apicore/
├── common/
└── modules/
├── auth/
└── cms/

Flujo de Desarrollo​

  1. Identificar requisito funcional.
  2. Escribir test que lo valide.
  3. Confirmar que falla (Red).
  4. Implementar código mínimo (Green).
  5. Refactorizar manteniendo tests verdes.
  6. Repetir para cubrir casos de uso y bordes.

Secuencia Recomendada​

  1. Dominio: User, Post, Category, Comment.
  2. Repositorios: interfaces JPA.
  3. Servicios: lógica de negocio.
  4. Controladores: APIs REST.
  5. Seguridad: Spring Security + JWT.
  6. Cache: Redis para entidades frecuentes.
  7. Documentación: Swagger / OpenAPI.

8. Técnicas Avanzadas​

Behavior-Driven Development (BDD)​

  • Lenguaje natural (Gherkin: Given / When / Then).
  • Herramientas: Cucumber, JBehave.
  • Beneficio: describe el comportamiento desde la perspectiva del usuario.

Pruebas de Código Asíncrono​

  • Mocks y stubs para servicios externos.
  • Corutinas Kotlin: runTest, runBlocking.
  • Timeouts con assertTimeout o @Timeout.

Testcontainers​

@Testcontainers
@SpringBootTest
class UserRepositoryIntegrationTest {

companion object {
@Container
@JvmStatic
val postgres = PostgreSQLContainer("postgres:16-alpine").apply {
withDatabaseName("testdb")
withUsername("test")
withPassword("test")
}

@DynamicPropertySource
@JvmStatic
fun props(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl)
registry.add("spring.datasource.username", postgres::getUsername)
registry.add("spring.datasource.password", postgres::getPassword)
}
}

@Autowired
private lateinit var userRepository: UserRepository

@Test
fun `persists and retrieves user against real PostgreSQL`() {
val saved = userRepository.save(
User(username = "ana", email = "[email protected]", password = "x")
)
assertThat(userRepository.findById(saved.id!!)).isPresent
}
}

Decisiones​

  • Kotlin como lenguaje principal: sintaxis concisa, seguridad de nulos e interoperabilidad con Java.
  • JUnit 5 sobre JUnit 4: pruebas parametrizadas, extensiones y mejor soporte Kotlin.
  • Mockito + MockK: Mockito para compatibilidad general, MockK para features específicas de Kotlin.
  • H2 en tests de repositorio: rápido y aislado; Testcontainers cuando se requiera fidelidad con PostgreSQL.
  • Ciclo Red-Green-Refactor estricto por cada funcionalidad.
  • Anotaciones por capa: @DataJpaTest, @WebMvcTest, @SpringBootTest según alcance.
  • Nombres descriptivos en backticks para expresar el comportamiento esperado.

Pendientes​

  • Configurar pipeline CI/CD con GitHub Actions.
  • Añadir cobertura con JaCoCo y umbrales mínimos.
  • Tests de contrato con Spring Cloud Contract.
  • Tests de carga con Gatling.
  • Tests de mutación con PIT.
  • Documentar API con OpenAPI/Swagger.

Referencias​