Pular para o conteúdo principal

Testes

O Valkarn Tasks inclui uma infraestrutura de testes dedicada que permite escrever testes unitários rápidos e determinísticos para código async — sem timers reais, sem esperas de frame, sem necessidade do Unity Editor para a suíte de testes principal.


Visão geral

O suporte a testes reside na assembly Testing/ (UnaPartidaMas.Valkarn.Tasks.Testing), visível ao runtime via InternalsVisibleTo. Ele expõe dois tipos públicos:

TipoFinalidade
ValkarnTaskTestHelperInicializar e encerrar o runtime do Valkarn Tasks para uma sessão de testes
TestClockProvedor de tempo e frames determinístico; substitui o TimeProvider do Unity durante os testes

ValkarnTaskTestHelper

ValkarnTaskTestHelper é uma classe utilitária estática no namespace UnaPartidaMas.Valkarn.Tasks.Testing.

O que faz

Setup() realiza três coisas:

  1. Cria um TestClock e o instala como TimeProvider.Current, substituindo qualquer provedor de tempo real do Unity.
  2. Chama PlayerLoopHelper.InitializeForTest(), que aloca as arrays ContinuationQueue e PlayerLoopRunner para todos os 16 timings do PlayerLoop e marca o runtime como inicializado.
  3. Retorna o TestClock para que seu teste possa controlar o tempo.

Teardown() reverte a configuração:

  1. Instala um novo TestClock vazio como TimeProvider.Current para evitar que o tempo residual vaze entre fixtures de teste.
  2. Chama PlayerLoopHelper.ShutdownForTest(), que desmonta as filas e runners.

Padrão 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();
}

// Testes aqui
}

Chame Setup uma vez por teste em [SetUp] e Teardown uma vez por teste em [TearDown]. O padrão garante que cada teste comece com um estado de runtime limpo e não vaze para o próximo teste.

Delta time padrão

Setup aceita um parâmetro opcional defaultDeltaTime (padrão: 1/60 segundos, ou seja, 60 fps):

clock = ValkarnTaskTestHelper.Setup(defaultDeltaTime: 1f / 30f);  // simulação a 30 fps

Esse valor é usado por TestClock.AdvanceFrame() e TestClock.AdvanceFrames(n).


TestClock

TestClock implementa ITimeProvider e dá aos testes controle total sobre:

  • Tempo simulado (via GetTimestamp(), respaldado por ticks Stopwatch.Frequency)
  • DeltaTime e UnscaledDeltaTime para o frame atual
  • FrameCount

Avançando o tempo

Advance(TimeSpan duration)

Avança o tempo pela duração especificada em um único passo. A duração inteira é aplicada como DeltaTime para aquele frame, FrameCount incrementa em 1, e todos os timings do PlayerLoop são processados. Após o processamento, DeltaTime é restaurado ao valor que tinha antes da chamada.

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

clock.Advance(TimeSpan.FromSeconds(2));
Assert.IsFalse(task.IsCompleted); // ainda não

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

Use isso quando quiser pular para um ponto específico no tempo sem simular frames individuais.

AdvanceFrame()

Avança um frame usando o DeltaTime atual. FrameCount incrementa em 1, todos os timings do PlayerLoop são processados, e um Thread.Sleep de 1 ms é inserido antes do processamento. O sleep existe porque threads de worker em background (usadas por RunOnThreadPool e integração com o Unity Job System) precisam de uma pequena janela para concluir entre ticks de frame — sem ele, os testes executam frames consecutivamente sem intervalo, causando condições de corrida que não ocorrem em frames reais do Unity.

clock.AdvanceFrame();

AdvanceFrames(int count)

Chama AdvanceFrame() count vezes.

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

ProcessTick(PlayerLoopTiming timing)

Processa uma única fase de timing do PlayerLoop sem avançar o tempo ou FrameCount. Útil ao testar código agendado em um timing específico (ex.: PlayerLoopTiming.FixedUpdate) e você não quer processar todos os outros timings.

clock.ProcessTick(PlayerLoopTiming.FixedUpdate);

Controlando o delta time

SetDeltaTime(float deltaTime)

Define DeltaTime e UnscaledDeltaTime para o mesmo valor para todos os frames subsequentes.

SetDeltaTime(float deltaTime, float unscaledDeltaTime)

Define-os independentemente para simular Time.timeScale diferente de 1. Por exemplo, para testar código que usa DelayType.UnscaledDeltaTime enquanto o tempo 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 não escalado, 0 ms escalado
Assert.IsFalse(scaledTask.IsCompleted); // tempo de jogo pausado nunca chega a 500ms
Assert.IsFalse(unscaledTask.IsCompleted); // 450ms < 500ms

clock.AdvanceFrame(); // 500 ms não escalado
Assert.IsTrue(unscaledTask.IsCompleted); // delay não escalado concluído
Assert.IsFalse(scaledTask.IsCompleted); // escalado ainda nunca se move

Escrevendo testes unitários para métodos async ValkarnTask

Testes que completam sincronamente

Algumas operações ValkarnTask completam sem suspender — por exemplo, aguardar ValkarnTask.CompletedTask, ValkarnTask.FromResult(value), ou um ValkarnTaskCompletionSource que foi completado antes de ser aguardado. Esses não requerem um clock e podem usar a classe interna TestHelper diretamente (interna à assembly de testes):

[TestFixture]
public class SyncTests
{
[SetUp]
public void SetUp()
{
// Define apenas o ID da thread principal — sem clock ou PlayerLoop necessário
TestHelper.EnsureInitialized();
}

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

TestHelper (em Tests/Editor/TestHelper.cs) é um helper interno usado pela própria suíte de testes:

  • TestHelper.EnsureInitialized() define ValkarnTaskPoolShared.MainThreadId para a thread atual. Isso é necessário para que as operações de pool sejam roteadas para o caminho rápido correto (não-atômico).
  • TestHelper.RunSync(ValkarnTask task) afirma que a task já está concluída e chama GetResult() — útil para testar caminhos de código que devem completar sincronamente.
  • TestHelper.RunSync<T>(ValkarnTask<T> task) faz o mesmo e retorna o valor do resultado.
  • TestHelper.UnobservedExceptionCollector assina ValkarnTask.UnobservedException pelo tempo de sua existência, coletando quaisquer exceções não observadas para asserção.

Testes que requerem tempo

Qualquer teste envolvendo ValkarnTask.Delay, ValkarnTask.Yield, ou código agendado em um timing do PlayerLoop requer ValkarnTaskTestHelper.Setup() e o TestClock retornado.

Exemplo: testando um método baseado em delay

Suponha que você tem:

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

Teste-o sem 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 (lança se faulted)
task.GetAwaiter().GetResult();
}

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

Testando cancelamento

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

cts.Cancel();
// O cancelamento se propaga no próximo tick
clock.AdvanceFrame();

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

Coletando exceções não observadas

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

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

// Não aguardar — fire and forget
task.Forget();

// Completar com exceção — aciona o caminho não observado
tcs.TrySetException(new InvalidOperationException("oops"));

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

Exemplo: Testando um pipeline Producer/Consumer com Channel

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

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

// Produtor: escrever sincronamente
channel.Writer.TryWrite(1);
channel.Writer.TryWrite(2);
channel.Writer.TryWrite(3);
channel.Writer.Complete();

// Consumidor: ReadAsync em um channel com itens completa sincronamente
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>();

// Leitura emitida antes de qualquer escrita
var readTask = channel.Reader.ReadAsync();
Assert.IsFalse(readTask.IsCompleted);

// Escrita completa a leitura pendente sincronamente
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); // item ainda não lido

channel.Reader.TryRead(out _);
Assert.IsTrue(completion.IsCompleted); // drenado — completion dispara
}

[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());
}
}

Exemplo: Testando ValkarnTask.Delay com avanço de frames

Este exemplo demonstra como testar um método que aguarda um delay configurável, verificando que o avanço parcial não completa a task 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 frame
var task = ValkarnTask.Delay(500);

clock.AdvanceFrames(9);
Assert.IsFalse(task.IsCompleted, "450 ms decorridos — não deveria estar pronto");

clock.AdvanceFrame();
Assert.IsTrue(task.IsCompleted, "500 ms decorridos — deveria estar pronto");
}

[Test]
public void Delay_Realtime_UsesTimestampNotDeltaTime()
{
// Delay realtime ignora DeltaTime; usa o timestamp do Stopwatch
clock.SetDeltaTime(0f); // DeltaTime congelado
var task = ValkarnTask.Delay(200, DelayType.Realtime);

// Advance move apenas o timestamp via TimeSpan
clock.Advance(TimeSpan.FromMilliseconds(199));
Assert.IsFalse(task.IsCompleted);

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

Integração com NUnit

A suíte de testes usa NUnit 3.x (fixado na versão 3.x em TestRunner.csproj). O NUnit 4 removeu a API clássica Assert.IsTrue / Assert.AreEqual que os testes utilizam; não atualize para o NUnit 4 a menos que as asserções sejam atualizadas para a API baseada em constraints.

Unity Test Framework

No Unity Editor, os testes em Tests/Editor/ são descobertos e executados pelo Unity Test Framework (UTF), que é baseado em NUnit. A integração com o test runner funciona da seguinte forma:

  • O UTF executa cada [Test] na thread principal.
  • ValkarnTaskTestHelper.Setup() instala o TestClock antes de cada teste; o atributo [SetUp] do UTF o chama.
  • ValkarnTaskTestHelper.Teardown() remove o clock em [TearDown].
  • Nenhuma coroutine ou [UnityTest] é necessário: todos os testes do Valkarn Tasks usam métodos NUnit [Test] síncronos. O TestClock controla o tempo manualmente, então não há necessidade de avanço real de frames.

O projeto _TestRunner~

O diretório _TestRunner~/ contém um projeto .NET 8 standalone que compila e executa toda a suíte de testes fora do Unity, usando o SDK .NET e dotnet test. Isso é usado para CI e desenvolvimento local de colaboradores.

Estrutura

_TestRunner~/
TestRunner.csproj — arquivo de projeto .NET 8
bin~/ — saída de build (gitignored)
obj~/ — saída intermediária (gitignored)

Como funciona

TestRunner.csproj compila quatro conjuntos de fontes em uma única assembly:

Conjunto de fontesCaminhoObservações
Runtime../Runtime/**/*.csTodos os arquivos de runtime
Testing../Testing/**/*.csValkarnTaskTestHelper, TestClock
Tests../Tests/Editor/**/*.csTodos os arquivos de teste .cs
ExclusõesUnityTimeProvider.cs, Bridge/, Burst/, ECS/Requer APIs Unity reais — excluídos

O projeto não define UNITY_5_3_OR_NEWER, VTASKS_HAS_BURST, VTASKS_HAS_COLLECTIONS ou VTASKS_HAS_ENTITIES, então todos os branches #if específicos do Unity são compilados fora. Isso significa que os testes exercitam os caminhos de código C# puro.

As DLLs de analisador são carregadas como itens <Analyzer>, então as mesmas regras que disparam na IDE também disparam durante o dotnet build do projeto de teste.

Executando os testes

cd _TestRunner~
dotnet test

Ou da raiz do repositório:

dotnet test _TestRunner~/TestRunner.csproj

Arquivos de teste excluídos

Três subdiretórios de teste são excluídos do projeto _TestRunner~ porque requerem APIs de runtime reais do Unity:

DiretórioMotivo
Tests/Editor/Bridge/Testes para bridge do Job System; requer Unity.Jobs
Tests/Editor/Burst/Testes para scheduler Burst; requer Unity.Burst
Tests/Editor/ECS/Testes para utilitários ECS; requer Unity.Entities

Esses testes são executados apenas dentro do Unity Editor via Unity Test Framework.

Adicionando um novo teste

  1. Adicione um novo arquivo .cs em Tests/Editor/ (não em um subdiretório de API Unity).
  2. Decore a classe com [TestFixture] e os métodos com [Test].
  3. Se o teste precisa de controle de tempo, use ValkarnTaskTestHelper.Setup() / Teardown() em [SetUp] / [TearDown].
  4. Se o teste precisa apenas de asserções síncronas de tasks (sem delay), use TestHelper.EnsureInitialized() em [SetUp] — nenhum teardown é necessário.
  5. Execute dotnet test _TestRunner~/TestRunner.csproj para verificar fora do Unity.
  6. Execute o Unity Test Framework para verificar dentro do Unity.