| name | unity-editor-driver-e2e |
| description | Implement E2E tests for Unity Editor extensions (EditorWindow and custom Inspector) using the unity-editor-driver framework in this repository. Use this skill whenever you need to write, fix, or extend EditMode E2E tests that interact with editor UI controls — even if the request only mentions "test", "automate inspector", "UI test", "check window behavior", or similar phrases. The framework works non-invasively: no changes to the target window or inspector code are ever required.
|
unity-editor-driver E2E Testing
EditorDriver lets you automate and assert on Unity Editor windows and custom Inspectors without
touching the target code. Tests live in EditMode test assemblies and run via Unity Test Runner.
Table of Contents
- Assembly Setup
- Test Structure
- Opening Windows and Inspectors
- Discovering Controls with Page.Scan
- Manual Control Descriptor with Page.Describe
- Locator API
- Interacting with Dynamic UIs
- State Verification Patterns
- UIElements-Specific Notes
- Screenshots
- Cross-Platform Notes
Assembly Setup
Create an .asmdef for your test assembly. Reference EditorDriver.Editor (and your target
window/component assemblies). Enable UNITY_INCLUDE_TESTS so the assembly is only included
in test builds.
{
"name": "MyPlugin.E2ETests.Editor",
"rootNamespace": "MyPlugin.Tests",
"references": [
"EditorDriver.Editor",
"MyPlugin.Editor",
"MyPlugin.Runtime"
],
"includePlatforms": ["Editor"],
"overrideReferences": true,
"precompiledReferences": ["nunit.framework.dll"],
"autoReferenced": false,
"defineConstraints": ["UNITY_INCLUDE_TESTS"]
}
Test Structure
Every E2E test fixture follows the same shape:
using System.Collections;
using NUnit.Framework;
using UnityEngine.TestTools;
using EditorDriver;
[TestFixture]
public class MyWindowE2ETests
{
private Driver _driver;
[SetUp]
public void SetUp()
{
_driver = new Driver();
}
[TearDown]
public void TearDown()
{
_driver?.Dispose();
}
[UnityTest]
public IEnumerator SomeTest()
{
var handle = _driver.OpenWindow<MyWindow>();
yield return null;
}
}
Key rules:
- Use
[UnityTest] with IEnumerator for any test that needs an OnGUI frame.
- Always
yield return null immediately after opening a window or inspector.
- Always call
_driver.Dispose() (or CloseAll()) in [TearDown] — never rely on
the test framework to clean up Unity objects.
Opening Windows and Inspectors
EditorWindows
var handle = _driver.OpenWindow<MyWindow>();
var window = handle.Window;
Custom Inspectors
var handle = _driver.OpenInspector<MyComponent>();
var handle = _driver.OpenInspector<MyComponent, MyComponentEditor>();
handle.Window
handle.Editor
handle.Component
handle.GameObject
Cleanup
_driver.CloseWindow(windowHandle);
_driver.CloseInspector(inspectorHandle);
_driver.CloseAll();
Discovering Controls with Page.Scan
Page.Scan is the recommended approach. It auto-discovers all interactive controls without any
manual descriptor. It works for both IMGUI and UIElements windows.
var handle = _driver.OpenWindow<MyWindow>();
yield return null;
var page = Page.Scan(handle.Window);
Querying controls
var locator = page.GetByValue("Hello World");
var locator = page.GetByLabel("Text Field");
var firstToggle = page.GetByType(ControlType.Toggle, 0);
var secondToggle = page.GetByType(ControlType.Toggle, 1);
IReadOnlyList<ControlInfo> all = page.Controls;
GetByValue / GetByLabel / GetByType all throw InvalidOperationException when the
control is not found — use this as an implicit assertion.
Manual Control Descriptor with Page.Describe
Use Page.Describe when you know the window's layout in advance and prefer label-based access.
This works for both IMGUI and UIElements; the resolver is auto-detected.
var page = Page.Describe(handle.Window, p =>
{
p.Label("Base Settings", EditorStyles.boldLabel);
p.TextField("Text Field");
p.BeginToggleGroup("Optional Settings");
p.Toggle("Inner Toggle");
p.Slider("Slider", -3f, 3f);
p.EndToggleGroup();
p.Button("Apply");
p.IntField("Count");
p.FloatField("Scale");
p.ObjectField("Target");
});
page.GetByLabel("Text Field").Fill("new value");
Available descriptor methods: Label, TextField, Toggle, Slider, BeginToggleGroup,
EndToggleGroup, Button, IntField, FloatField, ObjectField.
Locator API
All query methods return a Locator. Use it to interact with or inspect a single control.
| Method | Description |
|---|
ReadText() | Reads the displayed text (via clipboard SelectAll→Copy) |
Fill(string) | Clears the field and types the given text |
Toggle() | Clicks a checkbox or toggle control |
SetSlider(float) | Types a value into the slider's numeric field |
Click() | Clicks the center of the control |
CaptureScreenshot() | Returns a Texture2D cropped to the control area |
.ControlType | The ControlType enum value |
.Rect | The Rect of the control in screen space |
After Fill, Toggle, or SetSlider, always repaint and yield before asserting:
page.GetByValue("Hello World").Fill("New Text");
handle.Repaint();
yield return null;
page.Refresh();
Assert.AreEqual("New Text", page.GetByValue("New Text").ReadText());
Interacting with Dynamic UIs
When the UI changes (e.g. a toggle group expands to reveal more controls), call
page.Refresh() after the change to re-discover the new state. Controls inside a disabled
group are not discoverable until the group is enabled.
var page = Page.Scan(handle.Window);
int countBefore = page.Controls.Count;
page.GetByType(ControlType.Toggle, 1).Toggle();
handle.Repaint();
yield return null;
page.Refresh();
Assert.Greater(page.Controls.Count, countBefore);
var slider = page.GetByValue("1.23");
slider.Fill("2.5");
State Verification Patterns
EditorWindow — UI→State and State→UI
WindowHandle exposes reflection helpers for white-box state assertions:
page.GetByValue("Hello World").Fill("Updated");
handle.Repaint();
yield return null;
string val = handle.GetFieldValue<string>("myString");
Assert.AreEqual("Updated", val);
handle.SetFieldValue("myString", "From Code");
handle.Repaint();
yield return null;
page.Refresh();
Assert.AreEqual("From Code", page.GetByValue("From Code").ReadText());
IMGUI Inspector — Component field access
For IMGUI inspectors, cast handle.Component and read/write public fields directly:
var component = (MyComponent)handle.Component;
page.GetByValue("Hello World").Fill("Inspector Test");
handle.Repaint();
yield return null;
Assert.AreEqual("Inspector Test", component.myString);
component.myString = "From Code";
handle.Repaint();
yield return null;
page.Refresh();
Assert.AreEqual("From Code", page.GetByValue("From Code").ReadText());
UIElements Inspector — SerializedObject sync required
UIElements inspectors use BindProperty, so after a direct field change you must notify
the binding system via serializedObject.Update() before repainting:
var component = (MyUIElementsComponent)handle.Component;
component.myString = "From Code";
handle.Editor.serializedObject.Update();
handle.Repaint();
yield return WaitForBindings();
page.Refresh();
Assert.AreEqual("From Code", page.GetByLabel("Text Field").ReadText());
UIElements-Specific Notes
Waiting for bindings
UIElements bindings may not resolve in a single frame. For read operations on UIElements
inspectors, yield multiple frames before scanning:
private static IEnumerator WaitForBindings()
{
yield return null;
yield return null;
yield return null;
}
[UnityTest]
public IEnumerator ReadUIElementsField()
{
var handle = _driver.OpenInspector<MyUIElementsComponent>();
yield return WaitForBindings();
var page = Page.Scan(handle.Window);
Assert.AreEqual("Hello World", page.GetByLabel("Text Field").ReadText());
}
Label-based lookup available
Unlike IMGUI Scan (value-only), UIElements Scan exposes labels from the visual tree, so
GetByLabel works out of the box:
page.GetByLabel("Text Field").Fill("new");
page.GetByValue("Hello World").ReadText();
InspectorHandle.Repaint()
On Linux, call handle.Repaint() before reading UIElements state after mutating serialized
data. It walks IBindable elements and forces binding.PreUpdate()/binding.Update(),
which avoids stale bindings.
Screenshots
Always build paths with Path.Combine and Path.GetTempPath() for cross-platform safety.
Clean up in [TearDown].
using System.IO;
[SetUp]
public void SetUp()
{
_driver = new Driver();
_screenshotDir = Path.Combine(Path.GetTempPath(), "E2E_" + System.Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_screenshotDir);
}
[TearDown]
public void TearDown()
{
_driver?.Dispose();
if (Directory.Exists(_screenshotDir))
Directory.Delete(_screenshotDir, recursive: true);
}
[UnityTest]
public IEnumerator TakeScreenshot()
{
var handle = _driver.OpenWindow<MyWindow>();
yield return null;
var path = Path.Combine(_screenshotDir, "window.png");
handle.Screenshot(path);
Assert.IsTrue(File.Exists(path));
var page = Page.Scan(handle.Window);
var tex = page.GetByValue("Hello World").CaptureScreenshot();
Assert.Greater(tex.width, 0);
UnityEngine.Object.DestroyImmediate(tex);
}
Cross-Platform Notes
-
Window positioning: Use EditorWindowExtensions.SetPosition() instead of assigning
EditorWindow.position directly. On Linux, the window manager applies position changes
asynchronously; SetPosition uses a min/max-size trick to make resizes deterministic.
-
UIElements on Linux: Call handle.Repaint() before reading UIElements state after
mutating serialized data. This forces IBindable.binding.PreUpdate()/Update() and
avoids stale bindings.
-
File paths: Always use Path.Combine(...) — never concatenate with \ or / directly.
Running Tests
uloop run-tests --filter-type assembly --filter-value "MyPlugin.E2ETests.Editor"
uloop run-tests --filter-type exact --filter-value "MyPlugin.Tests.MyWindowE2ETests.SomeTest"
See Assets/Tests/Editor/ for complete worked examples covering IMGUI windows, IMGUI
inspectors, UIElements windows, and UIElements inspectors — including bidirectional state
verification patterns.