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
| Fase | Acción | Resultado |
|---|---|---|
| 🔴 Red | Escribir una prueba que valide la nueva funcionalidad | La prueba falla (aún no hay implementación) |
| 🟢 Green | Implementar el código mínimo para pasar la prueba | La prueba pasa |
| 🔵 Refactor | Mejorar legibilidad, rendimiento y diseño | Las 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 classgeneraequals(),hashCode()ytoString(). - Seguridad ante nulos: previene
NullPointerExceptiona 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ón | Propósito |
|---|---|
@SpringBootTest | Carga el contexto completo para pruebas de integración |
@WebMvcTest | Carga solo la capa web (controladores) |
@DataJpaTest | Configura JPA con base en memoria |
MockMvc | Prueba controladores sin levantar servidor |
@MockBean | Sustituye 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())
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 / API | Uso |
|---|---|
@Test | Declara un método de prueba |
@BeforeEach / @AfterEach | Setup / teardown por prueba |
@BeforeAll / @AfterAll | Setup / teardown por clase |
@ParameterizedTest | Múltiples conjuntos de datos |
assertEquals, assertTrue, assertThrows | Verificación de comportamiento |
Mockito — Funciones Clave
| API | Uso |
|---|---|
@Mock | Crea un mock |
@InjectMocks | Inyecta 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`() {
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",
password = "hashedpassword"
)
assertThat(user.id).isEqualTo(1L)
assertThat(user.username).isEqualTo("testuser")
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
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"
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"
whenever(userService.getUserByUsername(username)).thenReturn(user)
mockMvc.perform(get("/api/v1/users/$username"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.username").value(username))
}
}
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
- Identificar requisito funcional.
- Escribir test que lo valide.
- Confirmar que falla (Red).
- Implementar código mínimo (Green).
- Refactorizar manteniendo tests verdes.
- Repetir para cubrir casos de uso y bordes.
Secuencia Recomendada
- Dominio:
User,Post,Category,Comment. - Repositorios: interfaces JPA.
- Servicios: lógica de negocio.
- Controladores: APIs REST.
- Seguridad: Spring Security + JWT.
- Cache: Redis para entidades frecuentes.
- 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
assertTimeouto@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(
)
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,@SpringBootTestsegú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.