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();
}
}Navigation Flow Tests
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.