JanetRossini.github.io

Lua, LSL, Blender, Python in Second Life


Project maintained by JanetRossini

Expectations Tool

Nov 2, 2026 • [lua, recipe, testing, tutorial]


Recipe for using the Expectations testing tool, based vaguely on CodeaUnit. (Updated 02 November 2026)

We’ll cover the basic concepts with a small but complete test example and discuss its main components. Then we’ll look at some detailed Mini Recipes. To skip to the specific examples, use these links:

Concepts: Testing with Expectations

local __ = require('../inc/expectations')

__:describe("Example Test Suite", function()

    local foo_string
    local bar_string
    
    __:setup(function() 
        foo_string = "Foo"
        bar_string =  "Bar"
    end)

    __:teardown(function() 
        foo_string = nil
        bar_string = nil
    end)

    __:test("Equality Test", function()
        local result = foo_string..bar_string
        __:expect(result).is("FooBar")
    end)

    __:test("Expected to Fail", function()
        local result = bar_string..foo_string
        __:assert(result).is("FooBar")
    end)

    __:test("Totaling", function() 
        local price = 100
        local tax = 6
        local total = price + tax
        __:expect(total).is(106)
    end)
end)

Scanning that file, we see that we require ‘expectations’, saving the return in the local variable __ (double underbar). Then we describe one feature or suite of tests, this one containing three tests, each with one expectation. We’ll examine those in detail just below.

When we run the above file, we get this output:

Feature: Example Test Suite
Actual: BarFoo, Expected: FooBar. Test: 'Expected to Fail'.
Example Test Suite, 3 Tests: Pass 2, Fail 1, Ignored 0.

The output lists the feature and its description, “Example Test Suite”. Then, for every test that fails, an error message is produced, showing information about the specific failure, and including the name of the failing test, in our case “Expected to Fail”.

Important Note
In what follows, we’ll encounter many options for using Expectations. But more than 90 percent of the time, you’ll probably find that you need nothing more than what’s above, a feature function with a describe, and a series of tests that just check that a value is what you expect.

All else is there for the other ten percent of times when checking a single value or table for equality isn’t quite the thing.

Don’t let all the details daunt you: it’s really quite easy and nice.

Now let’s step softly through the details:

local __= require('../inc/expectations')

Requiring the ‘expectations’ file returns an object containing all of Expectations’s code. Conventionally we assign the return to __ (two underbars) for convenience and neatness in writing our tests. You can assign it to anything you like, and use that as a prefix instead of underbar, if you wish. I’ve tried _T:, Test:, and other options. It’s up to you. Let your freak flag fly!

On a system without require, you may have to use a weaker feature, such as include or copy the file into your source manually. Currently (November 2026) the slua that I run on my Mac has require, and the Second Life Lua has a plug-in from Rider Linden that copies the necessary code into your script-bearing objects. Rider’s plug-in is a bit clunky, but it works well.

We typically test one feature of our code at a time. A feature might be an entire class, or one aspect of a more complex class. A feature can even be a single function. Sometimes I even test aspects of the SLua language or its libraries, just to ensure that I understand how things work. I keep the tests, quite often, as a reminder.

All actual testing code must be in a function, in a call to __:describe, providing a suite name. That is how Expectations finds our tests, in order to run them.

   __:describe("Recipe Test Suite", function()
      ...
   end)

We describe our feature as above. All our tests and explanations go inside the function that is the second parameter to describe. It may be best not to think too hard about this and just to create these lines and fill them in.

What happens, if you want the details, is that the Expectations describe function builds a table of ‘{suite_name, suite_function}’ items, and the execute method goes through that list and executes each suite. Since that suite is a function, each __:test calls functions inside Expectations that do the work, such as this one:

__:test("Equality Test", function()
   local f = "Foo"
   local b = "Bar"
   local result = f..b
   __:expect(result).is("FooBar")
end)

We recommend writing many small tests, rather than a few large ones. Experiment to see what works best for you, but larger tests tend to be more fragile, requiring more updating, and they can get quite difficult to write. If all your small pieces are working, your larger pieces’ tests will likely be fewer and easier to write.

With these testing tools, we speak of a test as having three parts, arrange, act, and assert. In the test above the assignments to f and b are arrange, the computation of result is the act, the thing we want to test, in this case, our understanding of the .. operator.

And finally, we make an assertion, which we express with expect and some additional verbs:

__:expect(result).is("FooBar")

We enclose whatever we want to check in expect(), in this case, result. We could have said:

__:expect(f..b).is("FooBar")

but it is often better to compute the test result as a local and then refer to it.

Finally we come to the actual check, in this case is:

__:expect(result).is("FooBar")

That does what it says on the box. It checks whether the thing in expect is in fact the string “FooBar”. As we’ll see below, there are a few different checks:

word meaning
__:expect(result).is(expected) result == expected
__:expect(result).isnt(expected) result ~= expected
__:expect(aTable).has(element) aTable includes element
__:expect(aFunction()).throws(message) aFunction call errors with message

We’ll show examples of each of those below. Expectations also supports lt, le, gt, ge, less-than, less that or equal, and so on.

Review - so far

This may seem like a lot, but in fact it’s mostly just boilerplate, and what we get for our effort is some very comprehensive checking and reporting. Here is just part of the output from my current test suite for Expectations itself:

Feature: Expectations Test Suite
Actual: 1.4142135623730951, Expected: 1.414 +/- 0.00001. Test: 'Approximate value (fail)'.
Actual: table: 0x0000007ca6cd3060 did not contain 2. Test: 'Table has value (fail)'.
Actual: table: 0x0000007ca6cd2f48 did not contain 2. [table t2 fail] Test: 'Named expectations'.
Actual: table[y]=21, Expected: 20. Test: 'Table Equality (fail)'.
Actual: table[z]=nil, Expected: 30. Test: 'Table Equality (fail)'.
Actual: table[Z]=3, Unexpected key [Z]. Test: 'Table Equality (fail)'.
Expected throw: was neg. Test: 'Exception with parameters (fail)'.
Error:  /Users/ron/Desktop/vscode/bez_dev/tst/expect_recipe_new.lua:91: attempt to call a nil value. Test: 'Do something useful'.
Ignored test: 'time the search function'
Expectations Test Suite, 8 Expects: Pass 1, Fail 6, Ignored 1.

The errors above are all intentional, because I need to see that the right info comes out on a failure. When Expectations goes into the Downloads, I’ll make sure that you can get the current test suite for it as well.

Mini-Recipes

I think you’ll find that you use the is verb the great bulk of the time, because we typically know the exact answer that we want. But here are some specials:

Approximately Equal

Sometimes, with real numbers, we don’t know the precise answer and just want to make sure that our result is close:

__:test("Approximate value", function()
   local sqrt2 = math.sqrt(2)
   __:expect(sqrt2).is(1.414, 0.00001)
end)

We can provide a second parameter to the is verb, expressing the allowable error in a real number approximation. By the way, the test above fails, saying:

Actual: 1.4142135623730951, Expected: 1.414 +/- 0.00001. 
Test: 'Approximate value'.

Of course, if we had said 0.001 instead of 0.00001, or provided more digits, the test would have passed.

String Contains Substring

To determine whether a result string, we can check to see whether it contains any desired substring, including, of course, the whole string we expect:

__:test("String has value", function()
   __:expect('vorpal blade').has('vorpal blade')
end)

Table Contains Value

Similarly sometimes we want to know whether a specific value has appeared in a table. We use has for that question as well:

__:test("Table has value", function() 
   local tab = {1,3,4,5}
   __:expect(tab).has(2)
end)

There is no 2 in the table and we get:

Table: table: 0x000000015a0177c0 did not contain 2. 
Test: 'Table has value'.

The unfortunate table id is down to SLua. Tables do not have a standard way of naming them. Fortunately, the test name will let us isolate which table has caused the problem … and that’s why we typically only use one assertion per test. But we can do otherwise:

Named Expectations

In this test, for some reason, we check two tables:

__:test("Named expectations", function() 
   local t1 = {1, 2, 3, 4}
   local t2 = {1, 20, 3, 4}
   __:expect(t1).has(2)
   __:expect(t2).has(2)
end)

The output is a bit confusing, because we can’t tell which table is the offender:

Table: table: 0x00000001258176d0 did not contain 2. 
Test: 'Named expectations'.

We get the test name, but that’s all. To identify particular assertions, we can give each assertion its own name, optionally, as a second parameter to expect:

__:test("Named expectations", function() 
   local t1 = {1, 2, 3, 4}
   local t2 = {1, 20, 3, 4}
   __:expect(t1, "table t1").has(2)
   __:expect(t2, "table t2").has(2)
end)

Now we get this output:

Table: table: 0x000000012b0176d0 did not contain 2. 
[table t2] Test: 'Named expectations'.

The optional second parameter of expect is a string which will be displayed in square brackets if the test fails.

When we find ourselves doing this, it can often be a sign that our tests are too large, but you get to find your own balance. Personally, I try to avoid needing named expectations.

Checking Tables

We can compare tables for equality:

__:test("Table Equality", function() 
   local v_result = {x=10, y=20, z=3}
   local v_expect = {x=10, y=20, z=30}
   __:expect(v_result).is(v_expect)
end)
Actual: table[z]=3, Expected: 30. Test: 'Table Equality'.

The output will list each unmatched element, missing elements, or extra elements. Here is a more complex example:

__:test("Table Equality", function() 
   local v_result = {x=10, y=21, Z=3}
   local v_expect = {x=10, y=20, z=30}
   __:expect(v_result).is(v_expect)
end)

The output:

Actual: table[y]=21, Expected: 20. Test: 'Table Equality'.
Actual: table[z]=nil, Expected: 30. Test: 'Table Equality'.
Actual: table[Z]=3, Unexpected key [Z]. Test: 'Table Equality'.
Note
At this writing, vector and rotation (quaternion) objects do not give detailed results for equality. I propose to add that ASAP, perhaps before you even read this.

Expected Exceptions

This one is a bit tricky. We can indicate that a function call should throw an error:

__:test("Exception", function()
   local function might_throw()
      error("be more positive")
   end
   __:expect(might_throw).throws('more pos')
end)

Note that we name the function in the expect, we do not call it. The throws verb will call it and check to be sure the parameter string occurs in the error message.

There is no way to provide the parameters to the function in the expect, at least at this writing. We can do that by providing a function that calls our function that should throw, like this:

      __:test("Exception with parameters", function()
         local function throw_on_negative(parm)
            if parm > 0 then
               error("parm was negative")
            end
         end

         local function should_throw()
            throw_on_negative(-3)
         end
         __:expect(should_throw).throws('was neg')
      end)

Since the check for negative is wrong, > instead of <, in throw_on_negative, this test should fail. And it does:

Expected throw: was neg. Test: 'Exception with parameters'.

Helper Functions

Sometimes we might want something that Expectations does not offer. One example is checking vectors in some code I’ve been working on. Expectations can check individual values for being approximately equal, but cannot check a whole vector for being close enough. Here’s a nearly sensible test for vector scaling:

  __:test("vector scaling", function() 
      v1 = vector(100.0, 200.0, 300.0)
      vs = v1 * 1.1
      __:expect(vs).is(vector(110.0, 220.0, 330.0))
  end)  

This test fails, like this:

Actual: table[y]=220.00000000000003, Expected: 220. 
Test: 'vector scaling'.
Actual: table[x]=110.00000000000001, Expected: 110. 
Test: 'vector scaling'.

We need to accommodate the rounding. We could write out three checks every time we do this but we’ll probably need to check vectors for being close enough more than this once, so we write this:

function verify_vectors(check, original)
    __:expect(check.x).is( original.x, 0.001)
    __:expect(check.y).is( original.y, 0.001)
    __:expect(check.z).is( original.z, 0.001)
end

And say this:

__:test("vector scaling", function() 
   v1 = vector(100.0, 200.0, 300.0)
   vs = v1 * 1.1
   vexpect = vector(110.0, 220.0, 330.0)
   verify_vectors(vs, vexpect)
end)  

And, of course, this test passes as expected. For greater flexibility we could pass in the allowable delta, but the test above was all I needed in my current code.

So a common thing to do, when we have something special to check, and need to do it more than once, is to write a helper function that calls the basic expect checkers.

Again, I propose to extend Expectations to make some of these things easier.

Script Errors

Often, something in our script will just break. Expectations will give us an error message on a test that throws an unexpected error and then continue with other tests. For example:

__:test("Do something useful", function()
   local s = no_such_function()
   __:expect(s).is(42)
end)

That test calls a function that doesn’t exist, due to a typo or other oversight. We get this result:

Error:  /Users/jr/Desktop/repo/expect_recipe.lua:71: 
attempt to call a nil value. 
Test: 'Do something useful'.

Setup / Teardown

Often a series of tests all want to use some of the same objects for testing. In my Bezier-related tests, I use the same sample Bezier curves over and over. Expectations allows us to set up some variables before each test executes, with __:setup(). Our first two tests, shown at the beginning of this article, use setup just as a trivial example. We might have started with this:

      __:test("Equality Test", function()
         local f = "Foo"
         local b = "Bar"
         local result = f..b
         __:expect(result).is("FooBar")
      end)

      __:test("Expected to Fail", function()
         local f = "Foo"
         local b = "Bar"
         local result = b..f
         __:expect(result).is("FooBar")
      end)

Imagine that instead of just f and b we had a lot of work to do to get those values. We create a setup to do that work.

local f
local b
__:setup(function() 
   f = "Foo"
   b =  "Bar"
end)

__:test("Equality Test", function()
   local result = f..b
   __:expect(result).is("FooBar")
end)

__:test("Expected to Fail", function()
   local result = b..f
   __:expect(result).is("FooBar")
end)

Our tests run the same but now we have saved all that extra typing of parts of the setup. Note that the variables need to be defined inside describe, and outside the setup function, so that both setup and our tests can see them. And a wise programmer would probably not call them f and b but something more distinctive. A quick rename refactoring gives us this:

local foo_string
local bar_string
__:setup(function() 
   foo_string = "Foo"
   bar_string =  "Bar"
end)

__:test("Equality Test", function()
   local result = foo_string..bar_string
   __:expect(result).is("FooBar")
end)

__:test("Expected to Fail", function()
   local result = bar_string..foo_string
   __:expect(result).is("FooBar")
      end)

Rarely—in my experience almost never—we might want to tear down what we’ve built up with setup and a test. An example might be a series of tests that build up and use a very large table. Since the local variables in the feature site will persist over all the tests, we might want to release the storage after each test runs. Simple enough with teardown:

__:teardown(function() 
   foo_string = nil
   var_string = nil
end)

Each __:describe can have its own setup and teardown.

Ignore: Skipping a Test

Sometimes we really don’t want to run a test for a while for some reason. For me this happens in at least these situations:

  1. The test I write is too big to make work in a short time. I want to skip it for now, then turn it on later;
  2. A timing test may run for several seconds, so I don’t want to run it all the time;
  3. I have an idea for a test that should be written.

Instead of __:test, we can say __:ignore, like this:

__:ignore("time the search function", function() 
   local t_start = os.clock()
   for i = 1,1_000_000 do
      search('value_not_present')
   end
   local t_end = os.clock()
   __:expect(t_end-t_start).is(30, 5)
end)

We could, of course just delete the test, or comment it out. But when we ignore it, we get this result:

Ignored test: 'time the search function'
Recipe Test Suite, 10 Tests: Pass 2, Fail 7, Ignored 1.

Each ignored test is listed in the test results and counted in the final summary. Using ignore lets me tuck a test away without forgetting about it, You might find that useful as well.

How I Use Expectations

Currently I develop most of my SLua code using SL’s provided slua compiler installed on my Mac, editing with VS Code. Slua supports require via Rider’s plug-in, so I require in the framework and begin writing tests. Often I build the new code that I’m test-driving right in the test file, moving it over to a production folder when it starts to mature.

We don’t yet know about the memory constraints with SLua, but surely in production mode we’ll want to keep the test code separate from the working code. The process for doing that remains to be figured out after we know for what SL is going to give us, and I’m sure that different people will have different approaches. As of today (November 2026), I have three folders in my projects, ‘inc’, ‘src’, and ‘tst’, but in world, I just push the necessary plug-in-mangled files to the object I’m scripting.

It’s early days for that and I expect my practices will be adjusted. I’ll report as seems appropriate.

Summary

Working with so many tests did not come to me as second nature, but over time I’ve found that my best work happens when I create a small test for the next bit of feature that I want, see the test fail, then make it work, in very small steps. It feels slow but it is fast.

“Slow is smooth, smooth is fast” may have originated with the military, but I find that it is true in my work. When I’m at my best, I write a small test, then a small bit of code to make it work, repeat. The tests make sure that everything keeps working as I change things. I progress smoothly in small steps with few reversions and very little debugging.

You might be concerned that the tests themselves might be slow. If one’s tests are slow, one needs better tests. My current real project using this tool runs about 900 tests in 75 milliseconds, on my Mac. Test time is not an obstacle, and so I run them whenever I want to, which allows me to make very small changes, one at a time, very quickly.

I hope that you’ll give Expectations a try and find it of value. I’d love to hear your thoughts and ideas. You can IM me, or email valkyriejanet at gmail dot com, or stop me on the street.

Safe paths!