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"