# Evaluating options to do unit and integration tests in LibreMesh By [SAn](https://github.com/spiccinini) ![](https://i.imgur.com/WjQyk2C.jpg) ## 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](https://openwrt.org/packages/pkgdata/lua) * 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] (https://repohealth.info/report/bluebird75/luaunit), * popular (200k downloads luarocks, 200 stars Github), * supports multiple versions of Lua. * Has [TAP](https://en.wikipedia.org/wiki/Test_Anything_Protocol) 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] (https://github.com/vertti/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](http://olivinelabs.com/busted/) * It is integrated with [luacov](#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: ```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: ```lua 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!](https://leafo.net/guides/customizing-the-luarocks-tree.html) 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: ```bash $ 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: ```bash $ 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: ```bash 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: ```bash $ luarocks install --tree lua_modules luafilesystem-ffi ``` installed it and then touched the code were the penlight library was imported in busted: ```bash $ 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: ```Docker 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`: ```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()` into `get_proc_mtd()` and `get_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: ```diff [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`): ```shell (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` or `luaunit`. We are selecting `busted` 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. * A working Dockerfile is proposed. * Running tests in a local OpenWrt based device was investigated (thanks @nicopace!) * I did a real world example of unit testing a single function of a simple module. Little but working :)