Skip to content

I set up GLuaTest... now what

Brandon Sturgeon edited this page Jun 9, 2022 · 3 revisions

Intro

Nice! It's great that you're taking steps to make your code more robust and reliable.

You know how to use the tool, but now you're wondering how to actually apply that knowledge.

Don't worry, this is a very common hurdle that people are faced with.

(FYI: I'd be happy to help you directly with your project. I can help find good parts of the code to test, and help you write your first tests (seriously! add me).)

I think practical examples are the best way to explain this step.

For this exercise, let's write some test cases for Wiremod!


Case 1

The lowest-hanging fruit in automated testing are simple functions that take an input and produce an output.

Wiremod's WireLib has a lot of functions that fit this description. In this example, we'll be testing the nicenumber.nicetime function.

Here's what it looks like:

-------------------------
-- nicetime
-------------------------
local times = {
	{ "y", 31556926 }, -- years
	{ "mon", 2629743.83 }, -- months
	{ "w", 604800 }, -- weeks
	{ "d", 86400 }, -- days
	{ "h", 3600 }, -- hours
	{ "m", 60 }, -- minutes
	{ "s", 1 }, -- seconds
}
function nicenumber.nicetime( n )
	n = math.abs( n )

	if n == 0 then return "0s" end

	local prev_name = ""
	local prev_val = 0
	for i=1,#times do
		local name = times[i][1]
		local num = times[i][2]

		local temp = floor(n / num)
		if temp > 0 or prev_name ~= "" then
			if prev_name ~= "" then
				return prev_val .. prev_name .. " " .. temp .. name
			else
				prev_name = name
				prev_val = temp
				n = n % num
			end
		end
	end

	if prev_name ~= "" then
		return prev_val .. prev_name
	else
		return "0s"
	end
end

This function takes a number of seconds and produces a more legible time string. For example, if we passed in 5, it would return 5s.

We want to test this function so that, no matter what we change in the addon, we can be 100% sure that this function is always returning the right output. So if you ever see a time string that's wrong, you can safely rule out the nicetime function - saving you valuable debugging time!

Another benefit is if someone makes a change that inadvertently breaks this function, we'll know about it in the PR before we ever merge the code. The best way to fix errors is to not have any errors 😎.

So, first things first, let's set up the base of our test:

-- lua/tests/wire/nicetime.lua

return {
    groupName = "nicetime",
    cases = {
        {
            name = "Should return the correct string times",
            func = function()
            end
        }
    }
}

Okay, now we're ready to start actually testing.

For this test, we're going to include all of our expect calls in a single case. You can split them into their own tests if you'd like - that'd work just as well.

So, let's write our first expectation:

return {
    groupName = "nicetime",
    cases = {
        {
            name = "Should return the correct string times",
            func = function()
                local nicetime = WireLib.nicenumber.nicetime

                expect( nicetime( 5 ) ).to.equal( "5s" )
            end
        }
    }
}

Nice! This passes. nicetime returned the correct output.

[GLuaTest] PASS [Should return the correct string times]

From here, I'm going to create a table of inputs and manually calculate their outputs (we don't want to ask the function to do this, because we're testing the function! if it gave us the wrong output now, our tests would pass even when the output is wrong!).

I'm trying to create a varied set of inputs. Hours, seconds, years, and combinations thereof. That way we can be confident that the function doesn't have any unexpected edge cases.

I'll save you the process of calculating these. Here's what I came up with:

Input Output Note
5 5s
60 1m 0s
61 1m 1s
3600 1h 0m One hour
86400 1d 0h One day
604800 1w 0d One week
604801 1w 0d One week plus a second (should produce the same output)
2629743.83 1mon 0w One Month
31556926 1y 0mon One year

I tried to make it produce at least one of each time type. I also added a few curveballs (i.e. adding 1 second to the one-week timestamp should show the same output).

Okay, now all we have to do is write the expectations for these:

return {
    groupName = "nicetime",
    cases = {
        {
            name = "Should return the correct string times",
            func = function()
                local nicetime = WireLib.nicenumber.nicetime

                expect( nicetime( 5 ) ).to.equal( "5s" )
                expect( nicetime( 60 ) ).to.equal( "1m 0s" )
                expect( nicetime( 61 ) ).to.equal( "1m 1s" )
                expect( nicetime( 3600 ) ).to.equal( "1h 0m" )
                expect( nicetime( 86400 ) ).to.equal( "1d 0h" )
                expect( nicetime( 604800 ) ).to.equal( "1w 0d" )
                expect( nicetime( 604801 ) ).to.equal( "1w 0d" )
                expect( nicetime( 2629743.83 ) ).to.equal( "1mon 0w" )
                expect( nicetime( 31556926 ) ).to.equal( "1y 0mon" )
            end
        }
    }
}

Badabing - that was easy:

[GLuaTest] PASS [Should return the correct string times]

Case 2

In the real world, not all functions will be simple input-output functions.

Often times you'll have functions that have conditionals, perform actions on entities, add hooks, etc.

A good example of this is WireLib's setPos function:

function WireLib.setPos(ent, pos)
	if isnan(pos.x) or isnan(pos.y) or isnan(pos.z) then return end
	return ent:SetPos(WireLib.clampPos(pos))
end

It's very simple in concept. It takes an Entity and a vector/table, makes sure the given vector/table has valid coordinates, and then sets the entity's position.

So let's break that down. What do we expect this function to do?

We expect it to:

  • Do nothing if pos.x isn't a number
  • Do nothing if pos.y isn't a number
  • Do nothing if pos.z isn't a number
  • Set the entity's position to the clamped position

We'll trust that WireLib.Clamp does its job properly (we don't need to test that here, we can write tests for Clamp separately!), so we'll change that last point to "Set the entity's position to the given position".

Let's get our test set up:

-- lua/tests/wire/setpos.lua

return {
    groupName = "WireLib.setPos",
    cases = {
    }
}

Okay, one thing we need to talk about first; this function takes an Entity.

What do we do about that?

Well, GLuaTest doesn't really care what you do. You could create an actual entity to use for this function if you wanted to.

But this function doesn't actually care if the ent variable is actually an entity. It just expects a thing that responds to :SetPos(). We can fake that with a table!

Alright, so let's write the first test case:

return {
    groupName = "WireLib.setPos",
    cases = {
        {
            name = "Should do nothing if the x coordinate is nil",
            func = function()
                local invalid = 0 / 0

                local ent = { SetPos = stub() }
                local pos = { x = invalid, y = 1, z = 1 }

                WireLib.setPos( ent, pos )
                expect( ent.SetPos ).toNot.haveBeenCalled()
            end
        }
    }
}

Woah! Pump the breaks, that's a lot of new stuff. Let's unwrap this a bit.

local ent = { SetPos = stub() }

As we discussed, the setPos function wants something with a SetPos function on it. It doesn't care if it's an Entity or a Table. To make our tests easier and simpler, we made a new table and gave it that SetPos function!

The important part here is the stub(). You should read more about stubs in the README, but basically it makes a fake function that tracks if it was called. This means that in our test, we can call WireLib.setPos and expect that it didn't call :SetPos on the "entity" we gave it.

I'll repeat the tests for the y and z checks:

return {
    groupName = "WireLib.setPos",
    cases = {
        {
            name = "Should do nothing if the x coordinate is nil",
            func = function()
                local invalid = 0 / 0

                local ent = { SetPos = stub() }
                local pos = { x = invalid, y = 1, z = 1 }

                WireLib.setPos( ent, pos )
                expect( ent.SetPos ).toNot.haveBeenCalled()
            end
        },
        {
            name = "Should do nothing if the y coordinate is nil",
            func = function()
                local invalid = 0 / 0

                local ent = { SetPos = stub() }
                local pos = { x = 1, y = invalid, z = 1 }

                WireLib.setPos( ent, pos )
                expect( ent.SetPos ).toNot.haveBeenCalled()
            end
        },
        {
            name = "Should do nothing if the z coordinate is nil",
            func = function()
                local invalid = 0 / 0

                local ent = { SetPos = stub() }
                local pos = { x = 1, y = 1, z = invalid }

                WireLib.setPos( ent, pos )
                expect( ent.SetPos ).toNot.haveBeenCalled()
            end
        }
    }
}

Nice. So let's look at our list again:

  • Do nothing if pos.x isn't a number
  • Do nothing if pos.y isn't a number
  • Do nothing if pos.z isn't a number
  • Set the entity's position to the given position

Looking pretty good! Let's write a test for the last point:

{
    name = "Should set the entity's position to the given position",
    func = function()
        local ent = { SetPos = stub() }
        local pos = { x = 1, y = 1, z = 1 }

        WireLib.setPos( ent, pos )

        expect( ent.SetPos ).to.haveBeenCalled()
    end
}

DONE! We fully tested the WireLib.setPos function 🎉 image

View the full test file
return {
    groupName = "WireLib.setPos",
    cases = {
        {
            name = "Should do nothing if the x coordinate is nil",
            func = function()
                local invalid = 0 / 0

                local ent = { SetPos = stub() }
                local pos = { x = invalid, y = 1, z = 1 }

                WireLib.setPos( ent, pos )
                expect( ent.SetPos ).toNot.haveBeenCalled()
            end
        },
        {
            name = "Should do nothing if the y coordinate is nil",
            func = function()
                local invalid = 0 / 0

                local ent = { SetPos = stub() }
                local pos = { x = 1, y = invalid, z = 1 }

                WireLib.setPos( ent, pos )
                expect( ent.SetPos ).toNot.haveBeenCalled()
            end
        },
        {
            name = "Should do nothing if the z coordinate is nil",
            func = function()
                local invalid = 0 / 0

                local ent = { SetPos = stub() }
                local pos = { x = 1, y = 1, z = invalid }

                WireLib.setPos( ent, pos )
                expect( ent.SetPos ).toNot.haveBeenCalled()
            end
        },
        {
            name = "Should set the entity's position to the given position",
            func = function()
                local ent = { SetPos = stub() }
                local pos = { x = 1, y = 1, z = 1 }

                WireLib.setPos( ent, pos )

                expect( ent.SetPos ).to.haveBeenCalled()
            end
        }
    }
}

That's all I have for you! Hopefully after following these demonstration, you have a better understanding of:

  • How to find code to test
  • The benefits of testing simple functions
  • How to set up the test
  • How to actually test the code

Please reach out if you have any more questions!