using NUnit.Framework; using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; using UnityEngine; using UnityEngine.Events; using UnityEngine.TestTools; using UnityEngine.UI; namespace RuntimeUnitTestToolkit { public class UnitTestRunner : MonoBehaviour { // object is IEnumerator or Func<IEnumerator> Dictionary<string, List<KeyValuePair<string, object>>> tests = new Dictionary<string, List<KeyValuePair<string, object>>>(); List<Pair> additionalActionsOnFirst = new List<Pair>(); public Button clearButton; public RectTransform list; public Scrollbar listScrollBar; public Text logText; public Scrollbar logScrollBar; readonly Color passColor = new Color(0f, 1f, 0f, 1f); // green readonly Color failColor = new Color(1f, 0f, 0f, 1f); // red readonly Color normalColor = new Color(1f, 1f, 1f, 1f); // white bool allTestGreen = true; void Start() { try { UnityEngine.Application.logMessageReceived += (a, b, c) => { logText.text += "[" + c + "]" + a + "\n"; }; // register all test types foreach (var item in GetTestTargetTypes()) { RegisterAllMethods(item); } var executeAll = new List<Func<Coroutine>>(); foreach (var ___item in tests) { var actionList = ___item; // be careful, capture in lambda executeAll.Add(() => StartCoroutine(RunTestInCoroutine(actionList))); Add(actionList.Key, () => StartCoroutine(RunTestInCoroutine(actionList))); } var executeAllButton = Add("Run All Tests", () => StartCoroutine(ExecuteAllInCoroutine(executeAll))); clearButton.gameObject.GetComponent<Image>().color = new Color(170 / 255f, 170 / 255f, 170 / 255f, 1); executeAllButton.gameObject.GetComponent<Image>().color = new Color(250 / 255f, 150 / 255f, 150 / 255f, 1); executeAllButton.transform.SetSiblingIndex(1); additionalActionsOnFirst.Reverse(); foreach (var item in additionalActionsOnFirst) { var newButton = GameObject.Instantiate(clearButton); newButton.name = item.Name; newButton.onClick.RemoveAllListeners(); newButton.GetComponentInChildren<Text>().text = item.Name; newButton.onClick.AddListener(item.Action); newButton.transform.SetParent(list); newButton.transform.SetSiblingIndex(1); } clearButton.onClick.AddListener(() => { logText.text = ""; foreach (var btn in list.GetComponentsInChildren<Button>()) { btn.interactable = true; btn.GetComponent<Image>().color = normalColor; } executeAllButton.gameObject.GetComponent<Image>().color = new Color(250 / 255f, 150 / 255f, 150 / 255f, 1); }); listScrollBar.value = 1; logScrollBar.value = 1; if (Application.isBatchMode) { // run immediately in player StartCoroutine(ExecuteAllInCoroutine(executeAll)); } } catch (Exception ex) { if (Application.isBatchMode) { // when failed(can not start runner), quit immediately. WriteToConsole(ex.ToString()); Application.Quit(1); } else { throw; } } } Button Add(string title, UnityAction test) { var newButton = GameObject.Instantiate(clearButton); newButton.name = title; newButton.onClick.RemoveAllListeners(); newButton.GetComponentInChildren<Text>().text = title; newButton.onClick.AddListener(test); newButton.transform.SetParent(list); return newButton; } static IEnumerable<Type> GetTestTargetTypes() { foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { var n = assembly.FullName; if (n.StartsWith("UnityEngine")) continue; if (n.StartsWith("mscorlib")) continue; if (n.StartsWith("System")) continue; foreach (var item in assembly.GetTypes()) { foreach (var method in item.GetMethods()) { var t1 = method.GetCustomAttribute<TestAttribute>(true); if (t1 != null) { yield return item; break; } var t2 = method.GetCustomAttribute<UnityTestAttribute>(true); if (t2 != null) { yield return item; break; } } } } } public void AddTest(string group, string title, Action test) { List<KeyValuePair<string, object>> list; if (!tests.TryGetValue(group, out list)) { list = new List<KeyValuePair<string, object>>(); tests[group] = list; } list.Add(new KeyValuePair<string, object>(title, test)); } public void AddAsyncTest(string group, string title, Func<IEnumerator> asyncTestCoroutine) { List<KeyValuePair<string, object>> list; if (!tests.TryGetValue(group, out list)) { list = new List<KeyValuePair<string, object>>(); tests[group] = list; } list.Add(new KeyValuePair<string, object>(title, asyncTestCoroutine)); } public void AddCutomAction(string name, UnityAction action) { additionalActionsOnFirst.Add(new Pair { Name = name, Action = action }); } public void RegisterAllMethods<T>() where T : new() { RegisterAllMethods(typeof(T)); } public void RegisterAllMethods(Type testType) { try { var test = Activator.CreateInstance(testType); var methods = testType.GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public); foreach (var item in methods) { try { var iteratorTest = item.GetCustomAttribute<UnityEngine.TestTools.UnityTestAttribute>(true); if (iteratorTest != null) { if (item.GetParameters().Length == 0 && item.ReturnType == typeof(IEnumerator)) { var factory = (Func<IEnumerator>)Delegate.CreateDelegate(typeof(Func<IEnumerator>), test, item); AddAsyncTest(factory.Target.GetType().Name, factory.Method.Name, factory); } else { UnityEngine.Debug.Log(testType.Name + "." + item.Name + " currently does not supported in RuntumeUnitTestToolkit(multiple parameter or return type is invalid)."); } } var standardTest = item.GetCustomAttribute<NUnit.Framework.TestAttribute>(true); if (standardTest != null) { if (item.GetParameters().Length == 0 && item.ReturnType == typeof(void)) { var invoke = (Action)Delegate.CreateDelegate(typeof(Action), test, item); AddTest(invoke.Target.GetType().Name, invoke.Method.Name, invoke); } else { UnityEngine.Debug.Log(testType.Name + "." + item.Name + " currently does not supported in RuntumeUnitTestToolkit(multiple parameter or return type is invalid)."); } } } catch (Exception e) { UnityEngine.Debug.LogError(testType.Name + "." + item.Name + " failed to register method, exception: " + e.ToString()); } } } catch (Exception ex) { Debug.LogException(ex); } } System.Collections.IEnumerator ScrollLogToEndNextFrame() { yield return null; yield return null; logScrollBar.value = 0; } IEnumerator RunTestInCoroutine(KeyValuePair<string, List<KeyValuePair<string, object>>> actionList) { Button self = null; foreach (var btn in list.GetComponentsInChildren<Button>()) { btn.interactable = false; if (btn.name == actionList.Key) self = btn; } if (self != null) { self.GetComponent<Image>().color = normalColor; } var allGreen = true; logText.text += "<color=yellow>" + actionList.Key + "</color>\n"; WriteToConsole("Begin Test Class: " + actionList.Key); yield return null; var totalExecutionTime = new List<double>(); foreach (var item2 in actionList.Value) { // before start, cleanup GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); logText.text += "<color=teal>" + item2.Key + "</color>\n"; yield return null; var v = item2.Value; var methodStopwatch = System.Diagnostics.Stopwatch.StartNew(); Exception exception = null; if (v is Action) { try { ((Action)v).Invoke(); } catch (Exception ex) { exception = ex; } } else { var coroutineFactory = (Func<IEnumerator>)v; IEnumerator coroutine = null; try { coroutine = coroutineFactory(); } catch (Exception ex) { exception = ex; } if (exception == null) { yield return StartCoroutine(UnwrapEnumerator(coroutine, ex => { exception = ex; })); } } methodStopwatch.Stop(); totalExecutionTime.Add(methodStopwatch.Elapsed.TotalMilliseconds); if (exception == null) { logText.text += "OK, " + methodStopwatch.Elapsed.TotalMilliseconds.ToString("0.00") + "ms\n"; WriteToConsoleResult(item2.Key + ", " + methodStopwatch.Elapsed.TotalMilliseconds.ToString("0.00") + "ms", true); } else { // found match line... var line = string.Join("\n", exception.StackTrace.Split('\n').Where(x => x.Contains(actionList.Key) || x.Contains(item2.Key)).ToArray()); logText.text += "<color=red>" + exception.Message + "\n" + line + "</color>\n"; WriteToConsoleResult(item2.Key + ", " + exception.Message, false); WriteToConsole(line); allGreen = false; allTestGreen = false; } } logText.text += "[" + actionList.Key + "]" + totalExecutionTime.Sum().ToString("0.00") + "ms\n\n"; foreach (var btn in list.GetComponentsInChildren<Button>()) btn.interactable = true; if (self != null) { self.GetComponent<Image>().color = allGreen ? passColor : failColor; } yield return StartCoroutine(ScrollLogToEndNextFrame()); } IEnumerator ExecuteAllInCoroutine(List<Func<Coroutine>> tests) { allTestGreen = true; foreach (var item in tests) { yield return item(); } if (Application.isBatchMode) { var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene(); bool disableAutoClose = (scene.name.Contains("DisableAutoClose")); if (allTestGreen) { WriteToConsole("Test Complete Successfully"); if (!disableAutoClose) { Application.Quit(); } } else { WriteToConsole("Test Failed, please see [NG] log."); if (!disableAutoClose) { Application.Quit(1); } } } } IEnumerator UnwrapEnumerator(IEnumerator enumerator, Action<Exception> exceptionCallback) { var hasNext = true; while (hasNext) { try { hasNext = enumerator.MoveNext(); } catch (Exception ex) { exceptionCallback(ex); hasNext = false; } if (hasNext) { // unwrap self for bug of Unity // https://issuetracker.unity3d.com/issues/does-not-stop-coroutine-when-it-throws-exception-in-movenext-at-first-frame var moreCoroutine = enumerator.Current as IEnumerator; if (moreCoroutine != null) { yield return StartCoroutine(UnwrapEnumerator(moreCoroutine, ex => { exceptionCallback(ex); hasNext = false; })); } else { yield return enumerator.Current; } } } } static void WriteToConsole(string msg) { if (Application.isBatchMode) { Console.WriteLine(msg); } } static void WriteToConsoleResult(string msg, bool green) { if (Application.isBatchMode) { if (!green) { var currentForeground = Console.ForegroundColor; Console.ForegroundColor = ConsoleColor.Red; Console.Write("[NG]"); Console.ForegroundColor = currentForeground; } else { var currentForeground = Console.ForegroundColor; Console.ForegroundColor = ConsoleColor.Green; Console.Write("[OK]"); Console.ForegroundColor = currentForeground; } System.Console.WriteLine(msg); } } struct Pair { public string Name; public UnityAction Action; } } }