Un test concurrente no debería adivinar cuándo avanzar
Dormir un rato no demuestra que una operación llegó al estado esperado. Cómo probar una cola con señales explícitas y limpieza incluso cuando algo falla.
El test quería comprobar algo sencillo: si todos los lugares de un semáforo están ocupados, la siguiente operación tiene que esperar. Cuando se libera uno, le toca a quien ya estaba en la cola.
Para preparar esa escena, algunas operaciones retenían sus lugares durante una pausa corta. El test esperaba un poco menos y comprobaba que la siguiente todavía no hubiera entrado.
Parecía razonable. Pero había una condición escondida: la máquina tenía que volver a ejecutar la aserción antes de que terminaran aquellas pausas. Con el event loop atrasado, los lugares podían liberarse primero. El test encontraba una operación en marcha y denunciaba una violación que él mismo había permitido.
La prueba no controlaba el orden que pretendía verificar. Apostaba a llegar a tiempo.
Dormir no es sincronizar
Una pausa significa “no continúes antes de este plazo”. No significa “cuando vuelvas, las otras operaciones seguirán donde las dejaste”.
En una máquina ocupada, varios temporizadores pueden quedar listos antes de que el test recupere el control. Los márgenes que funcionaban durante una ejecución aislada dejan de representar el orden real.
Esto importa especialmente para una afirmación negativa:
La operación que espera todavía no pudo entrar.
Para sostenerla, necesitás saber que todos los lugares siguen ocupados. Haberlos ocupado hace un rato no alcanza. Si sus titulares pueden liberarlos por su cuenta, la condición puede desaparecer antes de observarla.
El problema no se arregla necesariamente agregando más espera. Podés darle todavía más tiempo al evento que querías impedir.
Tres formas de encarar la prueba
Una alternativa es agrandar las pausas de los titulares y ajustar las del test. Puede reducir la frecuencia del fallo, pero mantiene la dependencia de una relación entre tiempos. Además, vuelve más lenta la suite.
Otra es usar un reloj simulado. Sirve cuando querés probar vencimientos, reintentos o demoras. Requiere entender qué temporizadores controla y cómo se procesan las promesas; no reemplaza automáticamente la coordinación entre tareas.
La tercera es que el test decida cuándo se libera cada recurso. Si lo que importa es el orden, conviene expresar el orden directamente.
Ese fue el cambio concreto: reemplazar la retención por tiempo con promesas que la propia prueba puede resolver. También se reemplazaron pausas de preparación por esperas de un estado observable.
El código del semáforo no cambió. Cambió la forma de construir la situación que lo ponía a prueba.
Una señal en lugar de una suposición
La secuencia puede escribirse sin hablar de milisegundos:
- Ocupá todos los lugares y retenelos detrás de señales controladas por el test.
- Comprobá que el semáforo los registra como ocupados.
- Lanzá otra operación y esperá hasta observarla en la cola.
- Comprobá que no entró. Ningún titular recibió todavía la señal de liberación.
- Liberá un lugar y esperá a que avance la operación encolada.
- Liberá los recursos restantes y verificá el estado final.
Una promesa controlable es suficiente para armar esa barrera. Este ejemplo es didáctico, no una implementación completa de una prueba:
function crearSenal() {
let liberar;
const espera = new Promise((resolve) => {
liberar = resolve;
});
return { espera, liberar };
}
const salida = crearSenal();
// La tarea conserva el recurso hasta que el test lo permita.
const tarea = conRecurso(() => salida.espera);
try {
await esperarEstado("recurso ocupado");
// Acá se preparan y comprueban las operaciones que esperan.
} finally {
salida.liberar();
await tarea;
}
conRecurso y esperarEstado representan las operaciones del sistema bajo prueba. Lo importante es quién controla la salida: no un reloj que sigue corriendo mientras mirás otra cosa, sino la prueba que necesita conservar esa condición.
Esperar un estado también necesita un plazo máximo. La diferencia es que ese plazo sirve para detectar un cuelgue, no para decidir que la preparación seguramente terminó. El avance normal depende de una condición observada.
La limpieza forma parte de la prueba
Había otro problema en la versión anterior: si una aserción fallaba antes de liberar un lugar retenido manualmente, ese recurso quedaba ocupado. El semáforo conservaba estado entre pruebas y el fallo podía alterar los intentos siguientes.
Eso vuelve confuso el diagnóstico. Una falla inicial cambia el escenario y las que vienen después ya no están probando lo mismo.
Mover la liberación a un finally permite ejecutarla aunque falle una aserción. Después conviene esperar a que terminen las tareas iniciadas y comprobar que no queden recursos ocupados ni operaciones en cola.
No confundas limpiar con borrar la evidencia. Poner el contador en cero a la fuerza puede esconder que una operación nunca liberó lo que debía. Primero observá el estado que querés verificar; después asegurá la limpieza de los recursos que la prueba adquirió. Si necesitás una recuperación de emergencia para aislar la suite, mantenela separada de las aserciones de comportamiento.
Comprobar el traspaso, no sólo la espera
Retener los lugares resuelve la preparación, pero todavía falta una pregunta: ¿el lugar liberado pasa realmente al siguiente de la cola?
Una comprobación más precisa introduce un competidor justo después de liberar, antes de ceder otra vez el control. Ese recién llegado no debería adelantarse a quien ya esperaba.
La prueba agregada expresa ese orden con adquisiciones y liberaciones explícitas: observa el lugar reservado para el siguiente, registra al nuevo solicitante en la cola y comprueba quién avanza primero. No necesita acertar una pausa intermedia.
La revisión del cambio permite verificar esas propiedades en la estructura del test: los titulares ya no salen por tiempo, la liberación está protegida y el orden tiene aserciones propias. Eso no equivale a afirmar que la suite completa pasó ni que desapareció toda intermitencia. Son comprobaciones distintas.
Para validar una corrección así, ejecutá la prueba aislada y dentro de su suite, conservá los resultados y revisá también qué ocurre cuando una aserción falla. Un intento verde no demuestra por sí solo que el problema quedó eliminado.
Cuándo el reloj sí es parte del contrato
Si el comportamiento que querés probar es un timeout, no podés quitar el tiempo de la historia. Podés controlar el reloj o tolerar variaciones del entorno, pero necesitás comprobar el vencimiento.
Aun ahí, separá las responsabilidades: mantené ocupado el recurso con una señal y usá el tiempo para probar únicamente que la espera vence. No hagas que dos temporizadores compitan accidentalmente para preparar el caso.
Tampoco una señal explícita garantiza que toda implementación concurrente sea correcta. Esta técnica cubre los órdenes que construís; no explora por sí sola todas las intercalaciones posibles ni sustituye pruebas de carga.
La pregunta útil antes de agregar otro sleep es: ¿estoy probando una duración o esperando que algo haya ocurrido? Si es lo segundo, buscá el estado o la señal que lo demuestra. El test debería fallar porque se rompió el contrato, no porque llegó tarde a mirar.