Unity CI/CD Testing Pipeline: GitHub Actions, GameCI, and Cloud Build

Unity CI/CD Testing Pipeline: GitHub Actions, GameCI, and Cloud Build

Running Unity tests locally is fine. Running them automatically on every pull request is how you actually catch regressions before they ship. Setting up Unity CI/CD is notoriously painful because of licensing, but GameCI makes it manageable.

This guide covers a production-ready Unity testing pipeline using GitHub Actions and GameCI.

Why Unity CI Is Painful (And How GameCI Solves It)

Unity requires an activated license to run in batch mode. In CI, that means:

  1. Personal licenses — Free, but Unity requires a machine-specific activation file. You can't share this across CI runners.
  2. Professional/Plus licenses — Can be activated with serial + username + password, works in CI, but requires a paid plan.
  3. Unity Build Server — Enterprise option with floating licenses.

GameCI (game-ci/unity-builder, game-ci/unity-test-runner) abstracts all of this. It handles license activation, Unity installation, test execution, and artifact collection within GitHub Actions.

Project Structure

MyGame/
├── Assets/
│   ├── Scripts/
│   │   ├── Player/
│   │   │   ├── PlayerMovement.cs
│   │   │   └── PlayerHealth.cs
│   │   └── Enemy/
│   │       ├── EnemyAI.cs
│   │       └── EnemyAI.Tests.asmdef
│   └── Tests/
│       ├── EditMode/
│       │   ├── PlayerMovementTests.cs
│       │   └── Assembly.Tests.asmdef
│       └── PlayMode/
│           ├── GameFlowTests.cs
│           └── Assembly.PlayMode.Tests.asmdef
├── Packages/
│   └── manifest.json
└── ProjectSettings/
    └── ProjectSettings.asset

Assembly definition files are required for Unity's test runner to discover tests.

Assembly Definition Files

// Assets/Tests/EditMode/Assembly.Tests.asmdef
{
    "name": "Assembly.Tests",
    "rootNamespace": "MyGame.Tests.EditMode",
    "references": [
        "GUID:your-main-assembly-guid"
    ],
    "includePlatforms": [],
    "excludePlatforms": [],
    "allowUnsafeCode": false,
    "overrideReferences": true,
    "precompiledReferences": [
        "nunit.framework.dll"
    ],
    "autoReferenced": false,
    "defineConstraints": [],
    "versionDefines": [],
    "noEngineReferences": false
}
// Assets/Tests/PlayMode/Assembly.PlayMode.Tests.asmdef
{
    "name": "Assembly.PlayMode.Tests",
    "rootNamespace": "MyGame.Tests.PlayMode",
    "references": [
        "GUID:your-main-assembly-guid"
    ],
    "includePlatforms": [],
    "excludePlatforms": [],
    "allowUnsafeCode": false,
    "overrideReferences": true,
    "precompiledReferences": [
        "nunit.framework.dll"
    ],
    "autoReferenced": false,
    "defineConstraints": [],
    "versionDefines": [],
    "noEngineReferences": false
}

Writing Testable Code

Before configuring CI, ensure your code can be tested. The main pattern: dependency injection over FindObjectOfType and GetComponent in constructors.

// Bad — hard to test
public class PlayerHealth : MonoBehaviour {
    void Start() {
        var ui = FindObjectOfType<HealthUI>();
        ui.UpdateDisplay(100);
    }
}

// Good — injectable
public class PlayerHealth : MonoBehaviour {
    [SerializeField] private HealthUI healthUI;
    
    public void Initialize(HealthUI ui) {
        healthUI = ui;
    }
}

Edit Mode Tests

Edit Mode tests run outside Play Mode — fast, no scene loading:

// Assets/Tests/EditMode/PlayerMovementTests.cs
using NUnit.Framework;
using MyGame;

public class PlayerMovementTests {
    [Test]
    public void CalculateSpeed_WithMultiplier_ReturnsCorrectValue() {
        var movement = new PlayerMovementData(baseSpeed: 5f, multiplier: 2f);
        
        float result = movement.CalculateSpeed();
        
        Assert.AreEqual(10f, result, 0.001f);
    }

    [Test]
    public void CanJump_WhenGrounded_ReturnsTrue() {
        var state = new PlayerState { isGrounded = true, jumpCooldown = 0f };
        
        Assert.IsTrue(state.CanJump());
    }

    [Test]
    public void CanJump_WhenAirborne_ReturnsFalse() {
        var state = new PlayerState { isGrounded = false, jumpCooldown = 0f };
        
        Assert.IsFalse(state.CanJump());
    }

    [TestCase(100, 50, 50)]
    [TestCase(100, 100, 0)]
    [TestCase(100, 150, 0)] // Can't go below 0
    public void TakeDamage_ReducesHealthCorrectly(int startHp, int damage, int expectedHp) {
        var health = new HealthComponent(maxHp: 100, currentHp: startHp);
        
        health.TakeDamage(damage);
        
        Assert.AreEqual(expectedHp, health.CurrentHp);
    }
}

Play Mode Tests

Play Mode tests run in the actual game runtime:

// Assets/Tests/PlayMode/GameFlowTests.cs
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using UnityEngine.SceneManagement;

public class GameFlowTests {
    [UnitySetUp]
    public IEnumerator SetUp() {
        SceneManager.LoadScene("TestScene");
        yield return null; // Wait one frame for scene to load
    }

    [UnityTest]
    public IEnumerator Player_StartsAtSpawnPoint() {
        var player = GameObject.FindWithTag("Player");
        var spawnPoint = GameObject.Find("SpawnPoint");
        
        Assert.IsNotNull(player, "Player not found in scene");
        Assert.IsNotNull(spawnPoint, "SpawnPoint not found in scene");
        
        yield return new WaitForSeconds(0.1f);
        
        var distanceFromSpawn = Vector3.Distance(
            player.transform.position,
            spawnPoint.transform.position
        );
        Assert.Less(distanceFromSpawn, 0.5f, "Player should start near spawn point");
    }

    [UnityTest]
    public IEnumerator Player_TakesDamage_HealthDecreases() {
        var player = GameObject.FindWithTag("Player");
        var health = player.GetComponent<PlayerHealth>();
        int initialHp = health.CurrentHp;
        
        // Apply damage
        health.TakeDamage(25);
        yield return null;
        
        Assert.AreEqual(initialHp - 25, health.CurrentHp);
    }

    [UnityTest]
    public IEnumerator Enemy_Spawns_WithinTimeLimit() {
        var spawner = GameObject.FindWithTag("EnemySpawner").GetComponent<EnemySpawner>();
        
        spawner.SpawnEnemy();
        
        yield return new WaitForSeconds(2f);
        
        var enemies = GameObject.FindGameObjectsWithTag("Enemy");
        Assert.Greater(enemies.Length, 0, "At least one enemy should spawn");
    }

    [UnityTearDown]
    public IEnumerator TearDown() {
        // Clean up spawned objects
        foreach (var enemy in GameObject.FindGameObjectsWithTag("Enemy")) {
            Object.Destroy(enemy);
        }
        yield return null;
    }
}

GitHub Actions Pipeline

Minimal Pipeline (Free License)

# .github/workflows/unity-tests.yml
name: Unity Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    name: Test (${{ matrix.testMode }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        testMode: [editmode, playmode]

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          lfs: true

      - name: Cache Unity Library
        uses: actions/cache@v4
        with:
          path: Library
          key: Library-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}
          restore-keys: Library-

      - name: Run Tests (${{ matrix.testMode }})
        uses: game-ci/unity-test-runner@v4
        env:
          UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
          UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
          UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
        with:
          testMode: ${{ matrix.testMode }}
          artifactsPath: test-results/${{ matrix.testMode }}
          githubToken: ${{ secrets.GITHUB_TOKEN }}
          checkName: Test Results (${{ matrix.testMode }})
          coverageOptions: 'generateAdditionalMetrics;generateHtmlReport;generateBadgeReport'

      - name: Upload Test Results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: Test results (${{ matrix.testMode }})
          path: test-results/${{ matrix.testMode }}

Getting the Unity License for CI

For free Unity Personal:

  1. Run Unity locally: Help → Manage License → Activate License Manually
  2. Download the .ulf file
  3. Add it as a GitHub secret: UNITY_LICENSE (paste the file contents)

For Unity Pro/Plus:

env:
  UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
  UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
  UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}

Full Pipeline with Build Validation

name: Unity CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    outputs:
      test-result: ${{ steps.test.outcome }}

    steps:
      - uses: actions/checkout@v4
        with: { lfs: true }

      - uses: actions/cache@v4
        with:
          path: Library
          key: Library-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}

      - id: test
        uses: game-ci/unity-test-runner@v4
        env:
          UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
        with:
          testMode: all
          artifactsPath: test-results
          githubToken: ${{ secrets.GITHUB_TOKEN }}

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: test-results

  build:
    name: Build (${{ matrix.targetPlatform }})
    runs-on: ubuntu-latest
    needs: test  # Only build if tests pass
    strategy:
      matrix:
        targetPlatform:
          - StandaloneLinux64
          - WebGL

    steps:
      - uses: actions/checkout@v4
        with: { lfs: true }

      - uses: actions/cache@v4
        with:
          path: Library
          key: Library-${{ matrix.targetPlatform }}-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}

      - uses: game-ci/unity-builder@v4
        env:
          UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
        with:
          targetPlatform: ${{ matrix.targetPlatform }}
          buildName: MyGame

      - uses: actions/upload-artifact@v4
        with:
          name: Build-${{ matrix.targetPlatform }}
          path: build/${{ matrix.targetPlatform }}

Code Coverage Configuration

Add coverage reporting to measure test completeness:

- name: Run Tests with Coverage
  uses: game-ci/unity-test-runner@v4
  env:
    UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
  with:
    testMode: editmode
    artifactsPath: test-results
    coverageOptions: >-
      generateAdditionalMetrics;
      generateHtmlReport;
      generateBadgeReport;
      assemblyFilters:+MyGame.*,-MyGame.Tests.*;
      pathFilters:-**/Editor/**

Optimizing CI Run Time

Unity tests can be slow. These optimizations reduce run time significantly:

1. Cache the Library folder aggressively:

- uses: actions/cache@v4
  with:
    path: Library
    key: Library-${{ matrix.targetPlatform }}-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}
    restore-keys: |
      Library-${{ matrix.targetPlatform }}-
      Library-

2. Run Edit Mode and Play Mode in parallel (matrix strategy above does this).

3. Split large test suites with custom filter:

with:
  testMode: editmode
  customParameters: '-testFilter "MyGame.Tests.Fast"'

4. Skip slow tests in PRs, run all tests on main:

- name: Run Tests
  uses: game-ci/unity-test-runner@v4
  with:
    testMode: all
    customParameters: ${{ github.event_name == 'pull_request' && '-testCategory "!Slow"' || '' }}

Common CI Failures

Error Cause Fix
License activation failed Wrong license format Re-export .ulf from Unity Hub
No tests were run Missing assembly definition Add .asmdef file in Tests folder
Scene not found Test scene not in build settings Add test scene to Build Settings
Library rebuild each run Cache miss Check cache key hash
Out of memory Large project + Play Mode Increase runner RAM (ubuntu-latest-8-cores)

A working Unity CI pipeline means every PR gets automated test feedback in 5-10 minutes. The upfront investment in GameCI setup pays back immediately in caught regressions and faster code reviews.

Read more