unit testing - tipos - ¿Por qué las pruebas unitarias deben probar solo una cosa?
tipos de pruebas de software (17)
La verdadera pregunta es por qué hacer una prueba o más para todos los métodos, ya que pocas pruebas que cubren muchos métodos son más simples.
Bueno, entonces cuando falla alguna prueba, sabes qué método falla.
Cuando tiene que reparar un automóvil que no funciona, es más fácil saber qué parte del motor está fallando.
Un ejemplo: una clase de lista. ¿Por qué debería hacer pruebas separadas para agregar y eliminar? Una prueba que primero agrega luego elimina los sonidos más simples.
Supongamos que el método de adición está roto y no se agrega, y que el método de eliminación está roto y no se elimina. Su prueba verificará que la lista, después de la adición y eliminación, tenga el mismo tamaño que inicialmente. Tu prueba sería exitosa. Aunque ambos métodos se romperían.
¿Qué hace una buena prueba de unidad? dice que una prueba debería probar solo una cosa. ¿Cuál es el beneficio de eso?
¿No sería mejor escribir pruebas un poco más grandes que prueban un bloque de código más grande? Investigar una falla en la prueba es de todos modos difícil y no veo ayuda en pruebas más pequeñas.
Editar: La unidad de palabra no es tan importante. Digamos que considero la unidad un poco más grande. Ese no es el problema aqui. La verdadera pregunta es por qué hacer una prueba o más para todos los métodos, ya que pocas pruebas que cubren muchos métodos son más simples.
Un ejemplo: una clase de lista. ¿Por qué debería hacer pruebas separadas para agregar y eliminar? Una prueba que primero agrega luego elimina los sonidos más simples.
Apoyo la idea de que las pruebas unitarias solo deberían probar una cosa. También me desvío un poco. Hoy tuve una prueba en la que la instalación costosa parecía forzarme a hacer más de una afirmación por prueba.
namespace Tests.Integration
{
[TestFixture]
public class FeeMessageTest
{
[Test]
public void ShouldHaveCorrectValues
{
var fees = CallSlowRunningFeeService();
Assert.AreEqual(6.50m, fees.ConvenienceFee);
Assert.AreEqual(2.95m, fees.CreditCardFee);
Assert.AreEqual(59.95m, fees.ChangeFee);
}
}
}
Al mismo tiempo, realmente quería ver todas mis afirmaciones que fallaban, no solo la primera. Esperaba que todos fallaran, y necesitaba saber qué cantidades realmente estaba recuperando. Pero, un estándar [SetUp] con cada prueba dividida causaría 3 llamadas al servicio lento. De repente, recordé un artículo que sugería que el uso de construcciones de prueba "no convencionales" es donde se oculta la mitad del beneficio de las pruebas unitarias. (Creo que fue una publicación de Jeremy Miller, pero no puedo encontrarla ahora.) De repente me vino a la mente [TestFixtureSetUp], y me di cuenta de que podía hacer una única llamada de servicio, pero aún tenía métodos de prueba expresivos por separado.
namespace Tests.Integration
{
[TestFixture]
public class FeeMessageTest
{
Fees fees;
[TestFixtureSetUp]
public void FetchFeesMessageFromService()
{
fees = CallSlowRunningFeeService();
}
[Test]
public void ShouldHaveCorrectConvenienceFee()
{
Assert.AreEqual(6.50m, fees.ConvenienceFee);
}
[Test]
public void ShouldHaveCorrectCreditCardFee()
{
Assert.AreEqual(2.95m, fees.CreditCardFee);
}
[Test]
public void ShouldHaveCorrectChangeFee()
{
Assert.AreEqual(59.95m, fees.ChangeFee);
}
}
}
Hay más código en esta prueba, pero proporciona mucho más valor mostrándome todos los valores que no coinciden con las expectativas a la vez.
Un colega también señaló que esto es un poco como specunit.net de Scott Bellware: http://code.google.com/p/specunit-net/
Con el desarrollo basado en pruebas, primero debe escribir sus pruebas y luego escribir el código para pasar la prueba. Si sus pruebas están enfocadas, esto hace que escribir el código para pasar la prueba sea más fácil.
Por ejemplo, podría tener un método que tome un parámetro. Una de las cosas en las que podría pensar primero es, ¿qué debería pasar si el parámetro es nulo? Debería lanzar una excepción ArgumentNull (creo). Así que escribo una prueba que verifica si se lanza esa excepción cuando paso un argumento nulo. Ejecute la prueba. De acuerdo, arroja NotImplementedException. Voy y lo soluciono al cambiar el código para lanzar una excepción ArgumentNull. Ejecuta mi prueba que pasa. Entonces pienso, ¿qué pasa si es demasiado pequeño o demasiado grande? Ah, son dos pruebas. Yo escribo el caso demasiado pequeño primero.
El punto es que no pienso en el comportamiento del método todo a la vez. Lo construyo de forma incremental (y lógica) pensando en lo que debería hacer, luego implemento el código y la refactorización a medida que avanzo para que se vea bonito (elegante). Esta es la razón por la cual las pruebas deben ser pequeñas y enfocadas porque cuando se piensa en el comportamiento se debe desarrollar en incrementos pequeños y comprensibles.
Cuando una prueba falla, hay tres opciones:
- La implementación está rota y debe ser arreglada.
- La prueba está rota y debe arreglarse.
- La prueba ya no es necesaria y debe eliminarse.
Las pruebas detalladas con nombres descriptivos ayudan al lector a saber por qué se escribió la prueba, lo que a su vez hace que sea más fácil saber cuál de las opciones anteriores elegir. El nombre de la prueba debe describir el comportamiento que se especifica en la prueba, y solo un comportamiento por prueba , de modo que con solo leer los nombres de las pruebas el lector sepa qué hace el sistema. Vea este artículo para más información.
Por otro lado, si una prueba está haciendo muchas cosas diferentes y tiene un nombre no descriptivo (como pruebas nombradas después de los métodos en la implementación), entonces será muy difícil averiguar la motivación detrás de la prueba, y será difícil saber cuándo y cómo cambiar la prueba.
Esto es lo que puede parecer (con GoSpec ), cuando cada prueba prueba solo una cosa:
func StackSpec(c gospec.Context) {
stack := NewStack()
c.Specify("An empty stack", func() {
c.Specify("is empty", func() {
c.Then(stack).Should.Be(stack.Empty())
})
c.Specify("After a push, the stack is no longer empty", func() {
stack.Push("foo")
c.Then(stack).ShouldNot.Be(stack.Empty())
})
})
c.Specify("When objects have been pushed onto a stack", func() {
stack.Push("one")
stack.Push("two")
c.Specify("the object pushed last is popped first", func() {
x := stack.Pop()
c.Then(x).Should.Equal("two")
})
c.Specify("the object pushed first is popped last", func() {
stack.Pop()
x := stack.Pop()
c.Then(x).Should.Equal("one")
})
c.Specify("After popping all objects, the stack is empty", func() {
stack.Pop()
stack.Pop()
c.Then(stack).Should.Be(stack.Empty())
})
})
}
Descargo de responsabilidad: esta es una respuesta muy influenciada por el libro "xUnit Test Patterns".
Probar solo una cosa en cada prueba es uno de los principios más básicos que ofrece los siguientes beneficios:
- Localización de defectos : si falla una prueba, inmediatamente sabrá por qué falló (idealmente sin más resolución de problemas, si ha hecho un buen trabajo con las afirmaciones utilizadas).
- Prueba como una especificación : las pruebas no solo existen como una red de seguridad, sino que también se pueden usar fácilmente como especificaciones / documentación. Por ejemplo, un desarrollador debe ser capaz de leer las pruebas unitarias de un solo componente y comprender la API / contrato de la misma, sin necesidad de leer la implementación (aprovechando el beneficio de la encapsulación).
- Incompatibilidad de TDD : TDD se basa en tener pedazos de funcionalidad de tamaño pequeño y completar iteraciones progresivas de (escribir prueba de falla, escribir código, verificar que la prueba sea exitosa). Este proceso se ve muy alterado si una prueba tiene que verificar varias cosas.
- La falta de efectos secundarios : algo relacionado con el primero, pero cuando una prueba verifica varias cosas, es más posible que también esté relacionado con otras pruebas. Por lo tanto, es posible que estas pruebas necesiten un accesorio de prueba compartido, lo que significa que uno se verá afectado por el otro. Por lo tanto, con el tiempo es posible que falle una prueba, pero en realidad otra prueba es la que causó la falla, por ejemplo, al cambiar los datos del dispositivo.
Solo puedo ver una razón por la cual podría beneficiarse de tener una prueba que verifique varias cosas, pero esto debería verse como un olor de código en realidad:
- Optimización del rendimiento : hay algunos casos en los que las pruebas no solo se ejecutan en la memoria, sino que también dependen del almacenamiento persistente (p. Ej., Bases de datos). En algunos de estos casos, al realizar una prueba, verifique varias cosas que pueden ayudar a disminuir el número de accesos al disco, disminuyendo así el tiempo de ejecución. Sin embargo, las pruebas unitarias deberían ser ejecutables solo en la memoria, por lo que si se encuentra con un caso así, debería reconsiderar si va por el camino equivocado. Todas las dependencias persistentes deben reemplazarse por objetos simulados en pruebas unitarias. La funcionalidad de extremo a extremo debe estar cubierta por un conjunto diferente de pruebas de integración. De esta forma, ya no necesita preocuparse por el tiempo de ejecución, ya que las pruebas de integración generalmente las ejecutan los desarrolladores y no los desarrolladores, por lo que un tiempo de ejecución ligeramente mayor casi no tiene impacto en la eficiencia del ciclo de vida del desarrollo del software.
En cuanto a su ejemplo: si está probando agregar y eliminar en la misma prueba unitaria, ¿cómo verifica que el elemento se haya agregado alguna vez a su lista? Es por eso que necesita agregar y verificar que fue agregado en una prueba.
O para usar el ejemplo de la lámpara: si quiere probar su lámpara y todo lo que hace es encender y apagar el interruptor, ¿cómo sabe si la lámpara se encendió alguna vez? Debe tomar el paso intermedio para mirar la lámpara y verificar que esté encendida. Luego puede apagarlo y verificar que se apagó.
Haaa ... pruebas unitarias.
Empuje cualquier "directiva" demasiado lejos y rápidamente se vuelve inutilizable.
Prueba de prueba de una sola unidad, una sola cosa es tan buena práctica como un solo método hace una sola tarea. Pero en mi humilde opinión, eso no significa que una sola prueba solo contenga una sola afirmación.
Es
@Test
public void checkNullInputFirstArgument(){...}
@Test
public void checkNullInputSecondArgument(){...}
@Test
public void checkOverInputFirstArgument(){...}
...
mejor que
@Test
public void testLimitConditions(){...}
es una cuestión de gusto en mi opinión en lugar de una buena práctica. Yo personalmente prefiero este último.
Pero
@Test
public void doesWork(){...}
es en realidad lo que la "directiva" quiere que evites a toda costa y lo que agota mi cordura lo más rápido posible.
Como conclusión final, agrupe las cosas que están relacionadas semánticamente y que pueden comprobarse fácilmente juntas, de modo que un mensaje de prueba fallido, en sí mismo, sea realmente lo suficientemente significativo como para que vaya directamente al código.
Regla general aquí en un informe de prueba fallido: si primero tiene que leer el código de la prueba, entonces su prueba no está estructurada lo suficientemente bien y necesita más división en pruebas más pequeñas.
Mis 2 centavos.
La respuesta GLib, pero afortunadamente útil, es esa unidad = uno. Si prueba más de una cosa, entonces no está realizando pruebas unitarias.
Las pruebas que verifican más de una cosa no suelen recomendarse porque están más estrechamente unidas y quebradizas. Si cambia algo en el código, tomará más tiempo cambiar la prueba, ya que hay más cosas para tener en cuenta.
[Editar:] Ok, dicen que este es un método de prueba de muestra:
[TestMethod]
public void TestSomething() {
// Test condition A
// Test condition B
// Test condition C
// Test condition D
}
Si su prueba para la condición A falla, entonces B, C y D también parecerán fallar y no le proporcionarán ninguna utilidad. ¿Qué pasa si el cambio de código hubiera causado que C también fallara? Si los hubieras dividido en 4 pruebas separadas, sabrías esto.
Las pruebas que verifican solo una cosa facilitan la resolución de problemas. No quiere decir que tampoco deba tener pruebas que prueben varias cosas, o múltiples pruebas que compartan la misma configuración / desmontaje.
Aquí debería haber un ejemplo ilustrativo. Digamos que tienes una clase de pila con consultas:
- getSize
- esta vacio
- getTop
y métodos para mutar la pila
- push (anObject)
- popular()
Ahora, considere el siguiente caso de prueba (estoy usando Python como pseudocódigo para este ejemplo).
class TestCase():
def setup():
self.stack = new Stack()
def test():
stack.push(1)
stack.push(2)
stack.pop()
assert stack.top() == 1, "top() isn''t showing correct object"
assert stack.getSize() == 1, "getSize() call failed"
A partir de este caso de prueba, puede determinar si algo está mal, pero no si está aislado de las implementaciones push()
o pop()
, o las consultas que devuelven valores: top()
y getSize()
.
Si agregamos casos de prueba individuales para cada método y su comportamiento, las cosas se vuelven mucho más fáciles de diagnosticar. Además, al hacer una configuración nueva para cada caso de prueba, podemos garantizar que el problema está completamente dentro de los métodos que el método de prueba fallido llamó.
def test_size():
assert stack.getSize() == 0
assert stack.isEmpty()
def test_push():
self.stack.push(1)
assert stack.top() == 1, "top returns wrong object after push"
assert stack.getSize() == 1, "getSize wrong after push"
def test_pop():
stack.push(1)
stack.pop()
assert stack.getSize() == 0, "getSize wrong after push"
En lo que se refiere al desarrollo basado en pruebas. Personalmente escribo "pruebas funcionales" más grandes que terminan probando múltiples métodos al principio, y luego creo pruebas unitarias a medida que comienzo a implementar piezas individuales.
Otra forma de verlo es que las pruebas unitarias verifican el contrato de cada método individual, mientras que las pruebas más grandes verifican el contrato que deben seguir los objetos y el sistema en su conjunto.
Todavía test_push
tres llamadas a métodos en test_push
, sin embargo, top()
y getSize()
son consultas que se prueban con métodos de prueba separados.
Puede obtener una funcionalidad similar añadiendo más afirmaciones a la prueba individual, pero luego se ocultarían las fallas de afirmación posteriores.
Otra desventaja práctica de las pruebas unitarias muy granulares es que rompe el principio DRY . He trabajado en proyectos donde la regla era que cada método público de una clase tenía que tener una prueba de unidad (a [TestMethod]). Obviamente, esto agregó un poco de sobrecarga cada vez que creaste un método público, pero el verdadero problema fue que agregó algo de "fricción" a la refactorización.
Es similar a la documentación de nivel de método, es bueno tenerlo, pero es otra cosa que debe mantenerse y hace que cambiar la firma o el nombre de un método sea un poco más engorroso y ralentiza la refabricación de hilo dental (como se describe en "Herramientas de refabricación: aptitud para Propósito " por Emerson Murphy-Hill y Andrew P. Black. PDF, 1.3 MB).
Al igual que la mayoría de las cosas en el diseño, hay una compensación que la frase "una prueba debería probar solo una cosa" no captura.
Piensa en construir un auto. Si tuviera que aplicar su teoría, simplemente probar grandes cosas, entonces ¿por qué no hacer una prueba para conducir el automóvil a través del desierto? Se descompone. Bien, entonces dime qué causó el problema. No puedes. Esa es una prueba de escenario.
Una prueba funcional puede ser encender el motor. Falla. Pero eso podría deberse a una serie de razones. Aún no podías decirme exactamente qué fue lo que causó el problema. Sin embargo, nos estamos acercando.
Una prueba unitaria es más específica, y en primer lugar identificará dónde se rompe el código, pero también (si se usa TDD) ayudará a diseñar su código en fragmentos claros y modulares.
Alguien mencionó el uso del rastro de la pila. Olvídalo. Ese es un segundo recurso. Pasar por el seguimiento de la pila o usar la depuración es un problema y puede llevar mucho tiempo. Especialmente en sistemas más grandes, y errores complejos.
Buenas características de una prueba unitaria:
- Rápido (milisegundos)
- Independiente. No se ve afectado ni depende de otras pruebas
- Claro. No debe estar hinchado o contener una gran cantidad de configuración.
Probar una sola cosa aislará esa única cosa y probará si funciona o no. Esa es la idea con pruebas unitarias. No hay nada malo con las pruebas que prueban más de una cosa, pero eso generalmente se conoce como pruebas de integración. Ambos tienen méritos, basados en el contexto.
Para usar un ejemplo, si su lámpara de noche no se enciende, y usted reemplaza la bombilla y cambia el cable de extensión, no sabe qué cambio solucionó el problema. Debería haber hecho las pruebas unitarias y ha separado sus preocupaciones para aislar el problema.
Si está probando más de una cosa, se llama una prueba de integración ... no una prueba de unidad. Todavía ejecutará estas pruebas de integración en el mismo marco de prueba que las pruebas de su unidad.
Las pruebas de integración generalmente son más lentas, las pruebas unitarias son rápidas porque todas las dependencias son falsificadas / falsificadas, por lo que no hay servicio de base de datos / web / llamadas de servicio lento.
Ejecutamos nuestras pruebas unitarias en el compromiso con el control de origen, y nuestras pruebas de integración solo se ejecutan en la compilación nocturna.
Si prueba más de una cosa y lo primero que falla es que no sabrá si las siguientes cosas que está probando pasan o no. Es más fácil de arreglar cuando sabes todo lo que fallará.
Una prueba de unidad más pequeña deja más claro dónde está el problema cuando fallan.
Voy a dar un paso aquí y decir que el consejo de "solo probar una cosa" no es tan útil como a veces se dice.
A veces, las pruebas requieren una cierta cantidad de configuración. En ocasiones, incluso pueden tardar cierto tiempo en configurarse (en el mundo real). A menudo puedes probar dos acciones de una vez.
Pro: solo tienes que configurar todo una vez. Tus pruebas después de la primera acción demostrarán que el mundo es como esperas que sea antes de la segunda acción. Menos código, prueba más rápida.
Con: si cualquiera de las acciones falla, obtendrás el mismo resultado: la misma prueba fallará. Tendrá menos información sobre dónde está el problema que si solo tuviera una sola acción en cada una de las dos pruebas.
En realidad, encuentro que la "estafa" aquí no es un gran problema. La traza de pila a menudo reduce las cosas muy rápidamente, y me aseguraré de arreglar el código de todos modos.
Un "truco" ligeramente diferente aquí es que rompe el ciclo "escribir una nueva prueba, hacerlo pasar, refactorizar". Lo veo como un ciclo ideal , pero que no siempre refleja la realidad. A veces es simplemente más pragmático agregar una acción adicional y verificar (o posiblemente solo otro cheque a una acción existente) en una prueba actual que crear una nueva.