Unit testing - ThePix/QuestJS GitHub Wiki

For my own peace of mind, I have developed a unit testing regime. This is testing each unit of your software to ensure it does what you expect it to do. To make this as easy as possible, the testing process is usually automated so you just click a button, and the system runs a whole suite of tests, reporting at the end which, if any, failed. This is very common is software development (in fact, I would hope it is compulsory!), so is something you might want to consider too, especially if you have a lot of custom functions, templates or commands.

We can divide testing a text adventure into two parts - code and text. Issues with text would be spelling and grammar mistakes, but would also include text that is not clear enough or that does not keep the user's attention. However, this article is about testing the code.

We can further divide testing code in to function testing, system testing and game testing.

Game testing is ensuring the whole thing works. At its simplest, making sure the player can get from the start to the end. Unit testing is not the best tool for this - you should be using walk-throughs and beta-testing - so will will not consider it any further here.

Unit testing can be frustrating! I find about two thirds of the bugs it finds are bugs in my tests rather than my actual code, and it can feel like you are wasting your time. However, it does pick up real bugs, and when it does it is usually easier to locate the source - and it is far better if you find it than the users.

Where it really excels is when you make a change that could impact different parts of your game. How can you be sure every part still works as it should? If you have good unit testing, just run your tests and you can be confident all is good. This is especially good when updating a competition entry during the judging period, as you need to be quick getting the update out, but also need to sure you have not messed anything up.

In the long run it is definitely worthwhile for complicated games.

Basics

To turn testing on, you need to set settings.tests to true in settings.js. You also need settings.playMode to be set to "dev" (it probably is already).

settings.playMode = "dev"
settings.tests = true

Your tests should go in a file called "game/tests.js", inside a function called "tests" in the "test" object. Here is a simple example:

"use strict"

test.tests = function() {
  test.start("Simple object commands")
  test.assertCmd("i", "You are carrying a knife.")
  test.assertCmd("get knife", "You have it.")
  test.title("Going up")
  test.assertCmd("u", ["You head up.", "A large room, with a big bed and a wardrobe.", /^You can see/, "You can go in or down.",])
}

The test.title function just says what this section of the tests is. If you get an error, Quest will report which test number in which section was at fault, so the title is just a way to find the failing test faster. I suggest no more than ten tests in a section, but it is up to you.

The test.start function does that, but it also resets your game back to its initial state. There are a couple of issues to be aware of. The tests use the standard Quest save/load system. When the tests start, a save game is created (the name is set in test.saveFilename and defaults to "unit-test-save-file"). This means that the test.start function only resets to whatever the game state was when the tests were run - so you should always reload the page, and then run the tests immediately.

The reset is only as good as the save/load system. If you change attributes of the "settings" object, attributes of exits, etc. these are not (by default) saved, so will not get reset. If you cannot fathom why something is not working, it may be worthwhile commenting out all the earlier sections and see if the bug is still there. If not, it may be an issue with save/load.

When you come to release your game, ensure settings.playMode is set to "play", and then you can skip the file from your game package and "lib/test-lib.js" (though it makes little difference if you include the files).

Function testing

Function testing focuses on a single function (or text processor directive). Whenever you create a function that is not trivial, you should consider testing it to make sure it returns what you expect.

When building a complicated system, unit testing is an excellent way to test each building block and give you the confidence that they are working.

You usually use the test.assertEqual function, which takes two parameters, the first being the expected value, the second being the result of your code.

This example tests a custom text processor directive that returns "sir" if "callmemaam" is false and "ma'ma" if true.

  test.title("Text processor");
  test.assertEqual("Sir! Yes, sir", processText("{Sir}! Yes, {sir}"))
  game.player.callmemaam = true 
  test.assertEqual("Ma'am! Yes, ma'am", processText("{Sir}! Yes, {sir}"))

The expected parameter can be regular expression to be matched against, or an array, where every element must be the same and in the same order.

Checking game state

If your function does not return a value, call it first, and then check any attributes it has set. It is best to also set the attribute before hand.

  test.title("player.modEyeColour");
  player.eyeColour = blue
  player.modEyeColour()
  test.assertEqual("silver", player.eyeColour)

You can also just test the game state. Here is an example that tests locking and hiding exits. Note that it is often helpful to set up the situation first.

  test.title("Lock and hide");
  const room = w.far_away;
  test.assertEqual(true, room.hasExit("north"));
  test.assertEqual(true, room.hasExit("north", true));
  test.assertEqual(false, room.setExitLock("northeast", true));
  test.assertEqual(true, room.setExitLock("north", true));
  test.assertEqual(false, room.hasExit("north", true));
  test.assertEqual(true, room.hasExit("north"));
  room.templatePreSave();
  const landh = room.getSaveString();
  test.assertMatch(/customSaveExitnorth\:\"locked\/\"/, landh);

Testing functionality with output

If you are testing a function that prints to the screen, you need a different approach, because we need to capture that output, rather than have it go to screen, and compare that to the expected. We can use the test.assertOut function.

It is a little more complicated as it needs to be given a function. Here is a simple example. The first parameter is an array of strings; the expected output as with a command. The second is the function.

  test.assertOut(["Output from functionToTest"], functionToTest)

If you want to pass parameters to your function, you will need to put it inside another function. Similarly, if the function belongs to an object (and uses this). These two examples illustrate.

  test.assertOut(["Kyle says hello."], function() {
    functionToTest(w.Kyle)
  })

  test.assertOut(["Kyle says hello."], function() {
    w.Kyle.functionToTest()
  })

Alternatively, you can use test.function to test a function. This takes just the function as a parameter, and returns an array of strings - whatever would be printed to screen. This example does exactly the same as the first example above. If your function will produce a dozen lines of output and you only care about the third one, this may be easier.

  const result = test.function(function() {
    functionToTest(w.Kyle)
  })
  test.assertEqual("Kyle says hello.", result[0])

If you turn on test.fullOutputData both these functions will get arrays of dictionaries, rather than arrays of strings, allowing you to further check output. The advantage of test.function becomes more apparent as we can check each bit individually.

  test.fullOutputData = true
  let res
  res = test.function(function() { msg("#Kyle is {select:Kyle:colours:colour}.") })
  test.assertEqual("default-h default-h4", res[0].cssClass)
  test.assertEqual("h4", res[0].tag)
  test.assertEqual("Kyle is red.", res[0].text)
  test.fullOutputData = false

Remember to turn off test.fullOutputData when done, as seen here.

System Testing

At an intermediate level between game testing and function testing we have system testing. This is taking a part of your game and testing it thoroughly in isolation from the rest of the game. We are probably looking only at one location and only a handful of items at most.

There are likely to be a number of "use-cases". The player arrives at the location in one of a limited number of states - we only need to consider attributes, items carried, etc. that are relevant to this situation. There are then a series of steps that might be done. Ideally we need to test them all, though that may not be practical.

The general strategy is to move the player object directly to the relevant location. Then, for each use-case, set the situation, the inital state. Then test each command. Finally we will often want to check some attributes are now in the correct state.

This example shows just that.

  test.title("Hourglass")
  test.movePlayer('greenhouse_west')

  test.title("Plant seeds")
  test.assertEqual(0, w.grown_tamarind_tree.seedsPlanted)
  w.tamarind_seed.countableLocs[player.name] = 5
  test.assertCmd("plant 2 seeds in ground", ["Mandy carefully plants two tamarind seeds in the bare earth.",])
  test.assertCmd("put three seeds in earth", ["Mandy carefully plants three tamarind seeds in the bare earth.",])
  test.assertCmd("put phone in earth", ["Mandy wonders if burying the mobile phone is going to achieve anything. Probably not.",])
  test.assertCmd("get 5 seeds", ["She takes five tamarind seeds.",])
  test.assertCmd("plant 2 seed", ["Mandy carefully plants two tamarind seeds in the bare earth.",])
  
  test.title("Cannot plant/get seed while hourglass active")
  w.hourglass.active = true
  test.assertCmd("plant 3 seeds", ["Mandy starts to put another three tamarind seeds in the ground, but as her hands get near, they start to blur, and feel numb. Perhaps not such a good idea when the hourglass is running.",])
  test.assertCmd("get seed", ["Mandy starts to dig the tamarind seed from the ground, but as her hands get near, they start to blur, and feel numb. Perhaps not such a good idea when the hourglass is running.",])
  w.hourglass.active = false
  test.assertCmd("plant seed", ["Mandy carefully plants one tamarind seed in the bare earth.",])
  test.assertEqual(3, w.tamarind_seed.countAtLoc('bare_earth'))

System testing usually revolves around the test.assertCmd function, which runs the given command, and compares the output with what was expected. The first parameter is the command, just as the player might type it. The second parameter is the expected response, and can be either a string for an exact match or a regular expression. It can also be an array of strings and/or regular expressions for commands that output more than one paragraph, as in the third one above.

You should test failures too! It is important that you test that the player cannot do things she should not be able to, and that the game gives a sensible response. In the use-case above, we are testing the player cannot plant seeds when the magic stuff is happening.

Testing the Side Pane

You might want to test commands accessed from the side pane. Rather than test.assertCmd, use test.assertSPCmd, which should be given the item and the verb, rather than the command string. As a first step, it will check the verb is currently available. You can also check the verb is not currently available by sending null as the result.

  test.assertCmd(w.seed, "plant", "Mandy carefully plants one tamarind seed in the bare earth.")
  test.assertCmd(w.seed, "plant", null)

Note that the verb in the test must be what will be displayed (so a button with "press" displayed will not work with "push", even though the parser would accept either), but is not case sensitive.

This can also cope with items with "sidebarButtonVerb" set.

Ignore HTML codes

If there is some text in italics or bold or some other styling, behind the scenes there will be some HTML codes embedded in the text. If you want to check the text is styled, then you can include the codes in the text to test against, but often this is just a pain in the neck.

You can set test.ignoreHTML to true and all the HTML will be stripped out. In fact, you can turn test.ignoreHTML on and off as required through your unit tests.

Extra Output

Web pages do not always display text exactly - for example two spaces together will be displayed as one. This means you can occasionally have a test fail, and yet the two strings look identical. One solution is to add a third parameter to test.assertCmd, set to true.

  test.assertCmd("u", ["You head up.", "A large room, with a big bed and a wardrobe.", /^You can see/, "You can go in or down.",], true);

This will compare each dissimilar string character by character, and tell you the ASCII code and position where they are different (in the console window, so F12 to see). If the first difference is due to character number 32, that is an extraneous space character. Count along the string to that position to find it.

As an alternative, it can be helpful to look at the raw HTML. Open up the developer tools (F12) and go to the Elements tab, then dig down to find the offending strings. Copy-and-pasting the outer HTML from there into a text processor may help too.

Ignore Output

You can use test.noAssertCmd when you do not care what the output will be. It just takes the command as the only parameter. This is useful for setting up another test, without making your test suite too brittle. If you want to move the player to the kitchen to test something, you do not want to worry about the item list in the room description as that might later in development.

test.noAssertCmd("e")

Menus

To handle a menu, you must first set test.menuResponseNumber to the number of the response (they count from zero).

  test.menuResponseNumber = 1;
  test.assertCmd("speak to lara", /^You tell Lara/);

If there are multiple successive menus, you can set test.menuResponseNumber to an array.

Notes

If you move items around to prepare for a test, you may need to force Quest to update the world, so the scoping gets recalculated. Call world.update() before running that set of tests.

What Not To Test

It can be tempting to set you tests up as a walk-through, and to use them to check the game can play through to the end one command at a time.

While that is feasible, it can lead to very brittle tests (I know, it happened to me!). By that, I mean that small changes in your game, can lead to lots of tests failing. Suppose you decide one room description could do with a comma; you may find that half a dozen tests now fail because the player has to pass through the room six times to complete the game.

If is far better to use the walk-through facility to prove the player can get to the end of the game. Use unit-testing to test specific mechanisms, by teleporting the player to the location you want to test.

Move the player

It is often useful to move the player object to a different location to test things there. Using the normal world.setRoom will cause messages to be printed to the screen and could trigger various scripts. A better way is this:

  test.movePlayer('lounge')

Cheating...

Often you will only be interested in the first one or two paragraphs that get output (you do not really want to test the list of objects and the list of exits every time you move to another room), so we have the padArray function. This takes an array of strings you do want to test, and adds a set number of generic arrays that match anything.

  test.assertCmd("e", test.padArray(["The kitchen is very messy."], 2));

Resetting

By default, the game will reset back to its starting point after the tests complete. Often, it is convenient to keep it at that final point, so you can check what the game looks like there. Set test.resetOnCompletion to false to turn off resetting (I suggest doing this at the top of your tests.js file, after "use strict").

test.resetOnCompletion = false

Progress

If you have a vast number of tests, you might want to be reassured they are actually getting done. Put this at the top of your tests.js file, after "use strict", and the section titles will appear in the console as they are started. It can slow the tests down.

test.printTitles = true

Code Coverage

Once you have your testing in place, you might wonder how much of your code you are actually testing. Having two dozen tests for one command and no tests for the other seventeen is not great! This is called code coverage in the unit testing world, and is an important metric; Chrome therefore has a built in coverage tool. Open the developer tools with F12, and click on the three dots at the left of the menubar in the lower section; select Coverage (if you have no lower section, click the three dots in the section you do have and select "Show console drawer"). Click the "Reload" button in the section, then run your tests. You should see each file listed, together with a bar indicating how big it is and how much has been used.

image

Files with a lot of red in the bar need more testing!

That said, there will be several files that belong to the Quest 6 libraries and other libraries. In the image above, the file at the top that is nearly all red is jQuery; it is not mine, it is from a well-established library, so I can safely ignore it (you will not see that, as jQuery id no longer used).

If you click on a file, it will appear in the upper pane, and lines that have not been used with be flagged in red at the left of the line, so you can tell exactly what needs to be tested - or decide whether it is appropriate. Some things are easier to test than others. Functions are easy, so should always be tested. Novel effects on the screen are pretty much impossible to unit test.

Testing random

What do you do if your game has randomness? For an RPG, combat may depend on the roll of virtual dice, which can make testing tricky. How can you check the outcome if it cannot be predicted?

The way to do this is to "prime" the random.int function, so it is predictable. The random.prime function takes a number or array of numbers, and puts it in a buffer (clearing any that might already be there). The random.int will check if there is a number in that buffer; if there is, it will return that number instead of a random one.

In the tests, you first load up the array with some values, then check what is produced when they are used:

    test.title("Random");
    random.prime([4, 19])
    test.assertEqual(4, random.int(6))
    test.assertEqual(19, random.int(6))

Note that all the functions in random ultimately use the random.int function, so this technique will work for all of them. For example:

    random.buffer.push([3, 8])
    test.assertEqual(11, random.dice('2d6'))

This also illustrates that random.int will return any number given, whether it makes sense or not. In this example you roll a 3 and an 8 on a six-sided dice!

If you use random.chance, prime with 0 to get true and with 100 to get false.

Testing display verbs

Use test.verbs to check the display verbs on an item at any time. It expects an exact match!

  test.verbs(w.lounge_chair, ['Examine', 'Sit on'])
  test.assertCmd("sit on chair", ["You sit on the chair.",])
  test.verbs(w.lounge_chair, ['Examine', 'Get off'])

Testing events

You can readily test events just by calling the eventScript function on an object. If you want five turns to pass, call it five turns. You can then compare values before and after.

  test.assertEqual("In flight", probe.status)
  test.assertEqual(1, probe.launchCounter)
  for (let i = 0; i < 5; i++) probe.eventScript()
  test.assertEqual("Exploring", probe.status)
  test.assertEqual(6, probe.launchCounter)

If your events produce output (some text appears on screen), you can stop it appearing by setting test.testing to true (turn it back to false after). You can also capture it in test.output, allowing you to test what it is.

  test.testing = true;
  test.testOutput = [];
  test.assertEqual("In flight", probe.status)
  test.assertEqual(1, probe.launchCounter)
  for (let i = 0; i < 5; i++) probe.eventScript()
  test.assertEqual("Exploring", probe.status)
  test.assertEqual(6, probe.launchCounter)
  test.testing = false;
  test.assertEqual("'Bio-probe I has successfully landed on the planet.'", test.testOutput[0])

Testing errors

You might want to test that your code gives an error in a specific situation. Errors should be handled using the errormsg, and if they are, then we can test them with test.assertError. This should be passed a string or regular expression (not an array) that is what we expect to appear in the console, followed by a function that calls the offending code.

In this example, we expect an error because there is no such topic for Lara.

  test.assertError(/Trying to find topic/, function() {w.Lara.findTopic("What's the deal with the garden?")})

Comments

If something fails, and the test is some way though the file, it is often useful to stop the tests at that point - either before or after the failing test - so you can check the game state at that point. If you put this at the end of the file, just before the last curly brace:

/**/

... You can then put /* in the file at any point, and everything from there to the end will be commented out. When you are done, delete the /* (and leave the bit at the end), and it will all run.