Sure. Sorry for the late response, I was on holidays. I’m posting the relevant parts of the GitLab test setup. I was also thinking of writing it all up into a blog post at some point. It takes a bit of tinkering, but you have full control and it works quite well.
This is our current GitLab CI config file.
stages:
- Verify
- Build
- Test
.Job:
tags:
- self-hosted, windows, powershell, unity
variables:
GIT_CLEAN_FLAGS: -ffdx -e Library/
before_script:
- BuildScripts/before_script.ps1
.Verify:
extends: .Job
stage: Verify
Verify Commit:
extends: .Verify
script:
- BuildScripts/verify_lfs.ps1
.Build:
extends: .Job
stage: Build
rules:
- if: '$CI_MERGE_REQUEST_ID == null && $CI_COMMIT_MESSAGE !~ /[skip build]]/'
script:
- BuildScripts/build.ps1
artifacts:
name: "${env:CI_JOB_NAME}-${env:CI_COMMIT_SHORT_SHA}"
expire_in: 3 days
paths:
- "./Builds"
Redacted-Win64:
extends: .Build
variables:
BUILD_NAME: Redacted
BUILD_TARGET: StandaloneWindows64
Redacted-Linux64:
extends: .Build
variables:
BUILD_NAME: Redacted
BUILD_TARGET: StandaloneLinux64
.Test:
extends: .Job
stage: Test
dependencies: []
script:
- BuildScripts/build.ps1
artifacts:
when: on_failure
reports:
junit: TestResults/JUnit*.xml
cobertura: CodeCoverage/*-cobertura/Cobertura.xml
paths:
- TestResults/
Editor Tests:
extends: .Test
Runtime Tests:
extends: .Test
And the relevant part of the build script:
$BUILD_PATH = Join-Path $PROJECT_PATH -ChildPath "Builds/$BUILD_TARGET/"
$TEST_PATH = Join-Path $PROJECT_PATH -ChildPath "TestResults/"
$NUNIT_PATH = Join-Path $TEST_PATH -ChildPath "NUnit.xml"
$JUNIT_PATH = Join-Path $TEST_PATH -ChildPath "JUnit.xml"
$LOG_PATH = Join-Path $PROJECT_PATH -ChildPath "Logs/Unity.log"
New-Item -Path $LOG_PATH -ItemType "file" -Force | Out-Null
# Prepare the arguments to pass to Unity.
$UNITY_ARGS = @()
$UNITY_ARGS += "-projectPath", """$PROJECT_PATH"""
$UNITY_ARGS += "-batchmode"
$UNITY_ARGS += "-logFile", "-" # This makes Unity print the logs to stdout, which we forward to the console (for GitLab and PowerShell) and a log file
$UNITY_ARGS += "-buildTarget", """$BUILD_TARGET"""
$UNITY_ARGS += "-buildPath", """$BUILD_PATH"""
$UNITY_ARGS += "-buildName", """$BUILD_NAME"""
if ($UNITY_CACHE_SERVER) {
$UNITY_ARGS += "-CacheServerIPAddress", "$UNITY_CACHE_SERVER"
}
if ($CI_JOB_STAGE -eq "Build") {
$UNITY_ARGS += "-executeMethod", "Redacted.Editor.BuildScript.PerformBuild"
$UNITY_ARGS += "-quit"
}
if ($CI_JOB_NAME -eq "Editor Tests") {
$UNITY_ARGS += "-executeMethod", "Redacted.Tests.TestRunner.RunEditorTests"
$UNITY_ARGS += "-enableCodeCoverage"
$UNITY_ARGS += "-coverageOptions", "assemblyFilters:+Redacted"
}
if ($CI_JOB_NAME -eq "Runtime Tests") {
$UNITY_ARGS += "-executeMethod", "Redacted.Tests.TestRunner.RunRuntimeTests"
}
if ($CI_JOB_NAME -eq "Standalone Tests") {
$UNITY_ARGS += "-executeMethod", "Redacted.Tests.TestRunner.RunStandaloneTests"
}
if (!(Test-Path $UNITY_EXE)) {
"Did not find Unity $UNITY_VERSION."
exit 1
}
# Run Unity
& $UNITY_EXE $UNITY_ARGS | Tee-Object -FilePath $LOG_PATH
$UNITY_EXIT_CODE = $LastExitCode
"--------------------------------------------------------------------------------"
"Finished with exit code: $UNITY_EXIT_CODE"
exit $UNITY_EXIT_CODE
The build method:
static void PerformBuild()
{
Console.WriteLine("Performing build");
// Use the build target specified on the command line, or default to the active build target otherwise.
string buildTargetArg = GetArgument("buildTarget");
if (string.IsNullOrWhiteSpace(buildTargetArg) || !Enum.TryParse(buildTargetArg, out BuildTarget buildTarget))
{
buildTarget = EditorUserBuildSettings.activeBuildTarget;
}
// If we do not support the selected build target (e.g. corresponding editor module is missing), abort.
if (!BuildPipeline.IsBuildTargetSupported(BuildTargetGroup.Standalone, buildTarget))
{
Console.WriteLine($"Build target {buildTarget.ToString()} not supported. Install the module from Unity Hub.");
if (Application.isBatchMode)
{
EditorApplication.Exit(1);
}
return;
}
string buildName = GetArgument("buildName") ?? Application.productName;
string buildPath = GetArgument("buildPath") ?? Path.Combine("./Builds", buildTarget.ToString());
string fullPath = Path.Combine(buildPath, buildName);
if (buildTarget == BuildTarget.StandaloneWindows64 || buildTarget == BuildTarget.StandaloneWindows)
{
fullPath += ".exe";
}
var options = new BuildPlayerOptions
{
scenes = (from scene in EditorBuildSettings.scenes where scene.enabled select scene.path).ToArray(),
locationPathName = fullPath,
target = buildTarget,
options = Application.isBatchMode ? BuildOptions.None : BuildOptions.ShowBuiltPlayer
};
var report = BuildPipeline.BuildPlayer(options);
LogBuildReport(report);
if (Application.isBatchMode)
EditorApplication.Exit(report.summary.result == BuildResult.Succeeded ? 0 : 1);
}
And the test runner:
public class TestRunner : IPrebuildSetup, IPostBuildCleanup, ITestRunCallback
{
#if UNITY_EDITOR
[MenuItem("Test/Run Editor Tests")]
static void RunEditorTests()
{
Console.WriteLine("Running editor tests");
var filter = new Filter
{
testMode = TestMode.EditMode,
groupNames = new[] {@"^Redacted\.Tests\.Editor\."}
};
ScriptableObject.CreateInstance<TestRunnerApi>().Execute(new ExecutionSettings(filter));
}
[MenuItem("Test/Run Runtime Tests")]
static void RunRuntimeTests()
{
Console.WriteLine("Running runtime tests");
var testRunner = ScriptableObject.CreateInstance<TestRunnerApi>();
var filter = new Filter
{
testMode = TestMode.PlayMode,
groupNames = new[] {@"^Redacted\.Tests\.Runtime\."}
};
testRunner.Execute(new ExecutionSettings(filter));
}
[MenuItem("Test/Run Standalone Tests")]
static void RunStandaloneTests()
{
Console.WriteLine("Running standalone tests");
var testRunner = ScriptableObject.CreateInstance<TestRunnerApi>();
var filter = new Filter
{
testMode = TestMode.PlayMode,
groupNames = new[] {@"^Redacted\.Tests\.Runtime\."},
targetPlatform = EditorUserBuildSettings.activeBuildTarget
};
testRunner.Execute(new ExecutionSettings(filter));
}
#endif
public void RunStarted(ITest tests)
{
Debug.Log("TestManager.RunStarted");
}
public void RunFinished(ITestResult testResults)
{
Debug.Log("TestManager.RunFinished");
ResultsWriter.WriteResults(testResults);
CoverageReportGenerator.Generate();
if (Application.isBatchMode)
{
int returnValue = 1;
switch (testResults.ResultState.Status)
{
case NUnit.Framework.Interfaces.TestStatus.Inconclusive:
returnValue = 2;
break;
case NUnit.Framework.Interfaces.TestStatus.Skipped:
returnValue = 0;
break;
case NUnit.Framework.Interfaces.TestStatus.Passed:
returnValue = 0;
break;
case NUnit.Framework.Interfaces.TestStatus.Failed:
returnValue = 1;
break;
}
Console.WriteLine($"Test Result: {testResults.ResultState}");
#if UNITY_EDITOR
EditorApplication.Exit(returnValue);
#endif
}
}
}