Skip to content

Testing migrations

Set-up

An NPL migration is comprised of one or more changes outlined in the migration descriptor file (migration.yml). The testing tool, EngineMigrationTester, facilitates the execution of changes either all at once or individually, enabling comprehensive testing of new source code, migration configurations, and procedures. Migration tests can be written in Kotlin, using the EngineMigrationTester API to interact with the engine, or directly in NPL.

To get started,

  1. Specify the directory where the migration artifacts reside. To see what artifacts are necessary, see Migrations - getting started.
  2. Use a classpath resolver to determine the actual system location of the migration artifact directory.
  3. Provide various configuration details using the MigrationInMemoryTestConfiguration object.
  4. Write the test.
  5. Instantiate the tester.

Running a migration

In a single step

To run all changes and then verify, simply use the run method and then execute any necessary assertions.

// run all changes in migration
tester.run()

//  ... assertions verifying that the migration was successful ...

Step by step

To run multiple changes independently, use the runTo function. The runTo function will apply the changes in order, determined by the descriptor file, up to and including the change with the specified changeset name. All previously migrated steps will be skipped. This allows you to verify that the migration script and process have been successful.

// run to specific change
tester.runTo("1.0.2")

//  ... assertions verifying specified change was migrated successfully ...

tester.runTo("1.0.3")

//  ... assertions verifying specified change was migrated successfully ...

Verifying a change

To validate a changes, assertions can be made in both Kotlin and NPL. E.g.:

NPL

@test
function test101(test: Test) -> {
    var foo = loadProtocol<Foo>(test, "foo").getOrFail();

    test.assertEquals(100, foo.getValue[party]());
};

Kotlin

val allStates = tester.getAllProtocolStates().map { it.value }.toList()

assertEquals(listOf(calc101.value), allStates)
val allCalculatorStates = tester.getCurrentProtocolStates(
    prototypeId("1.0.1"),
).map { it.value }.toList()

assertEquals(listOf(calc101.value), allCalculatorStates)

Writing tests

Using Kotlin

This method uses the Engine API to handle all protocol interaction with the kernel. This method requires the developer to be more aware of system level details such as protocol IDs, qualified type names and the protocol variable frame. This can sometimes require casting and other workarounds to make the tests more concise and easier to understand.

The Engine API enables you to create protocols, execute protocol actions, and retrieve the current protocol state. Examples can be found below.

JavaDocs

For detailed specifications of the EngineMigrationTester class' public methods, refer to the published Javadocs.

Example

In the following example, we'll use a Calculator protocol to illustrate how to test each change separately by asserting success after each changeset. To see more information about available operations for the NPL Number type, click here.

Calculator.npl

Version 1.0.0

protocol[party] Calculator() {
    var value = 0;
    permission[party] add(addend: Number) returns Number {
        value = (value + addend);
        return value;
    }
    permission[party] subtract(subtrahend: Number) returns Number {
        value = (value - subtrahend);
        return value;
    }
    permission[party] setValue(newValue: Number) { value = newValue; }
    permission[party] clear() { value = 0; }
}

Added multiply and divide permissions

Version 1.0.1

protocol[party] Calculator(private var value: Number) {
    permission[party] add(addend: Number) returns Number {
        value = (value + addend);
        return value;
    }
    permission[party] subtract(subtrahend: Number) returns Number {
        value = (value - subtrahend);
        return value;
    }
    permission[party] multiply(factor: Number) returns Number {
        value = (value * factor);
        return value;
    }
    permission[party] divide(divisor: Number) returns Number {
        require(divisor > 0, "Divisor cannot be zero");
        value = (value / divisor);
        return value;
    }
    permission[party] setValue(newValue: Number) { value = newValue; }
    permission[party] getValue() returns Number { return value; };
    permission[party] clear() { value = 0; }
}

To begin, instantiate the tester by providing the migration artifacts root directory and the Configuration object.

fun getTester(directory: File) = EngineMigrationTester(
    migrationHome = directory,
    configuration = MigrationInMemoryTestConfiguration(
        NplContribConfiguration.NPL_CONTRIB_DEFAULT_PATH,
    ),
)

Next, run the migration using the run or runTo methods as mentioned earlier. Follow up with the necessary assertions to insure the migration was successful

test("A sample of using EngineMigrationTester using Kotlin") {
    fun Value.toNumber() = (this as? NumberValue)?.value?.intValueExact() ?: 0
    fun Value.toProtoRef() = this as? ProtocolReferenceValue
    fun prototypeId(version: String) = "/TEST_SYSTEM-$version?/npl/Calculator"

    val party = PartyValue("accountant")
    val migrationRoot = getCalculatorMigrationBuilder(tempdir()).build()

    getTester(migrationRoot).use { tester ->
        tester.runTo("1.0.0")

        val calcRef = tester.create(
            prototypeId = prototypeId("1.0.0"),
            arguments = listOf(),
            parties = listOf(party),
            observers = mapOf(),
        )

        tester.selectAction(
            protocolId = calcRef.id,
            action = "add",
            arguments = listOf(NumberValue(30)),
            party = party,
        ).toNumber().also { got ->
            assertEquals(30, got)
        }

        tester.selectAction(
            protocolId = calcRef.id,
            action = "subtract",
            arguments = listOf(NumberValue(15)),
            party = party,
        ).toNumber().also { got ->
            assertEquals(15, got)
        }

        tester.selectAction(calcRef.id, "clear", listOf(), party)

        val calc100 = tester.getProtocolStateById(calcRef.id)

        assertNotNull(calc100)
        assertEquals(0, calc100.value.frame.slots["value"]?.toNumber())

        tester.selectAction(
            protocolId = calcRef.id,
            action = "setValue",
            arguments = listOf(NumberValue(24)),
            party = party,
        )

        tester.runTo("1.0.1")

        val calc101 = tester.getProtocolStateById(calcRef.id)

        assertNotNull(calc101)
        assertEquals(
            expected = 24,
            actual = calc101.value.frame.slots["value"]?.toNumber(),
        )

        tester.selectAction(
            protocolId = calcRef.id,
            action = "divide",
            arguments = listOf(NumberValue(12)),
            party = party,
        ).toNumber().let { got ->
            assertEquals(2, got)
        }

        tester.selectAction(
            protocolId = calcRef.id,
            action = "multiply",
            arguments = listOf(NumberValue(8)),
            party = party,
        ).toNumber().let { got ->
            assertEquals(16, got)
        }

        val allStates = tester.getAllProtocolStates().map { it.value }.toList()

        assertEquals(listOf(calc101.value), allStates)
        val allCalculatorStates = tester.getCurrentProtocolStates(
            prototypeId("1.0.1"),
        ).map { it.value }.toList()

        assertEquals(listOf(calc101.value), allCalculatorStates)
    }
}

Using NPL

New Feature (Added in 2024.1.8)

Tests written in NPL are easier to write and understand. Protocols are instantiated without the need to save the protocol ID or specify the fully qualified type. All types are handled natively, as they would be in an NPL application. Access to functions, permissions and data is determined by protocol access modifiers. The developer no longer needs to be concerned with anything but the NPL constructs needed for the test itself.

The runNPL method can execute tests using either an inline snippet or a Path object pointing to a file that contains the test source. Whether using a test source file or a code snippet, one or more tests can be specified. This collection of tests is referred as a test set. Tests in a test set are not guaranteed to be executed in any particular order.

It is possible to save a protocol for reference in subsequent runNPL invocations. To do this, you must first store the protocol in the test where it was instantiated using the storeProtocol method. Along with the protocol instance, a unique key must be provided. This key will be used to load the protocol in later tests. Protocols must be stored if you want to preserve state changes. Conversely, if you do not wish to preserve state changes that occur during the test, simply avoid using storeProtocol.

storeProtocol can be invoked multiple times with any protocol. However, a storage key can be used only once per test set. If using an existing key, the given reference will replace the previous one, making the previous reference inaccessible.

To retrieve a stored protocol, use the loadProtocol method. Supply the expected protocol type as a type argument along with the key used to store it. An Optional of the fully typed protocol will be returned. If no protocol is associated with the given key, it will return an empty Optional. If the type of the associated protocol does not match the specified type, an error will be thrown.

Example

Below is the Kotlin test example provided earlier, now converted to use NPL.

getTester(migrationRoot).use { tester ->
    tester.runTo("1.0.0")
    tester.runNPL(
        name = "test-1.0.0",
        snippet = """
            package test;

            use npl.Calculator;

            const party = 'accountant';

            @test
            function calculator100Test(test: Test) -> {
                var calculator = Calculator[party]();

                test.assertEquals(30, calculator.add[party](30));
                test.assertEquals(15, calculator.subtract[party](15));

                calculator.clear[party]();
                test.assertEquals(0, calculator.value);
                calculator.setValue[party](24);

                storeProtocol(test, "calc", calculator);
            };
        """.trimIndent(),
    )

    tester.runTo("1.0.1")
    tester.runNPL(
        name = "test-1.0.1",
        snippet = """
            package test;

            use npl.Calculator;

            const party = 'accountant';

            @test
            function calculator101Test(test: Test) -> {
                var calculator = loadProtocol<Calculator>(test, "calc")
                                    .getOrFail();

                test.assertEquals(24, calculator.getValue[party]());

                calculator.divide[party](12);
                test.assertEquals(2, calculator.getValue[party]());

                calculator.multiply[party](8);
                test.assertEquals(16, calculator.getValue[party]());
            };
        """.trimIndent(),
    )
}