--- 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 1. [Assembly Setup](#assembly-setup) 2. [Test Structure](#test-structure) 3. [Opening Windows and Inspectors](#opening-windows-and-inspectors) 4. [Discovering Controls with Page.Scan](#discovering-controls-with-pagescan) 5. [Manual Control Descriptor with Page.Describe](#manual-control-descriptor-with-pagedescribe) 6. [Locator API](#locator-api) 7. [Interacting with Dynamic UIs](#interacting-with-dynamic-uis) 8. [State Verification Patterns](#state-verification-patterns) 9. [UIElements-Specific Notes](#uielements-specific-notes) 10. [Screenshots](#screenshots) 11. [Cross-Platform Notes](#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. ```json { "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: ```csharp 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(); // closes all windows and destroys GameObjects } [UnityTest] public IEnumerator SomeTest() { var handle = _driver.OpenWindow(); yield return null; // let Unity process the window's first OnGUI // ... interact and assert ... } } ``` 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 ```csharp // Opens a fresh instance (never reuses an existing window) var handle = _driver.OpenWindow(); var window = handle.Window; // the EditorWindow instance ``` ### Custom Inspectors ```csharp // Creates a temporary GameObject, adds TComponent, opens the default Editor in an isolated window var handle = _driver.OpenInspector(); // With an explicit Editor type var handle = _driver.OpenInspector(); // InspectorHandle properties handle.Window // EditorWindow hosting the inspector handle.Editor // UnityEditor.Editor instance handle.Component // the Component on the temporary GameObject handle.GameObject // the temporary GameObject ``` ### Cleanup ```csharp _driver.CloseWindow(windowHandle); // close a specific window _driver.CloseInspector(inspectorHandle); // close inspector and destroy its GameObject _driver.CloseAll(); // close everything (also called by Dispose) ``` --- ## 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. ```csharp var handle = _driver.OpenWindow(); yield return null; var page = Page.Scan(handle.Window); ``` ### Querying controls ```csharp // By displayed text value (IMGUI and UIElements) var locator = page.GetByValue("Hello World"); // By label text — works with UIElements; also works with IMGUI via Page.Describe var locator = page.GetByLabel("Text Field"); // By type and zero-based index var firstToggle = page.GetByType(ControlType.Toggle, 0); var secondToggle = page.GetByType(ControlType.Toggle, 1); // All discovered controls IReadOnlyList 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. ```csharp 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: ```csharp page.GetByValue("Hello World").Fill("New Text"); handle.Repaint(); yield return null; page.Refresh(); // re-discover controls after UI change 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. ```csharp var page = Page.Scan(handle.Window); int countBefore = page.Controls.Count; // Enable a toggle group (toggle at index 1 in this example) page.GetByType(ControlType.Toggle, 1).Toggle(); handle.Repaint(); yield return null; page.Refresh(); // picks up newly active controls Assert.Greater(page.Controls.Count, countBefore); // Inner slider is now reachable 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: ```csharp // UI → State: interact, then read internal field page.GetByValue("Hello World").Fill("Updated"); handle.Repaint(); yield return null; string val = handle.GetFieldValue("myString"); Assert.AreEqual("Updated", val); // State → UI: write internal field, then verify it appears in the UI 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: ```csharp var component = (MyComponent)handle.Component; // UI → State page.GetByValue("Hello World").Fill("Inspector Test"); handle.Repaint(); yield return null; Assert.AreEqual("Inspector Test", component.myString); // State → UI 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: ```csharp var component = (MyUIElementsComponent)handle.Component; // State → UI (UIElements binding requires serializedObject.Update) 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: ```csharp private static IEnumerator WaitForBindings() { yield return null; yield return null; yield return null; } [UnityTest] public IEnumerator ReadUIElementsField() { var handle = _driver.OpenInspector(); yield return WaitForBindings(); // instead of a single yield return null 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: ```csharp // UIElements windows support both: 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]`. ```csharp 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(); yield return null; // Whole window var path = Path.Combine(_screenshotDir, "window.png"); handle.Screenshot(path); Assert.IsTrue(File.Exists(path)); // Individual control 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 ```bash # Run all tests in the E2E assembly uloop run-tests --filter-type assembly --filter-value "MyPlugin.E2ETests.Editor" # Run a single test by full name 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.