Considere una función como esta:
function savePeople(dataStore, people) {
people.forEach(person => dataStore.savePerson(person));
}
Se podría usar así:
myDataStore = new Store('some connection string', 'password');
myPeople = ['Joe', 'Maggie', 'John'];
savePeople(myDataStore, myPeople);
Vamos a suponer que Store
tiene sus propias pruebas unitarias, o es proporcionado por el proveedor. En cualquier caso, confiamos Store
. Y supongamos además que el manejo de errores, por ejemplo, los errores de desconexión de la base de datos, no es responsabilidad de savePeople
. De hecho, supongamos que la tienda en sí es una base de datos mágica que no puede tener errores de ninguna manera. Dados estos supuestos, la pregunta es:
¿Debería savePeople()
probarse la unidad, o tales pruebas equivaldrían a probar la construcción del forEach
lenguaje incorporado ?
Podríamos, por supuesto, pasar una burla dataStore
y afirmar que dataStore.savePerson()
se llama una vez por cada persona. Ciertamente, podría argumentar que dicha prueba proporciona seguridad contra los cambios de implementación: por ejemplo, si decidimos reemplazarlo forEach
con un for
bucle tradicional o algún otro método de iteración. Entonces la prueba no es del todo trivial. Y sin embargo, parece terriblemente cerca ...
Aquí hay otro ejemplo que puede ser más fructífero. Considere una función que no hace más que coordinar otros objetos o funciones. Por ejemplo:
function bakeCookies(dough, pan, oven) {
panWithRawCookies = pan.add(dough);
oven.addPan(panWithRawCookies);
oven.bakeCookies();
oven.removePan();
}
¿Cómo debería una función como esta ser probada por unidad, suponiendo que piense que debería? Es difícil para mí imaginar cualquier tipo de prueba de unidad que no se limita a burlan dough
, pan
y oven
, a continuación, afirman que los métodos se llaman en ellos. Pero tal prueba no hace más que duplicar la implementación exacta de la función.
¿Esta incapacidad para probar la función de manera significativa en una caja negra indica un defecto de diseño con la función misma? Si es así, ¿cómo podría mejorarse?
Para dar aún más claridad a la pregunta que motiva el bakeCookies
ejemplo, agregaré un escenario más realista, que es el que he encontrado al intentar agregar pruebas y refactorizar el código heredado.
Cuando un usuario crea una nueva cuenta, deben suceder varias cosas: 1) se debe crear un nuevo registro de usuario en la base de datos 2) se debe enviar un correo electrónico de bienvenida 3) se debe registrar la dirección IP del usuario por fraude propósitos
Por lo tanto, queremos crear un método que vincule todos los pasos del "nuevo usuario":
function createNewUser(validatedUserData, emailService, dataStore) {
userId = dataStore.insertUserRecord(validateduserData);
emailService.sendWelcomeEmail(validatedUserData);
dataStore.recordIpAddress(userId, validatedUserData.ip);
}
Tenga en cuenta que si alguno de estos métodos arroja un error, queremos que el error aparezca en el código de llamada, para que pueda manejar el error como mejor le parezca. Si el código API lo invoca, puede traducir el error en un código de respuesta http apropiado. Si está siendo llamado por una interfaz web, puede traducir el error en un mensaje apropiado que se mostrará al usuario, y así sucesivamente. El punto es que esta función no sabe cómo manejar los errores que se pueden generar.
La esencia de mi confusión es que para probar una función de este tipo, parece necesario repetir la implementación exacta en la prueba misma (especificando que los métodos se invocan en simulacros en un cierto orden) y eso parece incorrecto.
fuente
Respuestas:
¿Se
savePeople()
debe probar la unidad? Si. No está probando quedataStore.savePerson
funcione, o que la conexión db funcione, o incluso queforeach
funcione. Está realizando pruebas quesavePeople
cumplen la promesa que hace a través de su contrato.Imagine este escenario: alguien realiza una gran refactorización de la base del código y elimina accidentalmente la
forEach
parte de la implementación para que siempre guarde solo el primer elemento. ¿No le gustaría una prueba de unidad para atrapar eso?fuente
Por lo general, este tipo de pregunta surge cuando las personas realizan el desarrollo "prueba después". Aborde este problema desde el punto de vista de TDD, donde las pruebas vienen antes de la implementación, y hágase esta pregunta nuevamente como ejercicio.
Al menos en mi aplicación de TDD, que generalmente es de afuera hacia adentro, no estaría implementando una función como
savePeople
después de haberlo implementadosavePerson
. Las funcionessavePeople
ysavePerson
comenzarían como una y serían controladas por las mismas pruebas unitarias; la separación entre los dos surgiría después de algunas pruebas, en el paso de refactorización. Este modo de trabajo también plantearía la cuestión de dóndesavePeople
debería estar la función , si es una función libre o parte de elladataStore
.Al final, las pruebas no solo verificarían si puede guardar correctamente un archivo
Person
enStore
, sino también a muchas personas. Esto también me llevaría a preguntarme si son necesarias otras verificaciones, por ejemplo, "¿Necesito asegurarme de que lasavePeople
función sea atómica, ya sea guardando todo o nada?", "¿Puede de alguna manera devolver errores a las personas que no pudieron t ser guardado? ¿Cómo se verían esos errores? ", y así sucesivamente. Todo esto equivale a mucho más que simplemente verificar el uso de unaforEach
u otras formas de iteración.Sin embargo, si el requisito de salvar a más de una persona a la vez se produjo solo después de que
savePerson
ya se entregó, entonces actualizaría las pruebas existentessavePerson
para ejecutar la nueva funciónsavePeople
, asegurándome de que aún pueda salvar a una persona simplemente delegando al principio, luego pruebe el comportamiento de más de una persona a través de nuevas pruebas, pensando si sería necesario hacer que el comportamiento sea atómico o no.fuente
savePeople
? Como describí en el último párrafo de OP o de alguna otra manera?savePerson
función como usted sugirió, sino que lo probaría de manera más generalsavePeople
. Las pruebas unitariasStore
se cambiarían para ejecutarse ensavePeople
lugar de llamar directamentesavePerson
, por lo que para esto no se utilizan simulacros. Pero la base de datos, por supuesto, no debería estar presente, ya que nos gustaría aislar los problemas de codificación de los diversos problemas de integración que ocurren con las bases de datos reales, por lo que aquí todavía tenemos una simulación.Si, deberia. Pero trate de escribir sus condiciones de prueba de manera independiente de la implementación. Por ejemplo, convirtiendo su ejemplo de uso en una prueba unitaria:
Esta prueba hace varias cosas:
savePeople()
savePeople()
savePeople()
Tenga en cuenta que aún puede simular / resguardar / falsificar el almacén de datos. En este caso, no buscaría llamadas explícitas a funciones, sino el resultado de la operación. De esta manera, mi prueba está preparada para futuros cambios / refactores.
Por ejemplo, la implementación de su almacén de datos podría proporcionar un
saveBulkPerson()
método en el futuro; ahora, un cambio en la implementación desavePeople()
usarsaveBulkPerson()
no interrumpiría la prueba unitaria mientrassaveBulkPerson()
funcione como se esperaba. Y si desaveBulkPerson()
alguna manera no funciona como se esperaba, su prueba de unidad lo captará.Como se dijo, intente probar los resultados esperados y la interfaz de la función, no para la implementación (a menos que esté haciendo pruebas de integración, entonces podría ser útil detectar llamadas a funciones específicas). Si hay varias formas de implementar una función, todas deberían funcionar con la prueba de su unidad.
Con respecto a su actualización de la pregunta:
Prueba de cambios de estado! Por ejemplo, se utilizará parte de la masa. De acuerdo con su implementación, afirme que la cantidad de utilizado
dough
encajapan
o afirme que sedough
está agotando. Afirma quepan
contiene cookies después de la llamada a la función. Afirma queoven
está vacío / en el mismo estado que antes.Para pruebas adicionales, verifique los casos límite: ¿Qué sucede si
oven
no está vacío antes de la llamada? ¿Qué pasa si no hay suficientedough
? Si elpan
ya está lleno?Debería poder deducir todos los datos requeridos para estas pruebas de los propios objetos de masa, sartén y horno. No es necesario capturar las llamadas a funciones. ¡Trate la función como si su implementación no estuviera disponible para usted!
De hecho, la mayoría de los usuarios de TDD escriben sus pruebas antes de escribir la función para que no dependan de la implementación real.
Para su última incorporación:
Para una función como esta, me burlaría / stub / fake (lo que parezca más general) los parámetros
dataStore
yemailService
. Esta función no realiza ninguna transición de estado en ningún parámetro por sí sola, los delega a los métodos de algunos de ellos. Intentaría verificar que la llamada a la función hizo 4 cosas:Los primeros 3 controles se pueden hacer con simulacros, talones o falsificaciones de
dataStore
yemailService
(realmente no desea enviar correos electrónicos durante la prueba). Como tuve que buscar esto para algunos de los comentarios, estas son las diferencias:dataStore
que simplemente implementa una versión adecuada deinsertUserRecord()
yrecordIpAddress()
.Las excepciones / errores esperados son casos de prueba válidos: Usted confirma que, en caso de que ocurra tal evento, la función se comporta de la manera que espera. Esto se puede lograr dejando que el objeto simulado / falso / trozo correspondiente se lance cuando se desee.
A veces esto tiene que hacerse (aunque en general te preocupas por esto en las pruebas de integración). Más a menudo, hay otras formas de verificar los efectos secundarios / cambios de estado esperados.
La verificación de las llamadas a funciones exactas hace que las pruebas unitarias sean bastante frágiles: solo pequeños cambios en la función original hacen que fallen. Esto puede desearse o no, pero requiere un cambio en la (s) prueba (s) de unidad correspondiente (s) cada vez que cambie una función (ya sea refactorización, optimización, corrección de errores, ...).
Lamentablemente, en ese caso, la prueba unitaria pierde parte de su credibilidad: desde que se modificó, no confirma la función después de que el cambio se comporta de la misma manera que antes.
Por ejemplo, considere a alguien agregando una llamada a
oven.preheat()
(¡optimización!) En su ejemplo de horneado de galletas:En mis pruebas unitarias, trato de ser lo más general posible: si la implementación cambia, pero el comportamiento visible (desde la perspectiva de la persona que llama) sigue siendo el mismo, mis pruebas deberían pasar. Idealmente, el único caso que necesito para cambiar una prueba de unidad existente debería ser una corrección de errores (de la prueba, no la función bajo prueba).
fuente
myDataStore.containsPerson('Joe')
, asume la existencia de una base de datos de prueba funcional. Una vez que hace eso, está escribiendo una prueba de integración y no una prueba unitaria.savePeople()
realmente agrega a esas personas a cualquier almacén de datos que proporcione siempre que ese almacén de datos implemente la interfaz esperada. Una prueba de integración sería, por ejemplo, verificar que mi contenedor de base de datos realmente haga las llamadas correctas a la base de datos para una llamada al método.myDataStore.containsPerson('Joe')
, debe usar un db funcional de algún tipo. Una vez que das ese paso, ya no es una prueba unitaria.myPeople
) están en la matriz. En mi humilde opinión, un simulacro aún debe tener el mismo comportamiento observable que se espera de un objeto real, de lo contrario está probando el cumplimiento de la interfaz simulada, no la interfaz real.El valor principal que proporciona esta prueba es que hace que su implementación sea refactible.
Solía hacer muchas optimizaciones de rendimiento en mi carrera y a menudo encontraba problemas con el patrón exacto que demostró: para guardar N entidades en la base de datos, realice N inserciones. Por lo general, es más eficiente hacer una inserción masiva con una sola declaración.
Por otro lado, tampoco queremos optimizar prematuramente. Si por lo general solo guarda de 1 a 3 personas a la vez, escribir un lote optimizado puede ser excesivo.
Con una prueba de unidad adecuada, puede escribirla de la manera en que la implementó anteriormente, y si considera que necesita optimizarla, puede hacerlo con la red de seguridad de una prueba automatizada para detectar cualquier error. Naturalmente, esto varía en función de la calidad de las pruebas, así que pruebe libremente y pruebe bien.
La ventaja secundaria de las pruebas unitarias de este comportamiento es servir como documentación de cuál es su propósito. Este ejemplo trivial puede ser obvio, pero dado el siguiente punto a continuación, podría ser muy importante.
La tercera ventaja, que otros han señalado, es que puede probar detalles ocultos que son muy difíciles de probar con pruebas de integración o aceptación. Por ejemplo, si existe un requisito de que todos los usuarios se guarden atómicamente, entonces puede escribir un caso de prueba para eso, que le brinda una forma de saber que se comporta como se esperaba, y también sirve como documentación para un requisito que puede no ser obvio a nuevos desarrolladores.
Agregaré un pensamiento que recibí de un instructor de TDD. No pruebes el método. Prueba el comportamiento. En otras palabras, no prueba que
savePeople
funciona, está probando que se pueden guardar múltiples usuarios en una sola llamada.Descubrí que mi capacidad para realizar pruebas unitarias de calidad y TDD mejoraba cuando dejé de pensar en las pruebas unitarias como la verificación de que un programa funciona, sino que verificaron que una unidad de código hace lo que esperaba . Esos son diferentes. No verifican que funcione, pero verifican que hace lo que creo que hace. Cuando comencé a pensar de esa manera, mi perspectiva cambió.
fuente
savePerson
Sin embargo, la posible prueba unitaria que sugerí en el OP, que una simulación de dataStore ha solicitado para cada persona en la lista, se rompería con una refactorización de inserción masiva. Lo que para mí indica que es una prueba unitaria pobre. Sin embargo, no veo una alternativa que pasaría tanto las implementaciones masivas como las de guardar por persona, sin usar una base de datos de prueba real y afirmar eso, lo que parece incorrecto. ¿Podría proporcionar una prueba que funcione para ambas implementaciones?savePerson
se llamó al método para cada entrada, y si reemplazara el bucle con una inserción masiva, ya no llamaría a ese método. Entonces tu prueba se rompería. Si tienes algo más en mente, estoy abierto a ello, pero aún no lo veo. (Y no ver que era mi punto.)Debe
bakeCookies()
ser probado? Si.Realmente no. Mire detenidamente QUÉ se supone que debe hacer la función: se supone que debe establecer el
oven
objeto en un estado específico. Al observar el código, parece que los estados de los objetospan
ydough
no importan mucho. Por lo tanto, debe pasar unoven
objeto (o simularlo) y afirmar que se encuentra en un estado particular al final de la llamada a la función.En otras palabras, debe afirmar que
bakeCookies()
horneó las cookies .Para funciones muy cortas, las pruebas unitarias pueden parecer poco más que tautología. Pero no olvide que su programa durará mucho más que el tiempo que está empleado escribiendo. Esa función puede o no cambiar en el futuro.
Las pruebas unitarias cumplen dos funciones:
Prueba que todo funciona. Esta es la función de prueba de unidad menos útil y parece que parece que solo tiene en cuenta esta funcionalidad al hacer la pregunta.
Comprueba que las modificaciones futuras del programa no rompan la funcionalidad que se implementó anteriormente. Esta es la función más útil de las pruebas unitarias y evita la introducción de errores en programas grandes. Es útil en la codificación normal cuando se agregan características al programa, pero es más útil en la refactorización y optimizaciones donde los algoritmos centrales que implementan el programa cambian drásticamente sin cambiar ningún comportamiento observable del programa.
No pruebe el código dentro de la función. En su lugar, pruebe que la función hace lo que dice que hace. Cuando observa las pruebas unitarias de esta manera (funciones de prueba, no código), se dará cuenta de que nunca prueba las construcciones de lenguaje o incluso la lógica de la aplicación. Estás probando una API.
fuente
bakeCookies
se prueban de esta manera, tienden a romperse durante los refactores que no afectarían el comportamiento observable de la aplicación.Si. Pero puede hacerlo de una manera que simplemente vuelva a probar la construcción.
Lo que hay que tener en cuenta aquí es cómo se comporta esta función cuando una
savePerson
falla a mitad de camino. Como se supone que funciona?Ese es el tipo de comportamiento sutil que proporciona la función que debe aplicar con las pruebas unitarias.
fuente
savePeople
no debería ser responsable del manejo de errores. Para aclarar de nuevo, suponiendo quesavePeople
es responsable solo de recorrer la lista y delegar el guardado de cada elemento en otro método, ¿aún debería probarse?foreach
construcción, y no a ninguna condición, efecto secundario o comportamiento fuera de ella, entonces tienes razón; La nueva prueba de unidad no es realmente tan interesante.La clave aquí es su perspectiva sobre una función particular como trivial. La mayor parte de la programación es trivial: asigne un valor, haga algunos cálculos, tome una decisión: si esto es eso, continúe un ciclo hasta ... De forma aislada, todo trivial. Acabas de leer los primeros 5 capítulos de cualquier libro que enseña un lenguaje de programación.
El hecho de que escribir una prueba sea tan fácil debería ser una señal de que su diseño no es tan malo. ¿Prefieres un diseño que no sea fácil de probar?
"Eso nunca cambiará". así es como comienzan la mayoría de los proyectos fallidos. Una prueba de unidad solo determina si la unidad funciona como se espera en un determinado conjunto de circunstancias. Haga que pase y luego puede olvidarse de los detalles de su implementación y simplemente usarlo. Usa ese espacio cerebral para la próxima tarea.
Saber que las cosas funcionan como se espera es muy importante y no trivial en proyectos grandes y especialmente en equipos grandes. Si hay algo que los programadores tienen en común, es el hecho de que todos hemos tenido que lidiar con el terrible código de otra persona. Lo menos que podemos hacer es tener algunas pruebas. En caso de duda, escriba una prueba y siga adelante.
fuente
Esto ya ha sido respondido por @BryanOakley, pero tengo algunos argumentos adicionales (supongo):
Primero, una prueba unitaria es para probar el cumplimiento de un contrato, no la implementación de una API; la prueba debe establecer condiciones previas y luego llamar, luego verificar los efectos, los efectos secundarios, cualquier invariante y las condiciones posteriores. Cuando decides qué probar, la implementación de la API no importa (y no debería) .
En segundo lugar, su prueba estará allí para verificar los invariantes cuando cambie la función . El hecho de que no cambie ahora no significa que no deba hacerse la prueba.
En tercer lugar, es valioso haber implementado una prueba trivial, tanto en un enfoque TDD (que lo exige) como fuera de él.
Cuando escribo C ++, para mis clases, tiendo a escribir una prueba trivial que crea una instancia de un objeto y verifica invariantes (asignables, regulares, etc.). Me sorprendió cuántas veces esta prueba se rompe durante el desarrollo (por ejemplo, al agregar un miembro no móvil en una clase, por error).
fuente
Creo que su pregunta se reduce a:
¿Cómo pruebo la unidad una función vacía sin que sea una prueba de integración?
Si cambiamos su función de horneado de galletas para devolver cookies, por ejemplo, se vuelve inmediatamente obvio cuál debería ser la prueba.
Si tenemos que llamar a pan.GetCookies después de llamar a la función, aunque podemos preguntarnos si es 'realmente una prueba de integración' o 'pero ¿no estamos probando el objeto pan?'
Creo que tiene razón en que tener pruebas unitarias con todo burlado y solo verificar las funciones xy y z se denominó falta de valor.
¡Pero! Yo diría que en este caso deberías refactorizar tus funciones vacías para devolver un resultado comprobable O usar objetos reales y hacer una prueba de integración
--- Actualización para el ejemplo createNewUser
OK, esta vez el resultado de la función no se devuelve fácilmente. Queremos cambiar el estado de los parámetros.
Aquí es donde me pongo un poco controvertido. Creo implementaciones simuladas concretas para los parámetros con estado
por favor, queridos lectores, ¡intenten controlar su ira!
entonces...
Esto separa el detalle de implementación del método bajo prueba del comportamiento deseado. Una implementación alternativa:
Aún pasará la prueba de la unidad. Además, tiene la ventaja de poder reutilizar los objetos simulados en las pruebas y también inyectarlos en su aplicación para UI o pruebas de integración.
fuente
bakeCookies
devolver las galletas horneadas es acertado, y pensé un poco después de publicarlo. Así que creo que una vez más no es un gran ejemplo. Agregué una actualización más que, con suerte, proporciona un ejemplo más realista de lo que motiva mi pregunta. Agradecería su aporte.También debe probar
bakeCookies
: ¿qué debería / debería, por ejemplo,bakeCookies(egg, pan, oven)
producir? Huevo frito o una excepción? Por sí solos,pan
nioven
se preocuparán por los ingredientes reales, ya que se supone que ninguno de ellos debe hacerlo, pero porbakeCookies
lo general deberían producir cookies. De manera más general que puede depender de cómodough
se obtiene y si es alguna posibilidad de que se convierta en meraegg
o, por ejemplowater
en su lugar.fuente