Automated UI Testing for Game Menus and HUDs in Unity

Automated UI Testing for Game Menus and HUDs in Unity

Game UIs are notoriously undertested. Most teams apply automated testing to gameplay logic and leave menus, HUDs, settings screens, and inventory UIs to manual QA. This is understandable — game UI testing is genuinely harder than web UI testing — but it leaves a class of bugs that consistently escape to players: broken navigation flows, stat displays that show wrong values, settings that do not persist, HUD elements that disappear after specific state transitions.

Unity provides real tools for automating UI testing. This guide covers both uGUI (the Canvas-based system) and UI Toolkit (the newer document-based system), with practical patterns for testing the UI scenarios that matter most.

Setting Up for UI Tests

UI tests run in play mode. Your assembly definition needs access to UI testing utilities:

{
  "name": "MyGame.UITests",
  "references": [
    "UnityEngine.TestRunner",
    "UnityEditor.TestRunner",
    "MyGame.Runtime",
    "MyGame.UI",
    "Unity.InputSystem"
  ],
  "includePlatforms": [],
  "optionalUnityReferences": ["TestAssemblies"]
}

Create a base class for your UI tests to handle common setup:

using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using UnityEngine.UI;

public abstract class UITestBase
{
    protected Canvas TestCanvas;
    protected EventSystem EventSystem;

    [SetUp]
    public virtual void SetUp()
    {
        // Create a test canvas
        var canvasGO = new GameObject("TestCanvas");
        TestCanvas = canvasGO.AddComponent<Canvas>();
        TestCanvas.renderMode = RenderMode.ScreenSpaceOverlay;
        canvasGO.AddComponent<CanvasScaler>();
        canvasGO.AddComponent<GraphicRaycaster>();

        // Event system for input
        var eventSystemGO = new GameObject("EventSystem");
        EventSystem = eventSystemGO.AddComponent<EventSystem>();
        eventSystemGO.AddComponent<StandaloneInputModule>();
    }

    [TearDown]
    public virtual void TearDown()
    {
        if (TestCanvas != null) Object.Destroy(TestCanvas.gameObject);
        if (EventSystem != null) Object.Destroy(EventSystem.gameObject);
    }
}

Testing uGUI Components

Button Interaction Tests

The most fundamental UI test: does pressing a button do what it should?

using UnityEngine.EventSystems;

public class MainMenuTests : UITestBase
{
    [UnityTest]
    public IEnumerator PlayButton_Click_LoadsGameScene()
    {
        string loadedScene = null;
        var menu = InstantiateMainMenu();
        menu.OnPlayClicked += (sceneName) => loadedScene = sceneName;

        // Find the button
        var playButton = menu.GetComponentsInChildren<Button>()
            .First(b => b.name == "PlayButton");

        // Simulate a click
        playButton.onClick.Invoke();

        yield return null;

        Assert.AreEqual("GameScene", loadedScene,
            "Play button should trigger load of GameScene");
    }

    [UnityTest]
    public IEnumerator PlayButton_WhileLoading_IsNonInteractable()
    {
        var menu = InstantiateMainMenu();
        menu.SetLoadingState(true);

        yield return null;

        var playButton = menu.GetComponentInChildren<Button>(includeInactive: false);
        Assert.IsFalse(playButton.interactable,
            "Play button should be non-interactable during loading");
    }

    private MainMenu InstantiateMainMenu()
    {
        var prefab = Resources.Load<GameObject>("UI/MainMenu");
        var go = Object.Instantiate(prefab, TestCanvas.transform);
        return go.GetComponent<MainMenu>();
    }
}

HUD Data Binding Tests

HUDs display game state — health, ammo, score, minimap. These need to update correctly when the underlying data changes:

public class HUDTests : UITestBase
{
    private PlayerHUD _hud;
    private PlayerData _playerData;

    [SetUp]
    public override void SetUp()
    {
        base.SetUp();

        var prefab = Resources.Load<GameObject>("UI/PlayerHUD");
        var go = Object.Instantiate(prefab, TestCanvas.transform);
        _hud = go.GetComponent<PlayerHUD>();

        _playerData = ScriptableObject.CreateInstance<PlayerData>();
        _playerData.health = 100f;
        _playerData.maxHealth = 100f;
        _playerData.ammo = 30;
        _playerData.maxAmmo = 30;

        _hud.Bind(_playerData);
    }

    [UnityTest]
    public IEnumerator HealthBar_DecreasesHealth_UpdatesFill()
    {
        var healthBar = _hud.GetComponentInChildren<Slider>();
        float initialFill = healthBar.value;

        _playerData.health = 50f;
        _playerData.NotifyChanged();

        yield return null; // Allow UI to update

        Assert.AreEqual(0.5f, healthBar.value, 0.01f,
            "Health bar fill should be 0.5 when health is 50% of max");
    }

    [UnityTest]
    public IEnumerator AmmoCounter_AfterFiring_ShowsDecrementedCount()
    {
        var ammoText = _hud.transform.Find("AmmoDisplay/AmmoCount")
            .GetComponent<TMPro.TextMeshProUGUI>();

        _playerData.ammo = 25;
        _playerData.NotifyChanged();

        yield return null;

        Assert.AreEqual("25 / 30", ammoText.text,
            "Ammo counter should show current/max format");
    }

    [UnityTest]
    public IEnumerator HealthBar_AtZero_ShowsDeathOverlay()
    {
        var deathOverlay = _hud.transform.Find("DeathOverlay").gameObject;
        Assert.IsFalse(deathOverlay.activeSelf, "Death overlay should be hidden initially");

        _playerData.health = 0f;
        _playerData.NotifyChanged();

        yield return null;

        Assert.IsTrue(deathOverlay.activeSelf,
            "Death overlay should appear when health reaches zero");
    }

    [TearDown]
    public override void TearDown()
    {
        Object.DestroyImmediate(_playerData);
        base.TearDown();
    }
}

Testing that menu navigation follows the correct flow:

[UnityTest]
public IEnumerator PauseMenu_ResumeButton_ReturnsToGameplay()
{
    var pauseMenu = LoadPauseMenu();
    bool resumeTriggered = false;
    pauseMenu.OnResume += () => resumeTriggered = true;

    Assert.IsTrue(pauseMenu.gameObject.activeSelf,
        "Pause menu should be visible when opened");

    // Click Resume
    pauseMenu.ResumeButton.onClick.Invoke();
    yield return null;

    Assert.IsTrue(resumeTriggered, "Resume action should have been triggered");
    Assert.IsFalse(pauseMenu.gameObject.activeSelf,
        "Pause menu should close after resume");
}

[UnityTest]
public IEnumerator SettingsMenu_BackButton_ReturnsToParentMenu()
{
    var mainMenu = LoadMainMenu();
    mainMenu.SettingsButton.onClick.Invoke();
    yield return null;

    var settingsPanel = mainMenu.transform.Find("SettingsPanel");
    Assert.IsTrue(settingsPanel.gameObject.activeSelf,
        "Settings panel should open when settings button is clicked");

    var backButton = settingsPanel.GetComponentsInChildren<Button>()
        .First(b => b.name == "BackButton");
    backButton.onClick.Invoke();
    yield return null;

    Assert.IsFalse(settingsPanel.gameObject.activeSelf,
        "Settings panel should close on back");
    Assert.IsTrue(mainMenu.MainPanel.activeSelf,
        "Main menu panel should be visible after returning from settings");
}

Testing UI Toolkit (UIElements)

UI Toolkit uses a document model similar to HTML/CSS. Testing it requires different utilities:

using UnityEngine.UIElements;

public class UIToolkitTests : UITestBase
{
    private UIDocument _document;
    private VisualElement _root;

    [SetUp]
    public override void SetUp()
    {
        base.SetUp();

        var go = new GameObject("UIDocument");
        _document = go.AddComponent<UIDocument>();
        _document.panelSettings = Resources.Load<PanelSettings>("UI/DefaultPanelSettings");
        _document.visualTreeAsset = Resources.Load<VisualTreeAsset>("UI/MainMenu");

        // Wait for document to initialize — access root after Start runs
    }

    [UnityTest]
    public IEnumerator MainMenu_PlayButton_IsVisible()
    {
        yield return null; // Wait for UIDocument.Start

        _root = _document.rootVisualElement;
        var playButton = _root.Q<Button>("play-button");

        Assert.IsNotNull(playButton, "Button with name 'play-button' should exist in the tree");
        Assert.AreEqual(DisplayStyle.Flex, playButton.resolvedStyle.display,
            "Play button should be visible");
    }

    [UnityTest]
    public IEnumerator ScoreDisplay_UpdatesText_WhenScoreChanges()
    {
        yield return null;

        _root = _document.rootVisualElement;
        var scoreLabel = _root.Q<Label>("score-label");
        var scoreController = _document.GetComponent<ScoreController>();

        scoreController.SetScore(1500);
        yield return null;

        Assert.AreEqual("1,500", scoreLabel.text,
            "Score label should display formatted score");
    }

    [UnityTest]
    public IEnumerator TabNavigation_ClickingTab_ShowsCorrectContent()
    {
        yield return null;
        _root = _document.rootVisualElement;

        var weaponsTab = _root.Q<Button>("tab-weapons");
        var armorTab = _root.Q<Button>("tab-armor");
        var weaponsPanel = _root.Q("panel-weapons");
        var armorPanel = _root.Q("panel-armor");

        // Initially weapons tab is active
        Assert.AreEqual(DisplayStyle.Flex, weaponsPanel.resolvedStyle.display);
        Assert.AreEqual(DisplayStyle.None, armorPanel.resolvedStyle.display);

        // Click armor tab
        using (var clickEvent = ClickEvent.GetPooled())
        {
            clickEvent.target = armorTab;
            armorTab.SendEvent(clickEvent);
        }
        yield return null;

        Assert.AreEqual(DisplayStyle.None, weaponsPanel.resolvedStyle.display,
            "Weapons panel should hide when armor tab is selected");
        Assert.AreEqual(DisplayStyle.Flex, armorPanel.resolvedStyle.display,
            "Armor panel should show when armor tab is selected");
    }
}

Layout and Responsive Testing

HUDs need to look correct across different aspect ratios. Test that critical elements remain within bounds:

[UnityTest]
public IEnumerator HUD_At16x9Aspect_ElementsWithinScreenBounds()
{
    SetScreenResolution(1920, 1080);
    yield return null;

    var hud = LoadHUD();
    yield return null; // Allow layout pass

    var allElements = hud.GetComponentsInChildren<RectTransform>();
    foreach (var rt in allElements)
    {
        var corners = new Vector3[4];
        rt.GetWorldCorners(corners);

        foreach (var corner in corners)
        {
            Assert.GreaterOrEqual(corner.x, 0,
                $"{rt.name} has a corner outside the left screen edge");
            Assert.LessOrEqual(corner.x, Screen.width,
                $"{rt.name} has a corner outside the right screen edge");
        }
    }
}

[UnityTest]
public IEnumerator HUD_At4x3Aspect_CriticalElementsNotOverlapping()
{
    SetScreenResolution(1024, 768); // 4:3
    yield return null;

    var hud = LoadHUD();
    yield return null;

    var healthBar = hud.transform.Find("HealthBar").GetComponent<RectTransform>();
    var minimap = hud.transform.Find("Minimap").GetComponent<RectTransform>();

    Assert.IsFalse(
        RectsOverlap(healthBar, minimap),
        "HealthBar and Minimap should not overlap at 4:3 aspect ratio");
}

private bool RectsOverlap(RectTransform a, RectTransform b)
{
    var cornersA = new Vector3[4];
    var cornersB = new Vector3[4];
    a.GetWorldCorners(cornersA);
    b.GetWorldCorners(cornersB);

    var rectA = new Rect(
        cornersA[0].x, cornersA[0].y,
        cornersA[2].x - cornersA[0].x,
        cornersA[2].y - cornersA[0].y);
    var rectB = new Rect(
        cornersB[0].x, cornersB[0].y,
        cornersB[2].x - cornersB[0].x,
        cornersB[2].y - cornersB[0].y);

    return rectA.Overlaps(rectB);
}

Settings Persistence Tests

Settings that do not save and restore correctly are one of the most common complaints in game reviews:

[UnityTest]
public IEnumerator VolumeSlider_Change_PersistsAcrossMenuClose()
{
    var settings = LoadSettingsMenu();
    var volumeSlider = settings.GetComponentsInChildren<Slider>()
        .First(s => s.name == "MasterVolume");

    float testVolume = 0.4f;
    volumeSlider.value = testVolume;
    settings.SaveSettings();

    // Simulate closing and reopening the menu
    Object.Destroy(settings.gameObject);
    yield return null;

    var settingsReopened = LoadSettingsMenu();
    var volumeSliderNew = settingsReopened.GetComponentsInChildren<Slider>()
        .First(s => s.name == "MasterVolume");

    Assert.AreEqual(testVolume, volumeSliderNew.value, 0.01f,
        "Volume setting should persist after closing and reopening settings");

    // Clean up PlayerPrefs after test
    PlayerPrefs.DeleteKey("MasterVolume");
}

Screenshot Regression Testing

For visual regression, capture screenshots of UI states and compare them:

[UnityTest]
public IEnumerator MainMenu_Screenshot_MatchesBaseline()
{
    LoadMainMenu();
    yield return new WaitForEndOfFrame();

    var screenshot = new Texture2D(Screen.width, Screen.height, TextureFormat.RGB24, false);
    screenshot.ReadPixels(new Rect(0, 0, Screen.width, Screen.height), 0, 0);
    screenshot.Apply();

    var screenshotBytes = screenshot.EncodeToPNG();
    var baselinePath = Path.Combine(
        Application.persistentDataPath, "UIBaselines", "MainMenu.png");

    if (!File.Exists(baselinePath))
    {
        // First run: save baseline
        Directory.CreateDirectory(Path.GetDirectoryName(baselinePath));
        File.WriteAllBytes(baselinePath, screenshotBytes);
        Assert.Pass("Baseline saved — run again to compare");
    }
    else
    {
        // Compare with baseline
        var baselineBytes = File.ReadAllBytes(baselinePath);
        float similarity = CompareImages(screenshotBytes, baselineBytes);
        Assert.GreaterOrEqual(similarity, 0.98f,
            $"UI screenshot differs from baseline by more than 2% (similarity: {similarity:P1})");
    }

    Object.Destroy(screenshot);
}

The combination of functional tests (does the button work?) and visual regression tests (does it look right?) gives you the coverage needed to ship UI changes confidently. The functional tests catch logic errors immediately; the visual tests catch layout regressions that logic tests cannot see.

Read more

Start now free