QA Pipeline for Indie Game Developers: Test Without a Team
Indie developers ship games with 1-5 person teams, no dedicated QA engineers, and budgets that don't stretch to $40K/year test automation platforms. But shipping without a QA pipeline means bugs reach players — and players leave negative reviews.
This guide shows how to build a QA pipeline that punches above its weight: automated testing for game logic, CI/CD for builds, structured playtesting, and bug tracking — all achievable solo.
The Indie QA Stack
The right tools for an indie team balance effectiveness with cost:
| Layer | Tool | Cost |
|---|---|---|
| Unit tests | Unity Test Framework / GUT (Godot) | Free |
| CI/CD | GitHub Actions | Free (2000 min/mo) |
| Build artifacts | itch.io butler / Steam | Free |
| Bug tracking | GitHub Issues or Linear | Free / $8/mo |
| Playtesting | Steam Playtest / itch.io beta | Free |
| Monitoring | HelpMeTest | Free (up to 10 tests) |
| Crash reporting | Sentry (Unity SDK) | Free tier |
Total cost for a solid indie QA pipeline: $0-16/month.
Layer 1: Automated Unit Tests
Focus automated tests on logic that's expensive to test manually:
High-value test targets for indie games:
- Save/load system
- Inventory and item interactions
- Combat calculations (damage, crit, status effects)
- Achievement/unlock conditions
- Level progression logic
- Economy balance constraints
// Save/load: the most common source of game-breaking bugs
[TestFixture]
public class SaveSystemTests
{
[Test]
public void SaveAndLoad_PreservesAllPlayerStats()
{
var player = CreatePlayer(level: 15, health: 80, gold: 2500);
player.AddItem(new Item("Flame Sword", rarity: Rarity.Rare));
player.UnlockAchievement("FirstBoss");
string saveData = SaveSystem.Serialize(player);
var loaded = SaveSystem.Deserialize(saveData);
Assert.AreEqual(15, loaded.Level);
Assert.AreEqual(80, loaded.Health);
Assert.AreEqual(2500, loaded.Gold);
Assert.AreEqual(1, loaded.Inventory.Count);
Assert.IsTrue(loaded.HasAchievement("FirstBoss"));
}
[Test]
public void SaveSystem_HandlesVersion_Migration()
{
// Old save format from v1.0 (before inventory was added)
string v1Save = @"{""version"":1,""player"":{""level"":5,""health"":100}}";
var player = SaveSystem.Deserialize(v1Save);
Assert.AreEqual(5, player.Level);
Assert.IsNotNull(player.Inventory, "v1 save should migrate with empty inventory");
Assert.AreEqual(0, player.Inventory.Count);
}
[Test]
public void SaveSystem_DoesNotCorrupt_OnInterruptedWrite()
{
var player = CreatePlayer();
// Write save to disk, then kill it halfway through
// (Test with atomic write pattern)
SaveSystem.Save(player);
// Verify file is either complete or old version — never corrupt
var loaded = SaveSystem.Load();
Assert.IsNotNull(loaded, "Corrupted save should return null or old save, not crash");
}
}
// Combat math: verify balance constraints
[TestFixture]
public class CombatCalculationTests
{
[Test]
[TestCase(100, 10, 0, 90)] // Base damage
[TestCase(100, 10, 50, 95)] // 50% armor = 50% damage reduction
[TestCase(100, 10, 100, 100)] // Full armor = 0 damage
public void DamageCalculation_AppliesArmorCorrectly(
int health, int damage, int armor, int expectedHealth)
{
var target = new Character(health: health, armor: armor);
CombatSystem.ApplyDamage(target, damage);
Assert.AreEqual(expectedHealth, target.Health);
}
[Test]
public void CriticalHit_ExactlyDoublesDamage()
{
var target = new Character(health: 100, armor: 0);
int normalDamage = CombatSystem.CalculateDamage(attacker: new Character(), weapon: Sword, isCrit: false);
int critDamage = CombatSystem.CalculateDamage(attacker: new Character(), weapon: Sword, isCrit: true);
Assert.AreEqual(normalDamage * 2, critDamage, "Crit should deal exactly 2x damage");
}
}Layer 2: CI/CD with GitHub Actions
Run tests automatically on every commit:
# .github/workflows/ci.yml
name: Game CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache Unity
uses: actions/cache@v4
with:
path: Library
key: Library-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}
- name: Run Tests
uses: game-ci/unity-test-runner@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
with:
testMode: editmode
artifactsPath: test-results
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: test-results/
build:
name: Build Check
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Build
uses: game-ci/unity-builder@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
with:
targetPlatform: StandaloneWindows64
buildName: GameBuildFor Godot:
# .github/workflows/godot-ci.yml
jobs:
test:
runs-on: ubuntu-latest
container: barichello/godot-ci:4.2.1
steps:
- uses: actions/checkout@v4
- name: Run GUT Tests
run: |
godot --headless --script addons/gut/gut_cmdln.gd \
-gdir=res://tests/ \
-gexit
- name: Build Export
run: |
mkdir -p exports/windows
godot --headless --export-release "Windows Desktop" exports/windows/game.exeLayer 3: Automated Playtesting with Bots
For single-player games, write bots that play through content automatically:
// Simple bot that plays through the first level
public class PlaytestBot : MonoBehaviour
{
private PlayerController player;
IEnumerator Start()
{
player = FindObjectOfType<PlayerController>();
yield return RunLevel1();
// Report results
Debug.Log($"Bot completed Level 1 in {Time.time:F1}s");
Debug.Log($"Deaths: {BotStats.Deaths}");
Debug.Log($"Enemies killed: {BotStats.EnemiesKilled}");
#if UNITY_EDITOR
EditorApplication.isPlaying = false;
#endif
}
IEnumerator RunLevel1()
{
// Navigate to first enemy
yield return NavigateTo(FindObjectOfType<Enemy>().transform.position);
// Fight until dead or enemy dies
yield return FightUntilResolved();
// Collect any items
foreach (var item in FindObjectsOfType<CollectibleItem>())
{
yield return NavigateTo(item.transform.position);
}
// Find and reach level exit
yield return NavigateTo(FindObjectOfType<LevelExit>().transform.position);
}
}Bots catch regressions that unit tests miss: level geometry holes, stuck enemies, unreachable items.
Layer 4: Structured Playtester Management
Human playtesters find what bots can't. Manage them effectively:
Playtester briefing template:
# Playtest Session Instructions
## Your Mission
Play the game naturally for 30-60 minutes. Focus on [Tutorial + Level 1-3].
## Please Report
- Anything that confused you (even if it worked correctly)
- Moments where you felt stuck > 1 minute
- Anything that felt unfair or random
- Bugs (screenshot if possible)
## Reporting
Use this Google Form: [link]
Fields: Type (Bug/Design/Confused), Description, Screenshot
## Do NOT
- Read the game description before playing
- Ask me how things work (figure it out like a new player)Bug report template:
**Steps to reproduce:**
1. Start new game
2. Pick up the torch in the starting area
3. Enter the first dungeon room
**Expected:** Torch illuminates the room
**Actual:** Room stays dark, torch has no effect
**Frequency:** Always
**Device:** Windows 10, 16GB RAMSet up GitHub Issues with labels:
bug/critical— game-breakingbug/minor— cosmetic or edge casefeedback/design— not a bug, but worth discussingfeedback/balance— damage/difficulty feels off
Layer 5: Release Checklist
Before every release, run through this checklist:
# Release Checklist
## Automated (CI must be green)
- [ ] All unit tests pass
- [ ] Build completes without errors
- [ ] No compiler warnings (treat warnings as errors)
## Manual Testing
- [ ] New game start → reach first checkpoint without issue
- [ ] Load game from each save slot
- [ ] All main story paths playable
- [ ] Credits sequence completes
- [ ] Settings menu: graphics, audio, keybinds all save/load correctly
- [ ] Game quits cleanly without crash dialog
## Platform-Specific
- [ ] Windows: tested on Win10 and Win11
- [ ] MacOS: Apple Silicon native build tested
- [ ] Steam: overlay, achievements, cloud saves work
- [ ] Signed builds: no "untrusted developer" warnings
## Performance
- [ ] Minimum spec hardware (or VM simulation) tested
- [ ] No memory leak over 30-minute session
- [ ] Load times under 10 seconds
## Post-Release Monitoring
- [ ] Crash reporting (Sentry) is active
- [ ] HelpMeTest health checks runningMonitoring After Launch
After launch, monitor your game's backend with HelpMeTest:
# Set up health checks for game services
<span class="hljs-comment"># Runs from your server or CI — free tier covers 10 checks
<span class="hljs-comment"># Check leaderboard API
helpmetest health <span class="hljs-string">"leaderboard-api" <span class="hljs-string">"5m"
<span class="hljs-comment"># Check achievement sync
helpmetest health <span class="hljs-string">"achievement-service" <span class="hljs-string">"5m"
<span class="hljs-comment"># Check patch server
helpmetest health <span class="hljs-string">"update-server" <span class="hljs-string">"10m"If any service goes down, you get alerted within 5 minutes — not when players tweet at you.
Budget Summary
A complete indie QA pipeline costs almost nothing:
| Tool | Free Tier |
|---|---|
| GitHub Actions | 2,000 min/month |
| Unity Test Framework | Included in Unity |
| Sentry (crash reports) | 5K errors/month |
| HelpMeTest | 10 tests, unlimited health checks |
| itch.io beta access | Free |
The bottleneck isn't money — it's writing the tests. Start with save/load tests (they pay back immediately), add combat math tests, then expand to other systems over time.
Summary
An indie QA pipeline doesn't need a budget — it needs discipline. The four layers:
- Automated unit tests for save/load, combat math, and game systems
- CI/CD to catch regressions on every commit
- Bots to automate playthrough testing
- Structured playtesting to catch what bots miss
Ship with confidence, not hope.