JanetRossini.github.io

Lua, LSL, Blender, Python in Second Life


Project maintained by JanetRossini

Test Framework

Jul 11, 2025 • [designluatesting]


Today I propose to improve our Tests framework a bit.

As all here surely recall, the current Tests framework is something that I just ginned up quickly to provide a consistent way to write tests. I find that when I have decent tools, I tend to use them to my benefit, and without tools, I am more included to just hack around. With testing, that usually comes down to programming with little or no organized testing.

Today I want to adopt an idea from CodeaUnit, a very nice little testing framework, written by Jake Sankey, for Codea Lua, an iPad product. He has a very nice assertion format. Let me write a test using the new feature that I propose to implement. I’ll put the new tests into the tests-samples file that contains the existing ones.

function Tests:test_expect_is()
   result = 54
   self:expect(result).is(54)
end

If this goes well, there will be other verbs in addition to is, such as isnt, has, hasnt, and throws. If it doesn’t go well, you will not be reading this.

What could expect return that can respond to tings like is? the short answer is that it will return a table of functions, with names like is.

I only have to run the tests in my samples file to realize why I don’t want to work in it: it has a handful of tests that intentionally fail, That makes it dump a lot of info that I don’t want to deal with.

I’ll create a new file. And I get the message I expect:

Error:  /Users/jr/Desktop/repo/test-expect.lua:24: 
attempt to call missing method 'expect' of table.  Tests:test_expect_is

Now we can get to work. In the end, if all goes well, we will put our new code into the file tests.lua but since we have the class available to us in test-expect, we can make tentative changes here without needing to version and revert the main tests.lua.

I add a second test, so as to check the fail side:

function Tests:test_expect_is_fail()
   result = 54
   self:expect(result).is(53)
end

And implement:

function Tests:expect(actual)
   local function is(expected, epsilon)
      self:assert_equals(actual, expected)
   end
   return {
      is = is,
   }
end

The expect method defines a local function that uses the existing assert_equal method for now. We’ll see about improving the whole thing after we have a lot more of this working. Will we replace the existing way of checking with this new one? We just might. I’m far from sure about that.

The tests operate as intended:

Actual: 54, Expected: 53.  Tests:test_expect_is_fail
All Tests: Pass 2 Fail 1 Ignored 0.  Tests:run_tests

CodeaUnit has you put your explanatory message, if you want one, in the expect, like this:

function Tests:test_expect_is_fail()
   result = 54
   self:expect(result, "expected to fail").is(53)
end

We try this:

function Tests:expect(actual, message)
   local function is(expected, epsilon)
      self:assert_equals(actual, expected, message)
   end
   return {
      is = is,
   }
end

And we get this:

Actual: 54, Expected: 53.  [expected to fail]
Tests:test_expect_is_fail

Perfect. Well, as intended, anyway.

We have an issue with the epsilon, and with tables. CodeaUnit, if I recall, does not check table contents but we do. So we’ll need to deal with those cases. First epsilon:

function Tests:test_close_enough()
   result = 55
   self:expect(result).is(54,1)
end

We expect this to pass because 54 is within 1 of the desired answer. It will not pass:

Actual: 55, Expected: 54.  
Tests:test_close_enough

We fix it:

function Tests:expect(actual, message)
   local function is(expected, epsilon)
      if epsilon then
         self:assert_nearly_equal(actual, expected, epsilon, message)
      else
         self:assert_equals(actual, expected, message)
      end
   end
   return {
      is = is,
   }
end

This is going nicely.

I am beginning to wish for a way to assert that a test should fail. I make a note of the idea. It would be useful for me, perhaps not for anyone else.

Let’s do tables while we are at it:

function Tests:test_table_is()
   result = {1, 2, 3}
   expected = {1, 2, 3}
   self:expect(result).is(expected)
end

This might actually work, since we use assert_equals, which handles tables. Let’s see. Yes, it passes. Change the expected table to see it fail.

Actual: table[4] is missing, 
Expected: [4]=4.  
Tests:test_table_is

Perfect!

Let’s do isnt.

function Tests:test_isnt()
   unexpected = 100
   result = 100
   self:expect(result, "better not be 100").isnt(unexpected)
end

This might be a bit more tricky, because we do not have a not_equal assertion in Tests, so we’re on our own to build it.

Turns out it’s not too bad, so far:

   local function isnt(expected, epsilon)
      if epsilon then
      else
         if not self:tally(actual ~= expected) then 
            local m = string.format('Actual: %s Unexpected.', tostring(actual))
            self:report(m, message)
         end
      end
   end

My test fails properly:

Actual: 100 Unexpected.  [better not be 100]  
Tests:test_isnt

So far so good! Do epsilon now:

function Tests:test_isnt_epsilon()
   unexpected = 100
   result = 99
   self:expect(result, "stay away from 100").isnt(unexpected, 2)
end

This should fail because 99 is within 2 of 100. Implement:

   local function isnt(expected, epsilon)
      if epsilon then
         ok = not (expected - epsilon <= actual and actual <= expected + epsilon)
         if not self:tally(ok) then
            local m = string.format('Actual: %s within %s of %s', 
               tostring(actual), tostring(epsilon), tostring(expected))
            self:report(m, message)
         end
      else
         if not self:tally(actual ~= expected) then 
            local m = string.format('Actual: %s Unexpected.', tostring(actual))
            self:report(m, message)
         end
      end
   end

Some duplication there that we might want to deal with. But the thing works.

I don’t think isnt has much value, so we’ll not give it a special feature for table inequality.

I do think that has might be useful, and perhaps not hasnt. has checks to see if a given value is in a table result. It doesn’t handle keys, just values.

function Tests:test_has()
   result = {1, 2, 3, 4}
   self:expect(result, "checking table").has(5)
end

Implement:

   local function has(expected)
      local found = false
      for _, v in actual do
         if v == expected then
            found = true
            break
         end
      end
      if not self:tally(found) then
         local m = string.format('Table: %s did not contain %s',
            tostring(actual), tostring(expected))
         self:report(m, message)
      end
   end

Works:

Table: table: 0x0000000140017b50 did not contain 5  [checking table]  Tests:test_has

Our tests are all running. We have implemented these new forms:

   self:expect(result).is(54)
   self:expect(result).is(54)
   self:expect(result).is(54,1)
   result = {1, 2, 3}
   expected = {1, 2, 3}
   self:expect(result).is(expected)
   self:expect(result, "better not be 100").isnt(unexpected)
   self:expect(result, "stay away from 100").isnt(unexpected, 2)
   self:expect(result, "checking table").has(3)

I’m not sure that I like including the message as an optional parameter. It might be better to say something like:

self:expect(result).message("checking table").has(3)

But fact is, we only slip the message in rarely, so maybe its OK as is. And you can always give the test itself a more meaningful name as well, since that’s printed as part of the result.

I think it comes down to whether we want to say

self:assert_equals(actual, expected)

Or

self:expect(actual).is(expected)

And of course, when we need has, the new form is available and there is no useful way to deal with it otherwise.

I think that if we want this “new and improved” syntax, we should cut over to it and use it exclusively. So far, I think the only person much affected by that change would be me, though Dizzi might have a few tests written.

We’ll let the idea perk, but I will put the features into the tests file that I use, and push it to the web site. It’s harmless, as nothing in the original was changed today.

This was fun and perhaps useful. Safe paths!