Proposal: Unit Testing Library - GetPoplog/Seed GitHub Wiki

Work in Progress

The main development work of Poplog predates the popularity of unit-testing frameworks. One of the challenges in putting unit testing into Poplog is the absence of any distinct 'compilation' phase in Poplog's workflow and hence no natural place to add testing hooks. Also it should always be possible to compile code that has unit tests without any overhead. This note describes a design that overcomes these challenges.

uses-unittests TESTS.p

The key idea is to separate unit-tests for a file SOURCE.p into another file (say) TESTS.p. When we compile SOURCE.p the only action taken is to register the link between SOURCE.p and TESTS.p. This link can be allowed to be a many-to-many relationship; no complications arise from this. We can pick up the filename using popfilename - if it is false then uses-unittests will have no effect (maybe a warning?). We also need to capture the popsection so we can reinstate that when we run the TESTS.p file.

Running Unit Test

The TESTS.p files will be compiled from source each time the tests are run. This means that top-level constants get recalculated on each test and subtle compile-time issues are avoided.

Defining/Registering a unit test

The test definition syntax needs to allow for an optional variable name, an optional descriptive name, an optional identification of the procedure that is under test. The minimal unit-test looks like:

define :unittest;
    ;;; Arrange
    lvars test_data = [ 1 2 3 ];
    ;;; Act
    lvars result = reverse( test_data );
    ;;; Assert
    assert test_data = result;
enddefine;

Unit-tests may be anonymous i.e. have no variable associated with them. This relieves the programmer of the burden of finding complicated naming conventions. Because this unit-test is not bound to a variable, the only effect is that the test is registered using register_unittest( unittest ).

And a fully instantiated unit test looks like:

with
    subject = reverse,
    expects = 'the reverse of a short list is equal to a reversed copy' 
define :unittest test_reverse();
    ;;; Arrange
    lvars test_data = [ 1 2 3 ];
    ;;; Act
    lvars result = reverse( test_data );
    ;;; Assert
    assert [ 3 2 1 ] = result;
enddefine

This declaration binds to the variable test_reverse, which is useful for testing during development. Once the test is developed the variable should be declared as lconstant. The test is also registered.

Registering a unit test only has an effect inside the unittest runner (run_unittests) where the test is added to the list of tests to run. Outside of the unittest runner it will simply run the test immediately as if the test runner was invoked on just that one test.

ENTER test [FolderPath] RETURN

To run unit tests inside VED we will use the ENTER test RETURN command (i.e. ved_test()). This will run the unit tests associated with this source file. To run all tests associated with files in the same parent directory we write ENTER test . or all tests that are associated with the grandparent directory we use ENTER test ...

This will report a green status on the command line. A red status is reported by opening a new write-protected buffer and adding the results there (using "text" format).

Outside of VED

To run unit tests at the top-level the procedure run_unittests(path: string, format?: word) can be used. The allowed formats will be "markdown", "xml" and "text". The default will be "text". The results are sent to cucharout.

Unit Test Objects

Calling one unittest from another

The define :unittest foo; syntax will declare foo and bind it to the unit-test object. It can then be called from inside another unit test like this:

foo()

It will be reported as a subsidiary test of the main test.

Generating new unittests

We often need to run the same test function over a lot of data. It is not enough to merely run a loop over test-data because then the final test-report will show only the first failure and will lack detail. Hence is useful to be able to dynamically generate unit tests. We accomplish that with the newunittest(test_name: word, subject: procedure, test_description: string, test_body: item* -> (), test_data: iterable<item>) procedure:

lvars mytest = newunittest(
    "mytest", 
    reverse, 
    'the reverse of a short list is equal to a reversed copy', 
    procedure();
    endprocedure,
    []
)

Having built a unit test, it can be called in order to execute the test & add to the test-results.

mytest()

Clearing pre-existing registrations

When SOURCE.p is recompiled, we want to clear away old registrations associated with SOURCE.p. This can only be reliably achieved if we can identify a recompilation. To do this, we will modify sysCOMPILE so that it has a unique value (an integer) associated with a new dlocalised variable pop_sysCOMPILE_ID. That ID is only guaranteed to be unique within a single Poplog process (it will count up from 0).

sysCOMPILE is a protected variable, so it is straightforward to redefine it.

pop_debugging

When pop_debugging is false, we are very likely to be compiling the code without any intention to use unit tests. If that is the case we do not want to be doing any registrations at all. On the other hand, we might be wanting to unit test the case when pop_debugging is false.

To accommodate that less common case, I propose that registration is controlled an the active variable pop_unittesting.

;;; undef means default to pop_debugging.
lvars pop_unittesting_backingstore = undef;

define active pop_unittesting;
    if pop_unittesting_backingstore == undef then pop_debugging
    else pop_unittesting_backingstore
    endif
enddefine;

define updaterof active pop_unittesting( v );
    v -> pop_unittesting_backingstore
enddefine;
⚠️ **GitHub.com Fallback** ⚠️