Escribir código comprobable en JavaScript: una breve descripción general

Publicado: 2022-03-11

Ya sea que estemos usando Node junto con un marco de prueba como Mocha o Jasmine, o activando pruebas dependientes de DOM en un navegador sin interfaz como PhantomJS, nuestras opciones para la prueba unitaria de JavaScript son mejores que nunca.

Sin embargo, esto no significa que el código que estamos probando sea tan fácil para nosotros como lo son nuestras herramientas. Organizar y escribir código que se pueda probar fácilmente requiere algo de esfuerzo y planificación, pero hay algunos patrones, inspirados en conceptos de programación funcional, que podemos usar para evitar entrar en una situación difícil cuando llega el momento de probar nuestro código. En este artículo, veremos algunos consejos y patrones útiles para escribir código comprobable en JavaScript.

Mantenga la lógica empresarial y la lógica de visualización separadas

Uno de los trabajos principales de una aplicación de navegador basada en JavaScript es escuchar los eventos DOM activados por el usuario final y luego responder a ellos ejecutando alguna lógica comercial y mostrando los resultados en la página. Es tentador escribir una función anónima que haga la mayor parte del trabajo justo donde está configurando sus detectores de eventos DOM. El problema que esto crea es que ahora tiene que simular eventos DOM para probar su función anónima. Esto puede crear una sobrecarga tanto en las líneas de código como en el tiempo que tardan en ejecutarse las pruebas.

En su lugar, escriba una función con nombre y pásela al controlador de eventos. De esa manera, puede escribir pruebas para funciones nombradas directamente y sin pasar por los aros para desencadenar un evento DOM falso.

Sin embargo, esto se aplica a más que el DOM. Muchas API, tanto en el navegador como en Node, están diseñadas para disparar y escuchar eventos o esperar a que se completen otros tipos de trabajo asíncrono. Una regla general es que si está escribiendo muchas funciones de devolución de llamada anónimas, es posible que su código no sea fácil de probar.

 // hard to test $('button').on('click', () => { $.getJSON('/path/to/data') .then(data => { $('#my-list').html('results: ' + data.join(', ')); }); }); // testable; we can directly run fetchThings to see if it // makes an AJAX request without having to trigger DOM // events, and we can run showThings directly to see that it // displays data in the DOM without doing an AJAX request $('button').on('click', () => fetchThings(showThings)); function fetchThings(callback) { $.getJSON('/path/to/data').then(callback); } function showThings(data) { $('#my-list').html('results: ' + data.join(', ')); }

Usar devoluciones de llamada o promesas con código asíncrono

En el ejemplo de código anterior, nuestra función fetchThings refactorizada ejecuta una solicitud AJAX, que realiza la mayor parte de su trabajo de forma asíncrona. Esto significa que no podemos ejecutar la función y probar que hizo todo lo que esperábamos, porque no sabremos cuándo terminó de ejecutarse.

La forma más común de resolver este problema es pasar una función de devolución de llamada como parámetro a la función que se ejecuta de forma asíncrona. En sus pruebas unitarias, puede ejecutar sus afirmaciones en la devolución de llamada que pasa.

Ilustración: uso de una función de devolución de llamada como parámetro en pruebas unitarias

Otra forma común y cada vez más popular de organizar el código asíncrono es con la API de Promise. Afortunadamente, $.ajax y la mayoría de las demás funciones asincrónicas de jQuery ya devuelven un objeto Promise, por lo que muchos casos de uso comunes ya están cubiertos.

 // hard to test; we don't know how long the AJAX request will run function fetchData() { $.ajax({ url: '/path/to/data' }); } // testable; we can pass a callback and run assertions inside it function fetchDataWithCallback(callback) { $.ajax({ url: '/path/to/data', success: callback, }); } // also testable; we can run assertions when the returned Promise resolves function fetchDataWithPromise() { return $.ajax({ url: '/path/to/data' }); }

Evite los efectos secundarios

Escriba funciones que tomen argumentos y devuelvan un valor basado únicamente en esos argumentos, al igual que introducir números en una ecuación matemática para obtener un resultado. Si su función depende de algún estado externo (las propiedades de una instancia de clase o el contenido de un archivo, por ejemplo) y tiene que configurar ese estado antes de probar su función, debe realizar más configuraciones en sus pruebas. Tendrá que confiar en que cualquier otro código que se esté ejecutando no altere ese mismo estado.

Ilustración: efecto de cascada causado por un estado externo.

Del mismo modo, evite escribir funciones que alteren el estado externo (como escribir en un archivo o guardar valores en una base de datos) mientras se ejecuta. Esto evita efectos secundarios que podrían afectar su capacidad para probar otro código con confianza. En general, es mejor mantener los efectos secundarios lo más cerca posible de los bordes del código, con la menor "área de superficie" posible. En el caso de clases e instancias de objetos, los efectos secundarios de un método de clase deben limitarse al estado de la instancia de clase que se está probando.

 // hard to test; we have to set up a globalListOfCars object and set up a // DOM with a #list-of-models node to test this code function processCarData() { const models = globalListOfCars.map(car => car.model); $('#list-of-models').html(models.join(', ')); } // easy to test; we can pass an argument and test its return value, without // setting any global values on the window or checking the DOM the result function buildModelsString(cars) { const models = cars.map(car => car.model); return models.join(','); }

Usar inyección de dependencia

Un patrón común para reducir el uso del estado externo de una función es la inyección de dependencia: pasar todas las necesidades externas de una función como parámetros de función.

 // depends on an external state database connector instance; hard to test function updateRow(rowId, data) { myGlobalDatabaseConnector.update(rowId, data); } // takes a database connector instance in as an argument; easy to test! function updateRow(rowId, data, databaseConnector) { databaseConnector.update(rowId, data); }

Uno de los principales beneficios de usar la inyección de dependencia es que puede pasar objetos simulados de sus pruebas unitarias que no causan efectos secundarios reales (en este caso, actualizar las filas de la base de datos) y puede simplemente afirmar que se actuó sobre su objeto simulado de la manera esperada.

Dale a cada función un solo propósito

Divide las funciones largas que hacen varias cosas en una colección de funciones cortas de un solo propósito. Esto hace que sea mucho más fácil probar que cada función hace su parte correctamente, en lugar de esperar que una función grande esté haciendo todo correctamente antes de devolver un valor.

En la programación funcional, el acto de encadenar varias funciones de un solo propósito se llama composición. Underscore.js incluso tiene una función _.compose , que toma una lista de funciones y las encadena juntas, tomando el valor de retorno de cada paso y pasándolo a la siguiente función en línea.

 // hard to test function createGreeting(name, location, age) { let greeting; if (location === 'Mexico') { greeting = '!Hola'; } else { greeting = 'Hello'; } greeting += ' ' + name.toUpperCase() + '! '; greeting += 'You are ' + age + ' years old.'; return greeting; } // easy to test function getBeginning(location) { if (location === 'Mexico') { return 'Hola'; } else { return 'Hello'; } } function getMiddle(name) { return ' ' + name.toUpperCase() + '! '; } function getEnd(age) { return 'You are ' + age + ' years old.'; } function createGreeting(name, location, age) { return getBeginning(location) + getMiddle(name) + getEnd(age); }

No mutar parámetros

En JavaScript, las matrices y los objetos se pasan por referencia en lugar de por valor, y son mutables. Esto significa que cuando pasa un objeto o una matriz como parámetro a una función, tanto su código como la función a la que pasó el objeto o la matriz tienen la capacidad de modificar la misma instancia de esa matriz u objeto en la memoria. Esto significa que si está probando su propio código, debe confiar en que ninguna de las funciones a las que llama su código está alterando sus objetos. Cada vez que agrega un nuevo lugar en su código que modifica el mismo objeto, se vuelve cada vez más difícil hacer un seguimiento de cómo debería verse ese objeto, lo que dificulta la prueba.

Ilustración: La mutación de parámetros puede causar problemas

En cambio, si tiene una función que toma un objeto o una matriz, haga que actúe sobre ese objeto o matriz como si fuera de solo lectura. Cree un nuevo objeto o matriz en el código y agréguele valores según sus necesidades. O bien, utilice el guión bajo o Lodash para clonar el objeto o la matriz pasados ​​antes de operar en él. Aún mejor, use una herramienta como Immutable.js que crea estructuras de datos de solo lectura.

 // alters objects passed to it function upperCaseLocation(customerInfo) { customerInfo.location = customerInfo.location.toUpperCase(); return customerInfo; } // sends a new object back instead function upperCaseLocation(customerInfo) { return { name: customerInfo.name, location: customerInfo.location.toUpperCase(), age: customerInfo.age }; }

Escriba sus pruebas antes de su código

El proceso de escribir pruebas unitarias antes del código que están probando se denomina desarrollo dirigido por pruebas (TDD). Muchos desarrolladores consideran que TDD es muy útil.

Al escribir sus pruebas primero, se ve obligado a pensar en la API que está exponiendo desde la perspectiva de un desarrollador que la consume. También ayuda a asegurarse de que solo está escribiendo suficiente código para cumplir con el contrato que sus pruebas imponen, en lugar de diseñar en exceso una solución que es innecesariamente compleja.

En la práctica, TDD es una disciplina con la que puede ser difícil comprometerse para todos los cambios de código. Pero cuando parece que vale la pena intentarlo, es una excelente manera de garantizar que mantiene todo el código comprobable.

Envolver

Todos sabemos que hay algunas trampas en las que es muy fácil caer al escribir y probar aplicaciones JavaScript complejas. Pero con suerte, con estos consejos, y recordando siempre mantener nuestro código lo más simple y funcional posible, ¡podemos mantener nuestra cobertura de prueba alta y la complejidad general del código baja!

Relacionados:
  • Los 10 errores más comunes que cometen los desarrolladores de JavaScript
  • The Need for Speed: una retrospectiva del desafío de codificación JavaScript de Toptal