Перейти к основному содержимому

Тестирование

Valkarn Tasks поставляется с выделенной инфраструктурой тестирования, позволяющей писать быстрые детерминированные юнит-тесты для асинхронного кода — без реальных таймеров, без ожидания кадров, без необходимости в Unity Editor для основного набора тестов.


Обзор

Поддержка тестирования находится в сборке Testing/ (UnaPartidaMas.Valkarn.Tasks.Testing), которая доступна среде выполнения через InternalsVisibleTo. Она предоставляет два публичных типа:

ТипНазначение
ValkarnTaskTestHelperИнициализация и завершение среды выполнения Valkarn Tasks для сессии тестирования
TestClockДетерминированный провайдер времени и кадров; заменяет Unity TimeProvider во время тестов

ValkarnTaskTestHelper

ValkarnTaskTestHelper — это статический вспомогательный класс в пространстве имён UnaPartidaMas.Valkarn.Tasks.Testing.

Что он делает

Setup() выполняет три действия:

  1. Создаёт TestClock и устанавливает его как TimeProvider.Current, заменяя любой реальный провайдер времени Unity.
  2. Вызывает PlayerLoopHelper.InitializeForTest(), который выделяет массивы ContinuationQueue и PlayerLoopRunner для всех 16 таймингов PlayerLoop и помечает среду выполнения как инициализированную.
  3. Возвращает TestClock, чтобы тест мог управлять временем.

Teardown() отменяет настройку:

  1. Устанавливает свежий пустой TestClock как TimeProvider.Current, чтобы предотвратить утечку устаревшего времени между фикстурами тестов.
  2. Вызывает PlayerLoopHelper.ShutdownForTest(), который останавливает очереди и запускатели.

Паттерн использования

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

// Тесты здесь
}

Вызывайте Setup один раз за тест в [SetUp] и Teardown один раз за тест в [TearDown]. Паттерн гарантирует, что каждый тест начинается с чистым состоянием среды выполнения и не утекает в следующий тест.

Дельта-время по умолчанию

Setup принимает необязательный параметр defaultDeltaTime (по умолчанию: 1/60 секунды, то есть 60 fps):

clock = ValkarnTaskTestHelper.Setup(defaultDeltaTime: 1f / 30f);  // симуляция 30 fps

Это значение используется TestClock.AdvanceFrame() и TestClock.AdvanceFrames(n).


TestClock

TestClock реализует ITimeProvider и даёт тестам полный контроль над:

  • Симулируемым временем (через GetTimestamp(), основанный на тиках Stopwatch.Frequency)
  • DeltaTime и UnscaledDeltaTime для текущего кадра
  • FrameCount

Продвижение времени

Advance(TimeSpan duration)

Продвигает время на заданную длительность за один шаг. Вся длительность применяется как DeltaTime для этого кадра, FrameCount увеличивается на 1, и обрабатываются все тайминги PlayerLoop. После обработки DeltaTime восстанавливается до значения, которое было до вызова.

var task = ValkarnTask.Delay(3000);  // задержка 3 секунды

clock.Advance(TimeSpan.FromSeconds(2));
Assert.IsFalse(task.IsCompleted); // ещё нет

clock.Advance(TimeSpan.FromSeconds(1));
Assert.IsTrue(task.IsCompleted); // ровно в 3 секунды

Используйте это, когда хотите перейти к конкретной точке во времени без симуляции отдельных кадров.

AdvanceFrame()

Продвигает один кадр, используя текущий DeltaTime. FrameCount увеличивается на 1, все тайминги PlayerLoop обрабатываются, и перед обработкой вставляется Thread.Sleep на 1 мс. Задержка существует потому, что фоновые рабочие потоки (используемые RunOnThreadPool и интеграцией с Unity Job System) нуждаются в небольшом окне для завершения между тиками кадров — без этого тесты выполняют кадры один за другим без пауз, что вызывает гонки состояний, которых не бывает в реальных кадрах Unity.

clock.AdvanceFrame();

AdvanceFrames(int count)

Вызывает AdvanceFrame() count раз.

clock.AdvanceFrames(10);  // симулировать 10 кадров

ProcessTick(PlayerLoopTiming timing)

Обрабатывает одну фазу тайминга PlayerLoop без продвижения времени или FrameCount. Полезно при тестировании кода, запланированного на конкретный тайминг (например, PlayerLoopTiming.FixedUpdate), когда не нужно обрабатывать все остальные тайминги.

clock.ProcessTick(PlayerLoopTiming.FixedUpdate);

Управление дельта-временем

SetDeltaTime(float deltaTime)

Устанавливает и DeltaTime, и UnscaledDeltaTime в одно значение для всех последующих кадров.

SetDeltaTime(float deltaTime, float unscaledDeltaTime)

Устанавливает их независимо для симуляции отличного от единицы Time.timeScale. Например, для тестирования кода, использующего DelayType.UnscaledDeltaTime при паузе времени:

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 мс без масштаба, 0 мс масштабируемых
Assert.IsFalse(scaledTask.IsCompleted); // масштабируемое время игры никогда не достигает 500мс
Assert.IsFalse(unscaledTask.IsCompleted); // 450мс < 500мс

clock.AdvanceFrame(); // 500 мс без масштаба
Assert.IsTrue(unscaledTask.IsCompleted); // немасштабируемая задержка выполнена
Assert.IsFalse(scaledTask.IsCompleted); // масштабируемое по-прежнему не двигается

Написание юнит-тестов для async ValkarnTask-методов

Тесты, завершающиеся синхронно

Некоторые операции ValkarnTask завершаются без приостановки — например, ожидание ValkarnTask.CompletedTask, ValkarnTask.FromResult(value) или ValkarnTaskCompletionSource, завершённого до ожидания. Они вообще не требуют часов и могут использовать внутренний класс TestHelper напрямую (внутренний для тестовой сборки):

[TestFixture]
public class SyncTests
{
[SetUp]
public void SetUp()
{
// Только устанавливает ID основного потока — часы или PlayerLoop не нужны
TestHelper.EnsureInitialized();
}

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

TestHelperTests/Editor/TestHelper.cs) — это внутренний вспомогательный класс, используемый самим набором тестов:

  • TestHelper.EnsureInitialized() устанавливает ValkarnTaskPoolShared.MainThreadId в текущий поток. Это необходимо для правильной маршрутизации операций пула на корректный (неатомарный) быстрый путь.
  • TestHelper.RunSync(ValkarnTask task) утверждает, что задача уже завершена, и вызывает GetResult() — полезно для тестирования путей кода, которые должны завершаться синхронно.
  • TestHelper.RunSync<T>(ValkarnTask<T> task) делает то же самое и возвращает значение результата.
  • TestHelper.UnobservedExceptionCollector подписывается на ValkarnTask.UnobservedException на время своей области видимости, собирая необнаруженные исключения для проверки.

Тесты, требующие времени

Любой тест, включающий ValkarnTask.Delay, ValkarnTask.Yield или код, запланированный на тайминг PlayerLoop, требует ValkarnTaskTestHelper.Setup() и возвращённого TestClock.

Пример: тестирование метода на основе задержки

Предположим, у вас есть:

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

Тестируйте без реального ожидания:

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

// Получить результат (выбрасывает при ошибке)
task.GetAwaiter().GetResult();
}

[Test]
public void WaitAndLog_ZeroDelay_CompletesImmediately()
{
var task = WaitAndLog(0);
// ValkarnTask.Delay(0) возвращает CompletedTask немедленно
Assert.IsTrue(task.IsCompleted);
}
}

Тестирование отмены

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

cts.Cancel();
// Отмена распространяется на следующем тике
clock.AdvanceFrame();

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

Сбор необнаруженных исключений

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

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

// Не ожидаем — fire and forget
task.Forget();

// Завершаем с исключением — запускает путь необнаруженного исключения
tcs.TrySetException(new InvalidOperationException("oops"));

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

Пример: тестирование канала producer/consumer

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

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

// Производитель: пишет синхронно
channel.Writer.TryWrite(1);
channel.Writer.TryWrite(2);
channel.Writer.TryWrite(3);
channel.Writer.Complete();

// Потребитель: ReadAsync на канале с элементами завершается синхронно
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>();

// Чтение выдано до любой записи
var readTask = channel.Reader.ReadAsync();
Assert.IsFalse(readTask.IsCompleted);

// Запись завершает ожидающее чтение синхронно
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); // элемент ещё не прочитан

channel.Reader.TryRead(out _);
Assert.IsTrue(completion.IsCompleted); // опустошён — completion срабатывает
}

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

Пример: тестирование ValkarnTask.Delay с продвижением кадров

Этот пример демонстрирует тестирование метода, ожидающего настраиваемую задержку, с проверкой того, что частичное продвижение не завершает задачу преждевременно.

[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 мс на кадр
var task = ValkarnTask.Delay(500);

clock.AdvanceFrames(9);
Assert.IsFalse(task.IsCompleted, "Прошло 450 мс — не должно быть готово");

clock.AdvanceFrame();
Assert.IsTrue(task.IsCompleted, "Прошло 500 мс — должно быть готово");
}

[Test]
public void Delay_Realtime_UsesTimestampNotDeltaTime()
{
// Задержка Realtime игнорирует DeltaTime; она использует метку Stopwatch
clock.SetDeltaTime(0f); // DeltaTime заморожен
var task = ValkarnTask.Delay(200, DelayType.Realtime);

// Advance перемещает только метку через TimeSpan
clock.Advance(TimeSpan.FromMilliseconds(199));
Assert.IsFalse(task.IsCompleted);

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

Интеграция с NUnit

Набор тестов использует NUnit 3.x (фиксировано на версии 3.x в TestRunner.csproj). NUnit 4 удалил классический API Assert.IsTrue / Assert.AreEqual, который использует набор тестов; не обновляйтесь до NUnit 4 без обновления утверждений до API на основе ограничений.

Unity Test Framework

В Unity Editor тесты в Tests/Editor/ обнаруживаются и запускаются Unity Test Framework (UTF), основанным на NUnit. Интеграция с запускателем тестов работает следующим образом:

  • UTF запускает каждый [Test] в основном потоке.
  • ValkarnTaskTestHelper.Setup() устанавливает TestClock перед каждым тестом; атрибут [SetUp] UTF вызывает его.
  • ValkarnTaskTestHelper.Teardown() удаляет часы в [TearDown].
  • Корутина или [UnityTest] не нужны: все тесты Valkarn Tasks используют синхронные NUnit-методы [Test]. TestClock управляет временем вручную, поэтому нет необходимости в реальном продвижении кадров.

Проект _TestRunner~

Директория _TestRunner~/ содержит автономный проект .NET 8, который компилирует и запускает весь набор тестов вне Unity, используя .NET SDK и dotnet test. Это используется для CI и для локальной разработки участников.

Структура

_TestRunner~/
TestRunner.csproj — файл проекта .NET 8
bin~/ — вывод сборки (в .gitignore)
obj~/ — промежуточный вывод (в .gitignore)

Как это работает

TestRunner.csproj компилирует четыре набора исходных кодов в одну сборку:

Набор исходниковПутьПримечания
Runtime../Runtime/**/*.csВсе файлы среды выполнения
Testing../Testing/**/*.csValkarnTaskTestHelper, TestClock
Tests../Tests/Editor/**/*.csВсе .cs-файлы тестов
ИсключенияUnityTimeProvider.cs, Bridge/, Burst/, ECS/Требуют реальных API Unity — исключены

Проект не определяет UNITY_5_3_OR_NEWER, VTASKS_HAS_BURST, VTASKS_HAS_COLLECTIONS или VTASKS_HAS_ENTITIES, поэтому все Unity-специфичные ветви #if исключаются при компиляции. Это означает, что тесты проверяют пути кода на чистом C#.

DLL анализаторов загружаются как элементы <Analyzer>, поэтому те же правила, которые срабатывают в IDE, также срабатывают при dotnet build тестового проекта.

Запуск тестов

cd _TestRunner~
dotnet test

Или из корня репозитория:

dotnet test _TestRunner~/TestRunner.csproj

Исключённые тестовые файлы

Три поддиректории тестов исключены из проекта _TestRunner~, поскольку требуют реальных API среды выполнения Unity:

ДиректорияПричина
Tests/Editor/Bridge/Тесты для моста Job System; требует Unity.Jobs
Tests/Editor/Burst/Тесты для планировщика Burst; требует Unity.Burst
Tests/Editor/ECS/Тесты для утилит ECS; требует Unity.Entities

Эти тесты выполняются только внутри Unity Editor через Unity Test Framework.

Добавление нового теста

  1. Добавьте новый .cs-файл в Tests/Editor/ (не в поддиректорию с Unity API).
  2. Декорируйте класс [TestFixture], а методы — [Test].
  3. Если тесту нужен контроль времени, используйте ValkarnTaskTestHelper.Setup() / Teardown() в [SetUp] / [TearDown].
  4. Если тесту нужны только синхронные утверждения задач (без задержки), используйте TestHelper.EnsureInitialized() в [SetUp] — teardown не нужен.
  5. Запустите dotnet test _TestRunner~/TestRunner.csproj для проверки вне Unity.
  6. Запустите Unity Test Framework для проверки внутри Unity.