Testing Quest Command

How I test my CLI Open World RPG

Overview

  • Context
    • Method
    • Language
    • Game
  • Types of Tests
    • Unit
    • Data
    • Integration
    • Performance
    • Balance
    • Data Validation
  • Specific Test Cases
    • Commands
    • Listeners
    • Managers

Context

  • TDD
  • Kotlin
  • Quest Command

TDD

  • Write the Test
  • Make it Pass
  • Refactor

Kotlin

  • Built by Jetbrains (Intellij, Android Studio)
  • Java bytecode
  • OO or Functional
  • Concise
  • Developer Focused
  • First class citizen (Spring and Android)
  • Non-nullable and immutable focus

Data Classes - Java

package rak.pixellwp.cycling.models;

final class Pixel {
    private float x;
    private float y;
    private int index;

    public Pixel(float x, float y, int index){
        this.x = x;
        this.y = y;
        this.index = index;
    }

    public float getX(){
        return this.x;
    }

    public float getY(){
        return this.y;
    }

    public int getIndex(){
        return this.index;
    }
}

Data Classes - Kotlin

package rak.pixellwp.cycling.models

class Pixel(val x: Float, val y: Float, val index: Int)
  • Val = Immutable
  • Var = Mutable

Maps and Filters

@Test
fun mapFilterTest() {
   val list = listOf(1, 2, 3, 4, 5)

   list.map { number -> number + 1 }
   list.map { it + 1 }
   assertEquals(listOf(2,3,4,5,6), list.map { it + 1 })

   val expected = listOf(4, 5, 6)
   val actual = list.map { it + 1 }.filter { it > 3 }

   assertEquals(expected, actual)
}

Quest Command

  • Inspired by Elder Scrolls, Runescape, BOTW
  • Sacrifice GUI for systematic, deep levels of interaction
  • Large world with composable level of detail
  • Convenient and forgiving commands
  • Start with as much interaction as possible in a single small area
  • Single Threaded, 1fps

Event Driven

  • Command
    • Parses user intent and posts an event
  • Event
    • What is happening
  • Listener
    • Many listeners can listen to events
    • Change app state
    • Output to the user
    • Post more events

Tests

  • 285 Unit Tests, run in ~700 milliseconds
  • 37 Integration tests, run in ~2.7 seconds

Tests

  • 285 Unit Tests, run in ~700 milliseconds
  • 37 Integration tests, run in ~2.7 seconds

Tests

  • 285 Unit Tests, run in ~700 milliseconds
  • 37 Integration tests, run in ~2.7 seconds

Tests 2024

  • 338 Unit Tests, run in ~200 milliseconds
  • 40 Integration tests, run in ~0.5 seconds

Tests - What’s the point?

  • Less regression
  • Spot checking systems still work
  • Visibility into the system
  • Content safeguards

Types of Tests

  • Unit
  • Data
  • Integration
  • Performance
  • Balance
  • Data Validation

Unit Tests

  • Test a single class
  • Define and Document Behavior
  • Dependency Injection (more on that later)
  • Explore complex and detailed (crunchy) calculations (vector math etc)

Unit Tests

@Test
fun directionInverted() {
   assertEquals(Direction.BELOW, Direction.ABOVE.invert())
   assertEquals(Direction.ABOVE, Direction.BELOW.invert())
   assertEquals(Direction.NORTH, Direction.SOUTH.invert())
   assertEquals(Direction.SOUTH_WEST, Direction.NORTH_EAST.invert())
   assertEquals(Direction.NONE, Direction.NONE.invert())
}

Unit Tests

@Test
fun directionInverted() {
   assertEquals(Direction.BELOW, Direction.ABOVE.invert())
   assertEquals(Direction.ABOVE, Direction.BELOW.invert())
   assertEquals(Direction.NORTH, Direction.SOUTH.invert())
   assertEquals(Direction.SOUTH_WEST, Direction.NORTH_EAST.invert())
   assertEquals(Direction.NONE, Direction.NONE.invert())
}
fun invert(): Direction {
   return vector.invert().direction
}

Unit Tests

@Test
fun directionInverted() {
   assertEquals(Direction.BELOW, Direction.ABOVE.invert())
   assertEquals(Direction.ABOVE, Direction.BELOW.invert())
   assertEquals(Direction.NORTH, Direction.SOUTH.invert())
   assertEquals(Direction.SOUTH_WEST, Direction.NORTH_EAST.invert())
   assertEquals(Direction.NONE, Direction.NONE.invert())
}
fun invert(): Direction {
   return vector.invert().direction
}
fun invert(): Vector {
   return Vector(-x, -y, -z)
}

Unit Tests

fun String.apply(params: Map<String, String>): String

Unit Tests

fun String.apply(params: Map<String, String>): String
@Test
fun onlyEscapedVariablesReplaced() {
   val base = "\$amount fireHealth"
   val params = mapOf("amount" to "1")

   assertEquals("1 fireHealth", base.apply(params))
}

Data Tests

@RunWith(Parameterized::class)
class TimeManagerDateTest(private val ticks: Int, private val expectedDays: Int, private val expectedMonths: Int, private val expectedYears: Int) {

   companion object {
       @JvmStatic
       @Parameterized.Parameters
       fun data(): Collection<Array<Int>> {
           return listOf(
                   arrayOf(1, 0, 0, 0),
                   arrayOf(TimeManager.ticksInDay, 1, 0, 0),
                   arrayOf(TimeManager.ticksInDay + 1, 1, 0, 0)
	    ...
           )
       }
   }

Data Tests

@Test
fun doTest() {
   val time = TimeManager(ticks)
   assertEquals(expectedYears, time.getYear(), "$ticks should result in $expectedYears years.")
   assertEquals(expectedMonths, time.getMonth(), "$ticks should result in $expectedMonths months.")
   assertEquals(expectedDays, time.getDay(), "$ticks should result in $expectedDays days.")
}

Data Tests

@Test
fun directionInverted() {
   assertEquals(Direction.BELOW, Direction.ABOVE.invert())
   assertEquals(Direction.ABOVE, Direction.BELOW.invert())
   assertEquals(Direction.NORTH, Direction.SOUTH.invert())
   assertEquals(Direction.SOUTH_WEST, Direction.NORTH_EAST.invert())
   assertEquals(Direction.NONE, Direction.NONE.invert())
}

Data Tests

Consider a testing framework

Integration Tests

  • Reset Game
  • Input one or more commands
  • Check Gamestate

Integration Tests

@Before
fun reset() {
   EventManager.clear()
   EventManager.registerListeners()
   GameManager.newGame(testing = true)
   EventManager.executeEvents()
}

Integration Tests

@Test
fun chopApple() {
   val input = "chop apple"
   CommandParser.parseCommand(input)
   assertTrue(GameState.player.inventory.getItem("Apple") != null)
   assertTrue(GameState.player.inventory.getItem("Apple")?.properties?.tags?.has("Sliced") ?: false)
}

Integration Tests

@Test
fun useGate() {
   val input = "w && w && use gate"
   CommandParser.parseCommand(input)
   assertEquals("You can now access Kanbara City from Kanbara Gate.", ChatHistory.getLastOutput())
}

Performance Tests

  • Poor man’s method for narrowing down what caused that slow startup

Performance Tests

class StartupTest {

   @Ignore
   @Test
   fun startupPerformanceTest() {
       val timer = PoorMansInstrumenter(10000)
       timer.printElapsed("Starting")
       EventManager.registerListeners()
       timer.printElapsed("Listeners Registered")
       GameManager.newGame()
       timer.printElapsed("New Game Started")
       CommandParser.parseInitialCommand(arrayOf())
       timer.printElapsed("Initial Command Parsed")
   }

}

Performance Tests

1,261 time elapsed: Starting
856,693 time elapsed: Listeners Registered
49,285 time elapsed: New Game Started
You wake to An Open Field. It is neighbored by Farmer's Hut (WEST), Apple Tree (NORTH), Barren Patch (SOUTH), Training Circle (EAST), Windmill (NORTH_EAST).
You are at An Open Field
You find yourself surrounded by Wheat Field (20, 0, 0).
To see what I can do I should type 'help commands'.
50,893 time elapsed: Initial Command Parsed

Balance ‘Tests’

  • Try a bunch of scenarios to see which factors ‘feel’ the best

Balance ‘Tests’

fun main() {
    println("\tIncrease Agility")
    println("\tAgility \tStrength \tEncumbrance \tWeapon size \tWeapon weight \tTime needed")
    testAttackTime(agility: 1, strength: 1, weaponSize: "Small", weaponWeight: 1, otherWeight: 0)
    testAttackTime(agility: 2, strength: 1, weaponSize: "Small", weaponWeight: 1, otherWeight: 0)
    testAttackTime(agility: 5, strength: 1, weaponSize: "Small", weaponWeight: 1, otherWeight: 0)
    testAttackTime(agility: 10, strength: 1, weaponSize: "Small", weaponWeight: 1, otherWeight: 0)
}

Balance ‘Tests’

Data Validation Tests

  • Test json content won’t break the game

Data Validation Tests

@Test
fun allValidationsPass() {
   val warnings =
           ActivatorValidator().validate() +
           CommandValidator().validate() +
           CreatureValidator().validate() +
           LocationValidator().validate()

   assertEquals(0, warnings)<!-- _class: code-morph title-morph -->
}

Data Validation Tests

class CreatureValidator {

   fun validate(): Int {
       return noDuplicateNames() +
               itemsThatCreaturesHaveExist()
   }

Data Validation Tests

private val creatureMap: List<MutableMap<String, Any>>
private fun noDuplicateNames(): Int {
   var warnings = 0
   val names = mutableListOf<String>()
   creatureMap.forEach { creature ->
       val name = creature["name"] as String
       if (names.contains(name)) {
           println("WARN: Creature '$name' has a duplicate name.")
           warnings++
       } else {
           names.add(name)
       }
   }
   return warnings
}

Specific Test Cases

  • Commands
  • Listeners
  • Managers

Testing Commands

  • Check the message queue for the expected event

Testing Commands

private val command = AttackCommand()

@Test
fun attackCreatureWithoutDirection() {
   val rat = Target("Rat", bodyName = "human")
   ScopeManager.getScope().addTarget(rat)

   command.execute("sl", "rat".split(" "))
   val event = EventManager.getUnexecutedEvents()[0] as StartAttackEvent
   assertEquals(rat, event.target.target)
}

Testing Listeners

  • Post or supply an event
  • Check Gamestate

Testing Listeners

@Test
fun pickupItemFromScope() {
   ScopeManager.reset()

   val creature = getCreatureWithCapacity()
   val scope = ScopeManager.getScope(creature.location)
   val item = Target("Apple")
   scope.addTarget(item)

   TransferItem().execute(TransferItemEvent(creature, item, destination = creature))
   assertNotNull(creature.inventory.getItem(item.name))
   assertTrue(scope.getTargets(item.name).isEmpty())

   ScopeManager.reset()
}

Testing Managers

  • Store game state or parse content from Json
  • ‘Manage’ an entire system (Item Manager, Creature Manager etc)
  • Objects (basically singletons). This means shared state
  • Require Dependency Injection for testing
  • Require an easy way to reset state

Testing Managers

object ActivatorManager {
   private var parser = DependencyInjector.getImplementation(ActivatorParser::class.java)

   private var activators = parser.loadActivators()

   fun reset() {
       parser = DependencyInjector.getImplementation(ActivatorParser::class.java)
       activators = parser.loadActivators()
   }

   fun getActivator(name: String): Target {
       return Target(name, activators.get(name))
   }

Testing Managers

class ActivatorFakeParser(private val activators: NameSearchableList<Target> = NameSearchableList()) : ActivatorParser {

   override fun loadActivators(): NameSearchableList<Target> {
       return activators
   }

}

Testing Managers

@Test
fun topLevelValueIsParameterized() {
   val activator = Target("Target", dynamicDescription = DialogueOptions("This is a \$key"))
   val fakeParser = ActivatorFakeParser(NameSearchableList(listOf(activator)))
   DependencyInjector.setImplementation(ActivatorParser::class.java, fakeParser)
   ActivatorManager.reset()

   val target = LocationTarget("Target", null, NO_VECTOR, mapOf("key" to "value"))
   val result = ActivatorManager.getActivatorsFromLocationTargets(listOf(target)).first()

   assertEquals("This is a value", result.getDescription())
}

Overview

  • Context
    • TDD
    • Kotlin
    • Quest Command
  • Types of Tests
    • Unit
    • Data
    • Integration
    • Performance
    • Balance
    • Data Validation
  • Specific Test Cases
    • User Input
    • Event Listeners
    • Singletons

Takeaways

  • Fit tech to context
    • Find the type of test that fits your need
  • Simple is good
  • Tests should be faster than Twitter
  • Coverage is about concepts
  • Find tools that you like and stick with them
  • Find something that motivates you, and then do it

Feedback

Please take a minute to fill out the survey!