Files
zeling_v2/Docs/Verification/01_Unit_EventSystem_ServiceLocator.md

13 KiB
Raw Permalink Blame History

单元测试 01 · SO 事件系统 & ServiceLocator

测试类型EditMode 单元测试NUnit
测试文件Assets/Tests/EditMode/EventSystemTests.cs
被测程序集BaseGames.Core.Events
asmdef 依赖配置BaseGames.Tests.EditMode.asmdef 需引用 BaseGames.Core.Events


目录

  1. 测试覆盖范围
  2. EventSubscription & CompositeDisposable 测试
  3. BaseEventChannelSO 测试
  4. ServiceLocator 测试
  5. 完整测试代码

1. 测试覆盖范围

测试点
EventSubscription Dispose 后自动取消回调
CompositeDisposable Clear/Dispose 批量取消Add 后 Clear 清空
EventSubscriptionExtensions.AddTo 返回原订阅句柄(链式调用不丢失引用)
BaseEventChannelSO<T> Raise 触发所有订阅者;订阅者为 0 时 Raise 不抛异常
VoidBaseEventChannelSO 同上(无负载版)
ServiceLocator Register/Get/Unregister未注册时 Get 抛异常RegisterIfAbsent 防重复

2. EventSubscription & CompositeDisposable 测试

测试说明

EventSubscription 是纯 C# struct完全无 Unity 依赖,适合 EditMode 测试。

关键验证点:

  • Dispose() 调用 unsubscribe Action
  • CompositeDisposable.Clear() 遍历调用所有子项的 Dispose()
  • AddTo() 扩展方法正确将订阅加入 CompositeDisposable

3. BaseEventChannelSO 测试

测试说明

BaseEventChannelSO<T> 继承 ScriptableObject,在 EditMode 中需要用 ScriptableObject.CreateInstance<T>() 创建实例(不能 new)。

创建方式:

var channel = ScriptableObject.CreateInstance<IntEventChannelSO>();

⚠️ 测试结束后必须调用 Object.DestroyImmediate(channel) 防止内存泄漏。


4. ServiceLocator 测试

测试说明

ServiceLocator 是静态类,测试间有状态污染风险。每个测试前必须调用 ServiceLocator.Reset() 清空状态(已在 #if UNITY_EDITOR 块内提供)。

关键验证点:

  • Register<T>Get<T>() 返回正确实例
  • Get<T>() 未注册时抛出 InvalidOperationException
  • GetOrDefault<T>() 未注册时返回 default 不抛异常
  • RegisterIfAbsent<T>() 重复注册时不覆盖
  • Unregister<T>(impl) 只在实例匹配时才移除

5. 完整测试代码

将以下代码保存至 Assets/Tests/EditMode/EventSystemTests.cs

using System;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using BaseGames.Core.Events;
using BaseGames.Core;

namespace BaseGames.Tests.EditMode
{
    /// <summary>
    /// SO 事件系统 + ServiceLocator 单元测试EditMode无需 Play Mode
    /// </summary>
    [TestFixture]
    public class EventSystemTests
    {
        // ── EventSubscription ────────────────────────────────────────────────

        [Test]
        public void EventSubscription_Dispose_CallsUnsubscribeAction()
        {
            bool called = false;
            var sub = new EventSubscription(() => called = true);
            sub.Dispose();
            Assert.IsTrue(called, "Dispose 应调用 unsubscribe action");
        }

        [Test]
        public void EventSubscription_Dispose_NullAction_DoesNotThrow()
        {
            var sub = new EventSubscription(null);
            Assert.DoesNotThrow(() => sub.Dispose());
        }

        // ── CompositeDisposable ──────────────────────────────────────────────

        [Test]
        public void CompositeDisposable_Clear_DisposesAllItems()
        {
            var composite = new CompositeDisposable();
            int count = 0;
            composite.Add(new EventSubscription(() => count++));
            composite.Add(new EventSubscription(() => count++));
            composite.Add(new EventSubscription(() => count++));

            composite.Clear();
            Assert.AreEqual(3, count, "Clear 应 Dispose 所有已添加的订阅");
        }

        [Test]
        public void CompositeDisposable_Clear_TwiceSafe()
        {
            var composite = new CompositeDisposable();
            int count = 0;
            composite.Add(new EventSubscription(() => count++));
            composite.Clear();
            composite.Clear(); // 第二次 Clear 不应重复 Dispose
            Assert.AreEqual(1, count, "Clear 两次不应重复 Dispose");
        }

        [Test]
        public void CompositeDisposable_Dispose_SameAsClear()
        {
            var composite = new CompositeDisposable();
            bool disposed = false;
            composite.Add(new EventSubscription(() => disposed = true));
            ((IDisposable)composite).Dispose();
            Assert.IsTrue(disposed);
        }

        [Test]
        public void AddTo_ReturnsOriginalSubscription()
        {
            var composite = new CompositeDisposable();
            bool called = false;
            var sub = new EventSubscription(() => called = true);
            var returned = sub.AddTo(composite);
            returned.Dispose();
            Assert.IsTrue(called, "AddTo 返回的订阅 Dispose 后应触发原 unsubscribe");
        }

        [Test]
        public void AddTo_Collection_AddsToList()
        {
            var list = new List<IDisposable>();
            bool called = false;
            var sub = new EventSubscription(() => called = true);
            sub.AddTo(list);
            Assert.AreEqual(1, list.Count, "AddTo(ICollection) 应将订阅添加到列表");
            list[0].Dispose();
            Assert.IsTrue(called);
        }

        // ── IntEventChannelSO ────────────────────────────────────────────────

        [Test]
        public void IntEventChannel_Raise_CallsAllSubscribers()
        {
            var channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
            try
            {
                int sum = 0;
                channel.OnEventRaised += v => sum += v;
                channel.OnEventRaised += v => sum += v;
                channel.Raise(5);
                Assert.AreEqual(10, sum, "两个订阅者应各收到一次事件");
            }
            finally
            {
                Object.DestroyImmediate(channel);
            }
        }

        [Test]
        public void IntEventChannel_Raise_NoSubscribers_DoesNotThrow()
        {
            var channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
            try
            {
                Assert.DoesNotThrow(() => channel.Raise(42));
            }
            finally
            {
                Object.DestroyImmediate(channel);
            }
        }

        [Test]
        public void IntEventChannel_UnsubscribeAfterDispose()
        {
            var channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
            try
            {
                int callCount = 0;
                void Handler(int v) => callCount++;
                channel.OnEventRaised += Handler;
                channel.Raise(1);
                Assert.AreEqual(1, callCount);

                channel.OnEventRaised -= Handler;
                channel.Raise(1);
                Assert.AreEqual(1, callCount, "取消订阅后不应再收到事件");
            }
            finally
            {
                Object.DestroyImmediate(channel);
            }
        }

        // ── VoidEventChannelSO ───────────────────────────────────────────────

        [Test]
        public void VoidEventChannel_Raise_CallsSubscriber()
        {
            var channel = ScriptableObject.CreateInstance<VoidEventChannelSO>();
            try
            {
                bool received = false;
                channel.OnEventRaised += () => received = true;
                channel.Raise();
                Assert.IsTrue(received);
            }
            finally
            {
                Object.DestroyImmediate(channel);
            }
        }

        // ── ServiceLocator ───────────────────────────────────────────────────

        [SetUp]
        public void SetUp() => ServiceLocator.Reset();

        [TearDown]
        public void TearDown() => ServiceLocator.Reset();

        [Test]
        public void ServiceLocator_RegisterAndGet_ReturnsInstance()
        {
            var mock = new MockService();
            ServiceLocator.Register<IMockService>(mock);
            var retrieved = ServiceLocator.Get<IMockService>();
            Assert.AreSame(mock, retrieved);
        }

        [Test]
        public void ServiceLocator_GetUnregistered_ThrowsInvalidOperation()
        {
            Assert.Throws<InvalidOperationException>(
                () => ServiceLocator.Get<IMockService>());
        }

        [Test]
        public void ServiceLocator_GetOrDefault_Unregistered_ReturnsDefault()
        {
            var result = ServiceLocator.GetOrDefault<IMockService>();
            Assert.IsNull(result);
        }

        [Test]
        public void ServiceLocator_GetOrDefault_Registered_ReturnsInstance()
        {
            var mock = new MockService();
            ServiceLocator.Register<IMockService>(mock);
            var result = ServiceLocator.GetOrDefault<IMockService>();
            Assert.AreSame(mock, result);
        }

        [Test]
        public void ServiceLocator_RegisterIfAbsent_DoesNotOverwrite()
        {
            var first = new MockService();
            var second = new MockService();
            ServiceLocator.Register<IMockService>(first);
            ServiceLocator.RegisterIfAbsent<IMockService>(second);
            Assert.AreSame(first, ServiceLocator.Get<IMockService>(),
                "RegisterIfAbsent 不应覆盖已存在的注册");
        }

        [Test]
        public void ServiceLocator_Unregister_RemovesService()
        {
            ServiceLocator.Register<IMockService>(new MockService());
            ServiceLocator.Unregister<IMockService>();
            Assert.Throws<InvalidOperationException>(
                () => ServiceLocator.Get<IMockService>());
        }

        [Test]
        public void ServiceLocator_Unregister_WithWrongInstance_DoesNotRemove()
        {
            var first = new MockService();
            var second = new MockService();
            ServiceLocator.Register<IMockService>(first);
            ServiceLocator.Unregister<IMockService>(second); // 不同实例,不应移除
            Assert.AreSame(first, ServiceLocator.Get<IMockService>(),
                "错误实例的 Unregister 不应移除已注册的服务");
        }

        [Test]
        public void ServiceLocator_OverrideForTest_ReplacesService()
        {
            var original = new MockService { Value = 1 };
            var replacement = new MockService { Value = 99 };
            ServiceLocator.Register<IMockService>(original);
            ServiceLocator.OverrideForTest<IMockService>(replacement);
            Assert.AreEqual(99, ServiceLocator.Get<IMockService>().Value);
        }

        // ── Test Helpers ─────────────────────────────────────────────────────

        private interface IMockService { int Value { get; set; } }
        private class MockService : IMockService { public int Value { get; set; } }
    }
}

运行方式

  1. 将上方代码保存至 Assets/Tests/EditMode/EventSystemTests.cs
  2. 打开 Window → General → Test Runner
  3. 选择 EditMode → 展开 BaseGames.Tests.EditMode → 展开 EventSystemTests
  4. 点击 Run All 或逐个运行

asmdef 配置确认

打开 Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef,确认 references 数组包含:

{
  "references": [
    "BaseGames.Core.Events",
    "BaseGames.Core",
    "BaseGames.Combat.StatusEffects"
  ],
  "includePlatforms": [],
  "excludePlatforms": [],
  "allowUnsafeCode": false,
  "overrideReferences": true,
  "precompiledReferences": ["nunit.framework.dll"],
  "autoReferenced": false,
  "defineConstraints": [],
  "versionDefines": [],
  "noEngineReferences": false
}

预期结果

所有 20 个测试 全部通过(绿色)。

若出现失败,常见原因:

失败现象 排查方向
ServiceLocator.Reset() 方法不存在 检查 #if UNITY_EDITOR 编译条件是否生效EditMode 测试应在 UNITY_EDITOR 下)
IntEventChannelSO 找不到 确认 asmdef references 已包含 BaseGames.Core.Events
NullReferenceException on channel.Raise 确认使用 ScriptableObject.CreateInstance<>() 而非 new