Smart Contracts: Automated Testing and Test-Driven Development (TDD)

Created by:

About this lesson

Welcome to this lesson where we’ll be adding automated tests for our TierNFT smart contract created in the previous tutorial. In case you haven’t completed it already, we have a fast-track guide below to that project set-up - assuming you’re confident with Solidity and a professional development environment. If you're not, we’d recommend completing the tutorials in this track before this one, before moving on to Connecting your TierNFT Smart Contract to a Frontend, our project series finalé.

Back here, you’ll learn about the benefits of writing automated tests, general testing concepts and best practices. You’ll walk away with many practical tests for smart contracts and nibble on some food for thought about test-driven development.

As you dive in to the lesson, you'll come across some thought-provoking checkpoint questions designed to test your existing knowledge, measure how well you're absorbing the fresh content, and we might even ask you to predict next steps in the lesson. And to wrap up, a final quiz awaits. To complete this adventure might take anywhere from one to four hours. That all depends on your previous experience and how much new ground you need to cover. But enjoy the journey and remember to take regular breaks along the way. We care about your well-being so remember to appreciate nature's gifts and "go touch some grass" for a while. 🌱 Embrace the process.

Let's raise the sails to catch the wind of what's to come:

And set sail . . .

Why automated testing?

Testing can:

  • prove code actually works
  • help you write more modular code
  • ensure code doesn't break when you or others make changes or add new features. People new to the project may not even know when something breaks without tests.
  • ensure a bug is fixed and won't come back again
  • give you a safety net when you make improvements to the code later

Tests are a tool for writing your code and making sure it keeps working as expected when changes are made. Automated tests don't fully replace manually testing your smart contract but it's an important tool to ensuring your contract will work as expected.

Quick breakdown of lesson steps

  • Set up our local development environment
  • Write unit tests
  • Test NFT contract initialisation, mint and withdrawl functions
  • Refactor tokenURI() function and test new helper methods:
  • Summary: understanding the concept of Test-Driven Development (TDD)

Developer tools and libraries we'll be using

  • Hardhat development environment
  • Mocha JavaScript framework for writing units tests
  • Chai, an assertion library compatible with Mocha
  • OpenZeppelin libraries will help bring clarity to testing our contract

Let's Get Started

Setting up our project dependencies

If you have an existing project from your previous work on the TierNFT lesson, you have all the Hardhat dependencies installed. Otherwise let's create a new Hardhat project and copy in the contract which we'll be testing. To create a new project, please refer to Lesson 3 - Tier NFTs and search for the “First things first 👷‍♂️" section to get your project going. Follow the steps until "Let’s start coding”, then open up your code editor. If you are using VSCode, type code . in your terminal to open VSCode. After running through that section you'll have a working Hardhat project for the next steps.

Add test command for running tests

Add a line in your scripts section of package.json so it looks like this below. You may already have other scripts' lines so don't get rid of those. If you already have a "test" script line, make sure it now has hardhat test --network hardhat. If you have multiple script lines make sure the last line does not have a comma at the end, otherwise you'll see an error.

"scripts": {
  "something": "some other script",
  "test": "hardhat test --network hardhat"
},

Bring in the contract we'll be testing

If you don't already have TierNFT.sol in your project from Lesson 3, below is that same contract. Please create a new file called TierNFT.sol in a contracts folder in the root of the project, and copy in this code:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.12;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

string constant SVG_START = '<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500" fill="none" font-family="sans-serif"><defs><filter id="A" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="500" width="500"><feDropShadow dx="1" dy="2" stdDeviation="8" flood-opacity=".67" width="200%" height="200%" /></filter><linearGradient id="B" x1="0" y1="0" x2="15000" y2="0" gradientUnits="userSpaceOnUse"><stop offset=".05" stop-color="#ad00ff" /><stop offset=".23" stop-color="#4e00ec" /><stop offset=".41" stop-color="#ff00f5" /><stop offset=".59" stop-color="#e0e0e0" /><stop offset=".77" stop-color="#ffd810" /><stop offset=".95" stop-color="#ad00ff" /></linearGradient><linearGradient id="C" x1="0" y1="60" x2="0" y2="110" gradientUnits="userSpaceOnUse"><stop stop-color="#d040b8" /><stop offset="1" stop-color="#e0e0e0" /></linearGradient></defs><path fill="url(#B)" d="M0 0h15000v500H0z"><animateTransform attributeName="transform" attributeType="XML" type="translate" from="0 0" to="-14500 0" dur="16s" repeatCount="indefinite" /></path><circle fill="#1d1e20" cx="100" cy="90" r="45" filter="url(#A)" /><text x="101" y="99" text-anchor="middle" class="nftLogo" font-size="32px" fill="url(#C)" filter="url(#A)">D_D<animateTransform attributeName="transform" attributeType="XML" type="rotate" from="0 100 90" to="360 100 90" dur="5s" repeatCount="indefinite" /></text><g font-size="32" fill="#fff" filter="url(#A)"><text x="250" y="280" text-anchor="middle" class="tierName">';
string constant SVG_END = "</text></g></svg>";

string constant TIER_NAME_0 = "Basic";
string constant TIER_NAME_1 = "Medium";
string constant TIER_NAME_2 = "Premium";
uint256 constant TIER_VALUE_0 = 0.01 ether;
uint256 constant TIER_VALUE_1 = 0.02 ether;
uint256 constant TIER_VALUE_2 = 0.05 ether;

contract TierNFT is ERC721, Ownable {
    uint256 public totalSupply;
    mapping(uint256 => uint256) public tokenTier;

    constructor(string memory _name, string memory _symbol)
        ERC721(_name, _symbol)
    {}

    function mint() public payable {
        require(
            msg.value >= TIER_VALUE_0,
            "Not enough value for the minimum Tier"
        );

        uint256 tierId = 0;
        if (msg.value >= TIER_VALUE_2) {
            tierId = 2;
        } else if (msg.value >= TIER_VALUE_1) {
            tierId = 1;
        }

        totalSupply++;
        _safeMint(msg.sender, totalSupply);
        tokenTier[totalSupply] = tierId;
    }

    // Create the tokenURI json on the fly without creating files individually
    function tokenURI(uint256 tokenId)
        public
        view
        virtual
        override
        returns (string memory)
    {
        require(_exists(tokenId), "Nonexistent token");

        string memory tierName = tokenTier[tokenId] == 2
            ? TIER_NAME_2
            : tokenTier[tokenId] == 1
            ? TIER_NAME_1
            : TIER_NAME_0;

        string memory imageSVG = string(
            abi.encodePacked(SVG_START, tierName, SVG_END)
        );

        string memory json = Base64.encode(
            bytes(
                string(
                    abi.encodePacked(
                        '{"name": "',
                        name(),
                        " #",
                        Strings.toString(tokenId),
                        '", "description": "TierNFTs collection", "image": "data:image/svg+xml;base64,',
                        Base64.encode(bytes(imageSVG)),
                        '","attributes":[{"trait_type": "Tier", "value": "',
                        tierName,
                        '" }]}'
                    )
                )
            )
        );

        return string(abi.encodePacked("data:application/json;base64,", json));
    }

    // Function to withdraw funds from contract
    function withdraw() public onlyOwner {
        // Check that we have funds to withdraw
        uint256 balance = address(this).balance;
        require(balance > 0, "Balance should be > 0");

        // Withdraw funds.
        (bool success, ) = payable(owner()).call{value: balance}("");
        require(success, "Withdraw failed");
    }
}

Verify Solidity Version

One small, but crucial change before we proceed. Ensure the version of Solidity being used to run the contract is the same one configured in hardhat.config.js.

Copy the Solidity version from the pragma statement at the top of your contract, to the hardhat.config.js file and replace that solidity: "version" with that of your contract.

For example, since our contract is using Solidity 0.8.12:

require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();

module.exports = {
  solidity: "0.8.12",
};

Go touch grass 😊 🌱

What do we want to test on our TierNFT contract?

Now that the tools are set up, let's talk about what we want to test in our smart contract. We'll then create the tests.

What is the contract trying to do?

  • it accepts payment and mints NFTs
  • it renders art as a SVG image
  • it allows the contract owner to withdraw funds

If any of these functions are broken, you may not be able to mint NFTs, render the correct art or withdraw funds at the end.

We want to test that those functions work in a variety of cases.

For example, we can test that a mint happens when enough Eth is spent and that a mint fails if there is not enough Eth spent.

Creating our test file

If you don't have one already, create a test folder in the root of your project, and add a new file called tier-nft.test.js which will hold our new test code.

Start testing constructor(), mint() and withdraw()

Let's start with testing the constructor and making sure we can set and retrieve the NFT name and symbol.

In addition to these first two tests we'll add setup code that we'll reuse for all the other tests too.

Please add the following code to your new tier-nft.test.js file:

const { expect } = require("chai");

const CONTRACT_NAME = "TierNFT";
const COLLECTION_NAME = "TierNFT";
const COLLECTION_SYMBOL = "Tier";

describe("TierNFT", function () {
  let contract;
  let owner;
  let otherUser;

  beforeEach(async function () {
    const Contract = await hre.ethers.getContractFactory(CONTRACT_NAME);

    const [_owner, _otherUser] = await hre.ethers.getSigners();
    owner = _owner;
    otherUser = _otherUser;

    contract = await Contract.deploy(COLLECTION_NAME, COLLECTION_SYMBOL);
    await contract.waitForDeployment();
  });

  describe("constructor", async () => {
    it("set proper collection name", async function () {
      const name = await contract.name();
      expect(name).to.equal("TierNFT");
    });

    it("set proper collection symbol", async function () {
      const symbol = await contract.symbol();
      expect(symbol).to.equal("Tier");
    });
  });
});
💡

Before we walk through, let's make sure everything is working correctly. Run the tests using npm test and you should see these two initial tests pass. This is an important milestone! You created your first tests and they now pass.

Now let's walk through the test code we've pasted in and explain what it does.

💡

expect() from the Chai library is used to test various conditions. There are many examples in this lesson.

At the top we see constants with the name and symbol of the NFT collection:

const CONTRACT_NAME = "TierNFT";
const COLLECTION_NAME = "TierNFT";
const COLLECTION_SYMBOL = "Tier";

Next is a describe('TierNFT', function () { ... line. What's cool about describe() is that it logically groups tests together so you'll see nicely organized output.

When we run the tests we see:

TierNFT
    constructor
set proper collection name
set proper collection symbol

Notice "TierNFT" comes from the first describe() block and constructor is from the inner describe() block. The tests are then indented under there. Makes for a nicely organized suite of tests.

Next notice the beforeEach() code block. When tests run it's important that the contract is deployed from scratch for each test and this is where that happens. So before each test, this code will run and re-inialize everything, which prevents one test's results from interfering with another one.

What's happening in beforeEach()?

The hardhat framework gives us the tools to deploy our contract to a locally-running blockchain. So when the beforeEach() code finishes the tests have a fully deployed contract ready to call methods on. In addition there are two wallets provided by hardhat to use for testing, including owner who has all the permissions, since it deployed the contract, and otherUser with fewer permissions. These will be handy when testing withdraw later on.

Those let statements above are to hold these values so we can use them throughout all our tests.

Finally we then come across our first two actual tests:

it("set proper collection name", async function () {
  const name = await contract.name();
  expect(name).to.equal("TierNFT");
});

it("set proper collection symbol", async function () {
  const symbol = await contract.symbol();
  expect(symbol).to.equal("Tier");
});

They each call a method and verify the results. "Is the contract name really TierNFT?" "Is the contract symbol really Tier?"

💡

You'll notice a lot of await statements. That's because most of these calls are asynchronous. Without await the program would keep going without waiting for the result. You may want to take a detour and learn more about what those do, but for now, think of them as basically waiting for an asynchronous call to return before moving on to the next line.

General testing concepts

  • Tests should be as simple as possible. By moving setup code into beforeEach() that helps keep it easy to focus on the test itself. If you pack too much into a test, you could introduce bugs in the test code itself that may lead to incorrect results, and if something fails it may be hard to debug which piece is actually broken.
  • Therefore it's best practice to have many separate tests with one expect in each. In practice we could have tested both the name and the symbol in one test, but see bullet 1 above!
  • And basically every test has two pieces (in addition to the beforeEach() setup code): a call of a method to be tested, and code to see if the expected result happened.

Lastly when you write a test, always make sure it fails first! So try changing the expect line to test the name Anita instead of TierNFT, run the test via npm test and make sure it fails. If your test passes when you make an obvious change like that, something is wrong with your test.

So after intentionally causing one of our tests to fail, here is what the failure looks like:


  TierNFT
    construction
      1) set proper collection name
set proper collection symbol


  1 passing (2s)
  1 failing

  1) TierNFT
       construction
         set proper collection name:

      AssertionError: expected 'TierNFT' to equal 'Anita'
      + expected - actual

      -TierNFT
      +Anita

Let's add tests for mint()

Now is time to add tests for our mint() method. We'll group these new tests into a new describe section.

Where exactly do we add this describe block?

Currently the end of our tier-nft.test.js looks like this, with just one additional comment line I added that says // this is where existing describe section ends:

  describe('constructor', async () => {
    it('set proper collection name', async function () {
      const name = await contract.name()
      expect(name).to.equal('TierNFT')
    })

    it('set proper collection symbol', async function () {
      const symbol = await contract.symbol()
      expect(symbol).to.equal('Tier')
    })
  })
  // this is where existing describe section ends
})

Our new describe section shown below will replace that line that says // this is where existing describe section ends.

💡

Make sure you keep that last line }) which is the very end of the block of code that holds all of the describe blocks you're adding in this whole lesson.

describe("mint", async () => {
  it("should not mint if value is below the minimum tier", async function () {
    await expect(
      contract.mint({
        value: hre.ethers.parseEther("0.001"),
      }),
    ).to.be.revertedWith("Not enough value for the minimum Tier");
    await expect(await contract.totalSupply()).to.equal(0);
  });

  it("should increase total supply", async function () {
    await contract.mint({
      value: hre.ethers.parseEther("0.01"),
    });
    await expect(await contract.totalSupply()).to.equal(1);
  });

  it("should mint Tier 0", async function () {
    await contract.mint({
      value: hre.ethers.parseEther("0.01"),
    });
    await expect(await contract.tokenTier(1)).to.equal(0);
  });

  it("should mint Tier 1", async function () {
    await contract.mint({
      value: hre.ethers.parseEther("0.02"),
    });
    await expect(await contract.tokenTier(1)).to.equal(1);
  });

  it("should mint Tier 2", async function () {
    await contract.mint({
      value: hre.ethers.parseEther("0.05"),
    });
    await expect(await contract.tokenTier(1)).to.equal(2);
  });
});
💡
Run the tests using npm test

These tests introduce additional code that are helpful when testing contracts.

With minting, one needs to pay the right price, we should also see the total supply of TierNFTs increase with each mint, and since we're minting 3 different tiers, we need to test each one of those gets minted based on the right price.

The first test is a great example of how to test to ensure an expected error occurs. If not enough Eth is sent, the contract fails with an error and the transaction is reverted as if nothing happened.

In this first test we want to ensure our method call is properly reverted when the proper amount of Eth is not provided. This happens here:

await expect(
  contract.mint({
    value: hre.ethers.parseEther("0.001"),
  }),
).to.be.revertedWith("Not enough value for the minimum Tier");

We're expecting the call to be reverted when not sending enough payment, along with a specific error message from the contract.

Here we're just calling mint() with a parameter that sets the amount of Eth we want to send. In this case 0.001 Eth is less than our contract minimum of 0.01 Eth so this correctly fails. Our test confirms this.

Now for the remaining mint tests, these all depend on a successful mint, so they all need to mint with a minimum of 0.01 Eth. You'll see the same line at the top of the remaining tests in this section.

And each of those remaining tests has a single expect() line to test the various conditions upon a successful mint. The tier-related tests each put in enough Eth to trigger a mint for the correct tier.

Notice each line tests the first token (contract.tokenTier(1)). Remember that each test starts from scratch with no supply of NFTs, so after you mint one in a test, you just have one token, the first one.

Testing withdraw()

Let's look at testing withdraw() next with these 3 tests:

describe("withdrawal", async () => {
  it("should error if not owner", async function () {
    await expect(contract.connect(otherUser).withdraw()).to.be.revertedWith(
      "Ownable: caller is not the owner",
    );
  });

  it("should error if balance is zero", async function () {
    await expect(contract.withdraw()).to.be.revertedWith("Balance should be > 0");
  });

  it("should success if owner", async function () {
    await contract.mint({
      value: hre.ethers.parseEther("0.01"),
    });
    expect(await hre.ethers.provider.getBalance(contract.getAddress())).to.equal(
      hre.ethers.parseEther("0.01"),
    );
    await contract.withdraw();
    expect(await hre.ethers.provider.getBalance(contract.getAddress())).to.equal(
      hre.ethers.parseEther("0"),
    );
  });
});
💡
Run the tests using npm test

You'll see similar patterns from the previous mint() tests. Here are a couple of additional highlights:

  • In the contract, only owner can withdraw funds, so the first test changes the user to otherUser and makes sure that wallet can not mint. The snippet to change wallets before calling the method is: contract.connect(otherUser).
  • Another condition to test is that an error is raised if an owner tries to withdraw from a zero-balance contact
  • Finally let's make sure an owner can successfully withdraw funds. Notice the withdraw() method call is surrounded by hre.ethers.provider.getBalance(contract.address) lines. When we mint we know the contract now has 0.01 Eth so we check that. Then after running withdraw, check the balance again and it should be back to zero since all funds are withdrawn. Ok, yes we do have two expect() lines in one test that breaks our rule of one expect per test, but as long as the test is kept simple, we can choose what makes sense for what we're trying to test.

Go touch grass 😊 🌱

Testing tokenURI()

Here's where things get even more interesting!

Notice the current version of tokenURI() in our contract outputs a giant opaque Base64 string that's not discernable by humans. For example the text "Hello World" when encoded is SGVsbG8gV29ybGQ= (Base64 encoder)

This is impossible to test because if you make a change to the code, you get a different Base64 string. But how do you know if it's correct?

You need to refactor this method into smaller helper methods, each of which are more testable.

But first let's use the power of testing to make sure we don't break the implementation as we refactor. Let's wrap tokenURI() with a test that DOES capture this opaque string. This gives us a "safety net" as we refactor our code. If we break things while dividing this problem into helper methods, the test will help us discover that. We can then toss this test at the end.

Here's an example of what that looks like:

it("should return base64-encoded data for tokenURI for Tier 0", async function () {
  await contract.mint({
    value: hre.ethers.parseEther("0.01"),
  });
  await expect(await contract.tokenURI(1)).to.equal(
    "data:application/json;base64,eyJuYW1lIjogIlRpZXJORlQgIzEiLCAiZGVzY3JpcHRpb24iOiAiVGllck5GVHMgY29sbGVjdGlvbiIsICJpbWFnZSI6ICJkYXRhOmltYWdlL3N2Zyt4bWw7YmFzZTY0LFBITjJaeUI0Yld4dWN6MGlhSFIwY0RvdkwzZDNkeTUzTXk1dmNtY3ZNakF3TUM5emRtY2lJSGRwWkhSb1BTSTFNREFpSUdobGFXZG9kRDBpTlRBd0lpQm1hV3hzUFNKdWIyNWxJaUJtYjI1MExXWmhiV2xzZVQwaWMyRnVjeTF6WlhKcFppSStQR1JsWm5NK1BHWnBiSFJsY2lCcFpEMGlRU0lnWTI5c2IzSXRhVzUwWlhKd2IyeGhkR2x2YmkxbWFXeDBaWEp6UFNKelVrZENJaUJtYVd4MFpYSlZibWwwY3owaWRYTmxjbE53WVdObFQyNVZjMlVpSUdobGFXZG9kRDBpTlRBd0lpQjNhV1IwYUQwaU5UQXdJajQ4Wm1WRWNtOXdVMmhoWkc5M0lHUjRQU0l4SWlCa2VUMGlNaUlnYzNSa1JHVjJhV0YwYVc5dVBTSTRJaUJtYkc5dlpDMXZjR0ZqYVhSNVBTSXVOamNpSUhkcFpIUm9QU0l5TURBbElpQm9aV2xuYUhROUlqSXdNQ1VpSUM4K1BDOW1hV3gwWlhJK1BHeHBibVZoY2tkeVlXUnBaVzUwSUdsa1BTSkNJaUI0TVQwaU1DSWdlVEU5SWpBaUlIZ3lQU0l4TlRBd01DSWdlVEk5SWpBaUlHZHlZV1JwWlc1MFZXNXBkSE05SW5WelpYSlRjR0ZqWlU5dVZYTmxJajQ4YzNSdmNDQnZabVp6WlhROUlpNHdOU0lnYzNSdmNDMWpiMnh2Y2owaUkyRmtNREJtWmlJZ0x6NDhjM1J2Y0NCdlptWnpaWFE5SWk0eU15SWdjM1J2Y0MxamIyeHZjajBpSXpSbE1EQmxZeUlnTHo0OGMzUnZjQ0J2Wm1aelpYUTlJaTQwTVNJZ2MzUnZjQzFqYjJ4dmNqMGlJMlptTURCbU5TSWdMejQ4YzNSdmNDQnZabVp6WlhROUlpNDFPU0lnYzNSdmNDMWpiMnh2Y2owaUkyVXdaVEJsTUNJZ0x6NDhjM1J2Y0NCdlptWnpaWFE5SWk0M055SWdjM1J2Y0MxamIyeHZjajBpSTJabVpEZ3hNQ0lnTHo0OGMzUnZjQ0J2Wm1aelpYUTlJaTQ1TlNJZ2MzUnZjQzFqYjJ4dmNqMGlJMkZrTURCbVppSWdMejQ4TDJ4cGJtVmhja2R5WVdScFpXNTBQanhzYVc1bFlYSkhjbUZrYVdWdWRDQnBaRDBpUXlJZ2VERTlJakFpSUhreFBTSTJNQ0lnZURJOUlqQWlJSGt5UFNJeE1UQWlJR2R5WVdScFpXNTBWVzVwZEhNOUluVnpaWEpUY0dGalpVOXVWWE5sSWo0OGMzUnZjQ0J6ZEc5d0xXTnZiRzl5UFNJalpEQTBNR0k0SWlBdlBqeHpkRzl3SUc5bVpuTmxkRDBpTVNJZ2MzUnZjQzFqYjJ4dmNqMGlJMlV3WlRCbE1DSWdMejQ4TDJ4cGJtVmhja2R5WVdScFpXNTBQand2WkdWbWN6NDhjR0YwYUNCbWFXeHNQU0oxY213b0kwSXBJaUJrUFNKTk1DQXdhREUxTURBd2RqVXdNRWd3ZWlJK1BHRnVhVzFoZEdWVWNtRnVjMlp2Y20wZ1lYUjBjbWxpZFhSbFRtRnRaVDBpZEhKaGJuTm1iM0p0SWlCaGRIUnlhV0oxZEdWVWVYQmxQU0pZVFV3aUlIUjVjR1U5SW5SeVlXNXpiR0YwWlNJZ1puSnZiVDBpTUNBd0lpQjBiejBpTFRFME5UQXdJREFpSUdSMWNqMGlNVFp6SWlCeVpYQmxZWFJEYjNWdWREMGlhVzVrWldacGJtbDBaU0lnTHo0OEwzQmhkR2crUEdOcGNtTnNaU0JtYVd4c1BTSWpNV1F4WlRJd0lpQmplRDBpTVRBd0lpQmplVDBpT1RBaUlISTlJalExSWlCbWFXeDBaWEk5SW5WeWJDZ2pRU2tpSUM4K1BIUmxlSFFnZUQwaU1UQXhJaUI1UFNJNU9TSWdkR1Y0ZEMxaGJtTm9iM0k5SW0xcFpHUnNaU0lnWTJ4aGMzTTlJbTVtZEV4dloyOGlJR1p2Ym5RdGMybDZaVDBpTXpKd2VDSWdabWxzYkQwaWRYSnNLQ05ES1NJZ1ptbHNkR1Z5UFNKMWNtd29JMEVwSWo1RVgwUThZVzVwYldGMFpWUnlZVzV6Wm05eWJTQmhkSFJ5YVdKMWRHVk9ZVzFsUFNKMGNtRnVjMlp2Y20waUlHRjBkSEpwWW5WMFpWUjVjR1U5SWxoTlRDSWdkSGx3WlQwaWNtOTBZWFJsSWlCbWNtOXRQU0l3SURFd01DQTVNQ0lnZEc4OUlqTTJNQ0F4TURBZ09UQWlJR1IxY2owaU5YTWlJSEpsY0dWaGRFTnZkVzUwUFNKcGJtUmxabWx1YVhSbElpQXZQand2ZEdWNGRENDhaeUJtYjI1MExYTnBlbVU5SWpNeUlpQm1hV3hzUFNJalptWm1JaUJtYVd4MFpYSTlJblZ5YkNnalFTa2lQangwWlhoMElIZzlJakkxTUNJZ2VUMGlNamd3SWlCMFpYaDBMV0Z1WTJodmNqMGliV2xrWkd4bElpQmpiR0Z6Y3owaWRHbGxjazVoYldVaVBrSmhjMmxqUEM5MFpYaDBQand2Wno0OEwzTjJaejQ9IiwiYXR0cmlidXRlcyI6W3sidHJhaXRfdHlwZSI6ICJUaWVyIiwgInZhbHVlIjogIkJhc2ljIiB9XX0=",
  );
});

First, here's the new contract that now contains a bunch of new helper methods to make tokenURI() testing possible.

You'll still see tokenURI() but its implementation now calls several new helper methods.

Let's quickly describe the new helper methods you'll see below. Each one breaks down the "create an encoded SVG" problem into manageable functions that can each be tested and understood by humans writing these tests.

  • tierNameOf() just takes one of 3 tiers and returns the name of that tier. Easy to test!
  • imageSVGOf() just concatenates three values into a string. Also easy to test.
  • finalJSON() also just concatenates multiple values into a string, and like the method says, it creates a string that represents the final JSON blob.
  • Notice that none of these methods does the Base64 encoding, which immediately makes it impossible for a human to interpret the results and that makes for a bad test.
  • So that does leave some lifting at the end still in tokenURI() but it's much simpler now. It calls well-tested helper methods that do the heavy lifting and finally does the dreaded Base64 encoding. Which tests, and how many to write, is subjective. In this case the non-automated-tested part is minimized and a test wasn't added. Though it is possible someone will come along, and with a typo break this file. It's up to you as a tester to decide if more tests are needed for peace of mind.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.12;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

string constant SVG_START = '<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500" fill="none" font-family="sans-serif"><defs><filter id="A" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="500" width="500"><feDropShadow dx="1" dy="2" stdDeviation="8" flood-opacity=".67" width="200%" height="200%" /></filter><linearGradient id="B" x1="0" y1="0" x2="15000" y2="0" gradientUnits="userSpaceOnUse"><stop offset=".05" stop-color="#ad00ff" /><stop offset=".23" stop-color="#4e00ec" /><stop offset=".41" stop-color="#ff00f5" /><stop offset=".59" stop-color="#e0e0e0" /><stop offset=".77" stop-color="#ffd810" /><stop offset=".95" stop-color="#ad00ff" /></linearGradient><linearGradient id="C" x1="0" y1="60" x2="0" y2="110" gradientUnits="userSpaceOnUse"><stop stop-color="#d040b8" /><stop offset="1" stop-color="#e0e0e0" /></linearGradient></defs><path fill="url(#B)" d="M0 0h15000v500H0z"><animateTransform attributeName="transform" attributeType="XML" type="translate" from="0 0" to="-14500 0" dur="16s" repeatCount="indefinite" /></path><circle fill="#1d1e20" cx="100" cy="90" r="45" filter="url(#A)" /><text x="101" y="99" text-anchor="middle" class="nftLogo" font-size="32px" fill="url(#C)" filter="url(#A)">D_D<animateTransform attributeName="transform" attributeType="XML" type="rotate" from="0 100 90" to="360 100 90" dur="5s" repeatCount="indefinite" /></text><g font-size="32" fill="#fff" filter="url(#A)"><text x="250" y="280" text-anchor="middle" class="tierName">';
string constant SVG_END = "</text></g></svg>";

string constant TIER_NAME_0 = "Basic";
string constant TIER_NAME_1 = "Medium";
string constant TIER_NAME_2 = "Premium";
uint256 constant TIER_VALUE_0 = 0.01 ether;
uint256 constant TIER_VALUE_1 = 0.02 ether;
uint256 constant TIER_VALUE_2 = 0.05 ether;

contract TierNFT is ERC721, Ownable {
    uint256 public totalSupply;
    mapping(uint256 => uint256) public tokenTier;

    constructor(string memory _name, string memory _symbol)
        ERC721(_name, _symbol)
    {}

    function mint() public payable {
        require(
            msg.value >= TIER_VALUE_0,
            "Not enough value for the minimum Tier"
        );

        uint256 tierId = 0;
        if (msg.value >= TIER_VALUE_2) {
            tierId = 2;
        } else if (msg.value >= TIER_VALUE_1) {
            tierId = 1;
        }

        totalSupply++;
        _safeMint(msg.sender, totalSupply);
        tokenTier[totalSupply] = tierId;
    }

    function tierNameOf(uint256 _tokenTier)
        public
        pure
        returns (string memory)
    {
        if (_tokenTier == 0) return TIER_NAME_0;
        if (_tokenTier == 1) return TIER_NAME_1;
        return TIER_NAME_2;
    }

    function imageSVGOf(uint256 _tokenTier)
        public
        pure
        returns (string memory)
    {
        return
            string(
                abi.encodePacked(SVG_START, tierNameOf(_tokenTier), SVG_END)
            );
    }

    function finalJSON(
        string memory _name,
        uint256 _tokenId,
        string memory _imageSVG,
        uint256  _tokenTier
    ) public pure returns (string memory) {
        return
            string(
                abi.encodePacked(
                    '{"name": "',
                    _name,
                    " #",
                    Strings.toString(_tokenId),
                    '", "description": "TierNFTs collection", "image": "data:image/svg+xml;base64,',
                    _imageSVG,
                    '","attributes":[{"trait_type": "Tier", "value": "',
                    tierNameOf(_tokenTier),
                    '" }]}'
                )
            );
    }

    // Create the tokenURI json on the fly without creating files individually
    function tokenURI(uint256 tokenId)
        public
        view
        virtual
        override
        returns (string memory)
    {
        require(_exists(tokenId), "Nonexistent token");

        // string memory tierName = tierNameOf(tokenTier[tokenId]);
        string memory imageSVG = imageSVGOf(tokenTier[tokenId]);

        string memory json = Base64.encode(
            bytes(
                finalJSON(
                    name(),
                    tokenId,
                    Base64.encode(bytes(imageSVG)),
                    tokenTier[tokenId]
                )
            )
        );

        return string(abi.encodePacked("data:application/json;base64,", json));
    }

    // Function to withdraw funds from contract
    function withdraw() public onlyOwner {
        // Check that we have funds to withdraw
        uint256 balance = address(this).balance;
        require(balance > 0, "Balance should be > 0");

        // Withdraw funds.
        (bool success, ) = payable(owner()).call{value: balance}("");
        require(success, "Withdraw failed");
    }
}

Ok, here are some tests for these helpers!

💡

Since an SVG is basically a bunch of strings, you'll notice all these tests are nearly simple single lines -- but they're comparing the results with long strings that eventually make up the final SVG.

  describe('tokenURI and helpers', async () => {
    it('should error if token does not exist', async function () {
      await expect(contract.tokenURI(0)).to.be.revertedWith('Nonexistent token')
    })
  })

    describe('tierNameOf', async function () {
      it('should return proper tier name for Tier 0', async function () {
        await expect(await contract.tierNameOf(0)).to.equal('Basic')
      })
      it('should return proper tier name for Tier 1', async function () {
        await expect(await contract.tierNameOf(1)).to.equal('Medium')
      })
      it('should return proper tier name for Tier 2', async function () {
        await expect(await contract.tierNameOf(2)).to.equal('Premium')
      })
      it('should return Tier 2 name if tier number greater than all tiers', async function () {
        await expect(await contract.tierNameOf(999)).to.equal('Premium')
      })
    })

    describe('imageSVGOf', async function () {
      it('should return proper imageSVG for Tier 0', async function () {
        await expect(await contract.imageSVGOf(0)).to.equal(
          '<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500" fill="none" font-family="sans-serif"><defs><filter id="A" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="500" width="500"><feDropShadow dx="1" dy="2" stdDeviation="8" flood-opacity=".67" width="200%" height="200%" /></filter><linearGradient id="B" x1="0" y1="0" x2="15000" y2="0" gradientUnits="userSpaceOnUse"><stop offset=".05" stop-color="#ad00ff" /><stop offset=".23" stop-color="#4e00ec" /><stop offset=".41" stop-color="#ff00f5" /><stop offset=".59" stop-color="#e0e0e0" /><stop offset=".77" stop-color="#ffd810" /><stop offset=".95" stop-color="#ad00ff" /></linearGradient><linearGradient id="C" x1="0" y1="60" x2="0" y2="110" gradientUnits="userSpaceOnUse"><stop stop-color="#d040b8" /><stop offset="1" stop-color="#e0e0e0" /></linearGradient></defs><path fill="url(#B)" d="M0 0h15000v500H0z"><animateTransform attributeName="transform" attributeType="XML" type="translate" from="0 0" to="-14500 0" dur="16s" repeatCount="indefinite" /></path><circle fill="#1d1e20" cx="100" cy="90" r="45" filter="url(#A)" /><text x="101" y="99" text-anchor="middle" class="nftLogo" font-size="32px" fill="url(#C)" filter="url(#A)">D_D<animateTransform attributeName="transform" attributeType="XML" type="rotate" from="0 100 90" to="360 100 90" dur="5s" repeatCount="indefinite" /></text><g font-size="32" fill="#fff" filter="url(#A)"><text x="250" y="280" text-anchor="middle" class="tierName">Basic</text></g></svg>',
        )
      })
      it('should return proper imageSVG for Tier 1', async function () {
        await expect(await contract.imageSVGOf(1)).to.equal(
          '<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500" fill="none" font-family="sans-serif"><defs><filter id="A" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="500" width="500"><feDropShadow dx="1" dy="2" stdDeviation="8" flood-opacity=".67" width="200%" height="200%" /></filter><linearGradient id="B" x1="0" y1="0" x2="15000" y2="0" gradientUnits="userSpaceOnUse"><stop offset=".05" stop-color="#ad00ff" /><stop offset=".23" stop-color="#4e00ec" /><stop offset=".41" stop-color="#ff00f5" /><stop offset=".59" stop-color="#e0e0e0" /><stop offset=".77" stop-color="#ffd810" /><stop offset=".95" stop-color="#ad00ff" /></linearGradient><linearGradient id="C" x1="0" y1="60" x2="0" y2="110" gradientUnits="userSpaceOnUse"><stop stop-color="#d040b8" /><stop offset="1" stop-color="#e0e0e0" /></linearGradient></defs><path fill="url(#B)" d="M0 0h15000v500H0z"><animateTransform attributeName="transform" attributeType="XML" type="translate" from="0 0" to="-14500 0" dur="16s" repeatCount="indefinite" /></path><circle fill="#1d1e20" cx="100" cy="90" r="45" filter="url(#A)" /><text x="101" y="99" text-anchor="middle" class="nftLogo" font-size="32px" fill="url(#C)" filter="url(#A)">D_D<animateTransform attributeName="transform" attributeType="XML" type="rotate" from="0 100 90" to="360 100 90" dur="5s" repeatCount="indefinite" /></text><g font-size="32" fill="#fff" filter="url(#A)"><text x="250" y="280" text-anchor="middle" class="tierName">Medium</text></g></svg>',
        )
      })
      it('should return proper imageSVG for Tier 2', async function () {
        await expect(await contract.imageSVGOf(2)).to.equal(
          '<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500" fill="none" font-family="sans-serif"><defs><filter id="A" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse" height="500" width="500"><feDropShadow dx="1" dy="2" stdDeviation="8" flood-opacity=".67" width="200%" height="200%" /></filter><linearGradient id="B" x1="0" y1="0" x2="15000" y2="0" gradientUnits="userSpaceOnUse"><stop offset=".05" stop-color="#ad00ff" /><stop offset=".23" stop-color="#4e00ec" /><stop offset=".41" stop-color="#ff00f5" /><stop offset=".59" stop-color="#e0e0e0" /><stop offset=".77" stop-color="#ffd810" /><stop offset=".95" stop-color="#ad00ff" /></linearGradient><linearGradient id="C" x1="0" y1="60" x2="0" y2="110" gradientUnits="userSpaceOnUse"><stop stop-color="#d040b8" /><stop offset="1" stop-color="#e0e0e0" /></linearGradient></defs><path fill="url(#B)" d="M0 0h15000v500H0z"><animateTransform attributeName="transform" attributeType="XML" type="translate" from="0 0" to="-14500 0" dur="16s" repeatCount="indefinite" /></path><circle fill="#1d1e20" cx="100" cy="90" r="45" filter="url(#A)" /><text x="101" y="99" text-anchor="middle" class="nftLogo" font-size="32px" fill="url(#C)" filter="url(#A)">D_D<animateTransform attributeName="transform" attributeType="XML" type="rotate" from="0 100 90" to="360 100 90" dur="5s" repeatCount="indefinite" /></text><g font-size="32" fill="#fff" filter="url(#A)"><text x="250" y="280" text-anchor="middle" class="tierName">Premium</text></g></svg>',
        )
      })
    })

    describe('finalJSON', async function () {
      it('should return proper finalJSON for Tier 0', async function () {
        await expect(
          await contract.finalJSON('TierNFT', 111, 'IMAGE_SVG_BASE64_HERE', 0),
        ).to.equal(
          '{"name": "TierNFT #111", "description": "TierNFTs collection", "image": "data:image/svg+xml;base64,IMAGE_SVG_BASE64_HERE","attributes":[{"trait_type": "Tier", "value": "Basic" }]}',
        )
      })

      it('should return proper finalJSON for Tier 1', async function () {
        await expect(
          await contract.finalJSON('TierNFT', 222, 'IMAGE_SVG_BASE64_HERE', 1),
        ).to.equal(
          '{"name": "TierNFT #222", "description": "TierNFTs collection", "image": "data:image/svg+xml;base64,IMAGE_SVG_BASE64_HERE","attributes":[{"trait_type": "Tier", "value": "Medium" }]}',
        )
      })

      it('should return proper finalJSON for Tier 2', async function () {
        await expect(
          await contract.finalJSON('TierNFT', 333, 'IMAGE_SVG_BASE64_HERE', 2),
        ).to.equal(
          '{"name": "TierNFT #333", "description": "TierNFTs collection", "image": "data:image/svg+xml;base64,IMAGE_SVG_BASE64_HERE","attributes":[{"trait_type": "Tier", "value": "Premium" }]}',
        )
      })
    })

    it('should return base64-encoded data for tokenURI for Tier 0', async function () {
      await contract.mint({
        value: hre.ethers.parseEther('0.01'),
      })
      await expect(await contract.tokenURI(1)).to.equal(
          'data:application/json;base64,eyJuYW1lIjogIlRpZXJORlQgIzEiLCAiZGVzY3JpcHRpb24iOiAiVGllck5GVHMgY29sbGVjdGlvbiIsICJpbWFnZSI6ICJkYXRhOmltYWdlL3N2Zyt4bWw7YmFzZTY0LFBITjJaeUI0Yld4dWN6MGlhSFIwY0RvdkwzZDNkeTUzTXk1dmNtY3ZNakF3TUM5emRtY2lJSGRwWkhSb1BTSTFNREFpSUdobGFXZG9kRDBpTlRBd0lpQm1hV3hzUFNKdWIyNWxJaUJtYjI1MExXWmhiV2xzZVQwaWMyRnVjeTF6WlhKcFppSStQR1JsWm5NK1BHWnBiSFJsY2lCcFpEMGlRU0lnWTI5c2IzSXRhVzUwWlhKd2IyeGhkR2x2YmkxbWFXeDBaWEp6UFNKelVrZENJaUJtYVd4MFpYSlZibWwwY3owaWRYTmxjbE53WVdObFQyNVZjMlVpSUdobGFXZG9kRDBpTlRBd0lpQjNhV1IwYUQwaU5UQXdJajQ4Wm1WRWNtOXdVMmhoWkc5M0lHUjRQU0l4SWlCa2VUMGlNaUlnYzNSa1JHVjJhV0YwYVc5dVBTSTRJaUJtYkc5dlpDMXZjR0ZqYVhSNVBTSXVOamNpSUhkcFpIUm9QU0l5TURBbElpQm9aV2xuYUhROUlqSXdNQ1VpSUM4K1BDOW1hV3gwWlhJK1BHeHBibVZoY2tkeVlXUnBaVzUwSUdsa1BTSkNJaUI0TVQwaU1DSWdlVEU5SWpBaUlIZ3lQU0l4TlRBd01DSWdlVEk5SWpBaUlHZHlZV1JwWlc1MFZXNXBkSE05SW5WelpYSlRjR0ZqWlU5dVZYTmxJajQ4YzNSdmNDQnZabVp6WlhROUlpNHdOU0lnYzNSdmNDMWpiMnh2Y2owaUkyRmtNREJtWmlJZ0x6NDhjM1J2Y0NCdlptWnpaWFE5SWk0eU15SWdjM1J2Y0MxamIyeHZjajBpSXpSbE1EQmxZeUlnTHo0OGMzUnZjQ0J2Wm1aelpYUTlJaTQwTVNJZ2MzUnZjQzFqYjJ4dmNqMGlJMlptTURCbU5TSWdMejQ4YzNSdmNDQnZabVp6WlhROUlpNDFPU0lnYzNSdmNDMWpiMnh2Y2owaUkyVXdaVEJsTUNJZ0x6NDhjM1J2Y0NCdlptWnpaWFE5SWk0M055SWdjM1J2Y0MxamIyeHZjajBpSTJabVpEZ3hNQ0lnTHo0OGMzUnZjQ0J2Wm1aelpYUTlJaTQ1TlNJZ2MzUnZjQzFqYjJ4dmNqMGlJMkZrTURCbVppSWdMejQ4TDJ4cGJtVmhja2R5WVdScFpXNTBQanhzYVc1bFlYSkhjbUZrYVdWdWRDQnBaRDBpUXlJZ2VERTlJakFpSUhreFBTSTJNQ0lnZURJOUlqQWlJSGt5UFNJeE1UQWlJR2R5WVdScFpXNTBWVzVwZEhNOUluVnpaWEpUY0dGalpVOXVWWE5sSWo0OGMzUnZjQ0J6ZEc5d0xXTnZiRzl5UFNJalpEQTBNR0k0SWlBdlBqeHpkRzl3SUc5bVpuTmxkRDBpTVNJZ2MzUnZjQzFqYjJ4dmNqMGlJMlV3WlRCbE1DSWdMejQ4TDJ4cGJtVmhja2R5WVdScFpXNTBQand2WkdWbWN6NDhjR0YwYUNCbWFXeHNQU0oxY213b0kwSXBJaUJrUFNKTk1DQXdhREUxTURBd2RqVXdNRWd3ZWlJK1BHRnVhVzFoZEdWVWNtRnVjMlp2Y20wZ1lYUjBjbWxpZFhSbFRtRnRaVDBpZEhKaGJuTm1iM0p0SWlCaGRIUnlhV0oxZEdWVWVYQmxQU0pZVFV3aUlIUjVjR1U5SW5SeVlXNXpiR0YwWlNJZ1puSnZiVDBpTUNBd0lpQjBiejBpTFRFME5UQXdJREFpSUdSMWNqMGlNVFp6SWlCeVpYQmxZWFJEYjNWdWREMGlhVzVrWldacGJtbDBaU0lnTHo0OEwzQmhkR2crUEdOcGNtTnNaU0JtYVd4c1BTSWpNV1F4WlRJd0lpQmplRDBpTVRBd0lpQmplVDBpT1RBaUlISTlJalExSWlCbWFXeDBaWEk5SW5WeWJDZ2pRU2tpSUM4K1BIUmxlSFFnZUQwaU1UQXhJaUI1UFNJNU9TSWdkR1Y0ZEMxaGJtTm9iM0k5SW0xcFpHUnNaU0lnWTJ4aGMzTTlJbTVtZEV4dloyOGlJR1p2Ym5RdGMybDZaVDBpTXpKd2VDSWdabWxzYkQwaWRYSnNLQ05ES1NJZ1ptbHNkR1Z5UFNKMWNtd29JMEVwSWo1RVgwUThZVzVwYldGMFpWUnlZVzV6Wm05eWJTQmhkSFJ5YVdKMWRHVk9ZVzFsUFNKMGNtRnVjMlp2Y20waUlHRjBkSEpwWW5WMFpWUjVjR1U5SWxoTlRDSWdkSGx3WlQwaWNtOTBZWFJsSWlCbWNtOXRQU0l3SURFd01DQTVNQ0lnZEc4OUlqTTJNQ0F4TURBZ09UQWlJR1IxY2owaU5YTWlJSEpsY0dWaGRFTnZkVzUwUFNKcGJtUmxabWx1YVhSbElpQXZQand2ZEdWNGRENDhaeUJtYjI1MExYTnBlbVU5SWpNeUlpQm1hV3hzUFNJalptWm1JaUJtYVd4MFpYSTlJblZ5YkNnalFTa2lQangwWlhoMElIZzlJakkxTUNJZ2VUMGlNamd3SWlCMFpYaDBMV0Z1WTJodmNqMGliV2xrWkd4bElpQmpiR0Z6Y3owaWRHbGxjazVoYldVaVBrSmhjMmxqUEM5MFpYaDBQand2Wno0OEwzTjJaejQ9IiwiYXR0cmlidXRlcyI6W3sidHJhaXRfdHlwZSI6ICJUaWVyIiwgInZhbHVlIjogIkJhc2ljIiB9XX0=',
        )
      })
    })
💡
Run the tests using npm test

The final test is an example mentioned earlier about adding a test before refactoring with helper methods. It checks that the initial tokenURI() method (which returns an encoded Base64 string) returns the right result. It's helpful while refactoring to make sure we didn't break things along the way, and it's helpful at the end to ensure the tokenURI() method continues to work without fail when code changes.

Food for Thought

With all the helpfulness of tests, why didn't we start with them before we wrote any of the contract implementation? The main reason was trying to minimize all the things you needed to learn to get started, but the methodology of writing tests first is called "Test Driven Development" (TDD).

TDD is a very powerful methodology which goes like this:

  • before you write implementation code, you write one or more tests and run them.
  • of course those tests fail initially since there are methods that don't have any implementaiton yet. As mentioned earlier, it's always important to prove your test fails initially.
  • then add your implementation until the tests pass.

Voila!

With TDD:

  • you would have written tests as you go, which are much more fun to write than all at once at the end

  • you would have ended up writing better code because you discover quickly when code is impossible to test. You'll end up with the added benefit of breaking a big method into multiple smaller methods that are easily testable. So in the refactor example above for tokenURI() you would never have written the version that outputs a long base64 string because it's not testable.

    Bolting on tests after the fact can be painful and boring, and you lose the potential code improvements you can make when you test as you go. And speaking of testing as you go, why not take the quiz 😊



Connect your wallet and Sign in to start the quiz



You just did some amazing work, and you will do a great service to the web3 ecosystem by being conscious of the value of writing solid code. There’s lots more to explore in Academy, and we’re looking forward to seeing you in our final project in this ERC721 track where you’ll be Connecting your TierNFT Smart Contract to a Frontend. Why not dive into the forum in the mean time, and share your new found wisdom with our community.

Go forth, test, and prosper knowing you have confidence in the code you're deploying into the world.

;