Versión: 0.1.1-alpha.3
Fecha: 2026-03-17
Frameworks: JUnit 5 · Kotlin Coroutines Test · MockK · Robolectric
- Filosofía de Testing
- Utilitarios de Test del SDK
- Tests Unitarios del Core
- Tests de Integración — Transporte
- Tests en la App Consumidora
- Configuración de Gradle
El diseño por traits hace que cada componente sea testeable de forma aislada. No se necesita un dispositivo real ni red para testear el 90% del SDK.
/──────────────\
/ E2E (manual) \ ← Dispositivo real + Supabase staging
/──────────────────\
/ Integration \ ← FakeTransport + Robolectric
/────────────────────────\
/ Unit Tests \ ← JUnit 5 + MockK + runTest
/────────────────────────────\
El SDK nunca debe crashear ni lanzar excepciones al código de la app. Cada test debe verificar que bajo condiciones adversas (red caída, buffer lleno, crash en el transport), el SDK absorbe el fallo silenciosamente.
El módulo logger-test provee implementaciones de prueba listas para usar:
// Usar cuando el logger es un parámetro requerido pero no es el foco del test
val logger: AppLogger = NoOpLogger()// Uso en tests:
val logger = InMemoryLogger()
// Ejecutar el código bajo test
myComponent.performOperation()
// Verificar los logs emitidos
assertEquals(1, logger.errorCount)
assertTrue(logger.lastError?.message?.contains("expected error text") == true)
logger.assertLogged(LogLevel.ERROR, tag = "PAYMENT")
logger.assertNotLogged(LogLevel.DEBUG) // En modo producción, debug no se logueaval fakeTransport = FakeTransport(shouldSucceed = true)
// Simular fallo de red:
val failingTransport = FakeTransport(shouldSucceed = false, retryable = true)
// Verificar que se enviaron N eventos:
assertEquals(3, fakeTransport.sentEvents.size)
assertEquals(LogLevel.ERROR, fakeTransport.sentEvents.first().level)@Test
fun `rate limit filter blocks events exceeding threshold`() {
val filter = RateLimitFilter(maxEventsPerMinutePerTag = 3)
val tag = "TEST_TAG"
// Los primeros 3 eventos del mismo tag pasan
repeat(3) {
val event = buildTestEvent(tag = tag, level = LogLevel.INFO)
assertTrue(filter.passes(event))
}
// El 4to evento es bloqueado
val blockedEvent = buildTestEvent(tag = tag, level = LogLevel.INFO)
assertFalse(filter.passes(blockedEvent))
}
@Test
fun `rate limit filter always passes ERROR and CRITICAL events`() {
val filter = RateLimitFilter(maxEventsPerMinutePerTag = 1)
val tag = "TEST_TAG"
// Agotar el rate limit con INFO
filter.passes(buildTestEvent(tag = tag, level = LogLevel.INFO))
filter.passes(buildTestEvent(tag = tag, level = LogLevel.INFO))
// ERROR siempre pasa, incluso después del rate limit
val errorEvent = buildTestEvent(tag = tag, level = LogLevel.ERROR)
assertTrue(filter.passes(errorEvent))
}@Test
fun `error event triggers immediate flush`() = runTest {
val fakeTransport = FakeTransport(shouldSucceed = true)
val logger = buildTestLogger(
transport = fakeTransport,
batchSize = 20, // Batch alto — normalmente no se llena
flushIntervalSeconds = 300 // Intervalo largo — el flush periódico no dispara
)
// Un ERROR debe forzar flush inmediato sin esperar a que el batch se llene
logger.error("TAG", "Something failed")
advanceUntilIdle() // Kotlin Coroutines Test: ejecutar todas las coroutines pendientes
assertEquals(1, fakeTransport.sentEvents.size)
assertEquals(LogLevel.ERROR, fakeTransport.sentEvents.first().level)
}
@Test
fun `debug events are not sent in production mode`() = runTest {
val fakeTransport = FakeTransport(shouldSucceed = true)
val logger = buildTestLogger(transport = fakeTransport, isDebugMode = false)
logger.debug("TAG", "Debug message")
advanceUntilIdle()
assertTrue(fakeTransport.sentEvents.isEmpty())
}@Test
fun `failed transport does not crash the logger`() = runTest {
val failingTransport = FakeTransport(shouldSucceed = false, throwException = true)
val logger = buildTestLogger(transport = failingTransport)
// No debe lanzar ninguna excepción
assertDoesNotThrow {
logger.error("TAG", "Error message")
}
advanceUntilIdle()
// La app sigue funcionando
}@Test
fun `calling logger before initialize does not crash`() {
// AppLoggerSDK comienza como NoOpLogger — nunca lanza excepciones
assertDoesNotThrow {
AppLoggerSDK.error("TAG", "Early message")
AppLoggerSDK.critical("TAG", "Critical before init", RuntimeException("test"))
AppLoggerSDK.flush()
}
}@Test
@Tag("integration") // Marcar para no correr en CI normal
fun `supabase transport sends batch successfully`() = runTest {
val transport = SupabaseTransport(
endpoint = SUPABASE_STAGING_URL, // Variable de entorno en CI
apiKey = SUPABASE_STAGING_ANON_KEY
)
val events = List(3) { buildTestEvent(level = LogLevel.INFO) }
val result = transport.send(events)
assertIs<TransportResult.Success>(result)
}
@Test
@Tag("integration")
fun `supabase transport fails gracefully with invalid key`() = runTest {
val transport = SupabaseTransport(
endpoint = SUPABASE_STAGING_URL,
apiKey = "invalid_key"
)
val events = listOf(buildTestEvent(level = LogLevel.ERROR))
val result = transport.send(events)
assertIs<TransportResult.Failure>(result)
assertEquals(false, (result as TransportResult.Failure).retryable) // 401 no es retryable
}La app consumidora puede inyectar InMemoryLogger en sus ViewModels para testear sin dependencia del SDK de producción.
// ViewModel de producción
class PaymentViewModel(
private val paymentRepository: PaymentRepository,
private val logger: AppLogger = AppLoggerSDK // Default: SDK de producción
) : ViewModel() {
fun processPayment(amount: Double) {
viewModelScope.launch {
try {
paymentRepository.pay(amount)
logger.info("PAYMENT", "Payment successful", extra = mapOf("amount" to amount))
} catch (e: Exception) {
logger.error("PAYMENT", "Payment failed", throwable = e)
}
}
}
}
// Test del ViewModel
@Test
fun `payment failure is logged as error`() = runTest {
val inMemoryLogger = InMemoryLogger()
val failingRepo = mockk<PaymentRepository> { coEvery { pay(any()) } throws IOException("timeout") }
val viewModel = PaymentViewModel(
paymentRepository = failingRepo,
logger = inMemoryLogger
)
viewModel.processPayment(99.0)
advanceUntilIdle()
assertEquals(1, inMemoryLogger.errorCount)
inMemoryLogger.assertLogged(LogLevel.ERROR, tag = "PAYMENT")
}Las versiones se gestionan desde gradle/libs.versions.toml. Configuración en logger-core/build.gradle.kts:
// logger-core/build.gradle.kts — sourceSets
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
}
jvmTest.dependencies {
implementation(libs.junit5.api)
runtimeOnly(libs.junit5.engine)
implementation(libs.mockk)
}tasks.withType<Test> {
useJUnitPlatform()
excludeTags("integration")
}./gradlew test -Pintegration \
-PSUPABASE_STAGING_URL="https://staging.supabase.co" \
-PSUPABASE_STAGING_ANON_KEY="eyJ..."