Estructura AAA — Arrange, Act, Assert
Todo test bien escrito tiene tres partes claramente separadas:
- Arrange: preparar el estado inicial — crear objetos, configurar mocks, definir los datos de entrada.
- Act: ejecutar el código bajo prueba — llamar al método, lanzar la acción.
- Assert: verificar el resultado — que el valor es el esperado, que el método fue llamado, que la excepción fue lanzada.
@Test
fun `calcular precio con descuento retorna precio correcto`() {
// Arrange
val useCase = CalcularPrecioConDescuentoUseCase()
val precioOriginal = 100.0
val porcentajeDescuento = 20
// Act
val resultado = useCase(precioOriginal, porcentajeDescuento)
// Assert
assertThat(resultado).isEqualTo(80.0)
}
El primer test — lógica pura sin dependencias
// El código a testear:
class CalcularPrecioConDescuentoUseCase {
operator fun invoke(precio: Double, descuentoPorcentaje: Int): Double {
require(descuentoPorcentaje in 0..100) { "Descuento inválido" }
return precio * (1 - descuentoPorcentaje / 100.0)
}
}
// Los tests:
class CalcularPrecioConDescuentoUseCaseTest {
private val useCase = CalcularPrecioConDescuentoUseCase()
@Test
fun `descuento del 20 porciento calcula correctamente`() {
val resultado = useCase(100.0, 20)
assertThat(resultado).isEqualTo(80.0)
}
@Test
fun `descuento cero retorna precio original`() {
val resultado = useCase(100.0, 0)
assertThat(resultado).isEqualTo(100.0)
}
@Test
fun `descuento del 100 porciento retorna cero`() {
val resultado = useCase(100.0, 100)
assertThat(resultado).isEqualTo(0.0)
}
@Test(expected = IllegalArgumentException::class)
fun `descuento negativo lanza excepcion`() {
useCase(100.0, -10)
}
@Test(expected = IllegalArgumentException::class)
fun `descuento mayor a 100 lanza excepcion`() {
useCase(100.0, 101)
}
}
Testear los casos límite es donde está el valorEl caso "descuento del 20%" lo descubrís si hay un bug obvio. Los casos límite — descuento 0, descuento 100, descuento negativo — son donde los bugs reales se esconden. Siempre incluí los bordes del dominio válido.
Convención de nombres — tests legibles
// Kotlin permite backtick names — usalos en tests
// Formato recomendado: `[método] [condición] [resultado esperado]`
@Test fun `getProductos cuando hay red retorna lista del servidor`() { }
@Test fun `getProductos cuando no hay red retorna cache local`() { }
@Test fun `getProductos cuando cache vacio y sin red lanza IOException`() { }
// Alternativa con given/when/then (más verboso pero muy legible):
@Test fun `dado precio 100 cuando descuento es 20 entonces retorna 80`() { }
// Lo que NO hacer:
@Test fun test1() { } // sin contexto
@Test fun testGetProductos() { } // no describe el escenario ni el resultado
@Before y @After — setup y teardown
class ProductoRepositoryTest {
private lateinit var repository: ProductoRepositoryImpl
private lateinit var mockApi: ProductoApi
private lateinit var mockDao: ProductoDao
@Before
fun setUp() {
// Se ejecuta ANTES de cada test
// Ideal para crear las dependencias frescas por test
mockApi = mockk()
mockDao = mockk()
repository = ProductoRepositoryImpl(mockApi, mockDao)
}
@After
fun tearDown() {
// Se ejecuta DESPUÉS de cada test
// Limpiar recursos, verificar que no quedaron interacciones sin verificar
unmockkAll()
}
@Test
fun `getProductos retorna productos del DAO`() {
// mockApi y mockDao ya están inicializados por @Before
// ...
}
}
Mockk — la librería de mocking para Kotlin
Mockk es la alternativa nativa Kotlin a Mockito. Tiene soporte de primera clase para suspend functions, objetos companion, y propiedades.
// Crear un mock
val mockRepo = mockk<ProductoRepository>()
// Configurar qué retorna cuando se llama un método
every { mockRepo.getProductos() } returns flowOf(listOf(p1, p2))
// Para suspend functions:
coEvery { mockRepo.getProducto(id = 42) } returns producto
// Lanzar excepción:
coEvery { mockRepo.getProducto(any()) } throws IOException("Sin red")
// Retornar diferentes valores en llamadas sucesivas:
coEvery { mockRepo.getProducto(any()) } returnsMany listOf(
producto1, // primera llamada
producto2, // segunda llamada
null // tercera llamada
)
// Capturar el argumento recibido:
val slotId = slot<Int>()
coEvery { mockRepo.getProducto(capture(slotId)) } returns producto
// Después del Act, slotId.captured tiene el valor que se pasó
// Mock relajado — retorna valores default sin configuración explícita:
val relaxedMock = mockk<ProductoRepository>(relaxed = true)
// Todos los métodos retornan emptyList(), 0, false, etc. según el tipo
verify — verificar interacciones
// Verificar que un método fue llamado:
verify { mockRepo.getProductos() }
// Verificar que NO fue llamado:
verify(exactly = 0) { mockRepo.eliminar(any()) }
// Verificar número exacto de llamadas:
verify(exactly = 2) { mockRepo.getProductos() }
// Para suspend functions:
coVerify { mockRepo.guardar(producto) }
// Verificar con el argumento exacto:
coVerify { mockRepo.guardar(withArg { it.nombre == "Producto A" }) }
// Verificar orden de llamadas:
verifyOrder {
mockRepo.getProductos()
mockRepo.guardar(any())
}
Testear excepciones — dos formas
// Forma 1: anotación (solo para funciones normales, no suspend)
@Test(expected = IllegalArgumentException::class)
fun `argumento invalido lanza excepcion`() {
useCase.ejecutar(-1)
}
// Forma 2: assertThrows (preferida — más descriptiva)
@Test
fun `argumento invalido lanza excepcion con mensaje correcto`() {
val excepcion = assertThrows<IllegalArgumentException> {
useCase.ejecutar(-1)
}
assertThat(excepcion.message).contains("inválido")
}
// Para suspend functions con runTest:
@Test
fun `getProducto sin red lanza IOException`() = runTest {
coEvery { mockApi.getProducto(any()) } throws IOException()
assertThrows<IOException> {
repository.getProducto(42)
}
}
Google Truth — assertions legibles
Truth hace los mensajes de error mucho más claros que los assertEquals estándar de JUnit:
import com.google.common.truth.Truth.assertThat
// Comparaciones básicas
assertThat(resultado).isEqualTo(80.0)
assertThat(lista).hasSize(3)
assertThat(lista).contains(producto)
assertThat(lista).containsExactly(p1, p2, p3)
assertThat(lista).isEmpty()
assertThat(lista).isNotEmpty()
assertThat(valor).isNull()
assertThat(valor).isNotNull()
assertThat(booleano).isTrue()
assertThat(booleano).isFalse()
// Strings
assertThat(mensaje).contains("error")
assertThat(mensaje).startsWith("Error:")
assertThat(mensaje).matches("Error: .*")
// Cuando el test falla, Truth muestra:
// "expected: 80.0, but was: 75.0"
// En lugar del cryptico: "junit.framework.AssertionFailedError"