Testing and Debugging
Testing is essential to ensure the functionality, quality and security of any software products. This is especially true when it comes to smart contracts development because once deployed, smart contracts are much more difficult, if possible, to update compared to traditional software, and bugs in them can potentially lead to significant financial losses.
Testing is a very complex topic. Alephium's Web3 SDK makes the following opinionated design decisions when it comes to its testing framework:
- Unit tests and integration tests are both important. Even though in general the distinction between them can be blurry, the line drawn by the test framework is whether the smart contracts under test are required to be deployed or not.
- Test code is also code, it should be clean and maintainable as well. Typescript SDK automatically generates testing boilerplates to make writing and maintaining test cases much easier.
- Tests are run against the Alephium full node in
devnet, which has the same codebase as
Alephium
mainnetwith differences only in configurations.
Alephium also supports the ability to emit debug statements in the smart contracts, which is very useful to diagnose issues during development.
Unit Test
A unit test tests a specific function of a contract, without requiring the contract to be deployed. Let's start with a simple example:
Contract Math(mut counter: U256) {
event Add(x: U256, y: U256)
@using(updateFields = true, checkExternalCaller = false)
pub fn add(x: U256, y: U256) -> U256 {
emit Add(x, y)
counter = counter + 1
return x + y
}
}
The Math contract has a add function that adds two numbers
together. Every time it's called, it also increments the counter and
emits an Add event. Here is how we can test it using Web3
SDK:
const result = await Math.tests.add({
initialFields: { counter: 0n },
testArgs: { x: 1n, y: 2n }
})
expect(result.returns).toEqual(3n)
expect(result.events[0].name).toEqual('Add')
expect(result.events[0].fields).toEqual({ x: 1n, y: 2n })
expect(result.contracts[0].fields.counter).toEqual(1n)
To test the add function in Math contract, we need to setup
Math's initial state using initialFields, and provide the
test arguments for add using testArgs. After executing the test,
we can verify that add function returns the correct value, counter
field is updated properly, and Add event is emitted with the right
fields as well.
Now let's juice up Math a bit: everytime add function is called,
it will cost the caller 1 ALPH:
Contract Math(mut counter: U256) {
event Add(x: U256, y: U256)
@using(preapprovedAssets = true, assetsInContract = true, updateFields = true, checkExternalCaller = false)
pub fn add(x: U256, y: U256) -> U256 {
transferTokenToSelf!(callerAddress!(), ALPH, 1 alph)
emit Add(x, y)
counter = counter + 1
return x + y
}
}
To test the logic of asset transfer in add function, we use the
following test code:
const result = await Math.tests.add({
initialFields: { counter: 0n },
testArgs: { x: 1n, y: 2n },
initialAsset: { alphAmount: 2n * ONE_ALPH },
inputAssets: [{ address: testAddress, asset: { alphAmount: 2n * ONE_ALPH } }]
})
expect(result.txOutputs[0].alphAmount).toEqual(3n * ONE_ALPH)
expect(result.txOutputs[1].alphAmount).toEqual(ONE_ALPH - 625000n * (10n ** 11n))
initialAsset sets up the initial asset for the Math contract, in this
case 2 ALPH. inputAssets sets up all the input assets for the
transaction, in this case 2 ALPH from testAddress which will also
be the callerAddress when calling add function because it's in the
first input of the inputAssets. After executing the test, we can
verify that the balance of the first output is increased to 3 ALPH since
the Math contract receives 1 ALPH for running the add function. The
balance of the second output becomes less because testAddress spends
1 ALPH as well as the gas fee.
Now that we tested the assets, how about when the Math contract
relies on another contract to do the job?
Contract Math(add: Add, mut counter: U256) {
event Add(x: U256, y: U256)
@using(preapprovedAssets = true, assetsInContract = true, updateFields = true, checkExternalCaller = false)
pub fn add(x: U256, y: U256) -> U256 {
emit Add(x, y)
counter = counter + 1
transferTokenToSelf!(callerAddress!(), ALPH, 1 alph)
return add.exec(x, y)
}
}
Contract Add() {
@using(checkExternalCaller = false)
pub fn exec(x: U256, y: U256) -> U256 {
return x + y
}
}
In this case Math contract relies on the Add contract to perform
the add operation. Here is how we test the add function:
const addState = Add.stateForTest({})
const result = await Math.tests.add({
initialFields: { add: addState.contractId, counter: 0n },
testArgs: { x: 1n, y: 2n },
initialAsset: { alphAmount: 2n * ONE_ALPH },
inputAssets: [{ address: testAddress, asset: { alphAmount: 2n * ONE_ALPH } }],
existingContracts: [addState]
})
expect(result.returns).toEqual(3n)
// rest of the assertions ..
Add.stateForTest({}) creates a state of the Add contract that we
can pass on to the existingContracts parameter. We also need to pass
addState.contractId as an initial field to the Math contract. After
executing the test, we can verify the result with the same assertions
as before.
Integration Test
An integration test tests a feature of a set of deployed contracts. Let's use the last example from the Unit Test section:
Contract Math(add: Add, mut counter: U256) {
event Add(x: U256, y: U256)
@using(preapprovedAssets = true, assetsInContract = true, updateFields = true, checkExternalCaller = false)
pub fn add(x: U256, y: U256) -> U256 {
emit Add(x, y)
counter = counter + 1
transferTokenToSelf!(callerAddress!(), ALPH, 1 alph)
return add.exec(x, y)
}
}
Contract Add() {
@using(checkExternalCaller = false)
pub fn exec(x: U256, y: U256) -> U256 {
return x + y
}
}
Since add function in Math contract not only updates the contract
state but also transfer assets, we need to call it through
TxScript:
TxScript AddScript(math: Math, x: U256, y: U256) {
let _ = math.add{ callerAddress!() -> ALPH: 1 alph }(x, y)
}
In the integration test, we deploy both Add and Math contracts and
then execute the AddScript script:
const signer = await getSigner()
const { contractInstance: addContract } = await Add.deploy(signer, { initialFields: {} })
const { contractInstance: mathContract } = await Math.deploy(signer, {
initialFields: { add: addContract.contractId, counter: 0n },
initialAttoAlphAmount: 2n * ONE_ALPH
})
await AddScript.execute(signer, {
initialFields: { math: mathContract.address, x: 1n, y: 2n },
attoAlphAmount: 2n * ONE_ALPH,
})
// `counter` field in `Math` is updated
const mathContractState = await mathContract.fetchState()
expect(mathContractState.fields.counter).toEqual(1n)
// contract balance in `Math` is updated
expect(BigInt(mathContractState.asset.alphAmount)).toEqual(3n * ONE_ALPH)
// `Add` event in `Math` is emitted
const { events } = await signer.nodeProvider.events.getEventsContractContractaddress(mathContract.address, { start: 0 })
expect(events[0].eventIndex).toEqual(0)
expect(events[0].fields).toEqual([{ type: 'U256', value: '1' }, { type: 'U256', value: '2' }])
After AddScript is executed, we can verify the state, balance and
events of the Math contract. Please refer to Interact with
contracts for more details.
Debugging
Debug statement in Alephium supports string interpolation. Printing debug messages has the same syntax as emitting contract events. For example:
Contract Math(mut counter: U256) {
@using(checkExternalCaller = false)
pub fn add(x: U256, y: U256) -> U256 {
emit Debug(`${x} + ${y} = ${x + y}`)
return x + y
}
}
In the example above, the add function in Math contract is a pure
function that doesn't update the state of the blockchain. When we test
the add function using both unit and integration test, debug message
will be printed out in both the terminal console and the full node log:
# Your contract address should be different
> Contract @ vrcKqNuMrGpA32eUgUvAB3HBfkN4eycM5uvXukwn5SxP - 1 + 2 = 3
If the add function does update the blockchain state, therefore
requires TxScript to execute, the debug message will only be
printed out in the full node log for integration test because the
execution doesn't happen right away so the result can not be returned
to the terminal console immediately. For unit tests, debug messages
will still be printed out in both the terminal console and the full
node log.
Under the hood, Debug is a special system
events which is only available in
devnet.
Checking Assertion Error
To check for assertion errors during contract execution, use the helper function expectAssertionError(p, address, errorCode). This function verifies that a specific assertion error occurs.
p: The execution promiseaddress: The address of the contract where the error occurrederrorCode: The error code at the position of failure
Example usage:
await expectAssertionError(TokenFaucet.tests.withdraw(testParams), testContractAddress, 0)