wandres.dev
TESTEAR PLUGINS · busted y plenary

plenary.nvim y busted: describe, it y aserciones

Qué es busted como especificación y qué reimplementa plenary dentro del editor. Bloques anidados, ciclo de vida con before_each y after_each, el vocabulario de aserciones de luassert, espías y stubs, y cómo lanzar la suite entera o un solo archivo desde una instancia de Neovim.

⏱ 20 min

busted es el marco de pruebas canónico de Lua, y su gracia no está en el ejecutor sino en la gramática: bloques describe que agrupan, bloques it que afirman, y un ciclo de vida que garantiza estado limpio entre casos. El problema es que su ejecutor corre en un intérprete de Lua normal, y ahí la tabla vim no existe. plenary.nvim resuelve la tensión de la única forma sensata: reimplementa esa gramática dentro de Neovim, de modo que escribes pruebas con la sintaxis que ya conoce toda la comunidad y las ejecutas en un proceso donde la API del editor está viva. Entender qué parte es especificación y qué parte es reimplementación evita la mitad de los tropiezos.

🎯 Al terminar esta lección sabrás
  • Separar busted como especificación de sintaxis de plenary como ejecutor dentro del editor.
  • Estructurar una suite con bloques anidados y un ciclo de vida que garantice aislamiento entre casos.
  • Manejar el vocabulario de luassert: igualdad estructural frente a identidad, errores esperados, espías y stubs.
  • Lanzar la suite completa o un archivo suelto, y leer la salida cuando algo falla.

Especificación frente a ejecutor

Conviene fijar el reparto desde el principio. busted aporta tres cosas: un vocabulario de bloques, un ejecutor por línea de comandos y una biblioteca de aserciones llamada luassert. plenary.nvim toma el vocabulario y la biblioteca de aserciones, descarta el ejecutor y lo sustituye por uno propio que vive en el módulo plenary.busted y corre dentro de una instancia de Neovim arrancada en modo headless.

La consecuencia práctica es que no todo lo que documenta busted está disponible. Tienes describe, it, before_each, after_each y pending. No tienes la mayoría de banderas del ejecutor original, ni etiquetas para filtrar, ni aleatorización del orden, ni salidas en formatos alternativos listas para consumir. A cambio tienes lo único que aquí importa de verdad: la tabla vim completamente funcional dentro de cada caso.

⚠️
No instales busted para testear un plugin de Neovim

Es el error de arranque más frecuente. Instalar busted por gestor de paquetes de Lua te da un ejecutor externo que no puede tocar el editor, y acabarás intentando simular la API entera. Para un plugin, el ejecutor es Neovim y el marco lo aporta plenary. La única razón legítima para el busted original es una librería de Lua pura sin ninguna dependencia del editor.

Bloques, ciclo de vida y aislamiento

Un archivo de pruebas es un programa Lua que se ejecuta de arriba abajo: los describe construyen el árbol y los it registran los casos. El detalle que separa una suite fiable de una frágil es dónde se crea el estado. Todo lo que escribas en el cuerpo de un describe se ejecuta una sola vez, al construir el árbol, y por tanto se comparte entre todos los casos que cuelgan de él; lo que escribas en before_each se ejecuta antes de cada caso y es lo que garantiza el aislamiento.

-- tests/nucleo_spec.lua
local nucleo = require("miplugin.nucleo")

describe("nucleo.renombrar", function()
  local estado

  before_each(function()
    estado = { llamadas = 0 }        -- se reconstruye para cada caso
  end)

  after_each(function()
    estado = nil                     -- limpieza explicita, aunque aqui sea simbolica
  end)

  describe("cuando la linea contiene una asignacion", function()
    it("sustituye solo el primer identificador", function()
      local salida = nucleo.renombrar("total = a + total", "suma")
      assert.are.equal("suma = a + total", salida)
    end)

    it("conserva el resto de la linea intacto", function()
      local salida = nucleo.renombrar("  x = f(1)   -- nota", "y")
      assert.are.equal("  y = f(1)   -- nota", salida)
    end)
  end)

  describe("cuando la entrada es invalida", function()
    it("devuelve nil y un mensaje", function()
      local salida, err = nucleo.renombrar("sin asignacion", "y")
      assert.is_nil(salida)
      assert.are.equal("sin asignacion en la linea", err)
    end)

    it("rechaza un nombre vacio", function()
      local _, err = nucleo.renombrar("a = 1", "")
      assert.are.equal("argumentos invalidos", err)
    end)
  end)

  pending("normalizar nombres con acentos")
end)

Los describe anidados no son decoración: el nombre del caso que se imprime al fallar es la concatenación de todos los niveles, así que una jerarquía bien elegida convierte el informe de errores en una frase legible. La convención de nombrar el archivo terminado en _spec.lua tampoco es estética: el ejecutor la usa para descubrir qué archivos son pruebas.

El vocabulario de las aserciones

luassert ofrece una sintaxis encadenada donde las palabras de relleno se ignoran, de modo que assert.are.equal y assert.equals son la misma función. Esa flexibilidad es cómoda y peligrosa a la vez, porque invita a inventar cadenas que no existen y que fallan en silencio como si fueran ciertas.

La distinción capital es entre identidad y igualdad estructural. En Lua, dos tablas con el mismo contenido no son iguales, y esa es la trampa que se lleva por delante a casi todo el mundo la primera semana, porque los resultados interesantes de un plugin son casi siempre tablas.

assert.are.equal(3, 1 + 2)                        -- identidad, comparacion con dos iguales
assert.are.same({ 1, 2 }, { 1, 2 })               -- igualdad estructural, recursiva
assert.are_not.same({ a = 1 }, { a = 2 })         -- su negacion
assert.is_true(cond)                              -- exactamente true, no un valor veraz
assert.is_truthy(cond)                            -- cualquier cosa que no sea nil ni false
assert.is_nil(valor)
assert.has_error(function() explota() end, "mensaje esperado")
assert.has_no.errors(function() seguro() end)
assert.matches("^error:", mensaje)                -- patron de Lua, no expresion regular

Para verificar interacciones en lugar de valores, luassert incorpora espías y stubs, que son la contraparte de la superficie inyectada del capítulo anterior: el espía envuelve una función y registra sus llamadas sin alterar el comportamiento; el stub la sustituye por completo.

it("avisa una sola vez cuando no hay coincidencia", function()
  local avisos = {}
  local editor = {
    lineas   = function() return { "sin asignacion" } end,
    escribir = function() error("no deberia escribir") end,
    avisar   = function(msg) avisos[#avisos + 1] = msg end,
  }

  local s = spy.on(editor, "avisar")
  require("miplugin.adaptador").nuevo(editor):renombrar(0, 1, "y")

  assert.spy(s).was.called(1)
  assert.spy(s).was.called_with("sin asignacion en la linea")
  assert.are.same({ "sin asignacion en la linea" }, avisos)
end)
💡
Afirma sobre datos, no sobre llamadas, siempre que puedas

Una aserción sobre el valor devuelto sobrevive a las refactorizaciones; una sobre el número exacto de invocaciones se rompe en cuanto cambias el reparto interno sin cambiar el comportamiento. Usa espías solo cuando el efecto sea el comportamiento: que se notifique al usuario, que se lance un proceso, que se escriba en disco.

Ejecutar la suite

El ejecutor se invoca desde dentro de Neovim mediante dos comandos que plenary registra, y ambos aceptan un archivo de arranque mínimo para que la suite no herede tu configuración personal.

# Toda la carpeta, en un proceso sin interfaz que termina con codigo de salida
nvim --headless -c "PlenaryBustedDirectory tests/ { minimal_init = 'tests/minimal_init.lua' }"

# Un unico archivo, util mientras iteras sobre un fallo concreto
nvim --headless -c "PlenaryBustedFile tests/nucleo_spec.lua"
-- tests/minimal_init.lua: entorno reproducible y minimo
vim.opt.runtimepath:append(".")                          -- el plugin bajo prueba
vim.opt.runtimepath:append(vim.env.HOME .. "/.local/share/nvim/lazy/plenary.nvim")
vim.cmd("runtime plugin/plenary.vim")
vim.opt.swapfile = false

El comando de directorio hace algo que conviene saber: arranca un proceso nuevo por cada archivo de pruebas. Es más lento que ejecutarlos todos juntos, y a cambio te regala el aislamiento más fuerte que existe, porque ningún residuo global puede cruzar de un archivo a otro. Esa decisión de diseño explica también por qué el archivo suelto tarda tan poco y por qué la suite entera crece linealmente en tiempo de arranque.

flowchart TD
A[Comando PlenaryBustedDirectory] --> B[Descubre archivos que terminan en spec]
B --> C[Lanza un proceso headless por archivo]
C --> D[Carga el init minimo y el runtimepath]
D --> E[Ejecuta describe e it con la tabla vim viva]
E --> F[Informe por caso y codigo de salida]
F --> G[Exito o fallo para el proceso padre]
style E fill:#a6e3a1,color:#11111b
style G fill:#89b4fa,color:#11111b
El marco de pruebas no es una dependencia: es la decisión de qué es tu unidad de aislamiento

Hay una lectura de plenary.busted que solo aparece cuando dejas de verlo como un puerto conveniente de busted y empiezas a preguntarte por qué tuvo que existir. La respuesta es que un marco de pruebas no aporta principalmente aserciones —esas son diez líneas de Lua que cualquiera escribe en una tarde— sino una definición operativa de unidad de aislamiento, y esa definición no puede tomarse prestada de otro dominio. En una librería normal la unidad es la llamada a función, porque el intérprete garantiza que nada persiste entre ellas; el marco solo tiene que ordenar la ejecución e informar. En un editor esa garantía no existe: entre dos casos consecutivos siguen ahí los buffers creados, las opciones tocadas, los autocomandos registrados y los temporizadores en vuelo, de modo que la unidad de aislamiento tiene que construirse, y construirla cuesta o bien disciplina exhaustiva en before_each o bien un proceso nuevo. Que plenary elija lo segundo por archivo, sacrificando velocidad, es exactamente la clase de decisión que revela que el problema real nunca fue la sintaxis. Y ahí está lo que de verdad se lleva uno de este capítulo: la sintaxis de describe y de it es intercambiable y casi irrelevante, mientras que la frontera de aislamiento determina qué fallos puede detectar tu suite y cuáles va a ocultar para siempre. Una suite con aislamiento por proceso encuentra las fugas de estado global —la opción que un caso deja mal, el autocomando que nadie limpió— porque las hace fallar de forma reproducible al ejecutar el archivo aislado. Una suite que lo comparte todo pasa en verde mientras esconde precisamente los defectos más caros de depurar en producción, que en un plugin son casi siempre de estado residual. Elegir el marco es, sin que nadie lo diga en la documentación, elegir qué clase de errores estás dispuesto a no ver.

⚔️ Tu primera suite real
  1. Crea tests/minimal_init.lua y un archivo _spec.lua para tu módulo puro, con al menos dos niveles de describe anidados.
  2. Escribe una aserción de igualdad de tablas usando la comparación por identidad, obsérvala fallar y corrígela con la estructural.
  3. Añade un caso que espere un error concreto y otro que verifique el mensaje con un patrón de Lua.
  4. Sustituye una dependencia por un stub y comprueba con un espía que se llama exactamente una vez y con los argumentos previstos.
  5. Ejecuta la suite completa y luego un solo archivo. Cronometra ambas y explica de dónde sale la diferencia.