Chuyển đến nội dung chính
ToanLB
Quay lại

Làm sao viết Unit Test cho một dự án Unity ngập tràn Singleton mà không cần đập đi xây lại?

Khi tiếp nhận một dự án game đã chạy live được vài năm, kịch bản quen thuộc nhất mà hầu như lập trình viên nào cũng từng gặp phải là: mở file nào ra cũng thấy GameManager.Instance, SoundManager.Instance, DataManager.Instance.

Các Singleton toàn cục này nằm rải rác ở hàng trăm script. Khi game ngày càng nhiều tính năng, việc sửa một dòng code ở hệ thống này rất dễ vô tình làm gãy một chức năng ở hệ thống khác, sinh ra lỗi hồi quy (regression bug).

Đến một thời điểm, team nhận ra cần phải đưa Unit Test vào CI/CD pipeline để bảo vệ các logic tính toán quan trọng. Nhưng khi bắt tay vào viết test, rào cản xuất hiện ngay lập tức: logic nghiệp vụ dính chặt với GameManager.Instance (kế thừa MonoBehaviour). Muốn test một hàm tính điểm hay tính sát thương, bạn buộc phải khởi tạo cả GameObject, nạp Scene và kéo theo hàng loạt hệ thống râu ria khác.

Lúc này, ý nghĩ đầu tiên nảy ra trong đầu nhiều người là: “Hay là đập đi viết lại toàn bộ kiến trúc, gỡ sạch Singleton và thay bằng Dependency Injection framework như VContainer hay Zenject?”.

Tuy nhiên, trong thực tế sản xuất, không một Product Owner hay Game Producer nào chấp nhận dừng dự án cả tháng chỉ để đội ngũ kỹ thuật “dọn dẹp code cho đẹp”. Game vẫn phải ra bản cập nhật đúng hạn mỗi tuần. Bạn buộc phải tìm cách viết được Unit Test cho các tính năng mới ngay hôm nay, đồng thời từng bước kiểm soát và giảm dần technical debt mà không làm gián đoạn nhịp release.

Dưới đây là chiến lược xử lý dependency theo 3 cấp độ mà mình thường áp dụng, tùy thuộc vào độ gấp của deadline và mức độ can thiệp cho phép vào codebase cũ.


1. Bản chất điểm nghẽn khi viết Unit Test với Singleton

Để hiểu vì sao Singleton lại “kỵ” Unit Test, chúng ta nhìn vào bản chất của một bài test tự động:

flowchart TD
    subgraph Problem["Điểm nghẽn khi Unit Test dính Singleton"]
        Test["Unit Test Runner (CI/CD)"] -->|Gọi phương thức cần test| Method["CalculateReward()"]
        Method -->|Gọi trực tiếp biến static| GM["GameManager.Instance (MonoBehaviour)"]
        GM -->|Phụ thuộc engine| Engine["Unity Scene / GameObject Lifecycle"]
        GM -->|Kéo theo tài nguyên nặng| Res["SaveSystem / Audio / Network"]
    end

Hệ quả là: muốn chạy một phép tính toán, bạn phải dựng cả một Scene giả lập với đầy đủ GameObject trong bộ nhớ, khiến test suite chạy rất chậm và thường xuyên bị lỗi lan truyền khi ai đó can thiệp vào GameManager.

Để tháo gỡ điểm nghẽn này mà không làm tê liệt tiến độ dự án, chúng ta chia việc tái cấu trúc thành 3 cấp độ.


2. Cấp độ 1: Cách ly hoàn toàn bằng Adapter / Wrapper (Zero Touch Legacy)

Bối cảnh áp dụng

Đây là giải pháp phù hợp khi bạn cần làm gấp một tính năng mới trong sprint hiện tại, hoặc khi chính sách của team là: Tuyệt đối không được đụng chạm vào code cũ của GameManager để tránh mọi rủi ro ngoài ý muốn cho bản build sắp release.

Cách triển khai

Nguyên tắc cốt lõi: Tính năng mới không được phép gọi GameManager.Instance trong logic tính toán của nó. Thay vào đó, tính năng mới sẽ giao tiếp qua một Interface trừu tượng, và chúng ta dùng một class Adapter để làm cầu nối.

flowchart LR
    subgraph NewFeature["Feature Mới (Được bảo vệ)"]
        Logic["CombatRewardCalculator"]
        Interface["IPlayerStatsProvider"]
    end

    subgraph Production["Môi trường Game (Runtime)"]
        Adapter["GameManagerAdapter"]
        Legacy["GameManager.Instance"]
    end

    subgraph Testing["Môi trường Unit Test"]
        Mock["FakePlayerStatsProvider"]
    end

    Logic -->|Phụ thuộc vào| Interface
    Adapter -.->|implements| Interface
    Adapter -->|Gọi ngầm| Legacy
    Mock -.->|implements| Interface
    Logic -.->|Inject test data| Mock

Code demo Cấp độ 1: Bọc Adapter cách ly Singleton

Đầu tiên, feature mới tự định nghĩa chính xác những dữ liệu mà nó cần thông qua interface:

// 1. Interface chỉ chứa dữ liệu mà feature mới thực sự quan tâm
public interface IPlayerStatsProvider
{
    int CurrentLevel { get; }
    int BaseGold { get; }
}

Logic tính toán nghiệp vụ nhận interface này qua constructor:

// 2. Business logic thuần C#, độc lập 100% với Unity
public class CombatRewardCalculator
{
    private readonly IPlayerStatsProvider _statsProvider;

    public CombatRewardCalculator(IPlayerStatsProvider statsProvider)
    {
        _statsProvider = statsProvider;
    }

    public int CalculateGoldReward(int enemyBounty)
    {
        // Công thức tính thưởng theo level người chơi
        int multiplier = Math.Max(1, _statsProvider.CurrentLevel);
        return (_statsProvider.BaseGold + enemyBounty) * multiplier;
    }
}

Ở môi trường runtime của game, chúng ta tạo một Adapter bọc lại GameManager.Instance:

// 3. Adapter hoạt động như một lớp cách ly (Anti-Corruption Layer)
public class GameManagerStatsAdapter : IPlayerStatsProvider
{
    public int CurrentLevel => GameManager.Instance.PlayerLevel;
    public int BaseGold => GameManager.Instance.DefaultGoldBonus;
}

Viết Unit Test ở Cấp độ 1

Khi viết test, chúng ta không cần quan tâm GameManager là gì, cũng không cần Scene Unity:

using NUnit.Framework;

[TestFixture]
public class CombatRewardCalculatorTests
{
    // Tạo một Fake Provider đơn giản cho bài test
    private class FakePlayerStats : IPlayerStatsProvider
    {
        public int CurrentLevel { get; set; }
        public int BaseGold { get; set; }
    }

    [Test]
    public void CalculateGoldReward_Level2_ReturnsDoubledReward()
    {
        var fakeStats = new FakePlayerStats { CurrentLevel = 2, BaseGold = 100 };
        var calculator = new CombatRewardCalculator(fakeStats);

        int result = calculator.CalculateGoldReward(enemyBounty: 50);

        // (100 + 50) * 2 = 300
        Assert.AreEqual(300, result);
    }
}

Đánh giá Trade-off


3. Cấp độ 2: Trích xuất Interface theo Capability (Can thiệp tối thiểu vào Singleton)

Bối cảnh áp dụng

Khi tính năng bạn đang làm có độ phức tạp trung bình, hoặc dữ liệu cần dùng có khả năng tái sử dụng ở nhiều module khác nhau, và team đồng thuận cho phép chỉnh sửa nhẹ vào file GameManager.cs mà không làm thay đổi behavior hiện tại.

Cách triển khai

Thay vì trích xuất một interface khổng lồ IGameManager (vốn vi phạm nguyên lý Interface Segregation), chúng ta quan sát xem tính năng mới thực sự cần năng lực (capability) gì.

Ví dụ: Nếu hệ thống chiến đấu cần thông số phòng thủ của quái, chúng ta tạo IEnemyStatsProvider. Sau đó, cho chính GameManager implement interface này.

flowchart TD
    subgraph LegacySingleton["GameManager.cs (God Class cũ)"]
        GM["GameManager"]
    end

    subgraph Capabilities["Các Interface phân tách theo nhiệm vụ"]
        IEnemy["IEnemyStatsProvider"]
        IScore["IScoreManager"]
    end

    GM -.->|implements| IEnemy
    GM -.->|implements| IScore

    LegacyCode["Code cũ hàng trăm script"] -->|Vẫn gọi trực tiếp| GM
    NewCombatFeature["Combat System mới"] -->|Chỉ phụ thuộc vào| IEnemy

Code demo Cấp độ 2: Trích xuất Capability Interface

Trong file GameManager.cs, chúng ta cho class implement thêm interface mà không sửa đổi bất kỳ logic nội bộ nào:

public interface IEnemyStatsProvider
{
    int Defense { get; }
    float DamageReductionRate { get; }
}

// GameManager implement thêm interface
public class GameManager : MonoBehaviour, IEnemyStatsProvider
{
    public static GameManager Instance { get; private set; }

    [SerializeField] private int enemyDefense = 20;
    [SerializeField] private float damageReduction = 0.1f;

    // Implement interface một cách tường minh hoặc tự nhiên
    public int Defense => enemyDefense;
    public float DamageReductionRate => damageReduction;

    private void Awake()
    {
        if (Instance == null) Instance = this;
        else Destroy(gameObject);
    }

    // Hàng chục hàm cũ khác vẫn giữ nguyên...
    public void OldLegacyMethod() { }
}

Trong component mới, chúng ta inject dependency qua hàm Init (hoặc Initialize):

public class DamageSystem : MonoBehaviour
{
    private IEnemyStatsProvider _statsProvider;

    public void Initialize(IEnemyStatsProvider statsProvider)
    {
        _statsProvider = statsProvider;
    }

    public int ComputeDamage(int rawAttack)
    {
        if (_statsProvider == null) return rawAttack;
        return (int)(rawAttack * (1f - _statsProvider.DamageReductionRate)) - _statsProvider.Defense;
    }
}

Ở Scene Unity, script bootstrap (hoặc chính GameManager) chỉ cần truyền chính nó vào:

damageSystem.Initialize(GameManager.Instance);

Đánh giá Trade-off


4. Cấp độ 3: Bóc tách Service và chuyển GameManager thành Facade (Refactor có kiểm soát)

Bối cảnh áp dụng

Khi tính năng bạn đang phát triển chạm vào luồng logic xương sống của game (như Save/Load, Hệ thống tính điểm, Vòng đời màn chơi), hoặc khi team có một khoảng thời gian giữa các release (Hardening Sprint / Refactoring Sprint) để chủ động dọn dẹp technical debt.

Cách triển khai

Chúng ta không cố gắng xóa sổ GameManager ngay lập tức, mà biến nó từ một class “ôm đồm mọi việc” thành một Facade Coordinator mỏng:

  1. Tách từng nhóm dữ liệu và logic liên quan thành các Service thuần C# riêng biệt (ScoreService, InventoryService, LevelService).
  2. Các Service này hoàn toàn độc lập, có thể viết Unit Test bao phủ 100%.
  3. GameManager giữ instance của các Service này và ủy thác (delegate) công việc cho chúng.
  4. Với hàng trăm script cũ đang gọi GameManager.Instance.AddScore(10), code cũ vẫn chạy bình thường vì GameManager vẫn cung cấp phương thức đó, chỉ khác là nó chuyển tiếp cuộc gọi sang _scoreService.AddScore(10).
flowchart TD
    subgraph FacadePattern["GameManager chuyển thành Facade Coordinator"]
        GM["GameManager.Instance (MonoBehaviour)"]
    end

    subgraph PureServices["Các Service thuần C# (Dễ viết Unit Test)"]
        ScoreSvc["ScoreService (Pure C#)"]
        InventorySvc["InventoryService (Pure C#)"]
        StatsSvc["PlayerStatsService (Pure C#)"]
    end

    GM -->|Sở hữu và điều phối| ScoreSvc
    GM -->|Sở hữu và điều phối| InventorySvc
    GM -->|Sở hữu và điều phối| StatsSvc

    LegacyCode["Script cũ trong dự án"] -->|"Gọi GameManager.Instance.AddScore()"| GM
    NewCode["Tính năng mới trong tương lai"] -->|Inject trực tiếp| ScoreSvc

Code demo Cấp độ 3: Bóc tách Service và dựng Facade

Bóc tách service tính điểm sang class thuần C#:

// 1. Service thuần C# hoàn chỉnh, test độc lập cực nhanh
public class ScoreService
{
    public int CurrentScore { get; private set; }
    public event Action<int> OnScoreChanged;

    public void AddScore(int amount)
    {
        if (amount <= 0) return;
        CurrentScore += amount;
        OnScoreChanged?.Invoke(CurrentScore);
    }

    public void Reset()
    {
        CurrentScore = 0;
        OnScoreChanged?.Invoke(CurrentScore);
    }
}

Biến GameManager thành Facade ủy thác:

// 2. GameManager đóng vai trò Facade duy trì tính tương thích ngược
public class GameManager : MonoBehaviour
{
    public static GameManager Instance { get; private set; }

    // Các Service con thuần C#
    public ScoreService ScoreService { get; private set; }

    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
            InitializeServices();
        }
        else
        {
            Destroy(gameObject);
        }
    }

    private void InitializeServices()
    {
        ScoreService = new ScoreService();
    }

    // API tương thích ngược cho hàng trăm script cũ:
    // Code cũ gọi GameManager.Instance.AddScore() vẫn chạy bình thường!
    public void AddScore(int amount)
    {
        ScoreService.AddScore(amount);
    }

    public int GetCurrentScore()
    {
        return ScoreService.CurrentScore;
    }
}

Đánh giá Trade-off


5. Bảng so sánh Trade-off giữa 3 cấp độ

Không có phương pháp nào là “thượng đẳng” hay hoàn hảo cho mọi thời điểm. Sự chuyên nghiệp nằm ở việc chọn đúng giải pháp phù hợp với hoàn cảnh thực tế:

Tiêu chíCấp độ 1: Adapter / WrapperCấp độ 2: Capability InterfaceCấp độ 3: Service Facade
Mức độ can thiệp vào Singleton cũKhông đụng một dòng code nàoSửa nhẹ phần khai báo interfaceTái cấu trúc logic thành các service con
Khả năng viết Unit TestTốt cho tính năng mớiRất tốt cho tính năng mớiTuyệt vời cho cả logic cũ và mới
Tốc độ hoàn thànhRất nhanh (vài giờ)Nhanh (nửa ngày)Cần vài ngày lên kế hoạch và test
Tác động đến code cũRủi ro bằng 0Rủi ro rất thấpCần regression test cẩn thận
Mức độ triệt tiêu Technical DebtChỉ mang tính tạm thờiTrung bìnhRất cao, chuyển đổi kiến trúc căn bản
Tình huống nên áp dụngDeadline gấp, sửa bug nóng, tính năng nhỏTính năng mới cần dùng dữ liệu chungRefactor sprint, tính năng lớn chạm vào core

6. Lộ trình thực tế: Nguyên tắc Hướng đạo sinh (Boy Scout Rule)

Nếu bạn vừa tiếp nhận một dự án Unity đầy rẫy Singleton, đừng cố gắng trở thành “người hùng” bằng cách tạo một pull request hàng ngàn dòng code để xóa sạch Singleton trong một đêm. Cách làm đó gần như chắc chắn sẽ dẫn tới thảm họa xung đột code (merge conflict), trễ deadline và tạo ra hàng loạt bug mới.

Thay vào đó, hãy áp dụng Nguyên tắc Hướng đạo sinh (Boy Scout Rule):

“Hãy luôn để khu cắm trại sạch hơn lúc bạn mới đến.”

Bằng cách đi từng bước vững chắc, bạn vừa đảm bảo tiến độ release sản phẩm cho công ty, vừa từng bước biến một legacy codebase hỗn loạn thành một hệ thống module hóa sạch sẽ, có kiểm thử bảo vệ đàng hoàng.


Chia sẻ bài viết:

Nếu bài viết mang lại giá trị hữu ích cho công việc của bạn, bạn có thể mời mình một ly cafe tại đây.

Nhận bài viết mới qua Email

Đăng ký để nhận thông báo mỗi khi có bài phân tích chuyên sâu mới về Unity, C# và kinh nghiệm làm nghề.

* Không spam. Bạn có thể huỷ đăng ký bất kỳ lúc nào chỉ với 1 click.


Bài trước
Tại sao khi làm dự án lớn, chúng ta thường hạn chế dùng Singleton?