Nota
El acceso a esta página requiere autorización. Puede intentar iniciar sesión o cambiar directorios.
El acceso a esta página requiere autorización. Puede intentar cambiar los directorios.
Código asincrónico de pruebas de unidades
Descargar el código de muestra
Las pruebas de unidades es una piedra angular del desarrollo moderno. Los beneficios de las pruebas de unidades para un proyecto se entienden bastante bien: Las pruebas de unidades disminuyen el número de errores, reducen el tiempo de salida al mercado y disuaden del diseño demasiado acoplado. Esas son todos excelentes beneficios pero hay más ventajas que son directamente relevantes para los desarrolladores. Cuando escribo pruebas de unidades, puedo tener una confianza mucho mayor en el código. Es más sencillo agregar características o corregir errores en código probado porque las pruebas de unidades actúan como una red de seguridad mientras está cambiando el código.
La escritura de pruebas de unidades para código asincrónico conlleva algunos desafíos únicos. Además, el estado actual de la compatibilidad asincrónica en los marcos de simulación y unidad de prueba varía y todavía se está desarrollando. Este artículo considerará MSTest, NUnit y xUnit, pero los principios generales se aplican a cualquier marco de pruebas de unidad. La mayoría de los ejemplos de este artículo usarán la sintaxis de MSTest pero señalaré las diferencias en el comportamiento sobre la marcha. La descarga del código contiene ejemplos de los tres marcos.
Antes de profundizar en los detallas, revisaré brevemente un modelo conceptual de la manera en que funcionan las palabras clave async y await.
Async y Await en pocas palabras
La palabra clave async realiza dos tareas: habilita la palabra clave await dentro de ese método y transforma el método en una máquina de estados (de manera similar a la que la palabra clave keyword transforma los bloques del iterador en máquinas de estados). Los métodos asincrónicos deben devolver Task o Task<T> cuando sea posible. Se permite que un método asincrónico devuelva valor void pero no se recomienda porque es muy difícil de consumir (o probar) un método nulo asincrónico.
La instancia de tarea devuelta de un método asincrónico se administra por la máquina de estados. La máquina de estados creará la instancia de la tarea que se va a devolver y después completará dicha tarea.
Un método asincrónico comienza a ejecutarse de manera sincrónica. Solo cuando el método asincrónico alcanza un operador await el método puede convertirse en asincrónico. El operador await toma un solo argumento, uno que admita await como una instancia de Task. En primer lugar, el operador await comprobará el argumento que admita await para ver si ya ha finalizado; si lo ha hecho, el método continúa (de manera sincrónica). Si no se ha completado todavía el argumento que admite await, el operador await “pondrá en pausa” el método y se reanudará cuando finalice el argumento. La segunda cuestión que realiza el operador await es recuperar resultados del argumento que admite await, generando excepciones si el argumento finalizó con un error.
La Task o Task<T> devuelta por el método async representa conceptualmente la ejecución de dicho método. La tarea finalizará cuando se complete el método. Si el método devuelve un valor, la tarea finaliza con ese valor como su resultado. Si el método genera una excepción (y no la captura), la tarea finaliza con dicha excepción.
Se obtienen dos lecciones inmediatas de esta breve información general. En primer lugar, al probar los resultados de un método asincrónico, la parte importante es la Task que devuelve. El método asincrónico usa su Task para informar de la finalización, los resultados y las excepciones. La segundo lección es que el operador await tiene un comportamiento especial cuando su argumento awaitable ya ha finalizado. Trataré este tema más tarde al considerar códigos auxiliares asincrónicos.
Prueba de unidad superada incorrectamente
En las economías de libre mercado, las pérdidas son tan importantes como las ganancias; son los errores de las compañías los que les fuerzan a producir lo que las personas comprarán y fomentan la asignación óptima de recursos dentro del sistema en su conjunto. De manera similar, los errores de las pruebas de unidades son tan importantes como sus éxitos. Debe asegurarse de que se producirá un error en la prueba de la unidad cuando tenga que ser así o su éxito no significará nada.
Una prueba de unidad que se supone que dará error será correcta (en el sentido negativo) cuando esté probando algo incorrecto. Este es el motivo por el que el desarrollo orientado a pruebas (TDD) utiliza mucho el bucle rojo/verde/de refactorización: la parte “roja” del bucle asegura que se producirá un error en la prueba de la unidad cuando el código sea incorrecto. En principio, probar código que sabe que es incorrecto suena ridículo pero realmente es bastante importante porque debe asegurarse de que las pruebas darán error cuando tenga que ser así. La parte roja del bucle de TDD está probando realmente las pruebas.
Teniendo esto en cuenta, piense en el siguiente método asincrónico que se va a probar:
public sealed class SystemUnderTest
{
public static async Task SimpleAsync()
{
await Task.Delay(10);
}
}
Los usuarios nuevos de las pruebas de unidad asincrónicas a menudo realizan una prueba como esta como primer intento:
// Warning: bad code!
[TestMethod]
public void IncorrectlyPassingTest()
{
SystemUnderTest.SimpleAsync();
}
Lamentablemente, esta prueba de unidad no prueba realmente el método asincrónico correctamente. Si modifico el código que se está probando para que dé error, todavía se superará la prueba de la unidad:
public sealed class SystemUnderTest
{
public static async Task SimpleAsync()
{
await Task.Delay(10);
throw new Exception("Should fail.");
}
}
Esto muestra la primera lección del modelo conceptual async/await: Para probar el comportamiento de un método asincrónico, debe observar la tarea que devuelve. La mejor manera de hacerlo es esperar la tarea devuelta del método que se está probando. En este ejemplo también se muestra la ventaja del ciclo de desarrollo de pruebas rojo/verde/de refactorización; debe asegurarse de que las pruebas generarán error cuando se produzca un error en el código que se está probando.
Los marcos de prueba de unidad más modernos admiten las pruebas de unidad asincrónicas de devolución de Task. El método IncorrectlyPassingTest provocará la advertencia del compilador CS4014, que recomienda el uso de await para consumir la tarea devuelta de SimpleAsync. Cuando se cambia el método de prueba de unidad para esperar la tarea, el enfoque más natural es cambiar el método de prueba para que sea el método de Task asincrónico. Esto garantiza que el método de prueba generará (correctamente) un error:
[TestMethod]
public async Task CorrectlyFailingTest()
{
await SystemUnderTest.FailAsync();
}
Evitar pruebas de unidad nulas asincrónicas
Los usuarios con experiencia de asincronía saben cómo evitar la asincronía nula. He descrito los problemas con asincronía nula en mi artículo de marzo de 2013, “Prácticas recomendadas en la programación asincrónica” (bit.ly/1ulDCiI). Los métodos de prueba de unidad nula asincrónicos no proporcionan una manera sencilla de que el marco de prueba de unidad recupere los resultados de la prueba. A pesar de esta dificultad, algunos marcos de prueba de unidad admiten las pruebas de unidades nulas asincrónicas proporcionando su propio SynchronizationContext en el que se ejecutan sus pruebas de unidades.
Proporcionar un SynchronizationContext es algo controvertido porque cambia el entorno en el que se ejecuta la prueba. En concreto, cuando un método asincrónico espera una tarea, de manera predeterminada reanudará dicho método asincrónico en el SynchronizationContext actual. Por tanto, la presencia o la ausencia de un SynchronizationContext cambiará indirectamente el comportamiento del sistema que se está probando. Si tiene curiosidad acerca de los detalles de SynchronizationContext, vea mi artículo de MSDN Magazine sobre el tema en bit.ly/1hIar1p.
MSTest no proporciona un SynchronizationContext. De hecho, cuando MSBuild está detectando pruebas en un proyecto que usa pruebas de unidad nulas asincrónicas, detectará esto y emitirá la advertencia UTA007, notificando al usuario de que el método de la prueba de unidad debe devolver Task en lugar de void. MSBuild no ejecuta pruebas de unidad nulas asincrónicas.
NUnit no admite pruebas de unidad nulas asincrónicas, desde la versión 2.6.2. La siguiente actualización principal de NUnit, la versión 2.9.6, admite pruebas de unidad nulas asincrónicas pero los desarrolladores ya han decidido quitar la compatibilidad de la versión 2.9.7. NUnit solo ofrece un SynchronizationContext para pruebas de unidad nulas asincrónicas.
En el momento de escribirse este trabajo, xUnit está planeando agregar compatibilidad para pruebas nulas asincrónicas con la versión 2.0.0. A diferencia de NUnit, xUnit ofrece un SynchronizationContext para todos sus métodos de prueba, incluso los sincrónicos. Sin embargo, con MSTest sin admitir las pruebas de unidad nulas asincrónicas, y con NUnit revirtiendo su decisión anterior y quitando la compatibilidad, no me sorprendería si xUnit también elige quitar la compatibilidad de las pruebas de unidad nulas asincrónicas antes de que se publique la versión 2.
La conclusión es que las pruebas de unidad nulas asincrónicas son complicadas para que las admitan los marcos, requieren cambios en el entorno de ejecución de pruebas y no aportan beneficios sobre las pruebas de unidad de Task asincrónicas. Sin embargo, la compatibilidad de las pruebas de unidad nulas asincrónicas varían en los marcos, e incluso las versiones de marco. Por estos motivos, es mejor evitar las pruebas de unidad nulas asincrónicas.
Pruebas de unidad de tareas asincrónicas
Las pruebas de unidad asincrónicas que devuelven Task no tienen ninguno de los problemas de las pruebas de unidad asincrónicas que devuelven nulo. Las pruebas de unidad asincrónicas que devuelven Task disfrutan de una amplia compatibilidad de casi todos los marcos de prueba de unidad. MSTest ha agregado compatibilidad en Visual Studio 2012, NUnit en las versiones 2.6.2 y 2.9.6, y xUnit en la versión 1.9. Por tanto, siempre que su marco de pruebas de unidades tenga menos de 3 años, las pruebas de unidades de tareas asincrónicas simplemente deberían trabajar.
Lamentablemente, los marcos de prueba de unidad obsoletos no comprenden las pruebas de unidad de tareas asincrónicas. En el momento de escribir este documento, hay una plataforma principal que no los admite: Xamarin. Xamarin usa una versión anterior personalizada de NUnitLite y actualmente no admite las pruebas de unidad de tareas asincrónicas. Espero que se agregue compatibilidad próximamente. Mientras tanto, uso una solución alternativa poco eficiente pero que funciona: Ejecutar la lógica de prueba asincrónica en un subproceso de grupo de subprocesos diferente y, a continuación, bloquear (de manera sincrónica) el método de prueba de unidad hasta que finalice la prueba real. El código de solución alternativa usa GetAwaiter().GetResult() en lugar de Wait, porque Wait ajustará excepciones dentro de una AggregateException:
[Test]
public void XamarinExampleTest()
{
// This workaround is necessary on Xamarin,
// which doesn't support async unit test methods.
Task.Run(async () =>
{
// Actual test code here.
}).GetAwaiter().GetResult();
}
Excepciones de pruebas
En las pruebas, es natural probar el escenario correcto; por ejemplo, un usuario puede actualizar su propio perfil. Sin embargo, las pruebas de excepciones también son muy importantes; por ejemplo, un usuario no debería poder actualizar el perfil de otro usuario. Las excepciones forman parte de una superficie de API de la misma manera que los parámetros de método. Por tanto, es importante tener pruebas de unidad para código cuando se espera que genere un error.
Originalmente, el ExpectedExceptionAttribute se colocó en un método de prueba de unidad para indicar que se esperaba que la prueba de unidad generara error. Sin embargo, se produjeron algunos problemas con ExpectedExceptionAttribute. El primero fue que solo podría esperar que una prueba de unidad generara error como conjunto; no había manera de indicar que solo se esperaba que una parte concreta de la prueba generara error. Esto no es un problema con pruebas muy sencillas, pero puede tener resultados confusos cuando las pruebas duran más. El segundo problema con ExpectedExceptionAttribute es que se limita a la comprobación del tipo de la excepción; no hay manera de comprobar otros atributos, como mensajes o códigos de error.
Por estos motivos, en los últimos años, ha habido un cambio hacia el uso de algo similar a Assert.ThrowsException, que toma la parte importante del código como delegado y devuelve la excepción que se generó. Esto resuelve los defectos de ExpectedExceptionAttribute. El marco de MSTest de escritorio solo admite ExpectedExceptionAttribute, mientras que el marco de MSTest más reciente usado para los proyectos de prueba de unidad de la Tienda Windows solo admite Assert.ThrowsException. xUnit solo admite Assert.Throws y NUnit admite ambos enfoques. La Figura 1 es un ejemplo de ambos tipos de pruebas, usando la sintaxis de MSTest.
Figura 1. Prueba de excepciones con métodos de pruebas sincrónicos
// Old style; only works on desktop.
[TestMethod]
[ExpectedException(typeof(Exception))]
public void ExampleExpectedExceptionTest()
{
SystemUnderTest.Fail();
}
// New style; only works on Windows Store.
[TestMethod]
public void ExampleThrowsExceptionTest()
{
var ex = Assert.ThrowsException<Exception>(()
=> { SystemUnderTest.Fail(); });
}
¿Pero qué ocurre con el código asincrónico? Las pruebas de unidad de tareas asincrónicas funcionan perfectamente con el ExpectedExceptionAttribute tanto en MSTest como en NUnit (xUnit no admite ExpectedExceptionAttribute en absoluto). Sin embargo, la compatibilidad de una ThrowsException para asincronía es menos uniforme. MSTest admite una ThrowsException asincrónica pero solo para los proyectos de prueba de unidad de la Tienda Windows. xUnit ha introducido una ThrowsAsync asincrónica en las versiones preliminares de xUnit 2.0.0.
NUnit es más complejo. En el momento de escribir este documento, NUnit admite el código asincrónico en sus métodos de comprobación como Assert.Throws. Sin embargo, para que esto funcione, NUnit ofrece un SynchronizationContext, que presenta los mismos problemas que las pruebas de unidad nulas asincrónicas. Además, la sintaxis es provisional actualmente, como se muestra en el ejemplo de la Figura 2. NUnit ya está planeando quitar la compatibilidad para pruebas de unidad nulas asincrónicas y me sorprendería si se quita esta compatibilidad al mismo tiempo. En resumen: Recomiendo que no use este enfoque.
Figura 2. Pruebas de excepción de NUnit provisionales
[Test]
public void FailureTest_AssertThrows()
{
// This works, though it actually implements a nested loop,
// synchronously blocking the Assert.Throws call until the asynchronous
// FailAsync call completes.
Assert.Throws<Exception>(async () => await SystemUnderTest.FailAsync());
}
// Does NOT pass.
[Test]
public void BadFailureTest_AssertThrows()
{
Assert.Throws<Exception>(() => SystemUnderTest.FailAsync());
}
Por tanto, la compatibilidad actual para una ThrowsException/Throws para asincronía no es excelente. En mi propio código de pruebas de unidad, uso un tipo muy similar al de AssertEx en la Figura 3. Este tipo es más bien simplista en el sentido de que solo genera objetos Exception básicos en lugar de realizar aserciones pero este mismo código funciona en todos los marcos de pruebas principales.
Figura 3. La clase AssertEx para probar excepciones de manera asincrónica
using System;
using System.Threading.Tasks;
public static class AssertEx
{
public static async Task<TException>
ThrowsAsync<TException>(Func<Task> action,
bool allowDerivedTypes = true) where TException : Exception
{
try
{
await action();
}
catch (Exception ex)
{
if (allowDerivedTypes && !(ex is TException))
throw new Exception("Delegate threw exception of type " +
ex.GetType().Name + ", but " + typeof(TException).Name +
" or a derived type was expected.", ex);
if (!allowDerivedTypes && ex.GetType() != typeof(TException))
throw new Exception("Delegate threw exception of type " +
ex.GetType().Name + ", but " + typeof(TException).Name +
" was expected.", ex);
return (TException)ex;
}
throw new Exception("Delegate did not throw expected exception " +
typeof(TException).Name + ".");
}
public static Task<Exception> ThrowsAsync(Func<Task> action)
{
return ThrowsAsync<Exception>(action, true);
}
}
Esto permite a las pruebas de unidad de tarea asincrónica usar un ThrowsAsync más moderno en lugar del ExpectedExceptionAttribute, como este:
[TestMethod]
public async Task FailureTest_AssertEx()
{
var ex = await AssertEx.ThrowsAsync(()
=> SystemUnderTest.FailAsync());
}
Simulaciones y códigos auxiliares asincrónicos
A mi modo de ver, solo se puede probar el código más sencillo sin ningún tipo de código auxiliar, simulación, emulación u otro dispositivo de este tipo. En este artículo introductorio, consultaré todos estos asistentes de pruebas como simulaciones. Al usar simulaciones, es útil programas en interfaces en lugar de implementaciones. Los métodos asincrónicos funcionan perfectamente con interfaces; el código de la Figura 4 muestra cómo el código puede consumir una interfaz con un método asincrónico.
Figura 4. Uso de un método asincrónico desde una interfaz
public interface IMyService
{
Task<int> GetAsync();
}
public sealed class SystemUnderTest
{
private readonly IMyService _service;
public SystemUnderTest(IMyService service)
{
_service = service;
}
public async Task<int> RetrieveValueAsync()
{
return 42 + await _service.GetAsync();
}
}
Con este código, es bastante sencillo crear una implementación de prueba de la interfaz y pasarla al sistema que se está probando. En la Figura 5 se muestra cómo probar los tres casos de código auxiliar principales: éxito asincrónico, error asincrónico y éxito sincrónico. El éxito y el error asincrónico son los dos escenarios principales para probar código asincrónico pero también es importante probar el caso sincrónico. Esto se debe a que el operador await se comporta de manera diferente si su argumento que admita await ya se ha completado. El código de la Figura 5 usa el marco de simulación Moq para generar las implementaciones de código auxiliar.
Figura 5. Implementaciones de código auxiliar para código asincrónico
[TestMethod]
public async Task RetrieveValue_SynchronousSuccess_Adds42()
{
var service = new Mock<IMyService>();
service.Setup(x => x.GetAsync()).Returns(() => Task.FromResult(5));
// Or: service.Setup(x => x.GetAsync()).ReturnsAsync(5);
var system = new SystemUnderTest(service.Object);
var result = await system.RetrieveValueAsync();
Assert.AreEqual(47, result);
}
[TestMethod]
public async Task RetrieveValue_AsynchronousSuccess_Adds42()
{
var service = new Mock<IMyService>();
service.Setup(x => x.GetAsync()).Returns(async () =>
{
await Task.Yield();
return 5;
});
var system = new SystemUnderTest(service.Object);
var result = await system.RetrieveValueAsync();
Assert.AreEqual(47, result);
}
[TestMethod]
public async Task RetrieveValue_AsynchronousFailure_Throws()
{
var service = new Mock<IMyService>();
service.Setup(x => x.GetAsync()).Returns(async () =>
{
await Task.Yield();
throw new Exception();
});
var system = new SystemUnderTest(service.Object);
await AssertEx.ThrowsAsync(system.RetrieveValueAsync);
}
Hablando de marcos de simulación, hay algo de compatibilidad que pueden dar además a las pruebas de unidad asincrónicas. Piense un momento cuál debería ser el comportamiento predeterminado de un método, si no se ha especificado ningún comportamiento. Algunos marcos de simulación (como Microsoft Stubs) tomarán los valores predeterminados para generar una excepción; otros (como Moq) devolverán un valor predeterminado. Cuando un método asincrónico devuelve una Task<T>, un comportamiento predeterminado inocente sería devolver default(Task<T>), es decir, una tarea nula, que provocará una NullReferenceException.
Este comportamiento no es deseable. Un comportamiento predeterminado más razonable para métodos asincrónicos sería devolver Task.FromResult(default(T)), es decir, una tarea que se completa con el valor predeterminado de T. Esto permite al sistema que se está probando usar la tarea devuelta. Moq implementó este estilo de comportamiento predeterminado para métodos asincrónicos en Moq versión 4.2. Que yo sepa, en la fecha en que escribo este documento, es la única biblioteca de simulación que usa valores predeterminados para asincronía como ese.
Resumen
Async y await han estado disponibles desde la introducción de Visual Studio 2012, lo suficiente para que surgieran algunas prácticas recomendadas. Los marcos de prueba de unidad y los componentes auxiliares como las bibliotecas de simulación convergen hacia la compatibilidad asincrónica coherente. La prueba de unidad asincrónica ya es una realidad y mejorará aún más en el futuro. Si no lo ha hecho recientemente, ahora es un buen momento de actualizar sus marcos de prueba de unidad y las bibliotecas de simulación para asegurarse de que tienen la mejor compatibilidad asincrónica.
Los marcos de prueba de unidad convergen fuera de las pruebas nulas asincrónicas y hacia las pruebas de unidad de tareas asincrónicas. Si tiene pruebas de unidad nulas asincrónicas, recomiendo cambiarlas hoy a pruebas de unidad de tareas asincrónicas.
Espero que en las próximas semanas vea una compatibilidad mucho mejor para probar casos de errores en pruebas de unidad asincrónicas. Hasta que su marco de prueba de unidad tenga una buena compatibilidad, sugiero usar el tipo AssertEx mencionado en este artículo o algo similar más adaptado a su marco de prueba de unidad concreta.
Las pruebas de unidad asincrónicas adecuadas son una parte importante de la historia asincrónica y estoy emocionado al ver estos marcos y bibliotecas adoptar la asincronía. Una de mis primeras charlas iluminadoras fue sobre las pruebas de unidad de asincronía hace unos años cuando la sincronía estaba todavía en Community Technology Preview y es mucho más sencilla de realizar en la actualidad.
Stephen Cleary es esposo, padre, programador y vive en el norte de Michigan. Ha trabajado en programación multithreading y asincrónica durante 16 años y ha utilizado el soporte para asincronía en Microsoft .NET Framework desde la primera Community Technology Preview. Es el autor de “Concurrency in C# Cookbook” (O’Reilly Media, 2014). Su página principal, incluido su blog, se encuentra en stephencleary.com.
Gracias al siguiente experto técnico de Microsoft por revisar este artículo: James McCaffrey