A significant hurdle to testing code, and managing a game in Roblox is the tightly coupled nature of the DataStore. A good idea for dealing with the added complexity is abstracting away the idea of storing information to a swapable or customizable memory backing system.

Unifying Read/Write to a DataStore

The DataStore for Roblox is really just a glorified table that is written to a database. More than likely this is backed by Redis, information that is not really important here.

When you think about data you think about 4 essential operations: Loading, Reading, Writing and Saving.

We can create a module that has a swapable interface, and then in our code use that. During testing we can then back all of the game state with an in-memory database, and when it’s time to deploy the game, we can swap out the Loading and Saving functions to actually use the real datastore.

Logging Module

We should always connect things up with a logger, though in this example we don’t use it yet, it’s still good practice. Here’s my generic logging module:

local m = {}
      m.Level = 3 -- all messages, include info
      m.id ="Logger".. tostring(math.random(1000,9999))
      math.randomseed(math.random(9999999))
local function printable(msg)
    return {
        Print=function()
            print(msg)
        end,
        ToString=function()
            return msg
        end     
    }
end
local function table_print(tt, indent, done)
    if type(tt) == "string" then
        return tt
    end
    if type(tt) == "number" then
        return tostring(tt)
    end
    done = done or {}
    indent = indent or 0
    if type(tt) == "table" then
        local sb = {}
        for key, value in pairs (tt) do
            table.insert(sb, string.rep (" ", indent)) -- indent it
            if type (value) == "table" and not done [value] then
                done [value] = true
                table.insert(sb, "{[" .. key .. "] = ");
                table.insert(sb, table_print (value, indent + 2, done))
                table.insert(sb, string.rep (" ", indent)) -- indent it
                table.insert(sb, "}");
            elseif "number" == type(key) then
                table.insert(sb, string.format("\"%s\"\n", tostring(value)))
            else
                table.insert(sb, string.format("%s = \"%s\"\n", tostring (key), tostring(value)))
            end
        end
        return table.concat(sb)
    else
        return tostring(tt) .. "\n"
    end
end
function m.Info(msg)
    if m.Level < 3 then
        return
    end
    return printable("[Info]: " .. table_print(msg))
end
function m.Warn(msg)
    if m.Level < 2 then
        return
    end
    return printable("[Warn]: " .. table_print(msg))
end
function m.Error(msg)
    if m.Level < 1 then
        return
    end
    print("m.Error: " .. msg)
    return printable("[Error]: " .. table_print(msg))
end
m.Printable = printable
m.FormatValue = table_print
m.CloneInto = function (id,loc)
    for name,func in pairs(m) do
        if type(func) == "function" then
            loc[name] = function (msgx)
                local tmp = m.FormatValue(msgx)
                tmp = id .. " " .. tmp
                return m[name](tmp)
            end
        else
            loc[name] = func
        end
    end 
    return loc
end
return m

Notice that the Logger functions Info, Warn and Error return an object with a Print and a ToString function. This is a handy convention for when you want to collect up output and consolidate it. You would call it directly like so: logger.Info("Something to note").Print()

This is really just for forward compatibility, it serves no purpose beyond that.

The MemDB module

We only need a dead simple Reader/Writer for the memory backing, with blank Save and Load functions.

We want to be able to store and access tables, and they might be non-trivial in complexity. For instance, perhaps we'll want to use Memory backing for some things, and only save or load certain bits of the table to the DataStore.

memdb.Write = function (key,value)
    local tbl = memdb.DB
    if string.find(key,"/",1,true) then
        local parts = split(key,"/")
        local c_k = nil
        for i = 1,(#parts - 1) do
            c_k = parts[i]
            if not tbl[c_k] then
                tbl[c_k] = {}
            end
            tbl = tbl[c_k]
        end 
        key = parts[#parts]
    end
    tbl[key] = value
end

Here we take advantage of lua's natural representation of tables as references. This works because each time we assign tbl, we get a reference and not a new variable, so we can drill down recursively into a table, using the '/' as a path separator. Essentially, we treat a piece of data as a "Path", so if you wanted to set a flag on a player, that lets your game know the player has the ability to jump higher, or run faster, you might save that information in a path like so: data.Write("Players/" .. player.UserId .. "/JumpPower", 200)

The Read function is almost identical:

memdb.Read = function (key)
    local tbl = memdb.DB
    if string.find(key,"/",1,true) then
        local parts = split(key,"/")
        local c_k = nil
        for i = 1,(#parts - 1) do
            c_k = parts[i]
            if not tbl[c_k] then
                tbl[c_k] = {}
            end
            tbl = tbl[c_k]
        end 
        key = parts[#parts]
    end
    return tbl[key]
end

If tbl[key] is not set, it will be nil, and it's a good idea to use nil sparringly, because it's a good marker for something like key missing, or key not set.

The full code

local function split(str, delim)
    local result,pat,lastPos = {},"(.-)" .. delim .. "()",1
    for part, pos in string.gmatch(str, pat) do
        table.insert(result, part); lastPos = pos
    end
    table.insert(result, string.sub(str, lastPos))
    return result
end
local memdb = {}
local logger = {}
memdb.DB = {}
memdb.Read = function (key)
    local tbl = memdb.DB
    if string.find(key,"/",1,true) then
        local parts = split(key,"/")
        local c_k = nil
        for i = 1,(#parts - 1) do
            c_k = parts[i]
            if not tbl[c_k] then
                tbl[c_k] = {}
            end
            tbl = tbl[c_k]
        end 
        key = parts[#parts]
    end
    return tbl[key]
end
memdb.Write = function (key,value)
    local tbl = memdb.DB
    if string.find(key,"/",1,true) then
        local parts = split(key,"/")
        local c_k = nil
        for i = 1,(#parts - 1) do
            c_k = parts[i]
            if not tbl[c_k] then
                tbl[c_k] = {}
            end
            tbl = tbl[c_k]
        end 
        key = parts[#parts]
    end
    tbl[key] = value
end
memdb.Save = function () 
    return true,nil
end
memdb.Load = function (data)
    memdb.DB = data
end
memdb.Install = function(log)
    logger = log.CloneInto("memdb",logger)
    memdb.Load({})
end
return memdb

Simple test rig

Now let's pull it all together and write a simple unit test for the two use cases:

package.path = './?.lua;' .. package.path
local data = require("memdb")
local logger = require("logger")
data.Install(logger)
data.Write("testKey","Hello from the testKey")
data.Write("this/contains/one","Hello World")
print(data.Read("this/contains/one"))
print(data.Read("testKey"))
logger.Info(data.DB).Print()

Which outputs:

D:\Projects\Roblox>lua test.lua
Hello World
Hello from the testKey
[Info]: testKey = "Hello from the testKey"
{[this] =   {[contains] =     one = "Hello World"
  }}