Skip to content
ToanLB
Go back

How to Write Unit Tests for a Legacy Unity Project Full of Singletons (Without a Total Rewrite)

When taking over a live Unity game that has been running in production for several years, a familiar scenario unfolds: almost every script you open contains calls like GameManager.Instance, SoundManager.Instance, or DataManager.Instance.

These global Singletons are scattered across hundreds of files. As features pile up, tweaking a single line of code in one system can silently break completely unrelated functionality elsewhere, triggering frustrating regression bugs.

Eventually, the engineering team agrees that automated unit testing must be integrated into the CI/CD pipeline to safeguard mission-critical calculations. But the moment you start writing tests, a massive roadblock appears: core business logic is tightly coupled to GameManager.Instance (which inherits from MonoBehaviour). To test a simple damage formula or reward calculation, you are forced to instantiate GameObjects, load scenes, and spin up an army of unrelated peripheral systems.

At that moment, the first instinct for many developers is: “Should we rewrite the entire architecture from scratch, rip out all Singletons, and replace them with a Dependency Injection framework like VContainer or Zenject?”

In production reality, no Product Owner or Game Producer will pause game releases for a month just so engineers can “clean up code.” The game must ship weekly updates on schedule. You need a pragmatic approach: write solid unit tests for new features today, while systematically containing and paying down legacy technical debt without disrupting release velocity.

Below is a battle-tested 3-level dependency management strategy, chosen based on release deadlines and the allowable blast radius within the legacy codebase.


1. The Core Testing Bottleneck with Singletons

To understand why Singletons resist automated unit testing, consider the requirements of a clean unit test:

flowchart TD
    subgraph Problem["Bottleneck in Testing with Singletons"]
        Test["Unit Test Runner (CI/CD)"] -->|Calls method under test| Method["CalculateReward()"]
        Method -->|Directly calls static variable| GM["GameManager.Instance (MonoBehaviour)"]
        GM -->|Engine dependency| Engine["Unity Scene / GameObject Lifecycle"]
        GM -->|Pulls heavy resources| Res["SaveSystem / Audio / Network"]
    end

The consequence is severe: executing a basic calculation requires bootstrapping an entire mock scene with active GameObjects in memory. This slows test suite execution down to a crawl and causes cascading test failures whenever someone touches GameManager.

To eliminate this bottleneck without halting development, we divide refactoring into three distinct levels.


2. Level 1: Zero-Touch Isolation with Adapters (Zero Touch Legacy)

When to Use

This pattern is ideal when shipping an urgent feature in the current sprint, or when team policy strictly dictates: Do not touch legacy GameManager code under any circumstances to prevent unintended regressions in an upcoming release build.

Implementation Strategy

The core rule is simple: New features are prohibited from calling GameManager.Instance directly within their business logic. Instead, the new feature communicates strictly through an abstract interface, and we introduce an Adapter class as a thin bridge.

flowchart LR
    subgraph NewFeature["New Feature (Protected)"]
        Logic["CombatRewardCalculator"]
        Interface["IPlayerStatsProvider"]
    end

    subgraph Production["Production Runtime"]
        Adapter["GameManagerAdapter"]
        Legacy["GameManager.Instance"]
    end

    subgraph Testing["Unit Test Environment"]
        Mock["FakePlayerStatsProvider"]
    end

    Logic -->|Depends on| Interface
    Adapter -.->|implements| Interface
    Adapter -->|Calls implicitly| Legacy
    Mock -.->|implements| Interface
    Logic -.->|Inject test data| Mock

Code Demo: Adapter Wrapper Isolating Singleton

First, the new feature declares precisely the data it needs through a focused interface:

// 1. Interface contains only data required by the new feature
public interface IPlayerStatsProvider
{
    int CurrentLevel { get; }
    int BaseGold { get; }
}

The business logic accepts this interface through its constructor:

// 2. Pure C# business logic with zero Unity Engine dependencies
public class CombatRewardCalculator
{
    private readonly IPlayerStatsProvider _statsProvider;

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

    public int CalculateGoldReward(int enemyBounty)
    {
        int multiplier = Math.Max(1, _statsProvider.CurrentLevel);
        return (_statsProvider.BaseGold + enemyBounty) * multiplier;
    }
}

In the production runtime environment, an Adapter wraps GameManager.Instance:

// 3. Adapter acts as an Anti-Corruption Layer
public class GameManagerStatsAdapter : IPlayerStatsProvider
{
    public int CurrentLevel => GameManager.Instance.PlayerLevel;
    public int BaseGold => GameManager.Instance.DefaultGoldBonus;
}

Writing Unit Tests at Level 1

Inside the test suite, there is zero reliance on GameManager or Unity scene lifecycles:

using NUnit.Framework;

[TestFixture]
public class CombatRewardCalculatorTests
{
    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);
    }
}

Trade-Off Analysis


3. Level 2: Extracting Capability-Focused Interfaces (Minimal Singleton Touch)

When to Use

Suitable when the feature has moderate complexity, the shared data will be reused across multiple upcoming gameplay modules, and the team agrees to minor, non-breaking modifications to GameManager.cs.

Implementation Strategy

Rather than creating an enormous IGameManager interface (which blatantly violates the Interface Segregation Principle), identify the exact capability required by the new feature.

For example, if a combat system requires enemy defense parameters, declare an IEnemyStatsProvider interface. Then, have GameManager implement that interface directly without touching its internal state or existing APIs.

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

    subgraph Capabilities["Capability-focused Interfaces"]
        IEnemy["IEnemyStatsProvider"]
        IScore["IScoreManager"]
    end

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

    LegacyCode["Hundreds of legacy scripts"] -->|Continue calling directly| GM
    NewCombatFeature["New Combat System"] -->|Only depends on| IEnemy

Code Demo: Extracting Capability Interfaces

Inside GameManager.cs, implement the new interface while preserving all existing logic:

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

// GameManager implements the focused interface
public class GameManager : MonoBehaviour, IEnemyStatsProvider
{
    public static GameManager Instance { get; private set; }

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

    public int Defense => enemyDefense;
    public float DamageReductionRate => damageReduction;

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

    // Existing legacy methods remain untouched...
    public void OldLegacyMethod() { }
}

In the new component, inject the dependency via an Initialize() method:

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

In your Unity scene bootstrap script (or within GameManager itself), pass the reference during startup:

damageSystem.Initialize(GameManager.Instance);

Trade-Off Analysis


4. Level 3: Service Extraction & Turning GameManager into a Facade (Controlled Refactoring)

When to Use

Ideal when feature development directly impacts core game loops (Score, Save/Load, Inventory, Stage Lifecycle), or during dedicated stabilization/refactoring sprints where paying down technical debt is an explicit goal.

Implementation Strategy

Instead of attempting to delete GameManager overnight, transform it from an all-knowing God Class into a thin Facade Coordinator:

  1. Group cohesive data and logic into dedicated pure C# services (ScoreService, InventoryService, LevelService).
  2. Make these services 100% testable in pure C# without MonoBehaviour baggage.
  3. GameManager holds references to these services and delegates incoming requests to them.
  4. For hundreds of legacy scripts still invoking GameManager.Instance.AddScore(10), everything continues working seamlessly because GameManager still exposes that method, delegating the call internally to _scoreService.AddScore(10).
flowchart TD
    subgraph FacadePattern["GameManager Refactored to Facade Coordinator"]
        GM["GameManager.Instance (MonoBehaviour)"]
    end

    subgraph PureServices["Pure C# Services (Fully Testable)"]
        ScoreSvc["ScoreService (Pure C#)"]
        InventorySvc["InventoryService (Pure C#)"]
        StatsSvc["PlayerStatsService (Pure C#)"]
    end

    GM -->|Owns & coordinates| ScoreSvc
    GM -->|Owns & coordinates| InventorySvc
    GM -->|Owns & coordinates| StatsSvc

    LegacyCode["Legacy project scripts"] -->|"Calls GameManager.Instance.AddScore()"| GM
    NewCode["Future new features"] -->|Inject directly| ScoreSvc

Code Demo: Service Extraction and Facade Delegation

Extract score management into a pure C# class:

// 1. Pure C# service with full testability and fast execution
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);
    }
}

Refactor GameManager into a delegating Facade:

// 2. GameManager acts as a Facade maintaining backwards compatibility
public class GameManager : MonoBehaviour
{
    public static GameManager Instance { get; private set; }

    // Pure C# sub-services
    public ScoreService ScoreService { get; private set; }

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

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

    // Backwards-compatible API for legacy callers:
    // Existing code calling GameManager.Instance.AddScore() remains fully operational
    public void AddScore(int amount)
    {
        ScoreService.AddScore(amount);
    }

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

Trade-Off Analysis


5. Trade-Off Comparison Across All 3 Levels

No single approach fits every development timeline. Engineering maturity is about selecting the right solution for your team’s current constraints:

MetricLevel 1: Adapter / WrapperLevel 2: Capability InterfaceLevel 3: Service Facade
Intervention in Legacy SingletonZero modificationsMinor interface additionExtracts logic into pure services
Unit Test FeasibilityExcellent for new featureExcellent for new featureOutstanding for both old & new logic
Turnaround TimeVery fast (a few hours)Fast (half a day)Requires a few days of planning & tests
Blast Radius on Existing CodeZero riskVery low riskRequires careful regression testing
Technical Debt EliminationTemporary stopgapModerate improvementSubstantial architectural transformation
Recommended ScenarioTight deadlines, hotfixes, small featuresNew features sharing common stateDedicated refactor sprints, core loop changes

6. Practical Roadmap: The Boy Scout Rule

When inheriting a legacy Unity project riddled with Singletons, resist the urge to open a massive pull request rewriting thousands of lines of code over a weekend. That route almost always results in merge conflicts, broken releases, and elusive regressions.

Instead, adhere to the Boy Scout Rule:

“Always leave the campground cleaner than you found it.”

By taking disciplined, incremental steps, you maintain weekly release velocity while progressively transforming a chaotic legacy codebase into a clean, test-driven, modular architecture.


Share this post:

If this article brings value to your work, feel free to buy me a coffee here.

Previous Post
Why Do Large Projects Avoid the Singleton Pattern in Unity?

Get New Posts via Email

Subscribe to get notified whenever a new technical deep dive on Unity, C#, and software engineering is published.

* No spam. You can unsubscribe at any time with a single click.