Aller au contenu principal

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 mainnet with 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 promise
  • address: The address of the contract where the error occurred
  • errorCode: The error code at the position of failure

Example usage:

    await expectAssertionError(TokenFaucet.tests.withdraw(testParams), testContractAddress, 0)