Prior experience
Some people that have been writing about unit testing in lua, and also about lua for embedded:
- http://lua-users.org/wiki/UnitTesting
- https://blog.freifunk.net/2019/05/26/gsoc-2019-unit-testing-libremesh/
Requirements
The set of requirements for the LibreMesh project in regards of testing are the following:
- Must support lua 5.1, as it is the one packaged in OpenWRT
- Must provide helpful assert test functions, like showing table diffs or formatters for outputs to understand the difference easily
- We need mock functionality, because a lot of functions are hardware related and may not be possible to test them on hardware all the time.
- It is desirable for the library to be a one-file import, so we can use it in the routers and in the continuous integration in the same way we do it in the desktop.
Options
We need to consider unit testing, mocking and coverage tests.
Unit Testing
There is a list of unittesting libraries in the lua package manager, luarocks:
https://luarocks.org/labels/test?non_root=on
These are the ones analyzed:
LuaUnit
URL: https://github.com/bluebird75/luaunit
Upsides:
- No external dependencies (single file),
- it’s well maintained,
- popular (200k downloads luarocks, 200 stars Github),
- supports multiple versions of Lua.
- Has TAP support (for CI)
- It is being used in other OpenWRT based images like OpenWISP: https://github.com/openwisp/openwisp-config/blob/master/openwisp-config/tests/test_utils.lua
Downsides:
- It doesn’t have mocking helpers: Could be combined with a one file mocking library mockagne
Telescope
They describe themselves as a A highly customizable test library for Lua that allows declarative tests with nested contexts.
URL: https://github.com/norman/telescope/
Last release 2013. Lua 5.1 last release was done in 2012, so it is not that big of a deal… but has not received any updates since, so it might have not evolved since.
Busted
URL:
- https://github.com/Olivine-Labs/busted
- http://olivinelabs.com/busted/
Upsides:
- Very well maintained by olivinelabs and contributors,
- very popular (900k downloads luarocks, 800 github stars).
- It has setup/teardowns and also mocks, spies, and matchers.
- Has TAP support.
- Has good documentation
- It is integrated with luacov for test coverage
Downsides:
- Must be installed using luarocks (it is a lot of files). A question has been posted to luarocks to explore the possibility of creating bundles for a package (one file with all dependencies). That would simplify its use: https://github.com/luarocks/luarocks/issues/1023
Mocking libraries
lua-mock
URL: https://github.com/henry4k/lua-mock
Mach
URL: https://github.com/ryanplusplus/mach.lua/
More or less well maintain, though it is not so popular.
Coverage reports
luacov
URL: https://github.com/keplerproject/luacov
Upsides:
- Well maintained
Unit testing Architecture for LibreMesh (only for LibreMesh?)
The idea is to allow unit-testing packages and also the integration between them as some of the packages depend on other packages.
Context:
- LibreMesh enables functionality selecting which packages must be installed and changing enabling/disabling the exposed features in configuration files.
- In some packages the code is all inside the executable lua file (not like a library)
- Some packages are independent, provide functionality without depending on lime-system. This packages are in lime-packages for convenience.
- Packages could (should) be migrated into OpenWrt repositories. This migration may happen steps and when migrated the code may be in an independent repository for the package iteself.
- many packages are wrappers of bash code, and this complicates the tests as you need a running system to test it out
This context is not an easy one to test as it has a lot of trade offs!
Options
Single and global tests directory
The easiest architecture is to have a global tests directory and some utility functions that allow to “install” a certain module for testing
Directory structure:
lime-packages/package/package1/
lime-packages/package/package1/...
lime-packages/package/package2
lime-packages/package/package2/...
lime-packages/tests/utils.lua
lime-packages/tests/fake_modules/nixio.fs
lime-packages/tests/test_package_1.lua
lime-packages/tests/test_package_2.lua
lime-packages/tests/test_package_1_and_2_integration.lua
lime-packages/run_tests.sh
Example of a (integration) test that uses libraries and fake modules
test_lime_proto_anygw.lua:
utils = require("test.utils")
-- installs required modules in the lua path
utils.install_limesystem_module() -- to allow access to lime.network, etc
utils.install_module("packages/lime-proto-anygw/src/anygw.lua", "lime.proto.anygw")
utils.install_module("tests/fake_modules/nixio.fs", "nixio.fs")
-- now we can load the modules
anygw = require("lime.proto.anygw")
function test_foo()
assert anygw.foo() == 'bar'
end
Pros:
- Easy to start with and to understand
Cons:
- As all tests are together it is not easy to move a package to other repository or even to its own repository.
Tests inside each module and a shared tests directory to test integrations
Directory structure:
lime-packages/package/package1/
lime-packages/package/package1/tests/test_foo.lua
lime-packages/package/package2
lime-packages/package/package2/tests/test_bar.lua
lime-packages/tests/utils.lua
lime-packages/tests/fake_modules/nixio.fs
lime-packages/tests/test_package_1_and_2_integration.lua
lime-packages/run_tests.sh
Pros:
- Each package has more independence
Cons:
- ?
Testing in a fully working image with all packages and libraries installed
Tests can be run installing all files of the packages (by some script that parses the Makefiles, or “by hand in a helper script”).
Pros:
- It requires less boilerplate to test package integration
- Libraries of the target system can be used directly
- Other packages may be installed
Cons:
- Less control over what it is really happening
- slower than the other options, as it needs to load a full system
Fake modules as library
Testing executable modules
Executable lua modules can be tested with a simple modification in the file creating a main() function and then using something like:
function main()
--- the main code in here
end
-- detect if this module is run as a library or as a script
if pcall(debug.getlocal, 4, 1) then
-- Library mode, do nothing
else
-- Main script mode
main()
end
Then from a test file it can be loaded like any normal module and all the functions can be accesed without executing main()
Testing environment
A docker environment (or multiple, even using “qemu-user” under docker) with the testing libraries and target lua version is loaded by the “run_tests.sh” executable.
This environment can provide some useful libraries for testing (coverage reporting,
Direct import and testing can be done for unit tests were functions are not using system libraries, or when these are simple enough to be mockable (mocking shell() calls).
luarocks router-local environment
described by this guy, thanks!
We did a trial to run busted inside a router… that would have been useful for in-router tests and also for tests inside a virtual environment.
It used the strategy of installing luarocks dependencies in a separate directory and copying them to the router.
The steps are pretty straightforward:
$ sudo apt install luarocks
$ luarocks install --tree lua_modules busted
$ cat <EOF
require 'busted.runner'()
describe('Busted unit testing framework', function()
describe('should be awesome', function()
it('should be easy to use', function()
assert.truthy('Yup.')
end)
it('should have lots of features', function()
-- deep check comparisons!
assert.same({ table = 'great'}, { table = 'great' })
-- or check by reference!
assert.is_not.equals({ table = 'great'}, { table = 'great'})
assert.falsy(nil)
assert.error(function() error('Wat') end)
end)
it('should provide some shortcuts to common functions', function()
assert.unique({{ thing = 1 }, { thing = 2 }, { thing = 3 }})
end)
it('should have mocks and spies for functional tests', function()
local thing = require('thing_module')
spy.spy_on(thing, 'greet')
thing.greet('Hi!')
assert.spy(thing.greet).was.called()
assert.spy(thing.greet).was.called_with('Hi!')
end)
end)
end)
EOF
> test.lua
$ cat <EOF
-- set_paths.lua
local version = _VERSION:match("%d+%.%d+")
package.path = 'lua_modules/share/lua/' .. version .. '/?.lua;lua_modules/share/lua/' .. version .. '/?/init.lua;' .. package.path
package.cpath = 'lua_modules/lib/lua/' .. version .. '/?.so;' .. package.cpath
EOF
> set_paths.lua
$ scp -r test.lua set_path.lua lua_modules root@thisnode.info:~
$ ssh root@thisnode.info 'lua -l set_paths test.lua'
The output of this command though was not what we expected:
$ ssh root@thisnode.info 'lua -l set_paths test.lua'
lua: lua_modules/share/lua/5.1/pl/path.lua:28: pl.path requires LuaFileSystem
stack traceback:
[C]: in function 'error'
lua_modules/share/lua/5.1/pl/path.lua:28: in main chunk
[C]: in function 'require'
lua_modules/share/lua/5.1/busted/runner.lua:3: in main chunk
[C]: in function 'require'
test.lua:1: in main chunk
[C]: ?
Deeper inspection showed that the library’s dependencies had C bindings that we compiled for a different arquitecture, so tha strategy was not feasable for routers anymore:
find . -name \*.so
./lua_modules/lib/lua/5.1/lfs.so
./lua_modules/lib/lua/5.1/term/core.so
./lua_modules/lib/lua/5.1/system/core.so
where term/core.so and system/core.so are system libs, but lfs.so is from LuaFileSystem.
There is a lua-only implementation of LuaFileSystem: https://github.com/sonoro1234/luafilesystem , but as it doesn’t support luarocks deeper understanding of the platform is needed to attempt to replace the only binary binding with this implementation.
Found a sister library from that one in luarocks: https://luarocks.org/modules/3scale/luafilesystem-ffi based on this repo: https://github.com/spacewander/luafilesystem. So:
$ luarocks install --tree lua_modules luafilesystem-ffi
installed it and then touched the code were the penlight library was imported in busted:
$ grep -r require.\*lfs *
path.lua:local res,lfs = _G.pcall(_G.require,'lfs')
$ pwd
/home/nico/tmp/lua_local_test/lua_modules/share/lua/5.1/pl
but this library, as it depends on ffi (a module of luajit), it depends on a C extension too.
also, luafilesystem exists as a native library in OpenWRT, so it could be included just for the sake of the exercise: https://openwrt.org/packages/pkgdata/luafilesystem … but not this time.
Docker with LuaRocks and Lua 5.1
Some docker images already exist:
- https://github.com/akornatskyy/docker-library/
- https://hub.docker.com/r/abaez/luarocks/
- https://hub.docker.com/r/abaez/lua
- https://github.com/martijnrondeel/docker-luarocks
A simple Dockerfile whould be:
FROM abaez/luarocks:lua5.1
WORKDIR /root
RUN luarocks install luacov; \
luarocks install busted
Excellent blog post on handling Lua paths: http://www.thijsschreijer.nl/blog/?p=1025
Example of LUA_PATH to load executables (without ending in .lua): LUA_PATH="packages/safe-upgrade/files/usr/sbin/?;;"
. The double ;;
at the end means append the default paths.
First attempt running tests
I selected safe-uprgade
libremesh module to start doing unittests because I know the module as I wrote it so I already know which code would gain value being tested. Also I am confident to refactor the module if needed.
First I start using the busted
unittest library with a simple test of the function get_current_partition()
that must return the partition number that is currently running. As this is done from reading /proc/mtd
I refactored the function so we can pass from the outside the expected content.
Content of lime-packages/safe-upgrade/tests/test_safe_upgrade.lua
:
local su = require "safe-upgrade"
describe("safe-upgrade tests", function()
it("test get current partition", function()
proc_mtd = [[#!
dev: size erasesize name
mtd0: 00020000 00010000 "factory-uboot"
mtd1: 00020000 00010000 "u-boot"
mtd2: 00180000 00010000 "kernel"
mtd3: 00d40000 00010000 "rootfs"
mtd4: 00b10000 00010000 "rootfs_data"
mtd5: 000f0000 00010000 "config"
mtd6: 00010000 00010000 "firmware"
mtd7: 00ec0000 00010000 "fw2"
mtd8: 00ec0000 00010000 "ART"
]]
assert.is.equal(su.get_current_partition(proc_mtd), 1)
proc_mtd = [[#!
dev: size erasesize name
mtd0: 00020000 00010000 "factory-uboot"
mtd1: 00020000 00010000 "u-boot"
mtd2: 00180000 00010000 "kernel"
mtd3: 00d40000 00010000 "rootfs"
mtd4: 00b10000 00010000 "rootfs_data"
mtd5: 000f0000 00010000 "config"
mtd6: 00010000 00010000 "fw1"
mtd7: 00ec0000 00010000 "firmware"
mtd8: 00ec0000 00010000 "ART"
]]
assert.is.equal(su.get_current_partition(proc_mtd), 2)
end)
end)
The modifications I did to do to the safe-upgrade
module are:
- refactor
get_current_partition()
intoget_proc_mtd()
andget_current_partition(proc_mtd)
. This way we can inject different/proc/mtd
values for testing. - return a table containing the module exported functions when running in library mode. In this case we are exporting
get_current_partition
. - move argparse module loading to the
parse_args
function that only gets executed when the module is run in script mode (not library mode)
This changes may not be the best way of handling testing but for now it allow us to move forward without digging a hole too deep:
[san@jones lime-packages]$ git diff
diff --git a/packages/safe-upgrade/files/usr/sbin/safe-upgrade b/packages/safe-upgrade/files/usr/sbin/safe-upgrade
index 8aeece4..fc6d467 100755
--- a/packages/safe-upgrade/files/usr/sbin/safe-upgrade
+++ b/packages/safe-upgrade/files/usr/sbin/safe-upgrade
@@ -17,7 +17,6 @@
]]--
local io = require "io"
-local argparse = require 'argparse'
local version = '1.0'
local firmware_size_bytes = 7936*1024
@@ -114,10 +113,15 @@ function get_current_cmdline()
return data
end
-function get_current_partition()
+function get_proc_mtd()
local handle = io.open('/proc/mtd', 'r')
local data = handle:read("*all")
handle:close()
+ return data
+end
+
+function get_current_partition(proc_mtd)
+ local data = proc_mtd or get_proc_mtd()
if data:find("fw2") == nil then
return 2
else
@@ -289,6 +293,7 @@ end
function parse_args()
+ local argparse = require 'argparse'
local parser = argparse('safe-upgrade', 'Safe upgrade mechanism for dual-boot systems')
parser:command_target('command')
local show = parser:command('show', 'Show the status of the system partitions.')
@@ -338,6 +343,9 @@ end
-- detect if this module is run as a library or as a script
if pcall(debug.getlocal, 4, 1) then
-- Library mode
+ local safe_upgrade = {}
+ safe_upgrade.get_current_partition = get_current_partition
+ return safe_upgrade
else
-- Main script mode
To run the test inside the docker container we have to add the module under test to the LUA_PATH (check that it is an executable module that does not ends with .lua
so the expresion is ?
instead of ?.lua
):
(docker) [san@jones lime-packages]$ LUA_PATH="packages/safe-upgrade/files/usr/sbin/?;;" busted packages/safe-upgrade/tests/test_safe_upgrade.lua
●
1 success / 0 failures / 0 errors / 0 pending : 0.001127 seconds
Sum up
- We did an evaluation of testing libraries and shrinked our selection to
busted
orluaunit
. We are selectingbusted
as it has more pros than luaunit, mainly integrated mocking and coverage. - Some architectural options were proposed as starting point. We discussed them with @nicopace and we will be moving forward iterating with the Tests inside each module and a shared tests directory to test integrations idea.
- Running tests in a local OpenWrt based device was investigated (thanks @nicopace!)
- A working Dockerfile is proposed.
- I did a real world example of unit testing a single function of a simple module. Little but working 🙂
2 thoughts on “GSoC 2019 – Evaluating options to do unit and integration tests in LibreMesh (and a first working example)”