Jumping into Solidity — The ERC721 Standard (Part 8)

Well guys and gals, here we are, it’s time to test our Metadata and Enumerable extensions. Judging by how many of you skipped the last article on tests (that’s right, I have stats!), I know a lot of you think you can deploy untested smart contracts with no ramifications. Although some of you daredevils may be willing to live that kind of rock-n-roll lifestyle, I’ll have no part in it.

So for all the cautious codeslingers among you who don’t like explaining to users why their NFTs are broken or lost, here are some juicy tests to sink your teeth into.

“person pouring purple liquid on clear glass container” by Louis Reed on Unsplash

The Set-up

We’re going to be using the same testing environment as we did in Part 5. To jog your memory:

Today I’ll be using NodeJS, with the Mocha test framework. We’ll also be making use of Web3 and ganache-cli.

Part 5 includes a full explanation on how to get everything set up, so there’s no need to repeat it here. In fact, we’re going to be re-using all the tests we wrote then anyway, since the Metadata and Enumerable extensions both extend the basic ERC721 standard. Even though we changed the guts of a few functions with our Enumerable contract, the outward functionality is the same, and so the tests are the same.

Because of this we’re actually going to be writing two test files, one for each extension, and both of them are going to start off as duplicates of our original Token.test.js file we wrote in Part 5.

So start by making two duplicates of this file in your /test directory, and name one Token_Metadata.test.js and the other Token_Enumerable.test.js.

Rather than making you jump back and forth between these two test files, we’re going to tackle the whole Metadata test file first, and then come back round and look at Enumerable.

Token_Metadata.test.js

There are a few small changes we need to make to the set-up before we start. Since we want to test our Metadata contract, we need to point at it by changing the following line:

const compiledToken = require('../ethereum/build/TokenERC721.json');

to be:

const compiledToken = require('../ethereum/build/TokenERC721Metadata.json');

Also remember that our Metadata contract also has a few extra parameters in the constructor,

constructor(uint _initialSupply, string _name, string _symbol, string _uriBase)

So we need to make sure we pass them when our contract gets deployed in the beforeEach function. Right above that function, let’s define a couple of constants so we can test against them later,

const _name = "SomeTokenName";
const _symbol = "STN";
const _uriBase = "someTokenName.uri/";

And then inside the beforeEach we want to add them as arguments in the deploy function, by changing:

arguments: [initialTokens]

to be:

arguments: [initialTokens,_name,_symbol,_uriBase]

And those are all the changes we need to make to the set-up. If you run the test file, you should see your Metadata contract passing all the tests we wrote in Part 5. We now just have to add a few extra tests for the Metadata-specific functions.

A few extra Metadata tests

The three extra functions we need to test are name, symbol and tokenURI. The first two are nearly identical, we just compare the result of these functions to the _name and _symbol constants we declared at the start.

Correct token name is reported

it('Correct token name is reported', async () => {
const name_reported = await token.methods.name().call();

assert(_name == name_reported);
});

Correct token symbol is reported

it('Correct token symbol is reported', async () => {
const symbol_reported = await token.methods.symbol().call();

assert(_symbol == symbol_reported);
});

Correct tokenURI is reported

Remember that for any given token, the tokenURI is just the _uriBase concatenated with the tokenId. So for this test we construct the expected tokenURI for tokenID = 1 and then compare it with the reported value for the same tokenId.

it('Correct tokenURI is reported',async() => {
var tokenId = '1';
const tokenURI_reported = await token.methods.tokenURI(tokenId).call();
const tokenURI_expected = _uriBase + tokenId;
assert(tokenURI_expected == tokenURI_reported);
});

And that’s it for Metadata! We’ve barely broken a sweat and we’re already almost done. Let’s take care of our Enumerable tests and then we can all go home.

Token_Enumerable.test.js

Like with Metadata, we need to make sure we’re testing our Enumerable contract, so change the following line:

const compiledToken = require('../ethereum/build/TokenERC721.json');

to be:

const compiledToken = require('../ethereum/build/TokenERC721Enumerable.json');

As far as changes to the set-up go, that’s all there is for Enumerable, so let’s move onto those extra tests.

A few extra Enumerable tests

The only new functions in the Enumerable extension are totalSupply, tokenByIndex and tokenOfOwnerByIndex. The former will only require one test if you don’t have mint or burn functions, and an extra one for each of those if you do.

The latter two are a little more complex given the way our token assigns indices, especially if you have mint and burn functions, so we’ll have to do a little more work in that department.

Reported total supply is accurate for initial supply

it("Reported total supply is accurate for initial supply", async() => {
const totalSupply_reported = await token.methods.totalSupply().call();
assert(totalSupply_reported == initialTokens);
});

And then for you minters and burners, you’ll want something like the following. But I’ll remind you that neither of these methods form part of the standard, so if you’re homebrewing your contract you might need to tweak these a little.

Reported total supply is accurate after minting extra tokens

it("Reported total supply is accurate after minting extra tokens", async() => {
const toIssue = 2;
const owner = accounts[0];
await token.methods.issueTokens(toIssue).send({
from: owner,
gas:'8000000'
});
const totalSupply_expected = initialTokens + toIssue;

const totalSupply_reported = await token.methods.totalSupply().call();
assert(totalSupply_reported == totalSupply_expected);
});

Reported total supply is accurate after burning a token

it("Reported total supply is accurate after burning a token", async() => {
//Remember, this burns an individual token, not a group of tokens.
const tokenToBurn = '2'; //2 is the tokenId
const owner = accounts[0];
await token.methods.burnToken(tokenToBurn).send({
from: owner,
gas:'8000000'
});

const totalSupply_expected = initialTokens - 1;

const totalSupply_reported = await token.methods.totalSupply().call();
assert(totalSupply_reported == totalSupply_expected);
});

Initially reports correct tokenByIndex

Let’s just iterate through all ten initial tokens to be thorough,

it("Initially reports correct tokenByIndex", async() => {
let tokenId_expected, tokenId_reported;
for(var i = 0; i < initialTokens; i++){
tokenId_expected = String(i + 1);
tokenId_reported = await token.methods.tokenByIndex(i).call();
assert(tokenId_expected == tokenId_reported);
}
});

Reports correct tokenByIndex after minting extra tokens

Once again, you only need something like this if your contract allows minting. We’ll just mint a couple of extra tokens and then basically redo the last test.

it("Reports correct tokenByIndex after minting extra tokens", async() => {
const toIssue = 2;
const owner = accounts[0];
await token.methods.issueTokens(toIssue).send({
from: owner,
gas:'8000000'
});

let tokenId_expected, tokenId_reported;
for(var i = 0; i < initialTokens + toIssue; i++){
tokenId_expected = String(i + 1);
tokenId_reported = await token.methods.tokenByIndex(i).call();
assert(tokenId_expected == tokenId_reported);
}
});

Reports correct tokenByIndex after burning a token

Same sort of deal as the last one, but we’ll burn one of the initial tokens first. If we burn token 2, then the tokenIndexes should look like this:

[1,10,3,4,5,6,7,8,9]

Note, the last element filled the new gap. Rather than bothering to replicate the logic in Javascript, let’s just hard-code this new order and test for that.

it("Reports correct tokenByIndex after burning a token", async() => {
const tokenToBurn = '2'; //2 is the tokenId
const owner = accounts[0];
await token.methods.burnToken(tokenToBurn).send({
from: owner,
gas:'8000000'
});

const tokenIds_expected = ['1','10','3','4','5','6','7','8','9'];

let tokenId_expected, tokenId_reported;
for(var i = 0; i < tokenIds_expected.length; i++){
tokenId_expected = tokenIds_expected[i];
tokenId_reported = await token.methods.tokenByIndex(i).call();
assert(tokenId_expected == tokenId_reported);
}
});

The next three tests are almost identical to the last three

Initially reports correct tokenOfOwnerByIndex

it("Initially reports correct tokenOfOwnerByIndex", async() => {
const owner = accounts[0];
let tokenId_expected, tokenId_reported;
for(var i = 0; i < initialTokens; i++){
tokenId_expected = String(i + 1);
tokenId_reported = await token.methods.tokenOfOwnerByIndex(owner,i).call();
assert(tokenId_expected == tokenId_reported);
}
});

Reports correct tokenOfOwnerByIndex after minting extra tokens

it("Reports correct tokenOfOwnerByIndex after minting extra tokens", async() => {
const toIssue = 2;
const owner = accounts[0];
await token.methods.issueTokens(toIssue).send({
from: owner,
gas:'8000000'
});

let tokenId_expected, tokenId_reported;
for(var i = 0; i < initialTokens + toIssue; i++){
tokenId_expected = String(i + 1);
tokenId_reported = await token.methods.tokenOfOwnerByIndex(owner,i).call();
assert(tokenId_expected == tokenId_reported);
}
});

Reports correct tokenOfOwnerByIndex after burning a token

it("Reports correct tokenOfOwnerByIndex after burning a token", async() => {
const tokenToBurn = '2'; //2 is the tokenId
const owner = accounts[0];
await token.methods.burnToken(tokenToBurn).send({
from: owner,
gas:'8000000'
});

const tokenIds_expected = ['1','10','3','4','5','6','7','8','9'];

let tokenId_expected, tokenId_reported;
for(var i = 0; i < tokenIds_expected.length; i++){
tokenId_expected = tokenIds_expected[i];
tokenId_reported = await token.methods.tokenOfOwnerByIndex(owner,i).call();
assert(tokenId_expected == tokenId_reported);
}
});

Reports correct tokenOfOwnerByIndex after transferring tokens

Lastly, we just have to make sure tokenOfOwnerByIndex works after a few tokens have been traded back and forth, and then we’re done.

it("Reports correct tokenOfOwnerByIndex after transferring tokens", async() => {
const person_A = accounts[0];
const person_B = accounts[1];

//Person A sends token 2 to Person B
await token.methods.transferFrom(person_A,person_B,'2').send({
from: person_A,
gas:'8000000'
});
//Person A sends token 4 to Person B
await token.methods.transferFrom(person_A,person_B,'4').send({
from: person_A,
gas:'8000000'
});
//Person B sends token 2 back to Person A
await token.methods.transferFrom(person_B,person_A,'2').send({
from: person_B,
gas:'8000000'
});

const tokenIds_expected_A = ['1','10','3','9','5','6','7','8','2'];
const tokenIds_expected_B = ['4'];

let tokenId_reported, tokenId_expected;
for(let i = 0; i < tokenIds_expected_A.length; i++){
tokenId_expected = tokenIds_expected_A[i];
tokenId_reported = await token.methods.tokenOfOwnerByIndex(person_A,i).call();
assert(tokenId_expected == tokenId_reported);
}
for(let i = 0; i < tokenIds_expected_B.length; i++){
tokenId_expected = tokenIds_expected_B[i];
tokenId_reported = await token.methods.tokenOfOwnerByIndex(person_B,i).call();
assert(tokenId_expected == tokenId_reported);
}
});

Wrapping up

And that’s a wrap! If you’ve been playing along at home, you’ve written your own ERC721 implementation, complete with both extensions, and everything has been stringently tested.

As with last time, if you’re lazy or just want to ensure you haven’t made any mistakes, I’ve uploaded the test files to GitHub, so feel free to steal them.

This is the last article in this series, so thanks for reading, and I hope your non-fungible projects are as exciting as some of the one’s I’ve got brewing. If you have any questions or comments about anything covered here, then I’d be happy to answer them.

Codeslinger. Melbourne based Solidity developer for hire.