如果遇到不熟悉的API,第一反应肯定是查看Unity文档,通过文档示例来了解API使用方式。但如果按照文档示例却编译失败,那有没有可能是文档出错了呢?今天这篇文章将为大家分享Hackweek的一个项目,就是利用Unity 5.3及以上版本的编辑器测试工具,来自动检验所有Unity脚本文档的示例代码是否编译成功。

Unity脚本文档共有15000页左右,其中并非所有文档都包含示例(这需要另外解决),但包含示例的也不在少数。手动查看每个文档并进行测试在为期一周的Hackweek中是很难实现的,也无法解决未来的API更改导致的更新问题。

去年发布的Unity 5.3中包含一个新功能:Editor Test Runner,它是能在Unity中运行的单元测试框架。自该功能发布后就一直用于Unity内部测试,帮助我们追踪问题。Unity所有脚本文档都保存为XML文件,可以在Unity内部项目中进行编辑。

065056wec5ce30nlvf0tee.png

项目已经包含解析XML文件的代码,所以只需在项目中加上编辑器测试即可重用这些功能。在编辑器测试框架中会用到TestCaseSource属性,让测试多次在不同的源数据上运行。在本例中,源数据就是脚本案例文档:

[C#] 纯文本查看 复制代码public class ScriptVerification { public static IEnumerable TestFiles { get { // Get all the xml files var files = Directory.GetFiles(OurDocsApiPath/*.mem.xml, SearchOption.AllDirectories); // Each file is a separate test. foreach (var file in files) { string testName = Path.GetFileName(file).Replace(k_FileExtension, ); yield return new TestCaseData(file).SetName(testName); } } } [Test] [TestCaseSource(TestFiles)] public void TestDocumentationExampleScripts(string docXmlFile) { // Do the test } }

该方法展示了所有运行在Test Runner上的测试。每个测试都能独立运行,或通过Run ALL选项来同时运行。

065057w9stk7f4evesxffv.png

本例使用CodeDomProvider 编译,这样可以传递多个表示脚本的字符串,并编译和返回相关的错误及警告信息。

首次测试迭代的缩减版(已删除XML解析):
[C#] 纯文本查看 复制代码using UnityEngine; using NUnit.Framework; using System.CodeDom.Compiler; using System.Collections; using System.Reflection; using System.Xml; using System.IO; using UnityEditor; public class ScriptVerification { public static IEnumerable TestFiles { get { // Get all the xml files var files = Directory.GetFiles(OurDocsApiPath/*.mem.xml, SearchOption.AllDirectories); // Each file is a seperate test foreach (var file in files) { string testName = Path.GetFileName(file).Replace(k_FileExtension, ); yield return new TestCaseData(file).SetName(testName); } } } CodeDomProvider m_DomProvider; CompilerParameters m_CompilerParams; [SetUp] public void InitScriptCompiler() { m_DomProvider = CodeDomProvider.CreateProvider(CSharp); m_CompilerParams = new CompilerParameters { GenerateExecutable = false, GenerateInMemory = false, TreatWarningsAsErrors = false, }; Assembly unityEngineAssembly = Assembly.GetAssembly(typeof(MonoBehaviour)); Assembly unityEditorAssembly = Assembly.GetAssembly(typeof(Editor)); m_CompilerParams.ReferencedAssemblies.Add(unityEngineAssembly.Location); m_CompilerParams.ReferencedAssemblies.Add(unityEditorAssembly.Location); } [Test] [TestCaseSource(TestFiles)] public void TestDocumentationExampleScripts(string docXmlFile) { // Parse the xml and extract the scripts // foreach script example in our doc call TestCsharpScript } void TestCsharpScript(string scriptText) { // Check for errors CompilerResults compilerResults = m_DomProvider.CompileAssemblyFromSource(m_CompilerParams, scriptText); string errors = ; if (compilerResults.Errors.HasErrors) { foreach (CompilerError compilerError in compilerResults.Errors) { errors += compilerError.ToString() + \n; } } Assert.IsFalse(compilerResults.Errors.HasErrors, errors); } }

编译脚本的方式还需做些调整,因为有些脚本可能组合在一起形成一个示例。分别编译所有示例以检测是否有这种情况,如果出现错误再将其合并编译,看看是否成功。

还有一些简单示例就是一行代码,没有封装在函数内。可以在测试中进行封装来修复这个问题,要遵循的规则就是这些例子必须能独立运行(即用户将其复制粘贴到一个新的文件中,也可以被编译和运行),否则就将这些示例视为测试失败。

[C#] 纯文本查看 复制代码[Test] public void ScriptVerificationCSharp() { // Setup. Start all tests running on multiple threads. s_ThreadEvents = new ManualResetEvent[s_DocInfo.Count]; for (int i = 0; i < s_DocInfo.Count; ++i) { // Queue this example up for testing s_ThreadEvents[i] = new ManualResetEvent(false); ThreadPool.QueueUserWorkItem(TestDocumentationExampleScriptsThreaded, i); } // Check for errors and build the error output if required. bool testFailed = false; StringBuilder results = new StringBuilder(); for (int i = 0; i < s_ThreadEvents.Length; ++i) { // Wait for the test to finish. s_ThreadEvents[i].WaitOne(); if (s_DocInfo[i].status == TestStatus.Failed) { testFailed = true; GenerateFailureMessage(results, s_DocInfo[i]); } } // If a single item has failed then the test is considered a failure. Assert.IsFalse(testFailed, results.ToString()); } public static void TestDocumentationExampleScriptsThreaded(object o) { var infoIdx = (int)o; var info = s_DocInfo[infoIdx]; try { TestScriptsCompile(info); } catch (Exception e) { info.status = TestStatus.Failed; info.testRunnerFailure = e.ToString(); } finally { s_ThreadEvents[infoIdx].Set(); } }

该测试现在离正式作为文档验证工具并合并到主分支还有一段距离。还需解决一个小:测试运行需花费30分钟·。由于每天运行约7000个版本,仅作为一个版本验证测试来说这个运行时间太长了。

目前该测试是顺序进行的,一次一个脚本。由于测试彼此独立,且无需调用Unity API,因为仅测试编译是否成功,所以完全可以并行运行这些测试。下面引入用于并行执行任务的.NET API-线程池。将测试作为单个任务放入线程池中,当线程可用时立即执行。这需要从单个函数执行,即不能用单独的NUnit测试用例来测试文档的单一示例。尽管不能单独测试,但我们大大提高了整体运行的速度。

065059un2xo7akq2txn0tu.png

这将测试时间从30分钟缩短至2分钟,作为版本验证来说已满足需求。由于无法测试但个示例,所以在脚本文档编辑器中加入按钮,以便文档编写人员日后更新。运行测试时出错脚本会显示为红色,并在下方显示出错信息。


首次运行测试时有326个错误,将其加入白名单以便日后更新。现在已减少为32个,大多错误都是由于无法访问特定的程序集导致。集成该测试并未引入新的问题,所以可以确定如果弃用部分API导致测试失败,则需要更新文档来使用新的API。

结语

本文只是Editor Test Runner工具一个非常有趣的使用案例,该示例也有些缺点,例如只能获取C#示例,无法处理.js的编译。但在不久的将来,这也将不再是问题。我们还会为大家分享一些Unity内部有趣而实用的案例在Unity官方中文社区(unitychina.cn),请保持关注。

下面是完整的测试代码:
[C#] 纯文本查看 复制代码using System; using System.CodeDom.Compiler; using UnityEngine; using NUnit.Framework; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Threading; using System.Xml; using Microsoft.CSharp; using UnderlyingModel; using UnityEditor; public class ScriptVerification { const string k_PathToApiDocs = @../../../../Documentation/ApiDocs/; const string k_FileExtension = .mem.xml; const string k_WhiteList = Assets/Editor/ScriptVerificationWhiteList.txt; public enum TestStatus { Unknown, // Nothing has been done to this test yet. Ignored, // Tests are ignored if they contain no example code Failed, // The test failed to compile one or more of the examples. Passed, // All examples were compiled successfully. Whitelisted, // Test was ignored as the member is in the white list file. } public class ExampleScript { public string code; public CompilerResults compileResults; } public class ScriptingDocMember { public TestStatus status = TestStatus.Unknown; // Information on the test and the xml file it can be found in. public string path; public string parent; public string name; public string nspace; public bool editor; public List<ExampleScript> csharpExamples = new List<ExampleScript>(); // If we fail to compile multiple examples we also attempt to compile them as a single example. public CompilerResults combinedResults; // Error message if something caused the test runner to fail. public string testRunnerFailure; } static List<ScriptingDocMember> s_DocInfo; static ManualResetEvent[] s_ThreadEvents; [SetUp] public void SetupScriptVerification() { // Parse the scripting doc files and prepare the test data. string path = k_PathToApiDocs; if (!path.Contains(:)) { path = Application.dataPath + / + k_PathToApiDocs; } var files = Directory.GetFiles(path, * + k_FileExtension, SearchOption.AllDirectories); s_DocInfo = new List<ScriptingDocMember>(); var whiteList = GetWhiteList(); for (int i = 0; i < files.Length; ++i) { var xml = new XmlDocument(); xml.Load(files[i]); XmlNode xmlheader = xml.FirstChild; XmlNode docsheader = xmlheader.NextSibling; XmlNode namespaceTag = docsheader.FirstChild; ParseMemberNode(namespaceTag, files[i], , s_DocInfo, whiteList); } } [Test] public void ScriptVerificationCSharp() { // Setup. Start all tests running on multiple threads. // This gets the test time down from 30 minutes to around 2 minutes. s_ThreadEvents = new ManualResetEvent[s_DocInfo.Count]; for (int i = 0; i < s_DocInfo.Count; ++i) { if (s_DocInfo[i].csharpExamples.Count == 0) { // Ignore items with no examples s_ThreadEvents[i] = new ManualResetEvent(true); s_DocInfo[i].status = TestStatus.Ignored; } else if (s_DocInfo[i].status == TestStatus.Whitelisted) { // Skip white listed items s_ThreadEvents[i] = new ManualResetEvent(true); } else { // Queue this example up for testing s_ThreadEvents[i] = new ManualResetEvent(false); ThreadPool.QueueUserWorkItem(TestDocumentationExampleScriptsThreaded, i); } } // Check for errors and build the error output if required. bool testFailed = false; StringBuilder results = new StringBuilder(); for (int i = 0; i < s_ThreadEvents.Length; ++i) { s_ThreadEvents[i].WaitOne(); if (s_DocInfo[i].status == TestStatus.Failed) { testFailed = true; GenerateFailureMessage(results, s_DocInfo[i]); } } // If a single item has failed then the test is considered a failure. Assert.IsFalse(testFailed, results.ToString()); } static void GenerateFailureMessage(StringBuilder output, ScriptingDocMember info) { output.AppendLine(new string(-, 100)); output.AppendLine(Name: + info.name); output.AppendLine(Path: + info.path + \n); // Print out the example scripts along with their errors. for (int i = 0; i < info.csharpExamples.Count; ++i) { var example = info.csharpExamples[i]; if (example.compileResults != null example.compileResults.Errors.HasErrors) { output.AppendLine(Example Script + i + :\n); // Add line numbers var lines = example.code.SplitLines(); int lineNumber = 0; int startLine = 0; // Find the first line of code so the line numbers align correctly. // The compiler will ignore any empty lines at the start. for (; startLine < lines.Length; ++startLine) { if (string.IsNullOrEmpty(lines[startLine])) startLine++; else break; } for (; startLine < lines.Length; ++startLine) { // Does this line contain an error? string lineMarker = ; foreach (CompilerError compileResultsError in example.compileResults.Errors) { // Add a mark to indicate this line has a reported error. if (compileResultsError.Line == lineNumber) { lineMarker = -; break; } } output.AppendFormat({0}{1:000} {2}\n, lineMarker, lineNumber++, lines[startLine]); } output.Append(\n\n); output.AppendLine(ErrorMessagesToString(example.compileResults)); } } if (info.combinedResults != null) { output.AppendLine(Combined Example Scripts:\n); output.AppendLine(ErrorMessagesToString(info.combinedResults)); } if (!string.IsNullOrEmpty(info.testRunnerFailure)) { output.AppendLine(Test Runner Failure: + info.testRunnerFailure); } } // Concatenates all the errors into a formated list. public static string ErrorMessagesToString(CompilerResults cr) { string errorMessages = ; foreach (CompilerError compilerError in cr.Errors) { errorMessages += string.Format({0}({1},{2}): {3}\n, compilerError.ErrorNumber, compilerError.Line, compilerError.Column, compilerError.ErrorText); } return errorMessages; } public static void TestDocumentationExampleScriptsThreaded(object o) { var infoIdx = (int)o; var info = s_DocInfo[infoIdx]; try { TestScriptsCompile(info); } catch (Exception e) { info.status = TestStatus.Failed; info.testRunnerFailure = e.ToString(); } finally { s_ThreadEvents[infoIdx].Set(); } } // Tests all scripts compile for the selected scripting member. // First attempts to compile all scripts separately, if this fails then compiles them combined as a single example. public static void TestScriptsCompile(ScriptingDocMember info) { var scripts = info.csharpExamples; if (scripts.Count == 0) { info.status = TestStatus.Ignored; return; } // Setup compiler var providerOptions = new Dictionary<string, string>(); providerOptions.Add(CompilerVersion, v3.5); var domProvider = new CSharpCodeProvider(providerOptions); var compilerParams = new CompilerParameters { GenerateExecutable = false, GenerateInMemory = false, TreatWarningsAsErrors = false, }; Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies(); foreach (var assembly in assemblies) { compilerParams.ReferencedAssemblies.Add(assembly.Location); } // Attempt to compile the scripts separately. bool error = false; for (int i = 0; i < scripts.Count; i++) { scripts[i].compileResults = domProvider.CompileAssemblyFromSource(compilerParams, scripts[i].code); if (scripts[i].compileResults.Errors.HasErrors) error = true; } if (error) { // Its possible that the scripts are all part of one example so we should compile them together and see if that works instead. info.combinedResults = domProvider.CompileAssemblyFromSource(compilerParams, scripts.Select(s => s.code).ToArray()); if (!info.combinedResults.Errors.HasErrors) error = false; } info.status = error ? TestStatus.Failed : TestStatus.Passed; } static HashSet<string> GetWhiteList() { var textAsset = AssetDatabase.LoadAssetAtPath(k_WhiteList, typeof(TextAsset)) as TextAsset; var whiteList = new HashSet<string>(); if (textAsset) { foreach (var line in textAsset.text.Split(\n)) { whiteList.Add(line.Replace(\r, ).TrimEnd( )); } } return whiteList; } // Parses the scripting docs and generates our test data. static void ParseMemberNode(XmlNode node, string file, string parent, List<ScriptingDocMember> infoList, HashSet<string> whiteList) { ScriptingDocMember info = new ScriptingDocMember(); info.path = file; infoList.Add(info); info.parent = parent; foreach (XmlAttribute attr in node.Attributes) { // potential tag attributes: name, namespace, type var attrLowercase = attr.Name.ToLower(); if (attrLowercase == name) info.name = attr.Value; else if (attrLowercase == namespace) info.nspace = attr.Value; } if (whiteList.Contains(info.name)) info.status = TestStatus.Whitelisted; if (!string.IsNullOrEmpty(info.nspace)) { // trim down the namespace to remove UnityEngine and UnityEditor if (info.nspace.StartsWith(UnityEngine)) { info.editor = false; info.nspace = info.nspace.Remove(0, UnityEngine.Length); } if (info.nspace.StartsWith(UnityEditor)) { info.editor = true; info.nspace = info.nspace.Remove(0, UnityEditor.Length); } if (info.nspace.StartsWith(.)) info.nspace = info.nspace.Remove(0, 1); } foreach (XmlNode child in node.ChildNodes) { var childNameLowercase = child.Name.ToLower(); if (childNameLowercase == section) { // see if this section is undoc for (int i = 0; i < child.Attributes.Count; i++) { if (child.Attributes[i].Name == undoc child.Attributes[i].Value == true) { infoList.Remove(info); break; } } foreach (XmlNode grandChild in child.ChildNodes) { var codeLangNode = GetExample(grandChild); if (codeLangNode != null) { var scriptInfo = new ExampleScript(); scriptInfo.code = codeLangNode.InnerXml.Replace(<![CDATA[, ).Replace(]]>, ); info.csharpExamples.Add(scriptInfo); } } } else if (childNameLowercase == member) { ParseMemberNode(child, file, info.name, infoList, whiteList); } } } // Extract the cs example code. static XmlNode GetExample(XmlNode node) { if (node.Name.ToLower() == example) { for (int i = 0; i < node.Attributes.Count; ++i) { if (node.Attributes[i].Name == nocheck node.Attributes[i].Value == true) return null; } return node.SelectSingleNode(code[@lang=cs]); } return null; } }


原文链接:https://blogs.unity3d.com/cn/2017/08/18/verifying-the-scripting-docs-fun-with-editortests/

感谢Unity官方翻译组成员“yangtze0621”对本文的贡献(如何加入翻译组)
转载请注明:来自Unity官方中文社区(forum.china.unity3d.com) Unity, 游戏, 文档, 编辑器锐亚教育

锐亚教育 锐亚科技 unity unity教程