Saltar al contenido principal

Pruebas

Valkarn Tasks incluye una infraestructura de pruebas dedicada que te permite escribir pruebas unitarias rápidas y deterministas para código async — sin temporizadores reales, sin esperas de fotogramas, sin necesidad del Editor de Unity para el conjunto de pruebas principal.


Descripción General​

El soporte de pruebas reside en el ensamblado Testing/ (UnaPartidaMas.Valkarn.Tasks.Testing), que es visible para el runtime mediante InternalsVisibleTo. Expone dos tipos públicos:

TipoPropósito
ValkarnTaskTestHelperInicializa y desmonta el runtime de Valkarn Tasks para una sesión de pruebas
TestClockProveedor determinista de tiempo y fotogramas; reemplaza el TimeProvider de Unity durante las pruebas

ValkarnTaskTestHelper​

ValkarnTaskTestHelper es una clase utilitaria estática en el espacio de nombres UnaPartidaMas.Valkarn.Tasks.Testing.

Qué hace​

Setup() realiza tres cosas:

  1. Crea un TestClock y lo instala como TimeProvider.Current, reemplazando cualquier proveedor de tiempo real de Unity.
  2. Llama a PlayerLoopHelper.InitializeForTest(), que asigna los arrays ContinuationQueue y PlayerLoopRunner para todos los 16 timings del PlayerLoop y marca el runtime como inicializado.
  3. Devuelve el TestClock para que tu prueba pueda controlar el tiempo.

Teardown() revierte la configuración:

  1. Instala un TestClock nuevo sin operaciones como TimeProvider.Current para evitar que el tiempo obsoleto se filtre entre fixtures de prueba.
  2. Llama a PlayerLoopHelper.ShutdownForTest(), que desmonta las colas y los runners.

Patrón de uso​

using NUnit.Framework;
using UnaPartidaMas.Valkarn.Tasks;
using UnaPartidaMas.Valkarn.Tasks.Testing;

[TestFixture]
public class MyAsyncTests
{
TestClock clock;

[SetUp]
public void SetUp()
{
clock = ValkarnTaskTestHelper.Setup();
}

[TearDown]
public void TearDown()
{
ValkarnTaskTestHelper.Teardown();
}

// Las pruebas van aquí
}

Llama a Setup una vez por prueba en [SetUp] y a Teardown una vez por prueba en [TearDown]. El patrón garantiza que cada prueba comience con un estado de runtime limpio y no se filtre a la siguiente prueba.

Delta time predeterminado​

Setup acepta un parámetro opcional defaultDeltaTime (predeterminado: 1/60 segundos, es decir, 60 fps):

clock = ValkarnTaskTestHelper.Setup(defaultDeltaTime: 1f / 30f);  // simulación a 30 fps

Este valor es usado por TestClock.AdvanceFrame() y TestClock.AdvanceFrames(n).


TestClock​

TestClock implementa ITimeProvider y da a las pruebas control total sobre:

  • El tiempo simulado (mediante GetTimestamp(), respaldado por ticks de Stopwatch.Frequency)
  • DeltaTime y UnscaledDeltaTime para el fotograma actual
  • FrameCount

Avanzar el tiempo​

Advance(TimeSpan duration)​

Avanza el tiempo por la duración dada en un solo paso. La duración completa se aplica como DeltaTime para ese fotograma, FrameCount se incrementa en 1 y se procesan todos los timings del PlayerLoop. Después del procesamiento, DeltaTime se restaura al valor que tenía antes de la llamada.

var task = ValkarnTask.Delay(3000);  // retraso de 3 segundos

clock.Advance(TimeSpan.FromSeconds(2));
Assert.IsFalse(task.IsCompleted); // aún no

clock.Advance(TimeSpan.FromSeconds(1));
Assert.IsTrue(task.IsCompleted); // exactamente en 3 segundos

Usa esto cuando quieras saltar a un punto específico en el tiempo sin simular fotogramas individuales.

AdvanceFrame()​

Avanza un fotograma usando el DeltaTime actual. FrameCount se incrementa en 1, se procesan todos los timings del PlayerLoop y se inserta un Thread.Sleep de 1 ms antes del procesamiento. El sleep existe porque los hilos de trabajo en segundo plano (usados por RunOnThreadPool y la integración del Sistema de Jobs de Unity) necesitan una pequeña ventana para completarse entre ticks de fotograma — sin él, las pruebas ejecutan fotogramas consecutivos sin pausa, lo que provoca condiciones de carrera que no ocurren en fotogramas reales de Unity.

clock.AdvanceFrame();

AdvanceFrames(int count)​

Llama a AdvanceFrame() count veces.

clock.AdvanceFrames(10);  // simular 10 fotogramas

ProcessTick(PlayerLoopTiming timing)​

Procesa una única fase de timing del PlayerLoop sin avanzar el tiempo ni FrameCount. Útil cuando pruebas código programado en un timing específico (por ejemplo, PlayerLoopTiming.FixedUpdate) y no quieres procesar todos los demás timings.

clock.ProcessTick(PlayerLoopTiming.FixedUpdate);

Controlar el delta time​

SetDeltaTime(float deltaTime)​

Establece tanto DeltaTime como UnscaledDeltaTime al mismo valor para todos los fotogramas subsiguientes.

SetDeltaTime(float deltaTime, float unscaledDeltaTime)​

Los establece de forma independiente para simular un Time.timeScale no unitario. Por ejemplo, para probar código que usa DelayType.UnscaledDeltaTime mientras el tiempo está pausado:

clock.SetDeltaTime(deltaTime: 0f, unscaledDeltaTime: 0.05f);

var scaledTask = ValkarnTask.Delay(500, DelayType.DeltaTime);
var unscaledTask = ValkarnTask.Delay(500, DelayType.UnscaledDeltaTime);

clock.AdvanceFrames(9); // 450 ms sin escala, 0 ms con escala
Assert.IsFalse(scaledTask.IsCompleted); // el tiempo de juego pausado nunca alcanza 500ms
Assert.IsFalse(unscaledTask.IsCompleted); // 450ms < 500ms

clock.AdvanceFrame(); // 500 ms sin escala
Assert.IsTrue(unscaledTask.IsCompleted); // retraso sin escala completado
Assert.IsFalse(scaledTask.IsCompleted); // con escala sigue sin moverse

Escribir Pruebas Unitarias para Métodos ValkarnTask Async​

Pruebas que completan sincrónicamente​

Algunas operaciones de ValkarnTask completan sin suspenderse — por ejemplo, esperar ValkarnTask.CompletedTask, ValkarnTask.FromResult(value), o un ValkarnTaskCompletionSource que fue completado antes de ser esperado. Estas no requieren un reloj en absoluto y pueden usar la clase TestHelper interna directamente (interna al ensamblado de pruebas):

[TestFixture]
public class SyncTests
{
[SetUp]
public void SetUp()
{
// Solo establece el ID del hilo principal — no se necesita reloj ni PlayerLoop
TestHelper.EnsureInitialized();
}

[Test]
public void FromResult_ReturnsValue()
{
var task = ValkarnTask.FromResult(42);
Assert.IsTrue(task.IsCompleted);
Assert.AreEqual(42, task.GetAwaiter().GetResult());
}
}

TestHelper (en Tests/Editor/TestHelper.cs) es un ayudante interno usado por el propio conjunto de pruebas:

  • TestHelper.EnsureInitialized() establece ValkarnTaskPoolShared.MainThreadId al hilo actual. Esto es necesario para que las operaciones del grupo se enruten a la ruta rápida correcta (no atómica).
  • TestHelper.RunSync(ValkarnTask task) afirma que la tarea ya está completada y llama a GetResult() — útil para probar rutas de código que deben completar sincrónicamente.
  • TestHelper.RunSync<T>(ValkarnTask<T> task) hace lo mismo y devuelve el valor del resultado.
  • TestHelper.UnobservedExceptionCollector se suscribe a ValkarnTask.UnobservedException durante la duración de su ámbito, recogiendo cualquier excepción no observada para su verificación.

Pruebas que requieren tiempo​

Cualquier prueba que involucre ValkarnTask.Delay, ValkarnTask.Yield o código programado en un timing del PlayerLoop requiere ValkarnTaskTestHelper.Setup() y el TestClock devuelto.

Ejemplo: probar un método basado en retraso

Supón que tienes:

public static async ValkarnTask WaitAndLog(int ms)
{
await ValkarnTask.Delay(ms);
Log("done");
}

Pruébalo sin espera real:

[TestFixture]
public class DelayTests
{
TestClock clock;

[SetUp]
public void SetUp() => clock = ValkarnTaskTestHelper.Setup();

[TearDown]
public void TearDown() => ValkarnTaskTestHelper.Teardown();

[Test]
public void WaitAndLog_CompletesAfterDelay()
{
var task = WaitAndLog(3000);
Assert.IsFalse(task.IsCompleted);

clock.Advance(TimeSpan.FromSeconds(2));
Assert.IsFalse(task.IsCompleted);

clock.Advance(TimeSpan.FromSeconds(1));
Assert.IsTrue(task.IsCompleted);

// Recuperar resultado (lanza si falló)
task.GetAwaiter().GetResult();
}

[Test]
public void WaitAndLog_ZeroDelay_CompletesImmediately()
{
var task = WaitAndLog(0);
// ValkarnTask.Delay(0) devuelve CompletedTask inmediatamente
Assert.IsTrue(task.IsCompleted);
}
}

Probar cancelación​

[Test]
public void Delay_CancelledMidway_ThrowsOCE()
{
var cts = new CancellationTokenSource();
var task = ValkarnTask.Delay(5000, cts.Token);
Assert.IsFalse(task.IsCompleted);

cts.Cancel();
// La cancelación se propaga en el siguiente tick
clock.AdvanceFrame();

Assert.IsTrue(task.IsCompleted);
Assert.Throws<OperationCanceledException>(() => task.GetAwaiter().GetResult());
}

Recoger excepciones no observadas​

[Test]
public void FaultedTask_PublishesUnobservedException()
{
using var collector = new TestHelper.UnobservedExceptionCollector();

var tcs = new ValkarnTaskCompletionSource();
var task = tcs.Task;

// No esperar — fire and forget
task.Forget();

// Completar con excepción — dispara la ruta no observada
tcs.TrySetException(new InvalidOperationException("oops"));

Assert.AreEqual(1, collector.Exceptions.Count);
Assert.IsInstanceOf<InvalidOperationException>(collector.Exceptions[0]);
}

Ejemplo: Probar un Canal Productor/Consumidor​

[TestFixture]
public class ChannelPipelineTests
{
[SetUp]
public void SetUp() => TestHelper.EnsureInitialized();

[Test]
public void Producer_WritesItems_ConsumerReadsInOrder()
{
var channel = ValkarnTask.Channel.CreateUnbounded<int>();

// Productor: escribir sincrónicamente
channel.Writer.TryWrite(1);
channel.Writer.TryWrite(2);
channel.Writer.TryWrite(3);
channel.Writer.Complete();

// Consumidor: ReadAsync en un canal con elementos completa sincrónicamente
Assert.AreEqual(1, TestHelper.RunSync(channel.Reader.ReadAsync()));
Assert.AreEqual(2, TestHelper.RunSync(channel.Reader.ReadAsync()));
Assert.AreEqual(3, TestHelper.RunSync(channel.Reader.ReadAsync()));
}

[Test]
public void ReadAsync_BeforeWrite_PendsThenCompletesOnWrite()
{
var channel = ValkarnTask.Channel.CreateUnbounded<string>();

// Lectura emitida antes de cualquier escritura
var readTask = channel.Reader.ReadAsync();
Assert.IsFalse(readTask.IsCompleted);

// La escritura completa la lectura pendiente sincrónicamente
channel.Writer.TryWrite("hello");
Assert.IsTrue(readTask.IsCompleted);
Assert.AreEqual("hello", readTask.GetAwaiter().GetResult());
}

[Test]
public void Complete_DrainedChannel_CompletionTaskCompletes()
{
var channel = ValkarnTask.Channel.CreateUnbounded<int>();
channel.Writer.TryWrite(42);

var completion = channel.Reader.Completion;
Assert.IsFalse(completion.IsCompleted);

channel.Writer.Complete();
Assert.IsFalse(completion.IsCompleted); // elemento aún sin leer

channel.Reader.TryRead(out _);
Assert.IsTrue(completion.IsCompleted); // drenado — se dispara la completación
}

[Test]
public void ClosedChannel_Read_ThrowsChannelClosedException()
{
var channel = ValkarnTask.Channel.CreateUnbounded<int>();
channel.Writer.Complete();

var readTask = channel.Reader.ReadAsync();
Assert.IsTrue(readTask.IsCompleted);
Assert.Throws<ChannelClosedException>(() => readTask.GetAwaiter().GetResult());
}
}

Ejemplo: Probar ValkarnTask.Delay Con Avance de Fotogramas​

Este ejemplo demuestra cómo probar un método que espera un retraso configurable, verificando que el avance parcial no complete la tarea prematuramente.

[TestFixture]
public class DelayFrameTests
{
TestClock clock;

[SetUp]
public void SetUp() => clock = ValkarnTaskTestHelper.Setup(defaultDeltaTime: 1f / 60f);

[TearDown]
public void TearDown() => ValkarnTaskTestHelper.Teardown();

[Test]
public void Delay_500ms_RequiresTenFramesAt50ms()
{
clock.SetDeltaTime(0.05f); // 50 ms por fotograma
var task = ValkarnTask.Delay(500);

clock.AdvanceFrames(9);
Assert.IsFalse(task.IsCompleted, "450 ms transcurridos — no debería estar listo");

clock.AdvanceFrame();
Assert.IsTrue(task.IsCompleted, "500 ms transcurridos — debería estar listo");
}

[Test]
public void Delay_Realtime_UsesTimestampNotDeltaTime()
{
// El retraso en tiempo real ignora DeltaTime; usa el timestamp del Stopwatch
clock.SetDeltaTime(0f); // DeltaTime congelado
var task = ValkarnTask.Delay(200, DelayType.Realtime);

// Advance solo mueve el timestamp mediante TimeSpan
clock.Advance(TimeSpan.FromMilliseconds(199));
Assert.IsFalse(task.IsCompleted);

clock.Advance(TimeSpan.FromMilliseconds(1));
Assert.IsTrue(task.IsCompleted);
}
}

Integración con NUnit​

El conjunto de pruebas usa NUnit 3.x (fijado en 3.x en TestRunner.csproj). NUnit 4 eliminó la API clásica Assert.IsTrue / Assert.AreEqual que usan las pruebas; no actualices a NUnit 4 a menos que las aserciones se actualicen a la API basada en restricciones.

Unity Test Framework​

En el Editor de Unity, las pruebas en Tests/Editor/ son descubiertas y ejecutadas por el Unity Test Framework (UTF), que está basado en NUnit. La integración del runner de pruebas funciona así:

  • UTF ejecuta cada [Test] en el hilo principal.
  • ValkarnTaskTestHelper.Setup() instala el TestClock antes de cada prueba; el atributo [SetUp] de UTF lo llama.
  • ValkarnTaskTestHelper.Teardown() elimina el reloj en [TearDown].
  • No se necesita corrutina ni [UnityTest]: todas las pruebas de Valkarn Tasks usan métodos NUnit [Test] sincrónicos. El TestClock controla el tiempo manualmente, por lo que no hay necesidad de avance de fotogramas real.

El Proyecto _TestRunner~​

El directorio _TestRunner~/ contiene un proyecto .NET 8 independiente que compila y ejecuta el conjunto de pruebas completo fuera de Unity, usando el SDK de .NET y dotnet test. Se usa para CI y para el desarrollo local de colaboradores.

Estructura​

_TestRunner~/
TestRunner.csproj — archivo de proyecto .NET 8
bin~/ — salida de compilación (ignorada por git)
obj~/ — salida intermedia (ignorada por git)

Cómo funciona​

TestRunner.csproj compila cuatro conjuntos de fuentes en un único ensamblado:

Conjunto de fuentesRutaNotas
Runtime../Runtime/**/*.csTodos los archivos de runtime
Testing../Testing/**/*.csValkarnTaskTestHelper, TestClock
Tests../Tests/Editor/**/*.csTodos los archivos .cs de pruebas
ExclusionesUnityTimeProvider.cs, Bridge/, Burst/, ECS/Requiere APIs reales de Unity — excluido

El proyecto no define UNITY_5_3_OR_NEWER, VTASKS_HAS_BURST, VTASKS_HAS_COLLECTIONS ni VTASKS_HAS_ENTITIES, por lo que todas las ramas #if específicas de Unity se compilan fuera. Esto significa que las pruebas ejercitan las rutas de código puras de C#.

Los DLLs del analizador se cargan como elementos <Analyzer>, por lo que las mismas reglas que se activan en el IDE también se activan durante dotnet build del proyecto de pruebas.

Ejecutar las pruebas​

cd _TestRunner~
dotnet test

O desde la raíz del repositorio:

dotnet test _TestRunner~/TestRunner.csproj

Archivos de prueba excluidos​

Tres subdirectorios de pruebas están excluidos del proyecto _TestRunner~ porque requieren APIs reales del runtime de Unity:

DirectorioRazón
Tests/Editor/Bridge/Pruebas para el puente del Sistema de Jobs; requiere Unity.Jobs
Tests/Editor/Burst/Pruebas para el programador Burst; requiere Unity.Burst
Tests/Editor/ECS/Pruebas para utilidades ECS; requiere Unity.Entities

Estas pruebas solo se ejecutan dentro del Editor de Unity mediante el Unity Test Framework.

Añadir una nueva prueba​

  1. Añade un nuevo archivo .cs en Tests/Editor/ (no en un subdirectorio de API de Unity).
  2. Decora la clase con [TestFixture] y los métodos con [Test].
  3. Si la prueba necesita control de tiempo, usa ValkarnTaskTestHelper.Setup() / Teardown() en [SetUp] / [TearDown].
  4. Si la prueba solo necesita aserciones de tareas sincrónicas (sin retraso), usa TestHelper.EnsureInitialized() en [SetUp] — no se necesita teardown.
  5. Ejecuta dotnet test _TestRunner~/TestRunner.csproj para verificar fuera de Unity.
  6. Ejecuta el Unity Test Framework para verificar dentro de Unity.