wandres.dev
TESTEAR PLUGINS · busted y plenary

Tests de integración sin intermitencia

Simular al usuario con feedkeys, nvim_input y normal, afirmar sobre el contenido del buffer, el cursor, las extmarks y los diagnósticos, y sustituir las esperas fijas por condiciones con vim.wait. Catálogo de las causas reales de una suite intermitente y cómo eliminar cada una.

⏱ 22 min

Llega el momento en que hay que probar el plugin como lo usa una persona: pulsando teclas y mirando qué pasa en el buffer. Aquí desaparecen las garantías cómodas del núcleo puro y aparece el enemigo característico de este dominio, la prueba que pasa nueve veces y falla la décima sin que nada haya cambiado. Esa intermitencia no es mala suerte ni una limitación del editor: es siempre una carrera entre lo que tu prueba afirma y un trabajo que aún no ha terminado. La disciplina que la elimina cabe en dos ideas: entregar las teclas de forma que sepas cuándo se han consumido, y no afirmar nunca por reloj sino por condición.

🎯 Al terminar esta lección sabrás
  • Elegir entre normal, nvim_feedkeys y nvim_input sabiendo qué garantiza cada uno sobre el momento de ejecución.
  • Afirmar sobre el estado observable: líneas, cursor, modo, extmarks y diagnósticos.
  • Sustituir toda espera fija por una condición explícita con un tiempo máximo.
  • Reconocer y eliminar las cinco causas habituales de una suite intermitente.

Simular al usuario

Hay tres formas de meter teclas en el editor y se diferencian en algo que lo cambia todo: cuándo se ejecutan respecto a la línea siguiente de tu prueba.

La más sencilla ejecuta un comando en modo normal de forma síncrona, y cuando la llamada retorna el efecto ya ocurrió. Es la que debes usar por defecto, y solo abandonarla cuando necesites algo que no puede expresarse así.

vim.cmd("normal! ggdd")                -- sincrono, sin remapeos, ya terminado al volver
vim.cmd.normal({ "ciwhola", bang = false })   -- con remapeos, para probar tus propios mapeos

La segunda encola teclas en el flujo de entrada con control fino sobre remapeo y modo. Requiere traducir la notación de teclas especiales a los bytes reales, y admite una bandera que fuerza a vaciar la cola antes de retornar.

local function teclas(cadena, modo)
  local bytes = vim.api.nvim_replace_termcodes(cadena, true, false, true)
  vim.api.nvim_feedkeys(bytes, modo or "x", false)      -- la x ejecuta ya, sin ella solo encola
end

teclas("ihola mundo<Esc>")             -- entra en insercion, escribe y vuelve a normal
teclas("<leader>rn", "m")              -- con remapeo, para ejercitar un mapeo propio

La tercera se comporta como si las teclas llegaran del terminal: es asíncrona, retorna de inmediato y el editor las procesará cuando le toque. Es la única capaz de atravesar situaciones que bloquean el intérprete, como una petición de confirmación, y por eso mismo es la que más pruebas intermitentes ha causado en la historia de este ecosistema.

vim.api.nvim_input("<CR>")             -- asincrono: aqui NO ha pasado nada todavia
vim.wait(200, function()               -- hay que esperar a la consecuencia observable
  return vim.api.nvim_get_mode().mode == "n"
end)
⚠️
La regla de oro de la simulación

Usa el comando en modo normal siempre que puedas, nvim_feedkeys con vaciado inmediato cuando necesites remapeos o cambios de modo, y nvim_input solo cuando nada más funcione. Cada escalón hacia abajo cambia una garantía por una espera, y cada espera es una oportunidad de intermitencia.

Afirmar sobre el estado observable

Sin interfaz, el estado que puedes inspeccionar es amplio y preciso. Conviene afirmar sobre lo más específico posible: comprobar el buffer entero cuando la operación lo reescribe, pero comprobar una sola línea o una sola marca cuando el efecto es local, porque una aserción demasiado ancha convierte cualquier cambio adyacente en un fallo falso.

local helpers = require("tests.helpers")

it("comenta el bloque seleccionado y deja el cursor donde estaba", function()
  helpers.con_buffer({ "local a = 1", "local b = 2", "local c = 3" }, "lua", function(buf)
    vim.api.nvim_win_set_cursor(0, { 1, 0 })
    vim.cmd("normal! Vj")                       -- seleccion visual de dos lineas
    vim.cmd("normal gc")                        -- el mapeo bajo prueba, con remapeos

    assert.are.same({ "-- local a = 1", "-- local b = 2", "local c = 3" }, helpers.lineas(buf))
    assert.are.same({ 1, 0 }, vim.api.nvim_win_get_cursor(0))
    assert.are.equal("n", vim.api.nvim_get_mode().mode)
  end)
end)

Para los efectos que no viven en el texto, cada subsistema expone su propia consulta, y usarla es preferible a inferir el resultado de forma indirecta.

-- Marcas invisibles: existen aunque nadie las dibuje
local ns = vim.api.nvim_create_namespace("miplugin")
local marcas = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true })
assert.are.equal(1, #marcas)
assert.are.equal("WarningMsg", marcas[1][4].virt_text[1][2])

-- Diagnosticos publicados por tu plugin, y efectos de orquestacion
assert.are.equal(vim.diagnostic.severity.WARN, vim.diagnostic.get(buf, { namespace = ns })[1].severity)
assert.is_not_nil(vim.fn.maparg("gc", "n"))
assert.are.equal(1, #vim.api.nvim_get_autocmds({ group = "MiPlugin", event = "BufWritePost" }))

Esperar por condición, nunca por reloj

Aquí está el noventa por ciento del problema. Cuando el efecto que quieres comprobar lo produce un temporizador, un proceso externo, un servidor de lenguaje o una llamada diferida al hilo principal, la tentación es dormir un rato y afirmar después. Esa prueba no es determinista: es una apuesta sobre la velocidad de la máquina que la ejecuta, y la máquina de integración continua siempre es más lenta y más irregular que la tuya.

La herramienta correcta bloquea procesando eventos hasta que una condición se cumple o hasta agotar un plazo, y devuelve cuál de las dos cosas ocurrió.

-- Mal: apuesta sobre el reloj. Pasa en tu portatil, falla en el servidor
vim.cmd("sleep 100m")
assert.are.equal("listo", estado.fase)

-- Bien: condicion explicita, plazo generoso, sondeo frecuente
local ok = vim.wait(2000, function() return estado.fase == "listo" end, 10)
assert.is_true(ok, "el analisis no termino dentro del plazo")
assert.are.equal("listo", estado.fase)

Tres matices convierten esto en una técnica sólida. El primero: el plazo debe ser generoso y la condición estricta, no al revés; un plazo de dos segundos no ralentiza nada cuando la condición se cumple en veinte milisegundos, porque la espera termina en cuanto es cierta. El segundo: afirma sobre el resultado de la espera, porque si solo compruebas el estado después, un agotamiento del plazo produce un mensaje de error confuso en lugar de decirte que hubo un tiempo excedido. El tercero: la espera procesa el bucle de eventos, de modo que las llamadas diferidas al hilo principal se ejecutan durante ella; ese es exactamente el mecanismo que hace observable un trabajo asíncrono.

-- Envolver el patron una vez y usarlo en toda la suite
local function esperar(cond, mensaje, plazo)
  assert.is_true(vim.wait(plazo or 2000, cond, 10), mensaje or "tiempo excedido")
end

it("carga los resultados del proceso externo", function()
  requiere.buscar("patron")
  esperar(function() return #requiere.resultados() > 0 end, "el proceso no devolvio nada")
  assert.are.equal("init.lua", requiere.resultados()[1].archivo)
end)
flowchart TD
A[Accion simulada] --> B[Trabajo asincrono en curso]
B --> C[Espera con condicion y plazo maximo]
C --> D[Condicion cierta antes del plazo]
C --> E[Plazo agotado]
D --> F[Aserciones sobre el estado observable]
E --> G[Fallo explicito con mensaje de tiempo excedido]
style D fill:#a6e3a1,color:#11111b
style E fill:#f38ba8,color:#11111b

Las cinco fuentes de intermitencia

Casi todos los casos intermitentes que verás en un plugin caen en cinco categorías, y cada una tiene una cura conocida.

La espera fija ya está tratada: sustitúyela por condición. El orden de iteración es más sutil, porque recorrer una tabla asociativa en Lua no garantiza ningún orden, y una prueba que compara la lista resultante contra una secuencia literal pasará o fallará según la disposición interna de la memoria; la cura es ordenar antes de comparar, o comparar conjuntos en lugar de secuencias. El estado residual entre casos se manifiesta como una prueba que solo falla cuando se ejecuta la suite entera, y se cura reconstruyendo todo en before_each en lugar de en el cuerpo del bloque.

La cuarta es la dependencia del entorno real: rutas absolutas, el reloj del sistema, la red, la disponibilidad de un binario, el contenido de un repositorio. Todo eso debe inyectarse o fijarse; una prueba que consulta la fecha actual falla puntualmente en el cambio de día, y una que asume un ejecutable presente falla en el contenedor donde no está.

La quinta, específica de este dominio, es el asincronismo de terceros. Un servidor de lenguaje tarda en conectarse y en indexar, y probar sus resultados exige esperar por el evento que confirma que está listo, no por su arranque. La forma correcta es engancharse al evento que el propio subsistema emite y esperar a la condición que de verdad importa.

before_each(function()
  vim.api.nvim_exec_autocmds("User", { pattern = "MiPluginReset" })
  estado = require("miplugin.estado").nuevo()          -- reconstruido por caso, no compartido
end)

it("usa los simbolos del servidor cuando esta adjunto", function()
  helpers.con_buffer({ "local x = 1" }, "lua", function(buf)
    esperar(function()
      return #vim.lsp.get_clients({ bufnr = buf }) > 0
    end, "ningun cliente se adjunto al buffer")

    local nombres = require("miplugin").simbolos(buf)
    table.sort(nombres)                                  -- orden estable antes de comparar
    assert.are.same({ "x" }, nombres)
  end)
end)
Una prueba intermitente no es una prueba defectuosa: es un defecto de concurrencia que tu programa ya tiene

Aquí conviene resistirse con firmeza a la interpretación que adopta casi todo el mundo, porque es cómoda y es cara. Cuando un caso falla una de cada veinte ejecuciones, el instinto es tratarlo como un problema del test —añadir cien milisegundos de espera, reintentar, marcarlo para saltarlo— y seguir adelante con la sensación de haber resuelto algo. No se ha resuelto nada: se ha silenciado un detector. La intermitencia es, por definición, la manifestación de un orden de eventos no garantizado, y ese orden no lo inventa la suite, ya estaba en tu código. Si tu prueba puede observar el estado antes de que el temporizador dispare, entonces el usuario también puede, y lo hará; si el resultado depende del orden en que se recorre una tabla, la lista que ve el usuario cambia entre sesiones sin motivo aparente; si el servidor de lenguaje aún no se adjuntó cuando tu función pregunta, en producción eso es una lista vacía presentada como si fuera la respuesta correcta. La suite no está siendo quisquillosa: está reproduciendo, en condiciones de carga distintas a las tuyas, exactamente el escenario que tu portátil rápido nunca te enseña y el ordenador cargado de tu usuario sí. De ahí que la máquina de integración continua sea un instrumento científico y no un obstáculo administrativo: es más lenta, más irregular y más contendida que tu entorno, y por eso hace visibles carreras que en local tienen una probabilidad de manifestación despreciable. La consecuencia de todo esto es una regla dura pero justa: cada vez que sientas la tentación de añadir una espera fija para estabilizar un caso, la pregunta correcta no es cuántos milisegundos hacen falta, sino cuál es la condición que estás esperando y por qué tu código no la publica. Casi siempre la respuesta correcta es que el plugin debería exponer ese momento —un evento de usuario, una promesa, una llamada de retorno— y que no hacerlo obliga a todo el mundo, tests incluidos, a adivinar. Así que la suite intermitente termina señalando una carencia de la interfaz pública, y arreglarla mejora el plugin para quien lo integra, no solo para quien lo prueba.

⚔️ Estabilizar lo inestable
  1. Escribe una prueba de integración que ejercite uno de tus mapeos con nvim_feedkeys y compruebe el buffer, el cursor y el modo resultante.
  2. Introduce a propósito una espera fija ajustada al límite y ejecuta la suite cincuenta veces seguidas. Anota cuántas fallan.
  3. Sustituye esa espera por una condición con plazo generoso y repite las cincuenta ejecuciones.
  4. Busca en tu suite alguna comparación contra una lista construida recorriendo una tabla asociativa y hazla determinista.
  5. Identifica un momento importante de tu plugin que hoy no publica ningún evento y añádelo. Reescribe la prueba para esperar a ese evento en lugar de a un efecto secundario.