Testing Unity Addressables: Asset Loading, Memory, and Build Validation

Testing Unity Addressables: Asset Loading, Memory, and Build Validation

Unity Addressables is one of those systems that works fine in the editor and breaks in surprising ways in production builds. Async loading races, missing assets at runtime, memory leaks from unreleased handles, and bundle fragmentation issues are all addressables bugs that only manifest after you've shipped.

This guide covers testing Addressables thoroughly, from unit tests of loading logic to validation of your asset bundles.

Common Addressables Bugs

Before writing tests, know what you're testing against:

  1. Missing asset — address exists in code but not in Addressables Groups
  2. Load-use race — code uses asset before Awaitable.Completed
  3. Memory leakAsyncOperationHandle released without Addressables.Release()
  4. Handle released twice — double-release causes errors or incorrect ref counting
  5. Bundle not included — asset exists in editor but excluded from build
  6. Label mismatch — code loads by label, label was renamed in editor
  7. Async on main thread — blocking WaitForCompletion() causes frame hitches
  8. Instantiate without trackingAddressables.InstantiateAsync() result not tracked for release

Abstraction Layer for Testability

Direct calls to Addressables.LoadAssetAsync<T>() are hard to test. Wrap them:

// IAssetLoader.cs
public interface IAssetLoader {
    UniTask<T> LoadAsync<T>(string address) where T : Object;
    UniTask<GameObject> InstantiateAsync(string address, Transform parent = null);
    void Release<T>(T asset) where T : Object;
    void ReleaseInstance(GameObject instance);
    bool IsLoaded(string address);
}

// AddressableAssetLoader.cs — production
public class AddressableAssetLoader : IAssetLoader {
    private readonly Dictionary<Object, AsyncOperationHandle> handles = new();

    public async UniTask<T> LoadAsync<T>(string address) where T : Object {
        var handle = Addressables.LoadAssetAsync<T>(address);
        var result = await handle.Task;
        
        if (handle.Status != AsyncOperationStatus.Succeeded) {
            Addressables.Release(handle);
            throw new AssetLoadException($"Failed to load '{address}': {handle.OperationException?.Message}");
        }
        
        handles[result] = handle;
        return result;
    }

    public async UniTask<GameObject> InstantiateAsync(string address, Transform parent = null) {
        var handle = Addressables.InstantiateAsync(address, parent);
        var instance = await handle.Task;
        handles[instance] = handle;
        return instance;
    }

    public void Release<T>(T asset) where T : Object {
        if (handles.TryGetValue(asset, out var handle)) {
            handles.Remove(asset);
            Addressables.Release(handle);
        }
    }

    public void ReleaseInstance(GameObject instance) {
        if (handles.TryGetValue(instance, out var handle)) {
            handles.Remove(instance);
            Addressables.ReleaseInstance(handle);
        }
    }

    public bool IsLoaded(string address) => handles.Values.Any(
        h => h.IsValid() && h.Status == AsyncOperationStatus.Succeeded
    );
}

// FakeAssetLoader.cs — test double
public class FakeAssetLoader : IAssetLoader {
    private readonly Dictionary<string, Object> registeredAssets = new();
    private readonly HashSet<string> loadedAddresses = new();
    
    public int LoadCallCount { get; private set; }
    public int ReleaseCallCount { get; private set; }

    public void RegisterAsset<T>(string address, T asset) where T : Object {
        registeredAssets[address] = asset;
    }

    public async UniTask<T> LoadAsync<T>(string address) where T : Object {
        LoadCallCount++;
        await UniTask.Yield(); // Simulate async
        
        if (!registeredAssets.TryGetValue(address, out var asset))
            throw new AssetLoadException($"Asset not found: {address}");
        
        loadedAddresses.Add(address);
        return (T)asset;
    }

    public async UniTask<GameObject> InstantiateAsync(string address, Transform parent = null) {
        LoadCallCount++;
        await UniTask.Yield();
        
        if (!registeredAssets.ContainsKey(address))
            throw new AssetLoadException($"Prefab not found: {address}");
        
        var go = new GameObject($"Fake_{address}");
        if (parent != null) go.transform.SetParent(parent);
        loadedAddresses.Add(address);
        return go;
    }

    public void Release<T>(T asset) where T : Object {
        ReleaseCallCount++;
    }

    public void ReleaseInstance(GameObject instance) {
        ReleaseCallCount++;
        Object.Destroy(instance);
    }

    public bool IsLoaded(string address) => loadedAddresses.Contains(address);
}

Unit Tests

With the abstraction in place, testing is straightforward:

// Tests/EditMode/AssetLoaderTests.cs
using NUnit.Framework;
using Cysharp.Threading.Tasks;
using UnityEngine;

public class AssetLoaderTests {
    private FakeAssetLoader loader;

    [SetUp]
    public void SetUp() {
        loader = new FakeAssetLoader();
    }

    [Test]
    public async void LoadAsync_RegisteredAsset_ReturnsCorrectAsset() {
        var expectedTexture = new Texture2D(64, 64);
        loader.RegisterAsset("ui/player-icon", expectedTexture);

        var result = await loader.LoadAsync<Texture2D>("ui/player-icon");

        Assert.AreSame(expectedTexture, result);
        Assert.AreEqual(1, loader.LoadCallCount);
    }

    [Test]
    public void LoadAsync_UnregisteredAddress_ThrowsAssetLoadException() {
        Assert.ThrowsAsync<AssetLoadException>(async () => {
            await loader.LoadAsync<Texture2D>("ui/nonexistent");
        });
    }

    [Test]
    public async void IsLoaded_AfterLoad_ReturnsTrue() {
        loader.RegisterAsset("audio/bgm", new AudioClip());
        
        await loader.LoadAsync<AudioClip>("audio/bgm");
        
        Assert.IsTrue(loader.IsLoaded("audio/bgm"));
    }

    [Test]
    public void IsLoaded_BeforeLoad_ReturnsFalse() {
        Assert.IsFalse(loader.IsLoaded("audio/bgm"));
    }
}

Testing Consumer Classes

// WeaponLoader.cs — example consumer
public class WeaponLoader {
    private readonly IAssetLoader assetLoader;
    private readonly Dictionary<string, GameObject> loadedWeapons = new();

    public WeaponLoader(IAssetLoader assetLoader) {
        this.assetLoader = assetLoader;
    }

    public async UniTask<GameObject> LoadWeapon(string weaponId) {
        if (loadedWeapons.TryGetValue(weaponId, out var cached))
            return cached;

        var prefab = await assetLoader.InstantiateAsync($"weapons/{weaponId}");
        loadedWeapons[weaponId] = prefab;
        return prefab;
    }

    public void UnloadAllWeapons() {
        foreach (var weapon in loadedWeapons.Values) {
            assetLoader.ReleaseInstance(weapon);
        }
        loadedWeapons.Clear();
    }
}
// Tests/EditMode/WeaponLoaderTests.cs
public class WeaponLoaderTests {
    private FakeAssetLoader fakeLoader;
    private WeaponLoader weaponLoader;

    [SetUp]
    public void SetUp() {
        fakeLoader = new FakeAssetLoader();
        weaponLoader = new WeaponLoader(fakeLoader);
        
        // Register test prefabs
        fakeLoader.RegisterAsset("weapons/sword", new GameObject("Sword"));
        fakeLoader.RegisterAsset("weapons/bow", new GameObject("Bow"));
    }

    [Test]
    public async void LoadWeapon_LoadsCorrectAddress() {
        var result = await weaponLoader.LoadWeapon("sword");
        
        Assert.IsNotNull(result);
        Assert.AreEqual(1, fakeLoader.LoadCallCount);
    }

    [Test]
    public async void LoadWeapon_CalledTwice_LoadsOnceFromCache() {
        await weaponLoader.LoadWeapon("sword");
        await weaponLoader.LoadWeapon("sword"); // Should use cache
        
        Assert.AreEqual(1, fakeLoader.LoadCallCount, "Should only load once");
    }

    [Test]
    public async void UnloadAllWeapons_ReleasesAllLoadedAssets() {
        await weaponLoader.LoadWeapon("sword");
        await weaponLoader.LoadWeapon("bow");
        
        weaponLoader.UnloadAllWeapons();
        
        Assert.AreEqual(2, fakeLoader.ReleaseCallCount);
    }

    [Test]
    public void UnloadAllWeapons_WithNothingLoaded_DoesNotThrow() {
        Assert.DoesNotThrow(() => weaponLoader.UnloadAllWeapons());
    }
}

Play Mode Tests: Integration Testing

For tests that require the actual Addressables runtime:

// Tests/PlayMode/AddressablesIntegrationTests.cs
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.TestTools;

public class AddressablesIntegrationTests {
    private List<AsyncOperationHandle> loadedHandles = new();

    [TearDown]
    public void TearDown() {
        // Always clean up handles after each test
        foreach (var handle in loadedHandles) {
            if (handle.IsValid()) Addressables.Release(handle);
        }
        loadedHandles.Clear();
    }

    [UnityTest]
    public IEnumerator LoadAsset_WithValidAddress_Succeeds() {
        var handle = Addressables.LoadAssetAsync<Texture2D>("ui/main-menu-bg");
        loadedHandles.Add(handle);
        
        yield return handle;
        
        Assert.AreEqual(AsyncOperationStatus.Succeeded, handle.Status,
            $"Asset load failed: {handle.OperationException?.Message}");
        Assert.IsNotNull(handle.Result);
    }

    [UnityTest]
    public IEnumerator LoadAsset_WithInvalidAddress_FailsGracefully() {
        var handle = Addressables.LoadAssetAsync<Texture2D>("ui/this-does-not-exist");
        loadedHandles.Add(handle);
        
        yield return handle;
        
        Assert.AreEqual(AsyncOperationStatus.Failed, handle.Status,
            "Should fail for invalid address");
        Assert.IsNull(handle.Result);
    }

    [UnityTest]
    public IEnumerator LoadByLabel_ReturnsMultipleAssets() {
        var handle = Addressables.LoadAssetsAsync<AudioClip>(
            "sfx",
            null
        );
        loadedHandles.Add(handle);
        
        yield return handle;
        
        Assert.AreEqual(AsyncOperationStatus.Succeeded, handle.Status);
        Assert.Greater(handle.Result.Count, 0, "Label 'sfx' should return at least one clip");
    }

    [UnityTest]
    public IEnumerator InstantiateAsync_CreatesGameObject() {
        var handle = Addressables.InstantiateAsync("enemies/goblin");
        loadedHandles.Add(handle);
        
        yield return handle;
        
        Assert.AreEqual(AsyncOperationStatus.Succeeded, handle.Status);
        Assert.IsNotNull(handle.Result);
        Assert.IsTrue(handle.Result.activeSelf);
        
        // Clean up the instance
        Addressables.ReleaseInstance(handle.Result);
    }

    [UnityTest]
    public IEnumerator LoadAndRelease_DoesNotLeakMemory() {
        long memoryBefore = GC.GetTotalMemory(forceFullCollection: true);

        // Load and release 10 times
        for (int i = 0; i < 10; i++) {
            var handle = Addressables.LoadAssetAsync<Texture2D>("ui/loading-screen");
            yield return handle;
            
            if (handle.Status == AsyncOperationStatus.Succeeded) {
                Addressables.Release(handle);
            }
        }

        GC.Collect();
        long memoryAfter = GC.GetTotalMemory(forceFullCollection: true);

        // Memory growth should be minimal
        long growth = memoryAfter - memoryBefore;
        Assert.Less(growth, 5 * 1024 * 1024, // 5MB tolerance
            $"Memory grew by {growth / 1024}KB — possible leak");
    }
}

Build Validation

The most important Addressables test: does your production build include all the assets your code references?

// Editor/AddressablesBuildValidator.cs
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
using NUnit.Framework;
using System.Collections.Generic;
using System.Linq;

[TestFixture]
public class AddressablesBuildValidator {
    private static AddressableAssetSettings Settings =>
        AddressableAssetSettingsDefaultObject.Settings;

    [Test]
    public void AllAddressesAreUnique() {
        var addresses = Settings.groups
            .SelectMany(g => g.entries)
            .Select(e => e.address)
            .ToList();

        var duplicates = addresses
            .GroupBy(a => a)
            .Where(g => g.Count() > 1)
            .Select(g => g.Key)
            .ToList();

        Assert.IsEmpty(duplicates, 
            $"Duplicate addresses found: {string.Join(", ", duplicates)}");
    }

    [Test]
    public void AllRequiredLabelsExist() {
        var requiredLabels = new[] { "ui", "audio", "sfx", "bgm", "enemies", "weapons" };
        
        var existingLabels = Settings.GetLabels();

        foreach (var label in requiredLabels) {
            Assert.Contains(label, existingLabels,
                $"Required label '{label}' not found in Addressables settings");
        }
    }

    [Test]
    public void NoEmptyGroups() {
        var emptyGroups = Settings.groups
            .Where(g => !g.ReadOnly && g.entries.Count == 0)
            .Select(g => g.Name)
            .ToList();

        Assert.IsEmpty(emptyGroups,
            $"Empty Addressables groups: {string.Join(", ", emptyGroups)}");
    }

    [Test]
    public void AllEntriesHaveValidAddresses() {
        var invalidEntries = Settings.groups
            .SelectMany(g => g.entries)
            .Where(e => string.IsNullOrEmpty(e.address) || e.address.Contains(" "))
            .Select(e => $"{e.AssetPath} -> '{e.address}'")
            .ToList();

        Assert.IsEmpty(invalidEntries,
            $"Invalid addresses: {string.Join("\n", invalidEntries)}");
    }

    [Test]
    public void CriticalAssetsExistInAddressables() {
        var criticalAddresses = new[] {
            "ui/main-menu",
            "ui/loading-screen",
            "audio/main-theme",
            "prefabs/player",
        };

        var allAddresses = Settings.groups
            .SelectMany(g => g.entries)
            .Select(e => e.address)
            .ToHashSet();

        foreach (var address in criticalAddresses) {
            Assert.IsTrue(allAddresses.Contains(address),
                $"Critical asset '{address}' not found in Addressables");
        }
    }
}
#endif

Run this as part of your build pipeline:

# Run Addressables build validation tests
Unity.exe -batchmode -nographics -runTests -testPlatform Editor \
  -testFilter <span class="hljs-string">"AddressablesBuildValidator" \
  -projectPath /path/to/project \
  -testResults results.xml

Performance: Async vs Sync Loading

Never block on Addressables in production code. Test that you don't:

// Tests/EditMode/AsyncUsageTests.cs
public class AsyncUsageAudit {
    [Test]
    public void NoSynchronousAddressablesCalls_InProductionCode() {
        // Use reflection to find WaitForCompletion() calls
        var assemblies = AppDomain.CurrentDomain.GetAssemblies()
            .Where(a => a.GetName().Name.StartsWith("MyGame"));

        var violations = new List<string>();

        foreach (var assembly in assemblies) {
            foreach (var type in assembly.GetTypes()) {
                foreach (var method in type.GetMethods(
                    BindingFlags.Public | BindingFlags.NonPublic | 
                    BindingFlags.Instance | BindingFlags.Static)) {
                    
                    // This is a simplified check — real implementation
                    // would inspect IL or use Roslyn analyzers
                    var body = method.GetMethodBody();
                    if (body != null) {
                        // Flag methods that call WaitForCompletion
                        // (full IL inspection requires additional tooling)
                    }
                }
            }
        }

        // In practice, use a Roslyn analyzer for this check
        // This test serves as documentation of the requirement
        Assert.Pass("Use Roslyn analyzer 'AddressablesSyncCallAnalyzer' for real validation");
    }
}

Addressables Testing Checklist

  • Loading valid address returns correct asset
  • Loading invalid address fails with clear error (not crash)
  • Loading by label returns all assets with that label
  • Instantiate creates active GameObject
  • All handles released after test teardown
  • Load/release cycle doesn't leak memory
  • Cached loads don't trigger duplicate loads
  • Unload releases all tracked handles
  • Build validator: no duplicate addresses
  • Build validator: all required labels exist
  • Build validator: no empty groups
  • Build validator: critical assets present

Addressables bugs are particularly costly because they manifest in production builds and are hard to reproduce without the exact build configuration. Build-time validation tests catch the most common class of Addressables issues before any player sees them.

Read more