Qué se puede testear de un plugin
Por qué un plugin de editor no se testea como una librería: el proceso es un singleton mutable y casi todo el valor está en los efectos. Separar núcleo puro, adaptadores y orquestación, situar la frontera donde aparece la palabra vim, e inyectar la superficie del editor para poder sustituirla.
La primera pregunta al testear un plugin no es qué framework usar, sino qué parte del plugin es siquiera testeable. Una librería normal se prueba porque sus funciones son cerradas: entra un valor, sale otro, y nada más ocurre. Un plugin de Neovim, en cambio, existe para provocar efectos sobre un editor global que ya está corriendo, y una función que lee el buffer actual, escribe en él y notifica al usuario no tiene, en sentido estricto, ningún valor de retorno que comprobar. La respuesta madura no es rendirse ni montar un simulador del editor entero: es mover la frontera, refactorizando hasta que la parte que decide sea pura y la parte que actúa sea trivial.
- Distinguir núcleo puro, adaptadores del editor y orquestación, y asignar a cada capa su forma de prueba.
- Situar la frontera de testabilidad exactamente donde el módulo empieza a mencionar
vim. - Diseñar módulos que reciban su superficie del editor por inyección en lugar de invocarla directamente.
- Decidir con criterio qué no se testea: envoltorios triviales, la propia API y la apariencia final.
El editor es un singleton mutable
Un proceso de Neovim no es un entorno de ejecución limpio en el que instanciar objetos y desecharlos. Es un único estado global compartido: opciones, buffers, ventanas, registros, marcas, autocomandos, namespaces, temporizadores y variables globales viven en el mismo espacio y sobreviven a la función que los tocó. De ahí salen las tres patologías que hacen incómodo el testeo de plugins.
La primera es que no hay aislamiento gratuito: cada prueba deja residuos —un buffer abierto, una opción cambiada, un autocomando registrado— que contaminan la siguiente. La segunda es que el orden importa, y una suite que pasa entera puede fallar si ejecutas un solo caso aislado, o al revés. La tercera es que el doble de prueba clásico encaja mal, porque la dependencia no es un objeto que se inyecta sino una tabla global que el intérprete expone en todas partes.
Míralo en el peor caso posible, una función que mezcla las tres responsabilidades en siete líneas.
-- Dificil de testear: decision, lectura del editor y escritura, todo junto
local function renombrar_simbolo(nombre)
local buf = vim.api.nvim_get_current_buf()
local fila = vim.api.nvim_win_get_cursor(0)[1]
local texto = vim.api.nvim_buf_get_lines(buf, fila - 1, fila, false)[1]
local nueva = texto:gsub("([%a_][%w_]*)%s*=", nombre .. " =", 1)
vim.api.nvim_buf_set_lines(buf, fila - 1, fila, false, { nueva })
vim.notify("renombrado")
end
Para comprobar la única regla interesante que hay aquí —qué considera el plugin una asignación y cómo la reescribe— necesitas una instancia real, un buffer con contenido, un cursor colocado y algo que intercepte la notificación. Estás pagando el precio de una prueba de integración para verificar una sustitución de texto que es aritmética pura.
Si para probar una decisión de tres líneas necesitas montar un buffer, colocar el cursor y silenciar una notificación, el problema no es el framework de pruebas. Es que la decisión y el efecto están en la misma función, y el coste de la prueba te lo está diciendo.
Tres capas y una frontera
La arquitectura que hace testeable un plugin no es sofisticada; es una separación en tres capas con una regla operativa exacta para saber en cuál estás.
El núcleo contiene funciones totales sobre datos: reciben cadenas, tablas y números, y devuelven cadenas, tablas y números. No leen configuración global, no consultan el reloj y, sobre todo, no mencionan vim. Aquí vive lo que realmente distingue a tu plugin de cualquier otro: normalizar rutas, ordenar y puntuar candidatos, calcular rangos, validar y fusionar la configuración del usuario, decidir transiciones de una máquina de estados, parsear la salida de un proceso externo.
Los adaptadores son la traducción entre el estado del editor y esos datos. Leen el buffer, obtienen el cursor, escriben líneas, emiten diagnósticos. Deben ser deliberadamente aburridos: cuanto más se parezcan a una tubería sin ramificaciones, menos hay que probar en ellos.
La orquestación es el pegamento declarativo: comandos de usuario, mapeos, autocomandos, la función de configuración. Se comprueba de forma indirecta, verificando que el efecto ocurre, no leyendo su código.
-- nucleo.lua: sin una sola mencion a vim, luego testeable en un instante
local M = {}
function M.renombrar(linea, nombre)
if type(linea) ~= "string" or type(nombre) ~= "string" or nombre == "" then
return nil, "argumentos invalidos"
end
local nueva, n = linea:gsub("([%a_][%w_]*)%s*=", nombre .. " =", 1)
if n == 0 then return nil, "sin asignacion en la linea" end
return nueva
end
return M
-- adaptador.lua: traduce estado del editor a datos y de vuelta, sin decidir nada
local nucleo = require("miplugin.nucleo")
local M = {}
function M.renombrar_en_cursor(nombre)
local buf = vim.api.nvim_get_current_buf()
local fila = vim.api.nvim_win_get_cursor(0)[1]
local actual = vim.api.nvim_buf_get_lines(buf, fila - 1, fila, false)[1] or ""
local nueva, err = nucleo.renombrar(actual, nombre)
if not nueva then
return vim.notify(err, vim.log.levels.WARN)
end
vim.api.nvim_buf_set_lines(buf, fila - 1, fila, false, { nueva })
end
return M
La regla operativa cabe en una frase: si el archivo menciona vim, es adaptador; si no, es núcleo. Es mecánica, verificable con una búsqueda, y basta para que un revisor detecte en segundos que una decisión se ha colado del lado equivocado. Su consecuencia práctica es una asimetría deseable: el núcleo crece en casos de prueba baratos y rápidos, y el adaptador se mantiene tan delgado que unas pocas pruebas de integración lo cubren entero.
flowchart TD A[Comando o mapeo] --> B[Adaptador que lee el estado] B --> C[Nucleo puro que decide] C --> B B --> D[Estado global de Neovim] C --> E[Test unitario rapido y determinista] B --> F[Test de integracion dentro de una instancia] style C fill:#a6e3a1,color:#11111b style D fill:#f38ba8,color:#11111b style E fill:#89b4fa,color:#11111b
Inyectar la superficie del editor
A veces la lógica interesante necesita de verdad hablar con el editor, y partirla en dos la vuelve artificial. La salida entonces no es la separación sino la inversión: el módulo declara qué operaciones necesita y las recibe como parámetro, de modo que en producción reciba las reales y en la prueba unas dobles que registran lo que se les pide.
local M = {}
local real = {
lineas = function(buf, a, b) return vim.api.nvim_buf_get_lines(buf, a, b, false) end,
escribir = function(buf, a, b, l) vim.api.nvim_buf_set_lines(buf, a, b, false, l) end,
avisar = function(msg) vim.notify(msg) end,
}
function M.nuevo(editor)
return setmetatable({ editor = editor or real }, { __index = M })
end
function M:sangrar(buf, desde, hasta, ancho)
local prefijo = string.rep(" ", ancho)
local origen = self.editor.lineas(buf, desde, hasta)
local salida = {}
for i, l in ipairs(origen) do
salida[i] = l == "" and l or (prefijo .. l)
end
self.editor.escribir(buf, desde, hasta, salida)
return #salida
end
return M
Con esa forma, la prueba construye un editor falso de doce líneas que devuelve un contenido fijo y guarda lo que se le manda escribir, y verifica el comportamiento completo —incluida la decisión de no sangrar líneas vacías— sin abrir un solo buffer. Y no es solo una comodidad de testeo: la inyección documenta con precisión qué superficie del editor toca cada módulo, algo que en un plugin de tamaño medio nadie sabe de memoria.
Núcleo puro
Datos dentro, datos fuera. Pruebas de milisegundos, sin instancia, sin limpieza, sin orden significativo.
Adaptador delgado
Traduce y nada más. Se cubre con unas pocas pruebas de integración que valen por todas.
Superficie inyectada
Declarar las operaciones necesarias permite sustituirlas y, de paso, revela el acoplamiento real.
Qué no merece la pena testear
Un catálogo de exclusiones honesto vale tanto como uno de inclusiones. No pruebes los envoltorios de una línea que solo reenvían argumentos: la prueba repite la implementación y falla cuando la refactorizas, no cuando la rompes. No pruebes la API de Neovim: que escribir líneas en un buffer funcione ya está verificado aguas arriba. No pruebes la apariencia, es decir, colores concretos o posiciones de píxel; sí puedes comprobar que existe una extmark con el grupo esperado en la posición esperada. No pruebes tiempos exactos de temporizadores ni el orden de callbacks concurrentes: comprueba el estado final tras esperar a una condición, no el cronómetro.
Y sobre todo, no persigas el porcentaje de líneas cubiertas. En un plugin, esa métrica premia exactamente lo contrario de lo que quieres: es facilísimo cubrir adaptadores triviales invocándolos una vez, y difícil cubrir los casos límite del núcleo, que son los que rompen en producción. Un plugin con el sesenta por ciento de cobertura concentrada en su lógica de decisión es mucho más fiable que uno con el noventa repartido a ciegas.
Aquí hay una inversión causal que conviene aceptar antes de escribir la primera prueba, porque ahorra meses. La sensación universal al empezar es que los plugins de editor son difíciles de testear y que hace falta encontrar la herramienta adecuada. Es falso. Lo que ocurre es que un plugin de editor castiga inmediatamente el mal diseño y una librería normal lo tolera durante años. Cuando una función mezcla decisión y efecto en una librería, el resultado es un módulo algo feo que nadie prioriza arreglar; cuando lo hace en un plugin, la prueba se vuelve literalmente imposible de escribir sin arrancar un editor completo, y la dificultad se te aparece como un problema de infraestructura cuando en realidad es un diagnóstico arquitectónico. La dificultad de testear es, en este dominio, la señal más fiable y más temprana de acoplamiento que existe, y su valor no está en la prueba resultante sino en la refactorización que te obliga a hacer para poder escribirla. Fíjate en lo que ocurre cuando aplicas la regla de la frontera durante una tarde sobre un plugin ya escrito: no aparecen tests, aparece el plugin que querías haber escrito. La lógica de decisión emerge concentrada en dos o tres módulos que puedes leer de un tirón, los efectos se reducen a tuberías sin ramas, y de pronto ves duplicaciones y casos límite que llevaban meses ocultos entre llamadas a la API. Ese es el motivo real por el que merece la pena, y por el que la pregunta correcta nunca fue cómo testear lo que ya tienes, sino qué forma debe tener para que testearlo sea aburrido. La suite es el subproducto; el diseño es el producto.
- Elige una función de un plugin tuyo que mezcle decisión y efecto, y pártela en un núcleo sin
vimy un adaptador que solo traduzca. - Ejecuta una búsqueda de la palabra
vimsobre tu directorio de código y clasifica cada archivo como núcleo u adaptador. Anota los que quedan a medias. - Toma un módulo que llame a cinco o seis funciones de la API e inyéctalas como una tabla de operaciones. Escribe el doble de prueba y mide cuántas líneas necesita.
- Redacta la lista de lo que tu plugin nunca va a probar y justifica cada exclusión en una frase.
- Identifica la parte de tu código con más casos límite y comprueba si hoy es núcleo puro. Si no lo es, describe qué refactorización la llevaría allí.