Testing with Typescript
Testing is an integral part of any piece of software, and especially important for any blockchain application. In this page we will cover how to interact with your Noir contracts in a testing environment to write automated tests for your apps.
We will be using typescript to write our tests, and rely on the aztec.js
client library to interact with a local Aztec network. We will use jest
as a testing library, though feel free to use whatever you work with. Configuring the nodejs testing framework is out of scope for this guide.
A simple example
Let's start with a simple example for a test using the Sandbox. We will create two accounts and deploy a token contract in a setup step, and then issue a transfer from one user to another.
describe('token contract', () => {
let pxe: PXE;
let owner: AccountWallet;
let recipient: AccountWallet;
let token: TokenContract;
beforeEach(async () => {
pxe = createPXEClient(PXE_URL);
owner = await createAccount(pxe);
recipient = await createAccount(pxe);
token = await TokenContract.deploy(owner, owner.getCompleteAddress()).send().deployed();
}, 60_000);
it('increases recipient funds on mint', async () => {
const recipientAddress = recipient.getAddress();
expect(await token.methods.balance_of_private(recipientAddress).view()).toEqual(0n);
const mintAmount = 20n;
const secret = Fr.random();
const secretHash = computeMessageSecretHash(secret);
const receipt = await token.methods.mint_private(mintAmount, secretHash).send().wait();
const storageSlot = new Fr(5); // The storage slot of `pending_shields` is 5.
const note = new Note([new Fr(mintAmount), secretHash]);
const extendedNote = new ExtendedNote(note, recipientAddress, token.address, storageSlot, receipt.txHash);
await pxe.addNote(extendedNote);
await token.methods.redeem_shield(recipientAddress, mintAmount, secret).send().wait();
expect(await token.methods.balance_of_private(recipientAddress).view()).toEqual(20n);
}, 30_000);
});
Source code: /yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L26-L58
This test sets up the environment by creating a client to the Private Execution Environment (PXE) running on the Sandbox on port 8080. It then creates two new accounts, dubbed owner
and recipient
. Last, it deploys an instance of the Token contract, minting an initial 100 tokens to the owner.
Once we have this setup, the test itself is simple. We check the balance of the recipient
user to ensure it has no tokens, send and await a deployment transaction, and then check the balance again to ensure it was increased. Note that all numeric values are represented as native bigints to avoid loss of precision.
We are using the Token
contract's typescript interface. Follow the typescript interface section to get type-safe methods for deploying and interacting with the token contract.
To run the test, first make sure the Sandbox is running on port 8080, and then run your tests using jest. Your test should pass, and you should see the following output in the Sandbox logs, where each chunk corresponds to a transaction. Note how this test run has a total of four transactions: two for deploying the account contracts for the owner
and recipient
, another for deploying the token contract, and a last one for actually executing the transfer.
pxe_service Registered account 0x061c94e053745973521de1826db5a1ee24af60a3c203c294570a35bd5afa3286
pxe_service Added contract SchnorrAccount at 0x061c94e053745973521de1826db5a1ee24af60a3c203c294570a35bd5afa3286
node Simulating tx 09023befa12d01db063235ef6d611ed1c7ba4625dac9d14cc0f1ff4dd8a00264
node Simulated tx 09023befa12d01db063235ef6d611ed1c7ba4625dac9d14cc0f1ff4dd8a00264 succeeds
pxe_service Executed local simulation for 09023befa12d01db063235ef6d611ed1c7ba4625dac9d14cc0f1ff4dd8a00264
pxe_service Sending transaction 09023befa12d01db063235ef6d611ed1c7ba4625dac9d14cc0f1ff4dd8a00264
node Received tx 09023befa12d01db063235ef6d611ed1c7ba4625dac9d14cc0f1ff4dd8a00264
sequencer Retrieved 1 txs from P2P pool
sequencer Building block 1 with 1 transactions
sequencer Submitted rollup block 1 with 1 transactions
pxe_service Registered account 0x109e72d06371d98cef1a10ec137e01139edb64b3b399c1b761d12d06de15af3c
pxe_service Added contract SchnorrAccount at 0x109e72d06371d98cef1a10ec137e01139edb64b3b399c1b761d12d06de15af3c
node Simulating tx 179d347ff45fc8da90155c38c7fd4358737fc7447b1f2ea2b3ff1fbc9dfb3d47
node Simulated tx 179d347ff45fc8da90155c38c7fd4358737fc7447b1f2ea2b3ff1fbc9dfb3d47 succeeds
pxe_service Executed local simulation for 179d347ff45fc8da90155c38c7fd4358737fc7447b1f2ea2b3ff1fbc9dfb3d47
pxe_service Sending transaction 179d347ff45fc8da90155c38c7fd4358737fc7447b1f2ea2b3ff1fbc9dfb3d47
node Received tx 179d347ff45fc8da90155c38c7fd4358737fc7447b1f2ea2b3ff1fbc9dfb3d47
sequencer Retrieved 1 txs from P2P pool
sequencer Building block 2 with 1 transactions
sequencer Submitted rollup block 2 with 1 transactions
pxe_service Added contract Token at 0x1345499a3f8325d614fa1d49cc2a6f21211d608c74728439076943f92340b936
node Simulating tx 0298295963669a4a1ccaddb40d78722c00136aad196b85306d63e4885799b1d8
node Simulated tx 0298295963669a4a1ccaddb40d78722c00136aad196b85306d63e4885799b1d8 succeeds
pxe_service Executed local simulation for 0298295963669a4a1ccaddb40d78722c00136aad196b85306d63e4885799b1d8
pxe_service Sending transaction 0298295963669a4a1ccaddb40d78722c00136aad196b85306d63e4885799b1d8
node Received tx 0298295963669a4a1ccaddb40d78722c00136aad196b85306d63e4885799b1d8
sequencer Retrieved 1 txs from P2P pool
sequencer Building block 3 with 1 transactions
sequencer Submitted rollup block 3 with 1 transactions
node Simulating tx 085665fbbad776a727cb92c4b62fbe2f57c83dfbccd191852e3c17efc12fdd4b
node Simulated tx 085665fbbad776a727cb92c4b62fbe2f57c83dfbccd191852e3c17efc12fdd4b succeeds
pxe_service Executed local simulation for 085665fbbad776a727cb92c4b62fbe2f57c83dfbccd191852e3c17efc12fdd4b
pxe_service Sending transaction 085665fbbad776a727cb92c4b62fbe2f57c83dfbccd191852e3c17efc12fdd4b
node Received tx 085665fbbad776a727cb92c4b62fbe2f57c83dfbccd191852e3c17efc12fdd4b
sequencer Retrieved 1 txs from P2P pool
sequencer Building block 4 with 1 transactions
sequencer Submitted rollup block 4 with 1 transactions
node Simulating tx 2755e9f5ad308fd135f606daf6208f5d711a3a6de6e4630d15d269af59f03e1c
node Simulated tx 2755e9f5ad308fd135f606daf6208f5d711a3a6de6e4630d15d269af59f03e1c succeeds
pxe_service Executed local simulation for 2755e9f5ad308fd135f606daf6208f5d711a3a6de6e4630d15d269af59f03e1c
pxe_service Sending transaction 2755e9f5ad308fd135f606daf6208f5d711a3a6de6e4630d15d269af59f03e1c
node Received tx 2755e9f5ad308fd135f606daf6208f5d711a3a6de6e4630d15d269af59f03e1c
sequencer Retrieved 1 txs from P2P pool
sequencer Building block 5 with 1 transactions
sequencer Submitted rollup block 5 with 1 transactions
Using Sandbox initial accounts
Instead of creating new accounts in our test suite, we can use the ones already initialized by the Sandbox upon startup. This can provide a speed boost to your tests setup. However, bear in mind that you may accidentally introduce an interdependency across test suites by reusing the same accounts.
pxe = createPXEClient(PXE_URL);
[owner, recipient] = await getSandboxAccountsWallets(pxe);
token = await TokenContract.deploy(owner, owner.getCompleteAddress()).send().deployed();
Source code: /yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L67-L71
Running Sandbox in the nodejs process
Instead of connecting to a local running Sandbox instance, you can also start your own Sandbox within the nodejs process running your tests, for an easier setup. To do this, import the @aztec/aztec-sandbox
package in your project, and run createSandbox
during setup. Note that this will still require you to run a local Ethereum development node like Anvil, Hardhat Network, or Ganache.
({ pxe, stop } = await createSandbox());
Source code: /yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L237-L239
The createSandbox
returns a stop
callback that you should run once your test suite is over to stop all Sandbox services.
afterAll(() => stop());
Source code: /yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L245-L247
Assertions
We will now see how to use aztec.js
to write assertions about transaction statuses, about chain state both public and private, and about logs.
Transactions
In the example above we used contract.methods.method().send().wait()
to create a function call for a contract, send it, and await it to be mined successfully. But what if we want to assert failing scenarios?
A private call fails
We can check that a call to a private function would fail by simulating it locally and expecting a rejection. Remember that all private function calls are only executed locally in order to preserve privacy. As an example, we can try transferring more tokens than we have, which will fail an assertion with the Balance too low
error message.
const call = token.methods.transfer(owner.getAddress(), recipient.getAddress(), 200n, 0);
await expect(call.simulate()).rejects.toThrowError(/Balance too low/);
Source code: /yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L186-L189
Under the hood, the send()
method executes a simulation, so we can just call the usual send().wait()
to catch the same failure.
const call = token.methods.transfer(owner.getAddress(), recipient.getAddress(), 200n, 0);
await expect(call.send().wait()).rejects.toThrowError(/Balance too low/);
Source code: /yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L193-L196
A transaction is dropped
We can have private transactions that work fine locally, but are dropped by the sequencer when tried to be included due to a double-spend. In this example, we simulate two different transfers that would succeed individually, but not when both are tried to be mined. Here we need to send()
the transaction and wait()
for it to be mined.
const call1 = token.methods.transfer(owner.getAddress(), recipient.getAddress(), 80n, 0);
const call2 = token.methods.transfer(owner.getAddress(), recipient.getAddress(), 50n, 0);
await call1.simulate();
await call2.simulate();
await call1.send().wait();
await expect(call2.send().wait()).rejects.toThrowError(/dropped/);
Source code: /yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L200-L209
A public call fails locally
Public function calls can be caught failing locally similar to how we catch private function calls. For this example, we use a TokenContract
instead of a private one.
Keep in mind that public function calls behave as in EVM blockchains, in that they are executed by the sequencer and not locally. Local simulation helps alert the user of a potential failure, but the actual execution path of a public function call will depend on when it gets mined.
const call = token.methods.transfer_public(owner.getAddress(), recipient.getAddress(), 1000n, 0);
await expect(call.simulate()).rejects.toThrowError(/Underflow/);
Source code: /yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L213-L216
A public call fails on the sequencer
We can ignore a local simulation error for a public function via the skipPublicSimulation
. This will submit a failing call to the sequencer, who will then reject it and drop the transaction.
const call = token.methods.transfer_public(owner.getAddress(), recipient.getAddress(), 1000n, 0);
await expect(call.send({ skipPublicSimulation: true }).wait()).rejects.toThrowError(/dropped/);
Source code: /yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L220-L223
If you run the snippet above, you'll see the following error in the Sandbox logs:
WARN Error processing tx 06dc87c4d64462916ea58426ffcfaf20017880b353c9ec3e0f0ee5fab3ea923f: Assertion failed: Balance too low.
In the near future, transactions where a public function call fails will get mined but flagged as reverted, instead of dropped.
State
We can check private or public state directly rather than going through view-only methods, as we did in the initial example by calling token.methods.balance().view()
. Bear in mind that directly accessing contract storage will break any kind of encapsulation.
To query storage directly, you'll need to know the slot you want to access. This can be checked in the contract's Storage
definition directly for most data types. However, when it comes to mapping types, as in most EVM languages, we'll need to calculate the slot for a given key. To do this, we'll use the CheatCodes
utility class:
cheats = CheatCodes.create(ETHEREUM_HOST, pxe);
// The balances mapping is defined on storage slot 3 and is indexed by user address
ownerSlot = cheats.aztec.computeSlotInMap(3n, ownerAddress);
Source code: /yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L143-L147
Querying private state
Private state in the Aztec Network is represented via sets of private notes. In our token contract example, the balance of a user is represented as a set of unspent value notes, each with their own corresponding numeric value.
struct ValueNote {
value: Field,
owner: Field,
randomness: Field,
header: NoteHeader,
}
Source code: /yarn-project/aztec-nr/value-note/src/value_note.nr#L19-L26
We can query the Private eXecution Environment (PXE) for all notes encrypted for a given user in a contract slot. For this example, we'll get all notes encrypted for the owner
user that are stored on the token contract address and on the slot we calculated earlier. To calculate the actual balance, we extract the value
of each note, which is the first element, and sum them up.
const notes = await pxe.getNotes({
owner: owner.getAddress(),
contractAddress: token.address,
storageSlot: ownerSlot,
});
const values = notes.map(note => note.note.items[0]);
const balance = values.reduce((sum, current) => sum + current.toBigInt(), 0n);
expect(balance).toEqual(100n);
Source code: /yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L151-L160
Querying public state
Public state behaves as a key-value store, much like in the EVM. This scenario is much more straightforward, in that we can directly query the target slot and get the result back as a buffer. Note that we use the TokenContract
in this example, which defines a mapping of public balances on slot 6.
await token.methods.mint_public(owner.getAddress(), 100n).send().wait();
const ownerPublicBalanceSlot = cheats.aztec.computeSlotInMap(6n, owner.getAddress());
const balance = await pxe.getPublicStorageAt(token.address, ownerPublicBalanceSlot);
expect(balance!.value).toEqual(100n);
Source code: /yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L164-L169
Logs
Last but not least, we can check the logs of events emitted by our contracts. Contracts in Aztec can emit both encrypted and unencrypted events.
At the time of this writing, only unencrypted events can be queried directly. Encrypted events are always assumed to be encrypted notes.
Querying unencrypted logs
We can query the PXE for the unencrypted logs emitted in the block where our transaction is mined. Note that logs need to be unrolled and formatted as strings for consumption.
const value = Fr.fromString('ef'); // Only 1 bytes will make its way in there :( so no larger stuff
const tx = await testContract.methods.emit_unencrypted(value).send().wait();
const filter = {
fromBlock: tx.blockNumber!,
limit: 1, // 1 log expected
};
const logs = (await pxe.getUnencryptedLogs(filter)).logs;
expect(Fr.fromBuffer(logs[0].log.data)).toEqual(value);
Source code: /yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L173-L182
Cheats
The CheatCodes
class, which we used for calculating the storage slot above, also includes a set of cheat methods for modifying the chain state that can be handy for testing.
Set next block timestamp
The warp
method sets the time for next execution, both on L1 and L2. We can test this using an isTimeEqual
function in a Test
contract defined like the following:
#[aztec(public)]
fn is_time_equal(time: Field) -> Field {
assert(context.timestamp() == time);
time
}
Source code: /yarn-project/noir-contracts/src/contracts/test_contract/src/main.nr#L144-L150
We can then call warp
and rely on the isTimeEqual
function to check that the timestamp was properly modified.
const newTimestamp = Math.floor(Date.now() / 1000) + 60 * 60 * 24;
await cheats.aztec.warp(newTimestamp);
await testContract.methods.is_time_equal(newTimestamp).send().wait();
Source code: /yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L106-L110
The warp
method calls evm_setNextBlockTimestamp
under the hood on L1.