Testing

Discover how to effectively test smart contracts on ZKsync Era ecosystem.

Welcome back to our ZKsync 101 series, your fast-track to ZKsync development! In this third guide, we transition from deploying and managing contracts to the critical phase of testing. This guide will walk you through the steps to ensure your CrowdfundingCampaign contracts, introduced in our first guide and efficiently deployed through contract factories in the second, work flawlessly.

Elevate your ZKsync toolkit with robust contract testing techniques.

Craft comprehensive tests for your CrowdfundingCampaign to ensure reliability and security.

Use Hardhat or Foundry to write and run tests, ensuring your campaigns are ready.

Dive into the world of smart contract testing and solidify the foundation of your ZKsync projects.

Setup the project

Run the following command in your terminal to initialize the project.

npx zksync-cli@latest create --template qs-testing contract-testing-quickstart
cd contract-testing-quickstart

Local Era Node

While setting up a local development environment was previously optional, testing contracts requires a more structured setup. We'll use hardhat-zksync to run tests against an In-memory node, which operates seamlessly within a separate process for an optimized testing workflow.

If you have not set up your local era node yet, follow the instructions in the Getting Started section.

Within the hardhat.config.ts, you'll observe the zksync flag set to true under the hardhat network, indicating the integration with ZKsync's testing environment.

hardhat.config.ts
hardhat: {
  zksync: true,
},

To use the In-memory node for testing, ensure the hardhat network is selected with the zksync flag enabled. This setup initiates the node alongside your tests and ensures it terminates once testing is complete. The node's port allocation starts at the default 8011, facilitating smooth and isolated test execution.

Secondly within the hardhat.config.ts, you'll observe the importing of @nomicfoundation/hardhat-chai-matchers. This plugin provides Hardhat with an extended suite of assertion methods tailored for contract testing, significantly improving the testing toolkit available for your project.

import "@nomicfoundation/hardhat-chai-matchers";

Test Wallet Configuration

For testing purposes, we use pre-configured, well-funded wallets. During this testing guide, we will use the following pre-configured wallet, which eliminates the need for manual funding or setup:

  • Account Address: 0x36615Cf349d7F6344891B1e7CA7C72883F5dc049
  • Private Key: 0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110

This streamlined approach allows us to focus on writing and running effective tests.


Compile the CrowdfundingCampaign contract

Now that our setup is complete, it's time to focus on the core of this guide - testing our CrowdfundingCampaign.sol contract. Here's a quick refresher on its structure:

Thorough testing involves scrutinizing every function and aspect of our contract, including potential failure scenarios. In this guide, we'll focus in on the contribute method to ensure it's tested.

As a challenge to hone your testing skills further, consider writing additional tests for the withdrawFunds, getTotalFundsRaised, and getFundingGoal methods, expanding your test coverage and reinforcing the reliability of the contract.

Smart contracts deployed to ZKsync must be compiled using our custom compiler. zksolc is the compiler used for Solidity.

To compile the contracts in a project, run the following command:

npm run compile

Upon successful compilation, you'll receive output detailing the zksolc and solc versions used during compiling and the number of Solidity files compiled.

Compiling contracts for ZKsync Era with zksolc v1.4.1 and solc v0.8.17
Compiling 15 Solidity files
Successfully compiled 15 Solidity files

The compiled artifacts will be located in the /artifacts-zk folder.


Test CrowdfundingCampaign

This section describes testing the CrowdfundingCampaign.sol contract. Let's start by reviewing the tests for CrowdfundingCampaign.sol contract provided during the initialization step in the /tests directory, specifically the crowdFunding.test.ts file.

crowdFunding.test.ts
import "@nomicfoundation/hardhat-chai-matchers";
import { expect } from "chai";
import { ethers } from "ethers";
import { getWallet, LOCAL_RICH_WALLETS, deployContract } from "../deploy/utils";

describe("CrowdfundingCampaign", function () {
  let campaign;
  let owner, addr1, addr2;

  beforeEach(async function () {
    owner = getWallet(LOCAL_RICH_WALLETS[0].privateKey);
    addr1 = getWallet(LOCAL_RICH_WALLETS[1].privateKey);
    addr2 = getWallet(LOCAL_RICH_WALLETS[2].privateKey);
    const fundingGoalInWei = ethers.parseEther('1').toString();
    campaign = await deployContract("CrowdfundingCampaign", [fundingGoalInWei], { wallet: owner, silent: true });
  });

  describe("Contribute", function () {
    it("should reject contributions of 0", async function () {
      await expect(campaign.connect(addr1).contribute({ value: ethers.parseEther("0") })).to.be.revertedWith("Contribution must be greater than 0");
    });

    it("should aggregate contributions in totalFundsRaised", async function () {
      await campaign.connect(addr1).contribute({ value: ethers.parseEther("0.5") });
      await campaign.connect(addr2).contribute({ value: ethers.parseEther("0.3") });
      expect(await campaign.getTotalFundsRaised()).to.equal(ethers.parseEther("0.8"));
    });

    it("should emit GoalReached event when funding goal is met", async function () {
      await expect(campaign.connect(addr1).contribute({ value: ethers.parseEther("1") }))
        .to.emit(campaign, "GoalReached")
        .withArgs(ethers.parseEther("1"));
    });
  });
});
  • Initialization: Each test case initializes with fresh contract instances and predefined rich wallet accounts to simulate various contributors and the contract owner.
  • Deployment: The CrowdfundingCampaign contract is deployed using the deployContract utility, setting a specific funding goal for each test scenario.

contribute Method Tests:

  • Zero Contributions: Verifies that the contract correctly rejects contribution attempts with zero value, ensuring the integrity of the contribution process.
  • Funds Aggregation: Tests the contract's ability to accurately aggregate contributions from multiple addresses and update the totalFundsRaised accordingly.
  • Goal Achievement: Checks for the GoalReached event emission upon meeting the funding goal, confirming the contract's responsiveness to achieving its set target.

Execute the test command corresponding to your package manager:

npx hardhat test --network hardhat

Upon completion, the test suite will provide a summary of all executed tests, indicating their success or failure:

  CrowdfundingCampaign
    Contribute
 should reject contributions of 0 (45ms)
 should aggregate contributions in totalFundsRaised (213ms)
 should emit GoalReached event when funding goal is met (113ms)

  3 passing (1s)

🎉 Congratulations! The contribute method of the CrowdfundingCampaign contract has been thoroughly tested and is ready for action.

foundry-zksync is still in an alpha stage, so some features might not be fully supported yet and may not work as fully intended. It is open-sourced and contributions are welcomed.

Run the following command in your terminal to initialize the Foundry project.

npx zksync-cli@latest create --template qs-fs-testing foundry-contract-testing-quickstart
cd foundry-contract-testing-quickstart

Test the CrowdfundingCampaign contract

Now that our setup is complete, it's time to focus on the core of this guide - testing our CrowdfundingCampaign.sol contract. Here's a quick refresher on its structure:

Thorough testing involves scrutinizing every function and aspect of our contract, including potential failure scenarios. In this guide, we'll focus in on the contribute method to ensure it's tested.

As a challenge to hone your testing skills further, consider devising additional tests for the withdrawFunds, getTotalFundsRaised, and getFundingGoal methods, expanding your test coverage and reinforcing the reliability of the contract.

Compile contract

Smart contracts deployed to ZKsync must be compiled using our custom compiler. For this particular guide we are making use of zksolc.

To compile the contracts in the project, run the following command:

forge build --zksync --use 0.8.20

Upon successful compilation, you'll receive output detailing the zksolc and solc versions used during compiling and the number of Solidity files compiled.

[⠒] Compiling...
[⠃] Compiling 22 files with 0.8.20
[⠊] Solc 0.8.20 finished in 736.48ms
Compiler run successful!
Compiling contracts for ZKsync Era with zksolc v1.4.0

The compiled zkEVM artifacts will be located in the /zkout folder, and the solc artifacts will be located in the /out folder.

Run the test command

This section describes the testing CrowdfundingCampaign.sol contract. Let's start by reviewing the tests for CrowdfundingCampaign.sol contract provided during the initialization step in the /test directory, specifically the CrowdfundingCampaign.t.sol file.

CrowdfundingCampaign.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../src/CrowdfundingCampaign.sol";

contract CrowdfundingCampaignTest is Test {
    CrowdfundingCampaign campaign;
    event GoalReached(uint256 totalFundsRaised);
    address owner;
    address addr1;
    address addr2;

    function setUp() public {
        owner = address(this);

        addr1 = vm.addr(1);
        addr2 = vm.addr(2);

        campaign = new CrowdfundingCampaign(1 ether);
        console.log("CrowdfundingCampaign deployed at: %s", address(campaign));
    }

    function test_RejectZeroContributions() public {
        vm.expectRevert("Contribution must be greater than 0");
        campaign.contribute{value: 0}();
    }

    function test_AggregateContributions() public {
        uint256 initialTotal = campaign.getTotalFundsRaised();

        vm.prank(addr1);
        vm.deal(addr1, 2 ether);
        campaign.contribute{value: 0.5 ether}();

        vm.prank(addr2);
        vm.deal(addr2, 2 ether);
        campaign.contribute{value: 0.3 ether}();

        assertEq(campaign.getTotalFundsRaised(), initialTotal + 0.8 ether);
    }

    function test_EmitGoalReachedWhenFundingGoalMet() public {
        vm.prank(addr1);
        vm.deal(addr1, 2 ether);
        vm.expectEmit(true, true, false, true);
        emit GoalReached(1 ether);
        campaign.contribute{value: 1 ether}();
    }
}
  • Environment Setup: Leverages Foundry's Test contract and setup functions to prepare the test environment, ensuring a fresh state for each test case.
  • Deployment and Address Simulation: Deploys the CrowdfundingCampaign contract within the test setup and simulates addresses using Foundry's vm.addr() function for various test actors.

contribute Method Tests:

  • Zero Contribution Validation: Asserts that the contract rejects contribution attempts with zero value, testing the contract's input validation logic.
  • Contribution Aggregation: Confirms the contract's ability to correctly tally contributions from various addresses, ensuring accurate tracking of the total funds raised.
  • Event Emission Upon Goal Achievement: Utilizes Foundry's vm.expectEmit to anticipate the GoalReached event when the funding goal is met, validating the contract's event logic and state transitions.

Execute the test command:

forge test --zksync

Upon completion, the test suite will provide a summary of all executed tests, indicating their success or failure:

Ran 3 tests for test/CrowdfundingCampaign.t.sol:CrowdfundingCampaignTest
[PASS] test_AggregateContributions() (gas: 29204)
[PASS] test_EmitGoalReachedWhenFundingGoalMet() (gas: 18862)
[PASS] test_RejectZeroContributions() (gas: 8148)
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 44.03ms (43.94ms CPU time)

Ran 1 test suite in 48.11ms (44.03ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

🎉 Congratulations! The contribute method of the CrowdfundingCampaign contract has been thoroughly tested and is ready for action.

Takeaways

  • Testing: Understanding contract testing is important for ensuring the reliability and security of your smart contracts on ZKsync. Proper testing safeguards against unforeseen errors and vulnerabilities.
  • Comprehensive Coverage: Achieving comprehensive test coverage, including both positive and negative testing scenarios, is essential for a robust smart contract. This guide emphasized the contribute method, but testing should encompass all aspects of your contract.
  • Tooling Efficiency: Leveraging Hardhat or Foundry for testing provides a streamlined and efficient workflow. These tools offer powerful features and plugins, like @nomicfoundation/hardhat-chai-matchers, that enhance the testing process.

Next Steps

With a solid foundation in contract testing, you're well-equipped to advance your ZKsync development journey. Consider the following steps to further your expertise:

  • Upgradeability: Dive into the Upgradeability article focusing on contract upgradeability. Learning to make your contracts upgradeable will enable you to update and improve your smart contracts over time without losing state or funds.
  • Advanced ZKsync Integrations: Explore deeper into ZKsync's ecosystem by implementing features like account abstraction and paymasters to enhance user experience and contract flexibility.
  • Community Engagement and Contribution: Join the vibrant ZKsync community. Participate in forums, Discord, or GitHub discussions. Sharing insights, asking queries, and contributing can enrich the ecosystem and your understanding of ZKsync.

Made with ❤️ by the ZKsync Community