Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support glob pattern in LoadScene attribute #66

Merged
merged 3 commits into from
Apr 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions Editor/TemporaryBuildScenesUsingInTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using TestHelper.Attributes;
using TestHelper.Editor;
using TestHelper.Utils;
using UnityEditor;
using UnityEditor.TestTools;
using UnityEngine;

[assembly: TestPlayerBuildModifier(typeof(TemporaryBuildScenesUsingInTest))]

Expand Down Expand Up @@ -58,17 +61,23 @@ internal static IEnumerable<string> GetScenesUsingInTest()
.Concat(FindLoadSceneAttributesOnMethods());
foreach (var attribute in attributes)
{
if (attribute.ScenePath.ToLower().EndsWith(".unity"))
string scenePath;
try
{
yield return attribute.ScenePath;
scenePath = ScenePathFinder.GetExistScenePath(attribute.ScenePath);
}
else
catch (ArgumentException e)
{
foreach (var guid in AssetDatabase.FindAssets("t:SceneAsset", new[] { attribute.ScenePath }))
{
yield return AssetDatabase.GUIDToAssetPath(guid);
}
Debug.LogWarning(e.Message);
continue;
}
catch (FileNotFoundException e)
{
Debug.LogWarning(e.Message);
continue;
}

yield return scenePath;
}
}

Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,15 +211,16 @@ public class MyTestClass

#### LoadScene

`LoadSceneAttribute` is an NUnit test attribute class to load scene before running test.
`LoadSceneAttribute` is a NUnit test attribute class that loads the scene before running the test.

It has the following benefits:

- Can be use same code for running Edit Mode tests, Play Mode tests in Editor, and on Player
- Can be specified scenes that are **NOT** in "Scenes in Build"
- Can be use same code for running Edit Mode tests, Play Mode tests in Editor, and on Player.
- Can be specified scenes that are **NOT** in "Scenes in Build".
- Can be specified path by glob pattern. However, there are restrictions, top level and scene name cannot be omitted.

This attribute can attached to test method only.
Can be used with sync Test, async Test, and UnityTest.
- This attribute can attached to the test method only.
It can be used with sync Tests, async Tests, and UnityTest.

Usage:

Expand Down
10 changes: 7 additions & 3 deletions Runtime/Attributes/LoadSceneAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections;
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using TestHelper.Utils;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
Expand Down Expand Up @@ -35,12 +36,14 @@ public class LoadSceneAttribute : NUnitAttribute, IOuterUnityTestAction

/// <summary>
/// Load scene before running test.
/// Can be specified path by glob pattern. However, there are restrictions, top level and scene name cannot be omitted.
/// </summary>
/// <param name="path">Scene file path.
/// The path starts with `Assets/` or `Packages/`.
/// And package name using `name` instead of `displayName`, when scenes in the package.
/// (e.g., `Packages/com.nowsprinting.test-helper/Tests/Scenes/Scene.unity`)
/// </param>
/// <seealso href="https://en.wikipedia.org/wiki/Glob_(programming)"/>
public LoadSceneAttribute(string path)
{
ScenePath = path;
Expand All @@ -49,6 +52,7 @@ public LoadSceneAttribute(string path)
/// <inheritdoc />
public IEnumerator BeforeTest(ITest test)
{
var existScenePath = ScenePathFinder.GetExistScenePath(ScenePath);
AsyncOperation loadSceneAsync = null;

if (Application.isEditor)
Expand All @@ -58,20 +62,20 @@ public IEnumerator BeforeTest(ITest test)
{
// Play Mode tests running in Editor
loadSceneAsync = EditorSceneManager.LoadSceneAsyncInPlayMode(
ScenePath,
existScenePath,
new LoadSceneParameters(LoadSceneMode.Single));
}
else
{
// Edit Mode tests
EditorSceneManager.OpenScene(ScenePath);
EditorSceneManager.OpenScene(existScenePath);
}
#endif
}
else
{
// Play Mode tests running on Player
loadSceneAsync = SceneManager.LoadSceneAsync(ScenePath);
loadSceneAsync = SceneManager.LoadSceneAsync(existScenePath);
}

yield return loadSceneAsync;
Expand Down
137 changes: 137 additions & 0 deletions Runtime/Utils/ScenePathFinder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Copyright (c) 2023-2024 Koji Hasegawa.
// This software is released under the MIT License.

using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
#if UNITY_EDITOR
using UnityEditor;
#endif

namespace TestHelper.Utils
{
internal static class ScenePathFinder
{
/// <summary>
/// Get existing scene file path matches a glob pattern.
/// </summary>
/// <param name="path">Scene file path. Can be specified path by glob pattern. However, there are restrictions, top level and scene name cannot be omitted.</param>
/// <returns>Existing scene file path</returns>
/// <exception cref="ArgumentException">Invalid path format</exception>
/// <exception cref="FileNotFoundException">Scene file not found</exception>
public static string GetExistScenePath(string path)
{
ValidatePath(path);
#if UNITY_EDITOR
return GetExistScenePathInEditor(path);
#else
return GetExistScenePathOnPlayer(path);
#endif
}

private static void ValidatePath(string path)
{
if (!path.StartsWith("Assets/") && !path.StartsWith("Packages/"))
{
throw new ArgumentException($"Scene path must start with `Assets/` or `Packages/`. path: {path}");
}

var split = path.Split('/');
if (split[0].Equals("Packages") && split[1].IndexOfAny(new[] { '*', '?' }) >= 0)
{
throw new ArgumentException($"Wildcards cannot be used in the package name of path: {path}");
}

if (!path.EndsWith(".unity"))
{
throw new ArgumentException($"Scene path must ends with `.unity`. path: {path}");
}

if (split.Last().IndexOfAny(new[] { '*', '?' }) >= 0)
{
throw new ArgumentException($"Wildcards cannot be used in the most right section of path: {path}");
}
}

private static string SearchFolder(string path)
{
var searchFolder = new StringBuilder();
var split = path.Split('/');
for (var i = 0; i < split.Length - 1; i++)
{
var s = split[i];
if (s.IndexOfAny(new[] { '*', '?' }) >= 0)
{
break;
}

searchFolder.Append($"{s}/");
}

return searchFolder.ToString();
}

private static Regex ConvertRegexFromGlob(string glob)
{
var regex = new StringBuilder();
foreach (var c in glob)
{
switch (c)
{
case '*':
regex.Append("[^/]*");
break;
case '?':
regex.Append("[^/]");
break;
case '.':
regex.Append("\\.");
break;
case '\\':
regex.Append("\\\\");
break;
default:
regex.Append(c);
break;
}
}

regex.Replace("[^/]*[^/]*", ".*"); // globstar (**)
regex.Insert(0, "^");
regex.Append("$");
return new Regex(regex.ToString());
}

#if UNITY_EDITOR
/// <summary>
/// For the run in editor, use <c>AssetDatabase</c> to search scenes and compare paths.
/// </summary>
/// <param name="path"></param>
private static string GetExistScenePathInEditor(string path)
{
var regex = ConvertRegexFromGlob(path);
foreach (var guid in AssetDatabase.FindAssets("t:SceneAsset", new[] { SearchFolder(path) }))
{
var existScenePath = AssetDatabase.GUIDToAssetPath(guid);
if (regex.IsMatch(existScenePath))
{
return existScenePath;
}
}

throw new FileNotFoundException($"Scene `{path}` is not found in AssetDatabase");
}
#endif

/// <summary>
/// For the run on player, returns scene name from the path.
/// </summary>
/// <param name="path"></param>
internal static string GetExistScenePathOnPlayer(string path)
{
return path.Split('/').Last().Split('.').First();
}
}
}
3 changes: 3 additions & 0 deletions Runtime/Utils/ScenePathFinder.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Tests/Editor/LoadSceneAttributeTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public class LoadSceneAttributeTest

[Test]
[LoadScene(TestScene)]
public void Attach_AlreadyLoadedSceneNotInBuild()
public void Attach_LoadedSceneNotInBuild()
{
var cube = GameObject.Find(ObjectName);
Assert.That(cube, Is.Not.Null);
Expand Down
5 changes: 3 additions & 2 deletions Tests/Editor/TemporaryBuildScenesUsingInTestTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ namespace TestHelper.Editor
[TestFixture]
public class TemporaryBuildScenesUsingInTestTest
{
private const string TestScene = "Packages/com.nowsprinting.test-helper/Tests/Scenes/NotInScenesInBuild.unity";

[Test]
public void GetScenesUsingInTest_AttachedToMethod_ReturnScenesSpecifiedByAttribute()
{
var actual = TemporaryBuildScenesUsingInTest.GetScenesUsingInTest();
Assert.That(actual,
Does.Contain("Packages/com.nowsprinting.test-helper/Tests/Scenes/NotInScenesInBuild.unity"));
Assert.That(actual, Does.Contain(TestScene));
}
}
}
16 changes: 13 additions & 3 deletions Tests/Runtime/Attributes/LoadSceneAttributeTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public class LoadSceneAttributeTest

[Test]
[LoadScene(TestScene)]
public void Attach_AlreadyLoadedSceneNotInBuild()
public void Attach_LoadedSceneNotInBuild()
{
var cube = GameObject.Find(ObjectName);
Assert.That(cube, Is.Not.Null);
Expand All @@ -27,7 +27,7 @@ public void Attach_AlreadyLoadedSceneNotInBuild()

[Test]
[LoadScene(TestScene)]
public async Task AttachToAsyncTest_AlreadyLoadedSceneNotInBuild()
public async Task AttachToAsyncTest_LoadedSceneNotInBuild()
{
var cube = GameObject.Find(ObjectName);
Assert.That(cube, Is.Not.Null);
Expand All @@ -38,13 +38,23 @@ public async Task AttachToAsyncTest_AlreadyLoadedSceneNotInBuild()

[UnityTest]
[LoadScene(TestScene)]
public IEnumerator AttachToUnityTest_AlreadyLoadedSceneNotInBuild()
public IEnumerator AttachToUnityTest_LoadedSceneNotInBuild()
{
var cube = GameObject.Find(ObjectName);
Assert.That(cube, Is.Not.Null);

Object.Destroy(cube); // For not giving false negatives in subsequent tests.
yield return null;
}

[Test]
[LoadScene("Packages/com.nowsprinting.test-helper/**/NotInScenesInBuild.unity")]
public void UsingGlob_LoadedSceneNotInBuild()
{
var cube = GameObject.Find(ObjectName);
Assert.That(cube, Is.Not.Null);

Object.Destroy(cube); // For not giving false negatives in subsequent tests.
}
}
}
3 changes: 3 additions & 0 deletions Tests/Runtime/Utils.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 59 additions & 0 deletions Tests/Runtime/Utils/ScenePathFinderTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) 2023-2024 Koji Hasegawa.
// This software is released under the MIT License.

using System;
using System.IO;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

namespace TestHelper.Utils
{
[TestFixture]
public class ScenePathFinderTest
{
[TestCase("Packages/com.nowsprinting.test-helper/Tests/Scenes/NotInScenesInBuild.unity")]
[TestCase("Packages/com.nowsprinting.test-helper/Tes?s/S?enes/NotInScenesInBuild.unity")]
[TestCase("Packages/com.nowsprinting.test-helper/*/*/NotInScenesInBuild.unity")]
[TestCase("Packages/com.nowsprinting.test-helper/**/NotInScenesInBuild.unity")]
public void GetExistScenePath_ExistPath_GotExistScenePath(string path)
{
#if UNITY_EDITOR
const string ExistScenePath = "Packages/com.nowsprinting.test-helper/Tests/Scenes/NotInScenesInBuild.unity";
#else
const string ExistScenePath = "NotInScenesInBuild"; // Scene name only
#endif

var actual = ScenePathFinder.GetExistScenePath(path);
Assert.That(actual, Is.EqualTo(ExistScenePath));
}

[TestCase("Packages/**/NotInScenesInBuild.unity")]
[TestCase("**/NotInScenesInBuild.unity")]
[TestCase("Packages/com.nowsprinting.test-helper/Tests/Scenes/NotInScenesInBuild")]
[TestCase("Packages/com.nowsprinting.test-helper/Tests/Scenes/Not??ScenesInBuild.unity")]
[TestCase("Packages/com.nowsprinting.test-helper/Tests/Scenes/*InScenesInBuild.unity")]
public void GetExistScenePath_InvalidGlobPattern_ThrowsArgumentException(string path)
{
Assert.That(() => ScenePathFinder.GetExistScenePath(path), Throws.TypeOf<ArgumentException>());
}

[TestCase("Packages/com.nowsprinting.test-helper/Tests/Scenes/NotExistScene.unity")] // Not exist path
[TestCase("Packages/com.nowsprinting.test-helper/*/NotInScenesInBuild.unity")] // Not match path pattern
[UnityPlatform(RuntimePlatform.OSXEditor, RuntimePlatform.WindowsEditor, RuntimePlatform.LinuxEditor)]
public void GetExistScenePath_NotExistPath_InEditor_ThrowsFileNotFoundException(string path)
{
Assert.That(() => ScenePathFinder.GetExistScenePath(path), Throws.TypeOf<FileNotFoundException>());
// Note: Returns scene name when running on player.
}

[TestCase("Packages/com.nowsprinting.test-helper/Tests/Scenes/NotInScenesInBuild.unity")]
public void GetExistScenePathOnPlayer_GotSceneName(string path)
{
const string SceneName = "NotInScenesInBuild";

var actual = ScenePathFinder.GetExistScenePathOnPlayer(path);
Assert.That(actual, Is.EqualTo(SceneName));
}
}
}
Loading