Game QA Testing — Testing & Debugging Games Guide
In this tutorial, you'll learn about Game QA Testing. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Game QA testing is the systematic process of verifying game functionality, performance, and user experience through manual and automated testing — finding bugs before players do and ensuring a polished, shippable product.
What You'll Learn
You'll implement unit tests in Unity Test Framework and Godot's GUT, automate playtesting with input recording, profile performance with Unity Profiler, create regression test suites, and build a CI/CD pipeline for game builds with automated QA gates.
Why Game QA Matters
A single game-breaking bug at launch can destroy weeks of marketing and sink a game's reputation. Cyberpunk 2077's launch bugs cost CD Projekt Red $1B+ in market cap. Conversely, Nintendo's rigorous QA process is why Zelda: Tears of the Kingdom launched with virtually no bugs. At DodaTech, we apply game QA practices to our software releases — automated testing catches regressions before they reach users.
Real-World Use Case
A mobile puzzle game with 500 levels uses automated playthrough testing. Each level is played by a script that tries all valid moves, checking for: (1) level completion possible, (2) no state corruption after undo, (3) score calculation correct. The test suite runs in 4 minutes, catching 2-3 regressions per sprint before they reach QA.
Testing Types in Game Development
| Type | What It Tests | Tools |
|---|---|---|
| Unit Testing | Individual functions | Unity Test Runner, GUT |
| Integration | System interactions | Custom test scenes |
| Regression | Existing features still work | Automated suite |
| Performance | FPS, memory, load times | Unity Profiler, RenderDoc |
| Playtesting | UX, fun, balance | Real players, analytics |
| Compatibility | Device/OS variations | Device Farm, Test Lab |
Unity Unit Testing with Test Framework
using NUnit.Framework;
using UnityEngine;
using System.Collections.Generic;
public class DamageSystemTests
{
private DamageSystem damageSystem;
[SetUp]
public void Setup()
{
damageSystem = new DamageSystem();
}
[Test]
public void PlayerTakesDamage_HealthReducesCorrectly()
{
// Arrange
int initialHealth = 100;
int damageAmount = 30;
var player = new PlayerCharacter(initialHealth);
// Act
damageSystem.ApplyDamage(player, damageAmount);
// Assert
Assert.AreEqual(70, player.Health);
Assert.IsFalse(player.IsDead);
}
[Test]
public void PlayerHealth_DoesNotGoBelowZero()
{
var player = new PlayerCharacter(50);
damageSystem.ApplyDamage(player, 100);
Assert.AreEqual(0, player.Health);
Assert.IsTrue(player.IsDead);
}
[Test]
public void Shield_AbsorbsPercentageOfDamage()
{
var player = new PlayerCharacter(100);
player.Shield = new Shield(0.5f); // 50% damage reduction
damageSystem.ApplyDamage(player, 50);
// 50 damage reduced by 50% = 25 damage
Assert.AreEqual(75, player.Health);
}
[Test]
public void CriticalHit_DealsDoubleDamage()
{
var player = new PlayerCharacter(100);
damageSystem.ApplyDamage(player, 30, isCritical: true);
Assert.AreEqual(40, player.Health); // 100 - (30 * 2)
}
[Test]
public void MultipleHits_StackCorrectly()
{
var player = new PlayerCharacter(100);
damageSystem.ApplyDamage(player, 25);
damageSystem.ApplyDamage(player, 25);
damageSystem.ApplyDamage(player, 25);
Assert.AreEqual(25, player.Health);
}
[Test]
public void InvincibilityFrames_PreventDamage()
{
var player = new PlayerCharacter(100);
player.TriggerInvincibility(2f); // 2 seconds of immunity
damageSystem.ApplyDamage(player, 99);
Assert.AreEqual(100, player.Health); // Damage ignored
}
}
// Supporting classes for testing
public class DamageSystem
{
public void ApplyDamage(PlayerCharacter target, int amount,
bool isCritical = false)
{
if (target.IsInvincible) return;
int finalDamage = isCritical ? amount * 2 : amount;
if (target.Shield != null)
{
finalDamage = (int)(finalDamage * (1 - target.Shield.Reduction));
}
target.Health = Mathf.Max(0, target.Health - finalDamage);
if (target.Health <= 0)
{
target.IsDead = true;
}
}
}
public class PlayerCharacter
{
public int Health { get; set; }
public bool IsDead { get; set; }
public Shield Shield { get; set; }
public bool IsInvincible { get; private set; }
public PlayerCharacter(int health)
{
Health = health;
IsDead = false;
}
public void TriggerInvincibility(float duration)
{
IsInvincible = true;
// In real implementation, timer would reset this
}
}
public class Shield
{
public float Reduction { get; }
public Shield(float reduction) => Reduction = reduction;
}
Expected output: All 7 tests pass in Unity Test Runner. Damage calculations, shields, critical hits, invincibility frames, and death state are verified.
Godot Unit Testing with GUT
# test_damage_system.gd
extends GutTest
func test_player_takes_damage():
var player = PlayerCharacter.new(100)
var system = DamageSystem.new()
system.apply_damage(player, 30)
assert_eq(player.health, 70, "Health should reduce by 30")
assert_false(player.is_dead, "Player should not be dead")
func test_health_does_not_go_below_zero():
var player = PlayerCharacter.new(50)
var system = DamageSystem.new()
system.apply_damage(player, 100)
assert_eq(player.health, 0, "Health should not go below 0")
assert_true(player.is_dead, "Player should be dead")
func test_shield_reduces_damage():
var player = PlayerCharacter.new(100)
player.shield = Shield.new(0.5)
var system = DamageSystem.new()
system.apply_damage(player, 50)
assert_eq(player.health, 75, "Shield should halve damage")
func test_multiple_hits():
var player = PlayerCharacter.new(100)
var system = DamageSystem.new()
system.apply_damage(player, 20)
system.apply_damage(player, 30)
system.apply_damage(player, 10)
assert_eq(player.health, 40, "Multiple hits should stack")
func test_critical_hit_doubles_damage():
var player = PlayerCharacter.new(100)
var system = DamageSystem.new()
system.apply_damage(player, 25, true)
assert_eq(player.health, 50, "Critical should double damage")
# Run with: gut --path=res://test/
Expected output: GUT reports 5/5 tests passed. GDScript tests run in-editor or via CLI.
Automated Playtesting with Input Recording
using UnityEngine;
using System.Collections.Generic;
using System.IO;
public class PlaytestRecorder : MonoBehaviour
{
private List<RecordedInput> recordedInputs = new List<RecordedInput>();
private bool isRecording = false;
private float startTime;
[System.Serializable]
public struct RecordedInput
{
public float timestamp;
public string inputType; // "touch", "key", "button"
public Vector2 position;
public string buttonName;
public bool pressed;
}
void Start()
{
if (Application.isEditor &&
PlayerPrefs.GetString("PlaytestMode") == "record")
{
StartRecording();
}
else if (PlayerPrefs.GetString("PlaytestMode") == "playback")
{
StartPlayback("recorded_playtest.json");
}
}
void Update()
{
if (isRecording)
{
// Record touch inputs
foreach (Touch touch in Input.touches)
{
RecordInput(new RecordedInput
{
timestamp = Time.time - startTime,
inputType = "touch",
position = touch.position,
pressed = touch.phase != TouchPhase.Ended
});
}
}
}
void StartRecording()
{
isRecording = true;
startTime = Time.time;
Debug.Log("Playtest recording started");
}
public void StopAndSave()
{
isRecording = false;
string json = JsonHelper.ToJson(recordedInputs);
File.WriteAllText(Application.dataPath + "/recorded_playtest.json", json);
Debug.Log($"Saved {recordedInputs.Count} recorded inputs");
}
void StartPlayback(string filename)
{
string path = Application.dataPath + "/" + filename;
if (File.Exists(path))
{
string json = File.ReadAllText(path);
var inputs = JsonHelper.FromJson<RecordedInput>(json);
StartCoroutine(PlaybackCoroutine(inputs));
}
}
System.Collections.IEnumerator PlaybackCoroutine(RecordedInput[] inputs)
{
float playbackStart = Time.time;
int index = 0;
while (index < inputs.Length)
{
float elapsed = Time.time - playbackStart;
while (index < inputs.Length &&
inputs[index].timestamp <= elapsed)
{
ReplayInput(inputs[index]);
index++;
}
yield return null;
}
Debug.Log("Playback complete");
}
void ReplayInput(RecordedInput input)
{
// Simulate input (would need custom input system)
Debug.Log($"Replay: {input.inputType} at {input.position}");
}
}
Expected output: Recording captures all touch inputs with timestamps. Playback replays them identically — enabling automated Regression Testing of complex game flows.
Performance Profiling
using UnityEngine;
using UnityEngine.Profiling;
public class GameProfiler : MonoBehaviour
{
private float frameTimeHistory;
private int frameCount;
void Update()
{
// Real-time profiling
Profiler.BeginSample("TotalFrame");
Profiler.BeginSample("UpdateLogic");
UpdateGameLogic();
Profiler.EndSample();
Profiler.BeginSample("RenderPreparation");
PrepareRenderData();
Profiler.EndSample();
Profiler.EndSample();
// Track frame times
frameTimeHistory += Time.unscaledDeltaTime;
frameCount++;
if (frameTimeHistory >= 1f)
{
float averageFPS = frameCount / frameTimeHistory;
Debug.Log($"Average FPS: {averageFPS:F1}");
if (averageFPS < 30f)
{
Debug.LogWarning("Performance warning: Below 30 FPS!");
// Trigger dynamic resolution scaling
AdjustQuality();
}
frameTimeHistory = 0;
frameCount = 0;
}
}
void AdjustQuality()
{
// Dynamic quality adjustment
if (QualitySettings.GetQualityLevel() > 0)
{
QualitySettings.SetQualityLevel(
QualitySettings.GetQualityLevel() - 1);
Debug.Log($"Quality reduced to level {QualitySettings.GetQualityLevel()}");
}
}
[ContextMenu("Profile Scene")]
void ProfileScene()
{
// Take a memory snapshot
Profiler.BeginSample("MemorySnapshot");
long totalMemory = Profiler.GetTotalReservedMemoryLong();
long textureMemory = Profiler.GetAllocatedMemoryForGraphicsDriver();
Debug.Log($"Total Memory: {totalMemory / (1024*1024)}MB");
Debug.Log($"Texture Memory: {textureMemory / (1024*1024)}MB");
Profiler.EndSample();
}
}
Expected output: Real-time FPS monitoring with automatic quality reduction when performance drops below 30fps.
Mermaid Diagram: QA Pipeline
flowchart TD
A[Code Commit] --> B[CI Build]
B --> C[Unit Tests]
C -->|Pass| D[Integration Tests]
C -->|Fail| E[Notify Developer]
D -->|Pass| F[Automated Playthrough]
D -->|Fail| E
F -->|Pass| G[Performance Tests]
F -->|Fail| H[QA Manual Test]
G -->|Pass| I[Regression Suite]
G -->|Fail| J[Profile & Optimize]
I -->|Pass| K[Release Candidate]
I -->|Fail| H
H -->|Bug Found| E
H -->|All Clear| K
style A fill:#d4edda
style C fill:#cce5ff
style K fill:#fff3cd
style E fill:#f8d7da
Bug Report Template
| Field | Example |
|---|---|
| ID | BUG-0427 |
| Title | Player clips through wall after dash+dive input |
| Platform | Android 14, Pixel 7, Unity 2022.3 |
| Steps | 1. Equip dash ability 2. Dash toward wall 3. Press dive during dash |
| Expected | Player stops at wall |
| Actual | Player clips through to other side |
| Severity | Critical (softlock — cannot return) |
| Frequency | 3/5 attempts |
| Attachment | Video, screenshot, log file |
Common QA Testing Errors
1. Testing Only on High-End Devices
Problem: Game passes on iPhone 15 Pro, crashes on iPhone 12. Fix: Test on 5+ devices covering low/mid/high tiers, including 2+ year old devices.
2. No Automated Regression Suite
Problem: Fixing one bug introduces two new ones (regressions). Fix: Automate core gameplay tests — every build runs the full suite.
3. Ignoring Edge Cases
Problem: Player dies exactly at the same frame as level completion. Fix: Test boundary conditions: 0 HP, max inventory, double-click, rapid inputs.
4. Performance Testing Only at Dev Time
Problem: Game runs at 60fps in editor, 25fps on device. Fix: Profile on actual devices, not just editor. Use device temperature to catch thermal throttling.
5. No Crash Reporting
Problem: 500 players crash at level 3, but no one reports it. Fix: Integrate Crashlytics, Sentry, or Unity Cloud Diagnostics.
6. Skipping Localization Testing
Problem: German translation has text overflowing button (40 chars vs 15 English). Fix: Test all supported languages, check UI layout for each.
Practice Questions
What is the difference between verification and validation in QA? Verification: "Did we build it right?" (meets spec). Validation: "Did we build the right thing?" (meets player needs).
Why automate playtesting? Reproducibility — manual playtests drift. Automated playthroughs catch regressions and verify 500 levels in minutes.
What is a smoke test in Game Development? A quick check that the game launches, main menu works, and can start a new game — run before every build.
How do you test multiplayer games? Use multiple instances on same network, automated bot clients, network condition simulators (lag, packet loss).
What is the difference between alpha and beta testing? Alpha: internal QA, focused on functionality and crashes. Beta: external players, focused on balance, fun, and feedback.
Challenge
Build a complete CI/CD pipeline for a Unity game: automate builds for Android and iOS, run unit tests (NUnit), run a playthrough recording (automated input), profile performance (capture FPS and memory), report results to a dashboard, and gate releases on all tests passing.
Real-World Task
Your game has a bug where collecting a power-up while simultaneously taking damage results in infinite lives. Replicate the bug, write a unit test that captures the scenario, fix the root cause (damage and power-up processing order), and run the regression suite to verify no new bugs introduced.
Mini Project: Automated Test Runner
using UnityEngine;
using UnityEngine.TestTools;
using NUnit.Framework;
using System.Collections;
public class GameplayTestSuite
{
[UnityTest]
public IEnumerator Level1_CanBeCompleted()
{
// Load test scene
UnityEngine.SceneManagement.SceneManager.LoadScene("Level1_Test");
yield return new WaitForSeconds(1f);
// Simulate player movement through level
var player = GameObject.FindObjectOfType<PlayerController>();
var goal = GameObject.FindObjectOfType<LevelGoal>();
float timeout = 30f;
float elapsed = 0f;
while (elapsed < timeout)
{
// Simulate right movement
player.MoveRight();
yield return new WaitForSeconds(0.1f);
// Check if reached goal
if (Vector3.Distance(player.transform.position,
goal.transform.position) < 2f)
{
Assert.Pass("Level 1 completed successfully");
yield break;
}
elapsed += 0.1f;
}
Assert.Fail("Level 1 could not be completed within 30 seconds");
}
[UnityTest]
public IEnumerator PlayerRespawnsAfterDeath()
{
var player = GameObject.FindObjectOfType<PlayerController>();
Vector3 startPos = player.transform.position;
// Kill player
player.TakeDamage(999);
yield return new WaitForSeconds(2f); // Wait for respawn
Assert.IsTrue(player.IsAlive, "Player should respawn");
Assert.AreEqual(startPos, player.transform.position,
"Player should respawn at checkpoint");
}
[UnityTest]
public IEnumerator PauseMenu_BlocksGameplay()
{
var pauseMenu = GameObject.FindObjectOfType<PauseMenu>();
pauseMenu.TogglePause();
yield return null;
Assert.IsTrue(Time.timeScale == 0, "Time should stop when paused");
pauseMenu.TogglePause();
yield return null;
Assert.IsTrue(Time.timeScale == 1, "Time should resume when unpaused");
}
}
Expected output: Unity Test Runner executes gameplay scenarios automatically — verifying level completion, respawn logic, and pause functionality without manual intervention.
Related Tutorials
- Game Optimization — Performance optimization for better testing results
- Mobile Game Development — Mobile-specific testing considerations
- Unity Guide — Unity editor and testing tools
- Next: (Next lesson series)
- Previous: Mobile Game Development — Unity & Godot for Mobile
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro