Discover Ethereum & Solidity

Back to All Courses

Lesson 7

Test-driven Solidity

Since our contracts are mainly interacted with through tests during the development process, it makes sense for us to adopt a test-driven process while coding.

That's why, in this chapter, we're going to iterate on our app by writing the tests first, and then implement the necessary features in our contracts to make them pass. Let's get started!

Retrieving the user info

The next step in our DApp is being able to retrieve a user's info (for now, that's only the ID and username) based on their ID. To do this in a test-driven manner, let's start by writing the test.

If you want to return structures in Solidity, you'll have to convert them to tuples, which in turn will be interpreted as arrays in the JavaScript environment.

Therefore, if our structure looks like this...

struct Profile {
  uint id;
  bytes32 username;
}

...we should expect to get a JavaScript array where the first element (of index 0) represents the ID, and the second one (of index 1) is the username.

// test/integration/users.js

const UserStorage = artifacts.require('UserStorage')

contract('users', () => {

  // ...

  // Add the following test:
  it("can get user", async () => {
    const storage = await UserStorage.deployed()
    const userId = 1

    // Get the userInfo array
    const userInfo = await storage.getUserFromId.call(userId)

    // Get the second element (the username)
    const username = userInfo[1]

    assert.equal(username, "tristan")
  });

})

This seems right, now let's write that getUserFromId function!

// contracts/users/UserStorage.sol

pragma solidity ^0.5.10;

contract UserStorage {

  // ...

  function getUserFromId(uint _userId) view public returns(uint, bytes32) {
    return (
      profiles[_userId].id,
      profiles[_userId].username
    );
  }

}

Alright, let's try this out. If you run truffle test, you'll see that our test unfortunately fails:

Oh no!

The reason the function returns "0x7472697374616e00000000..." instead of "tristan" is because it's hex-encoded. Whenever we retrieve a bytes32 value in a JavaScript environment, we have to convert it to a string before using it in our assertions!

Again, the web3 library has a utility function called toAscii which we can use:

// test/integration/users.js

// ...

contract('users', () => {

  // ...

  it("can get user", async () => {
    // ...

    // Use toAscii on the "username" variable:
    const username = web3.utils.toAscii(userInfo[1])

    assert.equal(username, "tristan")
  });

})

Hmm, still failing?

As you can see, the test still fails though because the returned string has a bunch of \u0000 characters at the end of it. Again, this is due to the fact that it's a bytes32 object and therefore must be exactly 32 characters long\u000 is simply a representation of a "null" character.

In order to remove these trailing null characters, we can simply replace them through a simple JavaScript RegEx:

// test/integration/users.js

// ...

contract('users', () => {

  // ...

  it("can get user", async () => {
    // ...

    // Use the "replace" function at the end:
    const username = web3.utils.toAscii(userInfo[1]).replace(/\u0000/g, '')

    assert.equal(username, "tristan")
  });

})

Finally it passes!

Using a public state variable

While our Solidity code above works great, there's actually a cleaner way to get all information for a profile.

By adding the keyword public in front of our profiles state variable, Solidity will automatically generate the getter function for us. In other words, we can skip the getUserFromId function altogether!

// contracts/users/UserStorage.sol

pragma solidity ^0.5.10;

contract UserStorage {

  // Add "public":
  mapping(uint => Profile) public profiles;

  struct Profile {
    uint id;
    bytes32 username;
  }

  uint latestUserId = 0;

  function createUser(bytes32 _username) public returns(uint) {
    latestUserId++;

    profiles[latestUserId] = Profile(latestUserId, _username);

    return latestUserId;
  }

  // Remove the "getUserFromId" function

}

This is a great time saver and also makes our code much more maintainable, since we don't have to return every single struct field in a separate function.

In our test file, we don't have to change anything, except that we call storage.profilesinstead of storage.getUserFromId.

// test/integration/users.js

// ...

const UserStorage = artifacts.require('UserStorage')

contract('users', () => {

  // ...

  it("can get user", async () => {
    const storage = await UserStorage.deployed()
    const userId = 1

    const userInfo = await storage.profiles.call(userId)
    const username = web3.utils.toAscii(userInfo[1]).replace(/\u0000/g, '')

    assert.equal(username, "tristan")
  });

})

Run your test again, and you'll see that it works just as well as before. Nifty!

Creating the TweetStorage contract

Now that we're getting a hang of testing, let's write some more for our upcoming TweetStorage contract!

Make a new folder called tweets inside your contracts folder, and add a file called TweetStorage.sol in it:

Our updated file structure!

// contracts/tweets/TweetStorage.sol

pragma solidity ^0.5.10;

contract TweetStorage {
  // We will add some code here soon
}

As usual, we also need to add a line to our deployment file so that our test can actually interact with the contract:

// migrations/2_deploy_storage.js

const UserStorage = artifacts.require('UserStorage');
const TweetStorage = artifacts.require('TweetStorage'); // <-- Add this...

module.exports = (deployer) => {
  deployer.deploy(UserStorage);
  deployer.deploy(TweetStorage); // <-- ...and this!
}

Now that that's out of the way, the things that we are interested in testing are the following:

  1. Creating a new tweet (and get its newly added ID)

  2. Get a tweet's data based on its ID (for now, that info will be the tweet's ID, text, author ID and creation date).

As we saw with our UserStorageintegration tests (in JavaScript) are a great way to verify the behaviour of our contracts from a user's perspective, whereas unit tests (in Solidity) are necessary to get the actual returned data from a writable function that performs a transaction.

When creating a tweet, we expect to be able to call a function named createTweet in the TweetStorage contract, pass a user ID and a text string as data, and get the newly created tweet's ID in return. In order to verify that tweet ID's value, we'll need to write a unit test.

Here's how we could write that test:

// test/unit/TestTweetStorage.sol

pragma solidity ^0.5.10;

import "truffle/Assert.sol";
import "truffle/DeployedAddresses.sol";
import "../../contracts/tweets/TweetStorage.sol";

contract TestTweetStorage {
  function testCreateTweet() public {
    TweetStorage _storage = TweetStorage(DeployedAddresses.TweetStorage());

    uint _userId = 1;
    uint _expectedTweetId = 1;

    Assert.equal(
      _storage.createTweet(_userId, "Hello world!"),
      _expectedTweetId,
      "Should create tweet with ID 1"
    );
  }
}

If we try to run it, it will obviously fail, since the "createTweet" function doesn't exist yet.

In order for it to pass, we're going to use some logic that's very similar to the one in the UserStorage contract, using a struct, a mapping, and a state variable keeping track of the latest ID:

// contracts/tweets/TweetStorage.sol

pragma solidity ^0.5.10;

contract TweetStorage {

  mapping(uint => Tweet) public tweets;

  struct Tweet {
    uint id;
    string text;
    uint userId;
    uint postedAt;
  }

  uint latestTweetId = 0;

  function createTweet(uint _userId, string memory _text) public returns(uint) {
    latestTweetId++;

    tweets[latestTweetId] = Tweet(latestTweetId, _text, _userId, now);

    return latestTweetId;
  }

}

Note that we're using string and not bytes32 to store our tweet text. As we know, bytes32 has a maximum limit of 32 characters, which isn't nearly enough for a tweet, so it's not very suitable for this use case.

We're also using the the built-in now variable -- which uses the current block's Unix timestamp -- to set the time when the tweet was posted.

Run the tests again, and you'll see that they pass!

Getting the Tweet data

Now we can move on to the second test -- getting the tweet's data! This one on the other hand, can be written in JavaScript. In fact, writing a unit test for this would result in an error since you cannot pass strings from one contract to another in Solidity.

Create a new file called tweets.js in test/integration and add the following code:

// test/integration/tweets.js

const TweetStorage = artifacts.require('TweetStorage')

contract('tweets', () => {

  it("can get tweet", async () => {
    const storage = await TweetStorage.deployed()

    const tweet = await storage.tweets.call(1) // Get the data
    const { id, text, userId } = tweet // Destructure the data

    // Check if the different parts contain the expected values:
    assert.equal(parseInt(id), 1)
    assert.equal(text, "Hello world!")
    assert.equal(parseInt(userId), 1)
  })

})

Notice how we're using the parseInt function before we compare the tweetId and userId to a number. The reason for this is that web3 uses a special number type (called bigNumber), in order to support Ethereum's standard numeric data type (which is much larger than the one built into JavaScript). Since we're dealing with such small numbers in this test, it's okay to convert them like this.

This all looks good! However, if we run the test...

Oh no! Not quite there yet...

It seems like our tweet hasn't been created, since it returns an ID of 0 instead of 1. But didn't we create the tweet earlier in our Solidity test?

Actually no. Our Solidity tests and JavaScript tests are completely separate! Just because we've created the tweet in our Solidity test, does not mean we can retrieve it in our JavaScript test -- each test in Truffle uses a clean room environment so that they don't accidentally share state with each other (which is a good thing)!

The solution is that we'll simply have to recreate the tweet in our tweets.js file as well. We can do this in a special before() function, which runs before any other test:

// test/integration/tweets.js

const TweetStorage = artifacts.require('TweetStorage')

contract('tweets', () => {

  before(async () => {
    const tweetStorage = await TweetStorage.deployed()
    await tweetStorage.createTweet(1, "Hello world!")
  })

  // ...

})

And now it passes!

You should now have an idea of how to combine unit tests and integrations tests in an efficient way to test different aspects of your contracts. You should also be aware of some of the gotchas with using types like bytes32 and uint in a JavaScript environment, and how to convert them into something more suitable for your application frontend.

In the next chapter, we will start creating our controller contracts in order to add more advanced smart contract logic to our DApp.