Testear contratos solidity

Solidity - 2 de agosto de 2022

Introducción

La cosa más importante de los smart contract son los test, un contrato antes de desplegarlo en la red tiene que muy bien testeado, un smart contract con un pequeño bug nos puede hacer perder mucho dinero y si no que se lo digan a los que aparecen en rekt.news. Rekt es un portal donde se publican todos los hackeos que ha habido en la blockchain y adivina que, la gran mayoría son por no testear bien los contratos.

Voy a crear los test para el contrato creado en el artículo Aprende a programar contratos inteligentes con Solidity, donde creamos un contrato utilizando truffle suite. Si no lo has leído, te lo recomiendo que lo leas antes de seguir con este articulo para ver el contrato que vamos a testear.

Truffle suite ofrece dos opciones para testear los smart contract, utilizando Solidity o Javascript con mocha y chai. Vamos a utilizar la segunda opción. Si quieres testear los contratos con solidity puedes ver la documentación oficial de test en solidity.

Contrato

Si te da pereza leer el articulo anterior, no te preocupes, aquí tienes el contrato que vamos a testear.

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

/// @title Votación simple
contract SimpleBallot {

  struct Participant {
    bool voted;
    bool exists;
  }

  // flag para comprobar que ya se ha inicializado el contrato
  bool private initialized;
  // direccion del creador del contrato
  address public owner; 
  // opciones de la votación
  string[] public options; 
  // contador de votos
  mapping(string => uint) public votes; 
  // participantes que tienen derecho a voto
  mapping(address => Participant) public participants; 
  
  
  constructor() {
    owner = msg.sender;
  }

  modifier onlyOwner() {
    require(msg.sender == owner, "Only owner can initialize SimpleBallot");
    _;
  }

  modifier onlyWithVotingRights() {
    require(participants[msg.sender].exists, 'You are not a participant');
    require(!participants[msg.sender].voted, 'You have already voted.');
    _;
  }

  modifier onlyNotInitialized(string[] memory _options, address[] memory _participans) {
    require(!initialized, "SimpleBallot already initialized");
    require(_options.length > 0, "At least one option must be provided");
    require(_participans.length > 0, "At least one participant must be provided");
    _;
  }

  // inicializa el contrato, solo puede ser ejecutado por el creador,
  // solo se puede ejecutar una vez
  function initialize(string[] memory _options, address[] memory _participans)
    public onlyOwner onlyNotInitialized(_options, _participans) {
    
    options = _options;
    initialized = true;

    for (uint i = 0; i < _participans.length; i++) {
      participants[_participans[i]] = Participant({
        voted: false,
        exists: true
      });
    }
  }

  // vota por una opcion, solo puede ser llamado una vez por participante
  function vote(uint _option) public onlyWithVotingRights {
    participants[msg.sender].voted = true;
    votes[options[_option]]++;
  }

  function getOptions() public view returns (string[] memory) {
    return options;
  }

  function getVotes() public view returns (uint[] memory) {
    uint[] memory result = new uint[](options.length);
    for (uint i = 0; i < options.length; i++) {
      result[i] = votes[options[i]];
    }
    return result;
  }
}

Testear el contrato en Truffle con Javascript

Como ya he comentado antes Truffle utiliza mocha y chai para crear los test, si nunca lo has utilizado te recomiendo que te leas la documentación oficial. No necesitas instalar ningún paquete, ya que ya vienen incorporados en truffle. A si que crearemos nuestro fichero para los test en test/SimpleBallot.test.js

// test/SimpleBallot.test.js

contract("SimpleBallot", (accounts) => {
  const owner = accounts[0]
  const voter1 = accounts[1]
  const voter2 = accounts[2]
  const nonParticipant = accounts[3]
  const candidate1 = 'Opcion 1'
  const candidate2 = 'Opcion 2'

	
	// tests...	

})

NOTA

Comando para ejecutar los test: truffle test

Inicialización de la votación

Primero vamos a crear los test para la inicialización de la votación, este método tiene dos modificadores que comprueban sí ya se ha inicializado, si el que ejecuta el método el propietario y si se estan enviando las variables correctamente. Esta seria la lista de las comprobaciones que vamos a realizar para la inicialización:

  • Se espera que se inicialize correctamente
  • Se espera que solo se pueda inicializar una vez
  • Se espera que no se pueda inicializar si no le pasamos a los votantes
  • Se espera que no se pueda inicializar si no le pasamos a los candidatos o a las opciones
  • Se espera que únicamente el owner pueda inicializar el contrato

Y estos serian los test:

describe("initialize simple ballot", async () => {
  describe("correct initialization", async () => {
    let simpleBallot

    before(async () => {
      simpleBallot = await SimpleBallot.deployed()
      await simpleBallot.initialize(
        [candidate1, candidate2],
        [voter1, voter2],
        { from: owner }
      )
    })

    it("should be correct option 1", async () => {
      const option = await simpleBallot.options(0)
      assert.equal(option, candidate1)
    })

    it('should be correct option 2', async () => {
      const option = await simpleBallot.options(1)
      assert.equal(option, candidate2)
    })

    it('shold be correct participant 1', async () => {
      const participant = await simpleBallot.participants(voter1)
      assert.isFalse(participant.voted)
      assert.isTrue(participant.exists)
    })
    
    it('shold be correct participant 2', async () => {
      const participant = await simpleBallot.participants(voter2)
      assert.isFalse(participant.voted)
      assert.isTrue(participant.exists)
    })
  })

  describe("several initializations", async () => {
    it("shouldn't initialize twice", async () => {
      const simpleBallot = await SimpleBallot.new();
      const init = async () => await simpleBallot.initialize(
        [candidate1, candidate2],
        [voter1, voter2],
        { from: owner }
      )

      try {
        await init()
        await init()
        throw null;
      }
      catch (error) {
        assert(error, "SimpleBallot already initialized");
      }
    })
  })
  
  describe("initialize without voters", async () => {
    it("shouldn't initialize without voters", async () => {
      const simpleBallot = await SimpleBallot.new();
      const init = async () => await simpleBallot.initialize(
        [candidate1, candidate2],
        [],
        { from: owner }
      )

      try {
        await init()
        throw null;
      }
      catch (error) {
        assert(error, "At least one participant must be provided");
      }
    })
  })
  
  describe("initialize without candidates", async () => {
    it("shouldn't initialize without candidates", async () => {
      const simpleBallot = await SimpleBallot.new();
      const init = async () => await simpleBallot.initialize(
        [],
        [voter1, voter2],
        { from: owner }
      )

      try {
        await init()
        throw null;
      }
      catch (error) {
        assert(error, "At least one option must be provided");
      }
    })
  })

  describe("the owner is the only one who can initialize", async () => {
    it("shouldn't initialize without owner", async () => {
      const simpleBallot = await SimpleBallot.new();
      const init = async () => await simpleBallot.initialize(
        [candidate1, candidate2],
        [voter1, voter2],
        { from: voter1 }
      )

      try {
        await init()
        throw null
      }
      catch (error) {
        assert(error, "Only owner can initialize SimpleBallot")
      }
    })
  })
})

Sistema de votación

Ahora vamos a testear el método para realizar una votación. Este método tiene un modificador que comprueba si el que esta ejecutando el método tiene permisos para votar y si ya ha votado anteriormente, ya que solo se puede votar una vez.

Estos son los requisitos que necesitamos testear en la función vote:

  • Si el votante tiene permisos para votar y aun no a votado, debería de poder votar correctamente
  • Si el votante ya ha votado, debería de devolver un error y no contabilizar el voto
  • Si el votante no tiene permisos para votar debería de devolver un error y no contabilizar el voto

Y aquí los test:

describe("voting", async () => {
  let simpleBallot
  
  before(async () => {
    simpleBallot = await SimpleBallot.new();
    await simpleBallot.initialize(
      [candidate1, candidate2],
      [voter1, voter2],
      { from: owner }
    )
  })

  describe('correct vote', async () => {
    before(async () => {
      await simpleBallot.vote(0, { from: voter1 })
    })

    it('should be voted', async () => {
      const participant = await simpleBallot.participants(voter1)
      assert(participant.voted, true)
    })

    it ('should add the vote in the count', async () => {
      const count = await simpleBallot.getVotes()
      assert(count[0].words[0], 1)
    })
  })

  describe('only vote once', async () => {
    it('shouldn\'t vote twice', async () => {
      try {
        await simpleBallot.vote(0, { from: voter1 })
        throw null
      }
      catch (error) {
        assert(error, "You have already voted.")
      }
    })
  })

  describe('vote with an external account', async () => {
    it('shouldn\'t vote with an external account', async () => {
      try {
        await simpleBallot.vote(0, { from: nonParticipant })
        throw null
      }
      catch (error) {
        assert(error, "You are not a participant")
      }
    })
  })
})

Pues ya estarían nuestros test creados, te dejo el fichero de los test completo:

const SimpleBallot = artifacts.require("SimpleBallot")

contract("SimpleBallot", (accounts) => {
  const owner = accounts[0]
  const voter1 = accounts[1]
  const voter2 = accounts[2]
  const nonParticipant = accounts[3]
  const candidate1 = 'Opcion 1'
  const candidate2 = 'Opcion 2'
 

  describe("initialize simple ballot", async () => {
    describe("correct initialization", async () => {
      let simpleBallot

      before(async () => {
        simpleBallot = await SimpleBallot.deployed()
        await simpleBallot.initialize(
          [candidate1, candidate2],
          [voter1, voter2],
          {from: owner}
        )
      })

      it("should be correct option 1", async () => {
        const option = await simpleBallot.options(0)
        assert.equal(option, candidate1)
      })

      it('should be correct option 2', async () => {
        const option = await simpleBallot.options(1)
        assert.equal(option, candidate2)
      })

      it('shold be correct participant 1', async () => {
        const participant = await simpleBallot.participants(voter1)
        assert.isFalse(participant.voted)
        assert.isTrue(participant.exists)
      })
      
      it('shold be correct participant 2', async () => {
        const participant = await simpleBallot.participants(voter2)
        assert.isFalse(participant.voted)
        assert.isTrue(participant.exists)
      })
    })

    describe("several initializations", async () => {
      it("shouldn't initialize twice", async () => {
        const simpleBallot = await SimpleBallot.new();
        const init = async () => await simpleBallot.initialize(
          [candidate1, candidate2],
          [voter1, voter2],
          {from: owner }
        )

        try {
          await init()
          await init()
          throw null;
        }
        catch (error) {
          assert(error, "SimpleBallot already initialized");
        }
      })
    })
    
    describe("initialize without voters", async () => {
      it("shouldn't initialize without voters", async () => {
        const simpleBallot = await SimpleBallot.new();
        const init = async () => await simpleBallot.initialize(
          [candidate1, candidate2],
          [],
          { from: owner }
        )

        try {
          await init()
          throw null;
        }
        catch (error) {
          assert(error, "At least one participant must be provided");
        }
      })
    })
    
    describe("initialize without candidates", async () => {
      it("shouldn't initialize without candidates", async () => {
        const simpleBallot = await SimpleBallot.new();
        const init = async () => await simpleBallot.initialize(
          [],
          [voter1, voter2],
          { from: owner }
        )

        try {
          await init()
          throw null;
        }
        catch (error) {
          assert(error, "At least one option must be provided");
        }
      })
    })

    describe("the owner is the only one who can initialize", async () => {
      it("shouldn't initialize without owner", async () => {
        const simpleBallot = await SimpleBallot.new();
        const init = async () => await simpleBallot.initialize(
          [candidate1, candidate2],
          [voter1, voter2],
          { from: voter1 }
        )

        try {
          await init()
          throw null
        }
        catch (error) {
          assert(error, "Only owner can initialize SimpleBallot")
        }
      })
    })
  })
  
  
  describe("voting", async () => {
    let simpleBallot
    
    before(async () => {
      simpleBallot = await SimpleBallot.new();
      await simpleBallot.initialize(
        [candidate1, candidate2],
        [voter1, voter2],
        { from: owner })
    })

    describe('correct vote', async () => {
      before(async () => {
        await simpleBallot.vote(0, { from: voter1 })
      })

      it('should be voted', async () => {
        const participant = await simpleBallot.participants(voter1)
        assert(participant.voted, true)
      })

      it ('should add the vote in the count', async () => {
        const count = await simpleBallot.getVotes()
        assert(count[0].words[0], 1)
      })
    })

    describe('only vote once', async () => {
      it('shouldn\'t vote twice', async () => {
        try {
          await simpleBallot.vote(0, { from: voter1 })
          throw null
        }
        catch (error) {
          assert(error, "You have already voted.")
        }
      })
    })

    describe('vote with an external account', async () => {
      it('shouldn\'t vote with an external account', async () => {
        try {
          await simpleBallot.vote(0, { from: nonParticipant })
          throw null
        }
        catch (error) {
          assert(error, "You are not a participant")
        }
      })
    })
  })
})

Seguramente me he dejado algún test, pero este artículo es para que veas un ejemplo de como se testean los contratos con Truffle. Cuando tengas que testear los tuyos asegúrate de crear todos los test posibles.